前台8项体验修复: 知识库上传自动刷新; 资产PDF归类; Token驾驶舱媒体统计; AI模糊意图澄清选择; ChatPage增加MCP/附件上传/语音; 技能市场安装态修复+清理演示数据; MCP协议应用改名MCP应用; Agent单应用接入(名称/介绍/API Key)

This commit is contained in:
QihuAI Design committed 2026-07-15 10:33:43 +08:00
1 parent 463824480b
commit bc2899ed09
11 files changed
+945 -154

No files matched your search

+260 -18
View File
@@ -236,6 +236,36 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
return <div className="markdown-body">{nodes}</div>;
};
// 解析后端返回的「模糊创作意图」确认指令:<<CLARIFY>>{json}<<CLARIFY>>
function parseClarify(content: string): { prompt: string; options: any[] } | null {
if (!content || typeof content !== 'string') return null;
const m = content.match(/^<<CLARIFY>>([\s\S]+)<<CLARIFY>>$/);
if (!m) return null;
try {
const obj = JSON.parse(m[1]);
if (obj && Array.isArray(obj.options)) return obj;
} catch { /* ignore */ }
return null;
}
// 模糊创作意图确认卡片:让用户选择产出方向(文案/图片/视频/其他)
const ClarifyCard: React.FC<{ data: { prompt: string; options: any[] }; onChoose: (opt: any) => void }> = ({ data, onChoose }) => {
return (
<div className="chat-clarify-card">
<div className="chat-clarify-title">🤔 这个请求的方向还不太明确,您希望以哪种形式呈现?</div>
<div className="chat-clarify-sub">「{data.prompt}」</div>
<div className="chat-clarify-options">
{data.options.map((opt: any) => (
<button key={opt.key} className="chat-clarify-option" onClick={() => onChoose(opt)}>
<span className="chat-clarify-opt-label">{opt.label}</span>
<span className="chat-clarify-opt-desc">{opt.desc}</span>
</button>
))}
</div>
</div>
);
};
function escapeHtml(text: string): string {
return text
@@ -358,6 +388,37 @@ const IconSkill = () => (
</svg>
);
const IconMcp = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/>
<line x1="7.5" y1="7.5" x2="11" y2="16"/><line x1="16.5" y1="7.5" x2="13" y2="16"/>
</svg>
);
const IconPaperclip = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
);
const IconMic = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="17" x2="12" y2="22"/>
</svg>
);
const IconStop = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<rect x="6" y="6" width="12" height="12" rx="2"/>
</svg>
);
const IconVolume = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
);
const IconModel = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
@@ -471,10 +532,17 @@ const ChatPage: React.FC = () => {
const [knowledgeBases, setKnowledgeBases] = useState<any[]>([]);
const [agents, setAgents] = useState<any[]>([]);
const [skills, setSkills] = useState<any[]>([]);
const [mcps, setMcps] = useState<any[]>([]);
// 多选组合模式
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([]);
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const [selectedKbIds, setSelectedKbIds] = useState<string[]>([]);
const [selectedMcpIds, setSelectedMcpIds] = useState<string[]>([]);
// 文件附件
const [attachments, setAttachments] = useState<{ name: string; file_type: string; content?: string }[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const recognitionRef = useRef<any>(null);
const [recording, setRecording] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -655,6 +723,13 @@ const ChatPage: React.FC = () => {
// 显示所有已发布的技能(不需要安装即可在聊天中使用)
setSkills(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
}).catch(() => {});
marketAPI.getApps({ type: 'mcp' }).then((res: any) => {
const data = res?.data !== undefined ? res.data : res;
let apps: any[] = [];
if (Array.isArray(data)) apps = data;
else if (data?.items) apps = data.items;
setMcps(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
}).catch(() => {});
}, []);
// Determine selected conversation
@@ -822,10 +897,9 @@ const ChatPage: React.FC = () => {
};
// Handle send message - CALLS REAL API
const handleSend = async () => {
if (!inputValue.trim() || sending) return;
const content = inputValue.trim();
setInputValue('');
// 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
const sendText = async (content: string) => {
if (!content.trim() || sending || !selectedConversation) return;
setSending(true);
// Add user message optimistically
@@ -846,7 +920,11 @@ const ChatPage: React.FC = () => {
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0,
mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined,
attachments: attachments.length > 0 ? attachments.map(a => ({
name: a.name, file_type: a.file_type, content: a.content || '',
})) : undefined,
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0,
});
const data = unwrapData(res);
@@ -886,6 +964,8 @@ const ChatPage: React.FC = () => {
// Refresh conversations to update lastMessage
loadConversations();
// 发送后清空本次附件(选择状态仍保留)
setAttachments([]);
// GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
if (selectedConversation?.id && data.assistant_message) {
@@ -908,6 +988,83 @@ const ChatPage: React.FC = () => {
}
};
// 文件上传:读取文本类文件内容作为附件上下文
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
const textExts = ['txt', 'md', 'markdown', 'csv', 'json', 'log', 'py', 'js', 'ts', 'tsx', 'jsx', 'html', 'css', 'xml', 'yaml', 'yml', 'sql', 'text'];
files.forEach(file => {
const ext = (file.name.split('.').pop() || '').toLowerCase();
const isText = textExts.includes(ext) || file.type.startsWith('text/');
if (isText && file.size <= 200 * 1024) {
const reader = new FileReader();
reader.onload = () => {
const content = String(reader.result || '');
setAttachments(prev => [...prev, { name: file.name, file_type: ext || 'txt', content }]);
};
reader.readAsText(file);
} else {
setAttachments(prev => [...prev, { name: file.name, file_type: ext || file.type || 'bin' }]);
}
});
e.target.value = '';
};
// 语音输入(浏览器 Web Speech API,支持中文)
const handleMicClick = () => {
const SR: any = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SR) {
alert('当前浏览器不支持语音输入,请使用 Chrome / Edge 等浏览器。');
return;
}
if (recording) {
recognitionRef.current?.stop();
return;
}
const rec = new SR();
recognitionRef.current = rec;
rec.lang = 'zh-CN';
rec.interimResults = true;
rec.continuous = false;
rec.onresult = (ev: any) => {
let text = '';
for (let i = 0; i < ev.results.length; i++) text += ev.results[i][0].transcript;
setInputValue(prev => (prev ? prev + text : text));
};
rec.onend = () => { setRecording(false); recognitionRef.current = null; };
rec.onerror = () => { setRecording(false); recognitionRef.current = null; };
setRecording(true);
rec.start();
};
// 语音合成(朗读回复,浏览器 TTS)
const speakText = (text: string) => {
if (typeof window === 'undefined' || !('speechSynthesis' in window)) {
alert('当前浏览器不支持朗读。');
return;
}
const synth = window.speechSynthesis;
synth.cancel();
const plain = text
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#>*`_~\-]+/g, ' ')
.replace(/\!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const u = new SpeechSynthesisUtterance(plain);
u.lang = 'zh-CN';
u.rate = 1;
synth.speak(u);
};
const handleSend = () => {
const content = inputValue.trim();
if (!content || sending) return;
setInputValue('');
sendText(content);
};
// Handle key down
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -1064,18 +1221,34 @@ const ChatPage: React.FC = () => {
</p>
</div>
) : (
messages.map(msg => {
// Real used_skills from API response
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
return (
<div key={msg.id} className={`message-row ${msg.role}`}>
<div className={`message-bubble ${msg.role}`}>
{msg.role === 'assistant' ? (
<MarkdownContent content={msg.content} />
) : (
<span>{msg.content}</span>
)}
</div>
messages.map(msg => {
// Real used_skills from API response
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
// 模糊创作意图确认卡片
const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
return (
<div key={msg.id} className={`message-row ${msg.role}`}>
<div className={`message-bubble ${msg.role}`}>
{clarify ? (
<ClarifyCard
data={clarify}
onChoose={(opt: any) => sendText(`${opt.medium}:${clarify.prompt}`)}
/>
) : msg.role === 'assistant' ? (
<MarkdownContent content={msg.content} />
) : (
<span>{msg.content}</span>
)}
</div>
{!clarify && msg.role === 'assistant' && msg.content && (
<button
className="msg-tts-btn"
title="朗读回复"
onClick={() => speakText(msg.content)}
>
<IconVolume />
</button>
)}
{/* Skill usage tags */}
{msg.role === 'assistant' && usedSkills.length > 0 && (
<div className="evo-skill-tags-row">
@@ -1216,19 +1389,88 @@ const ChatPage: React.FC = () => {
<div className="dropdown-item disabled">暂无可用技能</div>
)}
</Dropdown>
<Dropdown
dropup
closeOnClick={false}
trigger={
<button className="attach-btn" title="MCP 应用">
<IconMcp />
<span>{selectedMcpIds.length > 0 ? `MCP (${selectedMcpIds.length})` : 'MCP'}</span>
</button>
}
>
<div className="attach-dropdown-title">选择 MCP 应用(多选)</div>
{mcps.map((mcp: any) => (
<button key={mcp.id} className={`dropdown-item ${selectedMcpIds.includes(mcp.id) ? 'selected' : ''}`}
onClick={() => { setSelectedMcpIds(prev => prev.includes(mcp.id) ? prev.filter(id => id !== mcp.id) : [...prev, mcp.id]); }}>
<IconMcp />
<span>{mcp.name}</span>
{selectedMcpIds.includes(mcp.id) && <span style={{marginLeft:'auto',color:'var(--color-accent)'}}>✓</span>}
</button>
))}
{mcps.length > 0 && selectedMcpIds.length > 0 && (
<div className="dropdown-divider" />
)}
{selectedMcpIds.length > 0 && (
<button className="dropdown-item" style={{color:'var(--color-text-tertiary)'}} onClick={() => setSelectedMcpIds([])}>
清除选择 ({selectedMcpIds.length})
</button>
)}
{mcps.length === 0 && (
<div className="dropdown-item disabled">暂无可用 MCP 应用</div>
)}
</Dropdown>
<button className="attach-btn" title="上传文件" onClick={() => fileInputRef.current?.click()}>
<IconPaperclip />
<span>{attachments.length > 0 ? `文件 (${attachments.length})` : '文件'}</span>
</button>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
{attachments.length > 0 && (
<div className="attachment-chips">
{attachments.map((att, idx) => (
<span key={idx} className="attachment-chip">
<IconPaperclip />
<span className="attachment-name" title={att.name}>{att.name}</span>
<button
type="button"
className="attachment-remove"
onClick={() => setAttachments(prev => prev.filter((_, i) => i !== idx))}
title="移除"
>×</button>
</span>
))}
</div>
)}
<div className="input-row">
<textarea
ref={inputRef}
className="chat-textarea"
placeholder={`向 ${selectedModel.name} 发送消息...`}
placeholder={`向 ${selectedModel.name} 发送消息...(可点击 🎤 语音输入)`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
disabled={sending}
/>
<button
className={`mic-btn ${recording ? 'recording' : ''}`}
onClick={handleMicClick}
disabled={sending}
title={recording ? '停止录音' : '语音输入'}
>
{recording ? <IconStop /> : <IconMic />}
</button>
<button
className="send-btn"
onClick={handleSend}