前台8项体验修复: 知识库上传自动刷新; 资产PDF归类; Token驾驶舱媒体统计; AI模糊意图澄清选择; ChatPage增加MCP/附件上传/语音; 技能市场安装态修复+清理演示数据; MCP协议应用改名MCP应用; Agent单应用接入(名称/介绍/API Key)
This commit is contained in:
1 parent
463824480b
commit
bc2899ed09
11 files changed
+945
-154
No files matched your search
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user