Phase 0 SSE 流式聊天 landed in the running container and captured in Git so
the three sources (container := image := Git) agree.
Phase 0 — SSE 流式聊天(Web 端,IM bot 路径保持不变、共用引擎):
- app/api/chat.py: 新增独立 SSE 端点
POST /app/api/chat/conversations/{conv_id}/messages/stream
基于 asyncio.Queue + create_task 将 _send_message_core 桥接为
text/event-stream 生成器,吐出 delta / reasoning / done / error 事件
- app/api/chat_service.py: _send_message_core 增加 stream + on_event 参数;
普通(无工具)聊天 round-0 主调用走 achat_complete(stream=True) 流式;
done 事件携带 message_id / content / process_steps / used_skills /
used_mcp / generating
- app/core/llm_client.py: 修复 _create_kwargs 硬编码 stream=False 的根因 bug
(流式调用传 stream=True 导致 TypeError: multiple values for keyword),
现 _create_kwargs(request_body, stream=True) 字段随参数变化
- frontend/src/pages/front/ChatPage.tsx + ChatPage.css + types/index.ts:
发消息改为 fetch SSE 消费,实时增量渲染 AI 回复文本与思考链(reasoning),
done 事件用 message_id 定稿并渲染过程步骤;网络/流式失败自动回退非流式
Alignment performed 2026-07-24 18:25:11. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
1937 lines
82 KiB
TypeScript
1937 lines
82 KiB
TypeScript
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
|
||
import { formatChatTime } from '@/utils/datetime';
|
||
import type { Conversation, Message } from '@/types';
|
||
import './ChatPage.css';
|
||
|
||
// ===== 增强渲染依赖 =====
|
||
import 'highlight.js/styles/github.css';
|
||
import 'katex/dist/katex.min.css';
|
||
import mermaid from 'mermaid';
|
||
|
||
mermaid.initialize({ startOnLoad: false, theme: 'default' });
|
||
|
||
// ===== AI 回复异常检测 =====
|
||
const AI_ERROR_PATTERNS = [
|
||
'AI模型调用失败',
|
||
'AI服务暂时不可用',
|
||
'AI模型返回了空响应',
|
||
'AI模型已处理工具调用结果',
|
||
'该模型缺少API密钥或端点配置',
|
||
'请选择一个已配置的AI模型',
|
||
'请先在后台管理-大模型库配置默认对话模型',
|
||
'⚠️ 多 Agent 编排执行失败',
|
||
'⚠️ 外部 Agent 调用失败',
|
||
'编排未产生内容',
|
||
'外部 Agent 未返回内容',
|
||
];
|
||
function isAiError(content: string): boolean {
|
||
if (!content) return false;
|
||
return AI_ERROR_PATTERNS.some(p => content.includes(p));
|
||
}
|
||
|
||
// ===== Enhanced Markdown Renderer =====
|
||
// 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项
|
||
import { renderMarkdownToHtml, MediaInfo } from '@/utils/markdown';
|
||
|
||
// 全屏灯箱预览
|
||
const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => {
|
||
useEffect(() => {
|
||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||
document.addEventListener('keydown', h);
|
||
return () => document.removeEventListener('keydown', h);
|
||
}, [onClose]);
|
||
return (
|
||
<div className="chat-lightbox" onClick={onClose}>
|
||
<button className="chat-lightbox-close" onClick={onClose} aria-label="关闭">×</button>
|
||
<div className="chat-lightbox-content" onClick={(e) => e.stopPropagation()}>
|
||
{type === 'video'
|
||
? <video src={src} controls autoPlay style={{ maxWidth: '92vw', maxHeight: '86vh', borderRadius: 8 }} />
|
||
: <img src={src} alt="" style={{ maxWidth: '92vw', maxHeight: '86vh', borderRadius: 8 }} />}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 根据原始尺寸推算 3 个建议下载尺寸(保持宽高比)
|
||
function computeSizes(natural: { w: number; h: number }) {
|
||
if (natural.w && natural.h) {
|
||
const ratio = natural.w / natural.h;
|
||
const fit = (maxDim: number) => ratio >= 1
|
||
? { w: maxDim, h: Math.round(maxDim / ratio) }
|
||
: { w: Math.round(maxDim * ratio), h: maxDim };
|
||
return [
|
||
{ label: '小图', ...fit(512) },
|
||
{ label: '中图', ...fit(1024) },
|
||
{ label: '原始', w: natural.w, h: natural.h },
|
||
];
|
||
}
|
||
// 未知原始尺寸时按生成默认 4:3 给出三档
|
||
return [
|
||
{ label: '小图', w: 512, h: 384 },
|
||
{ label: '中图', w: 1024, h: 768 },
|
||
{ label: '大图', w: 2048, h: 1536 },
|
||
];
|
||
}
|
||
|
||
// 客户端画布转换格式+尺寸后下载;跨域受限时回退到新标签页打开原图
|
||
function downloadImage(src: string, format: 'png' | 'jpg' | 'webp', w: number, h: number) {
|
||
const fallback = () => window.open(src, '_blank');
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = () => {
|
||
try {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w; canvas.height = h;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) { fallback(); return; }
|
||
if (format === 'jpg') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); }
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
const mime = format === 'jpg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png';
|
||
canvas.toBlob((blob) => {
|
||
if (!blob) { fallback(); return; }
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = `aiflow-image-${w}x${h}.${format}`;
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}, mime, 0.92);
|
||
} catch (e) {
|
||
fallback();
|
||
}
|
||
};
|
||
img.onerror = fallback;
|
||
img.src = src;
|
||
}
|
||
|
||
// 媒体渲染组件:固定尺寸缩略图 + 点击放大 + 下载(格式/尺寸)
|
||
const ChatMedia: React.FC<{ info: MediaInfo }> = ({ info }) => {
|
||
const [open, setOpen] = useState(false);
|
||
const [dlOpen, setDlOpen] = useState(false);
|
||
const [fmt, setFmt] = useState<'png' | 'jpg' | 'webp'>('png');
|
||
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
|
||
|
||
if (info.type === 'video') {
|
||
return (
|
||
<div className="chat-media-wrap">
|
||
<video className="chat-media-video" src={info.src} controls preload="metadata" onClick={() => setOpen(true)} />
|
||
<div className="chat-media-bar">
|
||
<a className="chat-media-link" href={info.src} target="_blank" rel="noopener" onClick={(e) => e.stopPropagation()}>打开 / 下载原视频</a>
|
||
</div>
|
||
{open && <ChatLightbox src={info.src} type="video" onClose={() => setOpen(false)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const sizes = computeSizes(natural);
|
||
return (
|
||
<div className="chat-media-wrap">
|
||
<img
|
||
className="chat-media-img"
|
||
src={info.src}
|
||
alt={info.alt || '生成图片'}
|
||
loading="lazy"
|
||
onClick={() => setOpen(true)}
|
||
onLoad={(e) => {
|
||
const im = e.currentTarget;
|
||
if (im.naturalWidth && im.naturalHeight) setNatural({ w: im.naturalWidth, h: im.naturalHeight });
|
||
}}
|
||
/>
|
||
<button className="chat-media-dl-btn" title="下载图片" onClick={(e) => { e.stopPropagation(); setDlOpen((o) => !o); }}>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" />
|
||
</svg>
|
||
</button>
|
||
{dlOpen && (
|
||
<div className="chat-media-dl-pop" onClick={(e) => e.stopPropagation()}>
|
||
<div className="chat-media-dl-title">下载图片</div>
|
||
<div className="chat-media-dl-row">
|
||
<span className="chat-media-dl-label">格式</span>
|
||
<div className="chat-media-dl-fmts">
|
||
{(['png', 'jpg', 'webp'] as const).map((f) => (
|
||
<button key={f} className={`chat-media-dl-fmt ${fmt === f ? 'active' : ''}`} onClick={() => setFmt(f)}>{f.toUpperCase()}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="chat-media-dl-label">尺寸</div>
|
||
<div className="chat-media-dl-sizes">
|
||
{sizes.map((s, i) => (
|
||
<button key={i} className="chat-media-dl-size" onClick={() => { downloadImage(info.src, fmt, s.w, s.h); setDlOpen(false); }}>
|
||
<span className="chat-media-dl-size-label">{s.label}</span>
|
||
<span className="chat-media-dl-size-px">{s.w}×{s.h}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{open && <ChatLightbox src={info.src} type="img" onClose={() => setOpen(false)} />}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
|
||
// 渲染管线(代码块/加粗/表格/换行/媒体占位符等)已抽到 src/utils/markdown.ts,
|
||
// 组件只负责把返回的 html 切分为「文本片段 + 媒体组件」。
|
||
const { html, media } = useMemo(() => renderMarkdownToHtml(content), [content]);
|
||
|
||
const nodes = useMemo(() => {
|
||
// 12. 拆分为文本片段与媒体组件(媒体以 \x00MEDIA<n>\x00 占位符保留在 html 中)
|
||
const parts = html.split(/(\x00MEDIA\d+\x00)/g);
|
||
return parts.map((part: string, i: number) => {
|
||
const m = part.match(/^\x00MEDIA(\d+)\x00$/);
|
||
if (m) {
|
||
const info = media[parseInt(m[1])];
|
||
if (info && info.src) return <ChatMedia key={'m' + i} info={info} />;
|
||
return null;
|
||
}
|
||
if (!part) return null;
|
||
// 仅清除被媒体切分产生的悬空 <p> 标签,保留段落内部的成对 <p>
|
||
let s = part.replace(/^<\/p>/, '').replace(/<p class="md-p">$/, '');
|
||
if (!s.trim()) return null;
|
||
return <div key={'t' + i} className="md-text-seg" dangerouslySetInnerHTML={{ __html: s }} />;
|
||
});
|
||
}, [html, media]);
|
||
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
if (!containerRef.current) return;
|
||
const diagrams = containerRef.current.querySelectorAll('.md-mermaid:not(.md-mermaid-rendered)');
|
||
if (diagrams.length === 0) return;
|
||
const timer = setTimeout(async () => {
|
||
for (const el of diagrams) {
|
||
try {
|
||
const code = el.getAttribute('data-code') || '';
|
||
if (!code) continue;
|
||
const id = 'mermaid-' + Math.random().toString(36).slice(2, 10);
|
||
const { svg } = await mermaid.render(id, code);
|
||
el.innerHTML = svg;
|
||
el.classList.add('md-mermaid-rendered');
|
||
} catch (e) {
|
||
el.innerHTML = '<pre class="md-code-block"><code>Mermaid: ' + (e as Error).message + '</code></pre>';
|
||
}
|
||
}
|
||
}, 50);
|
||
return () => clearTimeout(timer);
|
||
}, [content]);
|
||
|
||
return <div className="markdown-body" ref={containerRef}>{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>
|
||
);
|
||
};
|
||
|
||
|
||
// ===== Time Formatting =====
|
||
function formatTime(iso: string): string {
|
||
return formatChatTime(iso);
|
||
}
|
||
|
||
// ===== Helpers: normalize API responses =====
|
||
function unwrapList(res: any): any[] {
|
||
const data = res?.data !== undefined ? res.data : res;
|
||
if (Array.isArray(data)) return data;
|
||
if (data?.items) return data.items;
|
||
return [];
|
||
}
|
||
|
||
function unwrapData(res: any): any {
|
||
return res?.data !== undefined ? res.data : res;
|
||
}
|
||
|
||
// ===== Icons =====
|
||
const IconSearch = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconPlus = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconMore = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconSend = () => (
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconChevronDown = () => (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="6 9 12 15 18 9"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconEdit = () => (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconArchive = () => (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconTrash = () => (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconPin = () => (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M12 2 L12 22"/><path d="M5 6 L5 6"/><path d="M6 6 L6 12 C6 15 12 17 12 22 C12 17 18 15 18 12 L18 6"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconGraph = () => (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/>
|
||
<line x1="8" y1="6" x2="10" y2="12"/><line x1="16" y1="6" x2="14" y2="12"/><line x1="8" y1="18" x2="10" y2="12"/><line x1="16" y1="18" x2="14" y2="12"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconKnowledge = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconAgent = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/><path d="M2 21c0-5.5 4.5-10 10-10s10 4.5 10 10"/>
|
||
<circle cx="12" cy="9" r="1" fill="currentColor" stroke="none"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconSkill = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||
</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"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconEmpty = () => (
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" opacity="0.3">
|
||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||
<line x1="9" y1="10" x2="15" y2="10"/><line x1="12" y1="7" x2="12" y2="13"/>
|
||
</svg>
|
||
);
|
||
|
||
const IconSpinner = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="spin-icon">
|
||
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"/>
|
||
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round"/>
|
||
</svg>
|
||
);
|
||
|
||
// ===== Dropdown Component =====
|
||
interface DropdownProps {
|
||
trigger: React.ReactNode;
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
align?: 'left' | 'right';
|
||
dropup?: boolean;
|
||
closeOnClick?: boolean;
|
||
}
|
||
|
||
const Dropdown: React.FC<DropdownProps> = ({ trigger, children, className = '', align = 'left', dropup = false, closeOnClick = true }) => {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
const openRef = useRef(open);
|
||
openRef.current = open;
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
const target = e.target as Node;
|
||
// 如果点击在wrapper内部,不关闭
|
||
if (ref.current?.contains(target)) return;
|
||
setOpen(false);
|
||
};
|
||
document.addEventListener('mousedown', handleClickOutside, true);
|
||
return () => document.removeEventListener('mousedown', handleClickOutside, true);
|
||
}, []);
|
||
|
||
// 打开时处理z-index
|
||
useEffect(() => {
|
||
if (open && dropup) {
|
||
const menu = menuRef.current;
|
||
if (menu) {
|
||
menu.style.zIndex = '9999';
|
||
}
|
||
}
|
||
}, [open, dropup]);
|
||
|
||
const alignClass = align === 'right' ? 'align-right' : 'align-left';
|
||
const dropupClass = dropup ? 'dropup' : '';
|
||
|
||
const handleTriggerMouseDown = (e: React.MouseEvent) => {
|
||
// 阻止冒泡到document,防止被handleClickOutside误关
|
||
e.stopPropagation();
|
||
// 基于当前状态切换(在mousedown阶段决定,避免与click冲突)
|
||
setOpen(prev => !prev);
|
||
};
|
||
|
||
const handleTriggerClick = (e: React.MouseEvent) => {
|
||
// 阻止click冒泡到父级的导航onClick
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
};
|
||
|
||
return (
|
||
<div className={`dropdown-wrapper ${className}`} ref={ref}>
|
||
<div
|
||
className="dropdown-trigger"
|
||
onMouseDown={handleTriggerMouseDown}
|
||
onClick={handleTriggerClick}
|
||
>
|
||
{trigger}
|
||
</div>
|
||
{open && (
|
||
<div
|
||
ref={menuRef}
|
||
className={`dropdown-menu ${alignClass} ${dropupClass}`}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onClick={(e) => { e.stopPropagation(); if (closeOnClick) setOpen(false); }}
|
||
>
|
||
{children}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ===== AI 思考 / 执行过程面板(顶部展开 + 自动收拢) =====
|
||
// autoExpand=true 时(本轮刚产生的助手消息)首屏展开呈现思考链路,
|
||
// 6s 后若用户未手动干预则自动收拢为一行摘要,避免与回复内容冲突。
|
||
// 展开时自动滚动到视图内,带脉冲动画提示用户注意。
|
||
const AiProcessBlock: React.FC<{ steps: any[]; autoExpand: boolean }> = ({ steps, autoExpand }) => {
|
||
const [open, setOpen] = useState(autoExpand);
|
||
const [fresh, setFresh] = useState(autoExpand);
|
||
const timerRef = useRef<number | null>(null);
|
||
const touchedRef = useRef(false);
|
||
const blockRef = useRef<HTMLDetailsElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!autoExpand) {
|
||
setOpen(false);
|
||
setFresh(false);
|
||
return;
|
||
}
|
||
setOpen(true);
|
||
setFresh(true);
|
||
touchedRef.current = false;
|
||
// 自动滚动到视图内(上方留 60px 避免被顶部遮挡)
|
||
if (blockRef.current) {
|
||
blockRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
timerRef.current = window.setTimeout(() => {
|
||
if (!touchedRef.current) {
|
||
setOpen(false);
|
||
// 收拢后短暂保留 fresh 样式再移除
|
||
setTimeout(() => setFresh(false), 500);
|
||
}
|
||
}, 6000);
|
||
return () => {
|
||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||
};
|
||
}, [autoExpand]);
|
||
|
||
if (!steps || steps.length === 0) return null;
|
||
|
||
const toggle = () => {
|
||
touchedRef.current = true;
|
||
setFresh(false);
|
||
if (timerRef.current) {
|
||
window.clearTimeout(timerRef.current);
|
||
timerRef.current = null;
|
||
}
|
||
setOpen(o => !o);
|
||
};
|
||
|
||
return (
|
||
<details ref={blockRef} className={`ai-process${open ? '' : ''}${fresh ? ' ai-process-fresh' : ''}`} open={open}>
|
||
<summary className="ai-process-summary" onClick={(e) => { e.preventDefault(); toggle(); }}>
|
||
<span className="ai-process-badge">🧠</span>
|
||
AI 思考 / 执行过程({steps.length} 步 — {open ? '自动收拢中... 点击手动收起' : '点击展开查看'})
|
||
</summary>
|
||
<div className="ai-process-list">
|
||
{steps.map((s: any, i: number) => (
|
||
<div key={i} className={`ai-process-item ai-process-${s.type}`}>
|
||
<span className="ai-process-ico">
|
||
{s.type === 'thinking' ? '💡' : s.type === 'skill' ? '🛠' : '🔌'}
|
||
</span>
|
||
<div className="ai-process-body">
|
||
{s.type === 'thinking' && (<><b className="ai-process-title">思考</b><p className="ai-process-text">{s.text}</p></>)}
|
||
{s.type === 'skill' && (<><b className="ai-process-title">调用技能:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>)}
|
||
{s.type === 'mcp' && (<><b className="ai-process-title">MCP · {s.server}:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</details>
|
||
);
|
||
};
|
||
|
||
// 思考过程收拢式展示(缺陷5:默认折叠;fresh 流式消息自动展开,数秒后自动收拢)
|
||
const ReasoningBlock: React.FC<{ text: string; fresh: boolean }> = ({ text, fresh }) => {
|
||
const [open, setOpen] = useState(fresh);
|
||
const [isFresh, setFreshState] = useState(fresh);
|
||
const touchedRef = useRef(false);
|
||
const timerRef = useRef<number | null>(null);
|
||
useEffect(() => {
|
||
if (!fresh) {
|
||
setOpen(false);
|
||
setFreshState(false);
|
||
return;
|
||
}
|
||
setOpen(true);
|
||
setFreshState(true);
|
||
touchedRef.current = false;
|
||
timerRef.current = window.setTimeout(() => {
|
||
if (!touchedRef.current) {
|
||
setOpen(false);
|
||
setTimeout(() => setFreshState(false), 500);
|
||
}
|
||
}, 5000);
|
||
return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
|
||
}, [fresh]);
|
||
if (!text) return null;
|
||
const toggle = () => {
|
||
touchedRef.current = true;
|
||
setFreshState(false);
|
||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||
setOpen(o => !o);
|
||
};
|
||
return (
|
||
<details className={`ai-process ai-process-reasoning${isFresh ? ' ai-process-fresh' : ''}`} open={open}>
|
||
<summary className="ai-process-summary" onClick={(e) => { e.preventDefault(); toggle(); }}>
|
||
<span className="ai-process-badge">🤔</span>
|
||
AI 思考过程{open ? '(收拢中... 点击收起)' : '(点击展开)'}
|
||
</summary>
|
||
<div className="ai-process-list">
|
||
<div className="ai-process-item ai-process-thinking">
|
||
<span className="ai-process-ico">💡</span>
|
||
<div className="ai-process-body">
|
||
<p className="ai-process-text">{text}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
);
|
||
};
|
||
|
||
// ===== Main Component =====
|
||
const ChatPage: React.FC = () => {
|
||
const { id } = useParams<{ id?: string }>();
|
||
const navigate = useNavigate();
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [selectedModelId, setSelectedModelId] = useState('auto');
|
||
const [chatModels, setChatModels] = useState<any[]>([]);
|
||
const [inputValue, setInputValue] = useState('');
|
||
const [sending, setSending] = useState(false);
|
||
const [sendingTimeout, setSendingTimeout] = useState(false);
|
||
const sendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
// 正在 SSE 流式渲染的助手消息 id;非空时隐藏"思考中"占位气泡
|
||
const [streamingId, setStreamingId] = useState<string | null>(null);
|
||
// 流式中断控制器(缺陷4:支持「停止生成」)
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
const stopStreaming = () => { try { abortRef.current?.abort(); } catch { /* noop */ } };
|
||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||
const [messages, setMessages] = useState<Message[]>([]);
|
||
const [loadingConversations, setLoadingConversations] = useState(true);
|
||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||
// 本轮对话新产生的助手消息 id 集合,用于触发"思考过程"首屏展开
|
||
const [freshMsgIds, setFreshMsgIds] = useState<Set<string>>(new Set());
|
||
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);
|
||
|
||
// Knowledge graph conversion state
|
||
const [kgModalOpen, setKgModalOpen] = useState(false);
|
||
const [kgStep, setKgStep] = useState<1 | 2 | 3>(1);
|
||
const [kgResult, setKgResult] = useState<{ nodes: number; edges: number; kbName: string; kbId: string } | null>(null);
|
||
const [kgError, setKgError] = useState<string | null>(null);
|
||
const [kgConvId, setKgConvId] = useState<string | null>(null);
|
||
|
||
// Load models from backend API
|
||
useEffect(() => {
|
||
chatAPI.getModels().then((res: any) => {
|
||
const data = res.data || res;
|
||
const models = Array.isArray(data) ? data : (data?.items || []);
|
||
setChatModels(models);
|
||
if (models.length > 0 && !selectedModelId) {
|
||
setSelectedModelId('auto');
|
||
}
|
||
}).catch(() => {
|
||
setChatModels([]);
|
||
});
|
||
}, []);
|
||
|
||
// Load conversations from API
|
||
const loadConversations = useCallback(async () => {
|
||
try {
|
||
setLoadingConversations(true);
|
||
const res = await chatAPI.getConversations();
|
||
const convs = unwrapList(res);
|
||
// Sort: pinned first, then by updatedAt desc
|
||
const sorted = [...convs].sort((a: any, b: any) => {
|
||
if (a.pinned && !b.pinned) return -1;
|
||
if (!a.pinned && b.pinned) return 1;
|
||
return new Date(b.updatedAt || b.updated_at || 0).getTime() - new Date(a.updatedAt || a.updated_at || 0).getTime();
|
||
});
|
||
// Normalize field names
|
||
const mapped: Conversation[] = sorted.map((c: any) => ({
|
||
id: c.id,
|
||
userId: c.userId || c.user_id || '',
|
||
title: c.title || '未命名对话',
|
||
modelId: c.modelId || c.model_id || '',
|
||
status: c.status || 'active',
|
||
pinned: c.pinned || false,
|
||
knowledgeGraphCount: c.knowledgeGraphCount ?? c.knowledge_graph_count ?? 0,
|
||
lastMessage: c.lastMessage || c.last_message || '',
|
||
createdAt: c.createdAt || c.created_at || new Date().toISOString(),
|
||
updatedAt: c.updatedAt || c.updated_at || new Date().toISOString(),
|
||
}));
|
||
setConversations(mapped);
|
||
} catch (err) {
|
||
console.error('加载对话列表失败', err);
|
||
} finally {
|
||
setLoadingConversations(false);
|
||
}
|
||
}, []);
|
||
|
||
// Load conversations on mount
|
||
useEffect(() => {
|
||
loadConversations();
|
||
}, [loadConversations]);
|
||
|
||
// Load messages for selected conversation
|
||
useEffect(() => {
|
||
if (!id) {
|
||
setMessages([]);
|
||
return;
|
||
}
|
||
const loadMessages = async () => {
|
||
try {
|
||
setLoadingMessages(true);
|
||
const res = await chatAPI.getMessages(id);
|
||
const msgs = unwrapList(res);
|
||
const mapped: Message[] = msgs.map((m: any) => ({
|
||
id: m.id,
|
||
conversationId: m.conversationId || m.conversation_id || id,
|
||
role: m.role,
|
||
content: m.content,
|
||
tokenUsed: m.tokenUsed ?? m.token_used ?? 0,
|
||
createdAt: m.createdAt || m.created_at || new Date().toISOString(),
|
||
}));
|
||
setMessages(mapped);
|
||
// 页面刷新时若仍有「⏳」占位的媒体消息,继续轮询后端回写结果
|
||
mapped.forEach((m) => {
|
||
if (m.role === 'assistant' && typeof m.content === 'string' && m.content.startsWith('⏳')) {
|
||
startMediaPolling(id, m.id);
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.error('加载消息失败', err);
|
||
setMessages([]);
|
||
} finally {
|
||
setLoadingMessages(false);
|
||
}
|
||
};
|
||
loadMessages();
|
||
}, [id]);
|
||
|
||
// 媒体(图像/视频)异步生成轮询:后端先返回「⏳」占位,前端定时拉取该消息最新内容并回写
|
||
const pollMediaRef = useRef<Record<string, ReturnType<typeof setInterval> | null>>({});
|
||
|
||
const stopMediaPolling = (msgId: string) => {
|
||
const t = pollMediaRef.current[msgId];
|
||
if (t) {
|
||
clearInterval(t);
|
||
pollMediaRef.current[msgId] = null;
|
||
}
|
||
};
|
||
|
||
const startMediaPolling = (convId: string, msgId: string) => {
|
||
if (pollMediaRef.current[msgId]) return; // 已在轮询
|
||
let ticks = 0;
|
||
const timer = setInterval(async () => {
|
||
ticks += 1;
|
||
try {
|
||
const res = await chatAPI.getMessages(convId);
|
||
const msgs: any[] = unwrapList(res);
|
||
const updated = msgs.find((m: any) => m.id === msgId);
|
||
if (!updated) {
|
||
stopMediaPolling(msgId);
|
||
return;
|
||
}
|
||
const content: string = updated.content || '';
|
||
if (content.startsWith('⏳')) {
|
||
if (ticks > 120) stopMediaPolling(msgId); // ~6 分钟兜底
|
||
return;
|
||
}
|
||
// 生成完成:回写消息内容
|
||
setMessages(prev => prev.map(m => m.id === msgId ? {
|
||
...m,
|
||
content,
|
||
tokenUsed: updated.tokenUsed ?? updated.token_used ?? m.tokenUsed,
|
||
createdAt: updated.createdAt || updated.created_at || m.createdAt,
|
||
used_skills: updated.usedSkills ?? updated.used_skills ?? m.used_skills,
|
||
used_mcp: updated.usedMcp ?? updated.used_mcp ?? m.used_mcp,
|
||
} : m));
|
||
stopMediaPolling(msgId);
|
||
} catch (e) {
|
||
if (ticks > 120) stopMediaPolling(msgId); // 网络异常兜底
|
||
}
|
||
}, 3000);
|
||
pollMediaRef.current[msgId] = timer;
|
||
};
|
||
|
||
// 卸载时清理所有轮询定时器
|
||
useEffect(() => {
|
||
return () => {
|
||
Object.values(pollMediaRef.current).forEach(t => t && clearInterval(t));
|
||
};
|
||
}, []);
|
||
|
||
// Load knowledge bases, agents, skills for attachment dropdowns
|
||
useEffect(() => {
|
||
knowledgeAPI.getBases().then((res: any) => {
|
||
const data = res?.data !== undefined ? res.data : res;
|
||
if (data?.bases && Array.isArray(data.bases)) {
|
||
setKnowledgeBases(data.bases);
|
||
} else if (Array.isArray(data)) {
|
||
setKnowledgeBases(data);
|
||
} else if (data?.items) {
|
||
setKnowledgeBases(data.items);
|
||
} else {
|
||
setKnowledgeBases([]);
|
||
}
|
||
}).catch(() => {});
|
||
marketAPI.getApps({ type: 'agent' }).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;
|
||
// 显示所有已发布的Agent(不需要安装即可在聊天中使用)
|
||
setAgents(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
|
||
}).catch(() => {});
|
||
marketAPI.getApps({ type: 'skill' }).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;
|
||
// 显示所有已发布的技能(不需要安装即可在聊天中使用)
|
||
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
|
||
const selectedConversation = useMemo(() => {
|
||
if (id) {
|
||
return conversations.find(c => c.id === id) || null;
|
||
}
|
||
return conversations[0] || null;
|
||
}, [id, conversations]);
|
||
|
||
// Filter conversations by search
|
||
const filteredConversations = useMemo(() => {
|
||
const pinned = conversations.filter(c => c.pinned);
|
||
const unpinned = conversations.filter(c => !c.pinned);
|
||
if (!searchQuery.trim()) return [...pinned, ...unpinned];
|
||
const q = searchQuery.toLowerCase();
|
||
return [...pinned.filter(c => c.title.toLowerCase().includes(q)), ...unpinned.filter(c => c.title.toLowerCase().includes(q))];
|
||
}, [conversations, searchQuery]);
|
||
|
||
// Selected model info(Auto 自动:按对话意图自动匹配后台各能力默认模型)
|
||
const isAutoModel = selectedModelId === 'auto';
|
||
const AUTO_MODEL = { id: 'auto', name: 'Auto 自动', status: 'free', provider_name: '按能力智能匹配', capabilities: [], price_per_million_tokens: 0 };
|
||
const selectedModel = isAutoModel
|
||
? AUTO_MODEL
|
||
: (chatModels.find(m => m.id === selectedModelId) || chatModels[0] || AUTO_MODEL);
|
||
|
||
// Scroll to bottom when messages change
|
||
useEffect(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, [messages]);
|
||
|
||
// Select first conversation if no id param
|
||
useEffect(() => {
|
||
if (!id && conversations.length > 0) {
|
||
navigate(`/chat/${conversations[0].id}`, { replace: true });
|
||
}
|
||
}, [id, conversations, navigate]);
|
||
|
||
// Handle new conversation
|
||
const handleNewConversation = async () => {
|
||
try {
|
||
const res = await chatAPI.createConversation({ title: '新对话', model_id: selectedModelId === 'auto' ? (chatModels[0]?.id || '') : selectedModelId });
|
||
const newConv = unwrapData(res);
|
||
const conv: Conversation = {
|
||
id: newConv.id,
|
||
userId: newConv.userId || newConv.user_id || '',
|
||
title: newConv.title || '新对话',
|
||
modelId: newConv.modelId || newConv.model_id || selectedModelId,
|
||
status: 'active',
|
||
pinned: false,
|
||
knowledgeGraphCount: 0,
|
||
createdAt: newConv.createdAt || newConv.created_at || new Date().toISOString(),
|
||
updatedAt: newConv.updatedAt || newConv.updated_at || new Date().toISOString(),
|
||
};
|
||
setConversations(prev => [conv, ...prev]);
|
||
navigate(`/chat/${conv.id}`);
|
||
} catch (err: any) {
|
||
alert('创建对话失败: ' + (err.message || '未知错误'));
|
||
}
|
||
};
|
||
|
||
// Handle rename
|
||
const handleRename = async (convId: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
const newTitle = prompt('请输入新名称:');
|
||
if (newTitle && newTitle.trim()) {
|
||
try {
|
||
await chatAPI.updateConversation(convId, { title: newTitle.trim() });
|
||
setConversations(prev => prev.map(c => c.id === convId ? { ...c, title: newTitle.trim() } : c));
|
||
} catch (err: any) {
|
||
alert('重命名失败: ' + (err.message || '未知错误'));
|
||
}
|
||
}
|
||
};
|
||
|
||
// Handle archive
|
||
const handleArchive = async (convId: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
const conv = conversations.find(c => c.id === convId);
|
||
if (!conv) return;
|
||
const newStatus = conv.status === 'archived' ? 'active' : 'archived';
|
||
try {
|
||
await chatAPI.updateConversation(convId, { status: newStatus });
|
||
setConversations(prev => prev.map(c => c.id === convId ? { ...c, status: newStatus } as Conversation : c));
|
||
} catch (err: any) {
|
||
alert('操作失败: ' + (err.message || '未知错误'));
|
||
}
|
||
};
|
||
|
||
// Handle delete
|
||
const handleDelete = async (convId: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (!confirm('确定要删除这个对话吗?')) return;
|
||
try {
|
||
await chatAPI.deleteConversation(convId);
|
||
const next = conversations.filter(c => c.id !== convId);
|
||
setConversations(next);
|
||
if (selectedConversation?.id === convId) {
|
||
if (next.length > 0) {
|
||
navigate(`/chat/${next[0].id}`);
|
||
} else {
|
||
navigate('/chat');
|
||
}
|
||
}
|
||
} catch (err: any) {
|
||
alert('删除失败: ' + (err.message || '未知错误'));
|
||
}
|
||
};
|
||
|
||
// Handle pin / unpin
|
||
const handlePin = async (convId: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
const conv = conversations.find(c => c.id === convId);
|
||
if (!conv) return;
|
||
const newPinned = !conv.pinned;
|
||
try {
|
||
await chatAPI.updateConversation(convId, { pinned: newPinned });
|
||
setConversations(prev => {
|
||
const updated = prev.map(c => c.id === convId ? { ...c, pinned: newPinned } : c);
|
||
const pinned = updated.filter(c => c.pinned);
|
||
const unpinned = updated.filter(c => !c.pinned).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||
return [...pinned, ...unpinned];
|
||
});
|
||
} catch (err: any) {
|
||
alert('操作失败: ' + (err.message || '未知错误'));
|
||
}
|
||
};
|
||
|
||
// Handle convert to knowledge graph
|
||
const handleKnowledgeGraph = async (convId: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setKgModalOpen(true);
|
||
setKgStep(1);
|
||
setKgResult(null);
|
||
setKgError(null);
|
||
setKgConvId(convId);
|
||
|
||
try {
|
||
await new Promise(r => setTimeout(r, 600));
|
||
setKgStep(2);
|
||
|
||
const res = await chatAPI.convertToKnowledgeGraph(convId);
|
||
await new Promise(r => setTimeout(r, 400));
|
||
setKgStep(3);
|
||
|
||
if (res.code === 0) {
|
||
const graph = res.data.graph;
|
||
setKgResult({
|
||
nodes: graph.stats.total_nodes,
|
||
edges: graph.stats.total_edges,
|
||
kbName: res.data.knowledge_base_name,
|
||
kbId: res.data.knowledge_base_id,
|
||
});
|
||
// 使用后端返回的真实计数(幂等,始终为 1),避免前端重复累加导致计数虚高
|
||
const newCount = res.data.knowledge_graph_count ?? 1;
|
||
setConversations(prev =>
|
||
prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: newCount } : c)
|
||
);
|
||
} else {
|
||
setKgError(res.message || '转换失败');
|
||
}
|
||
} catch (err) {
|
||
setKgError((err as Error).message || '转换失败,请稍后重试');
|
||
}
|
||
};
|
||
|
||
// 非流式兜底发送(与历史行为一致)——供 SSE 连接/流式失败时回退
|
||
const sendLegacy = async (
|
||
payload: any,
|
||
tempUserMsg: Message,
|
||
) => {
|
||
const res = await chatAPI.sendMessage(selectedConversation!.id, payload);
|
||
const data = unwrapData(res);
|
||
|
||
// Replace temporary user message and add assistant response
|
||
setMessages(prev => {
|
||
const filtered = prev.filter(m => m.id !== tempUserMsg.id);
|
||
const newMsgs: Message[] = [];
|
||
if (data.user_message) {
|
||
newMsgs.push({
|
||
id: data.user_message.id,
|
||
conversationId: selectedConversation?.id || '',
|
||
role: 'user',
|
||
content: data.user_message.content,
|
||
tokenUsed: data.user_message.token_used || 0,
|
||
createdAt: data.user_message.created_at || new Date().toISOString(),
|
||
});
|
||
}
|
||
if (data.assistant_message) {
|
||
newMsgs.push({
|
||
id: data.assistant_message.id,
|
||
conversationId: selectedConversation?.id || '',
|
||
role: 'assistant',
|
||
content: data.assistant_message.content,
|
||
tokenUsed: data.assistant_message.token_used || 0,
|
||
createdAt: data.assistant_message.created_at || new Date().toISOString(),
|
||
used_skills: data.assistant_message.used_skills || [],
|
||
used_mcp: data.assistant_message.used_mcp || [],
|
||
process_steps: data.assistant_message.process_steps || [],
|
||
});
|
||
}
|
||
return [...filtered, ...newMsgs];
|
||
});
|
||
|
||
// 标记本轮新产生的助手消息,触发"思考过程"首屏展开
|
||
if (data.assistant_message) {
|
||
setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id));
|
||
}
|
||
|
||
// 媒体异步生成:启动轮询,待后端回写后自动刷新
|
||
if (data.assistant_message?.generating) {
|
||
startMediaPolling(selectedConversation!.id, data.assistant_message.id);
|
||
}
|
||
|
||
// Refresh conversations to update lastMessage
|
||
loadConversations();
|
||
// 发送后清空本次附件(选择状态仍保留)
|
||
setAttachments([]);
|
||
|
||
// GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
|
||
if (selectedConversation?.id && data.assistant_message) {
|
||
const recentMsgs = [
|
||
...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []),
|
||
{ role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 },
|
||
];
|
||
evolutionAPI.submitTrajectory({
|
||
conversation_id: selectedConversation.id,
|
||
messages: recentMsgs,
|
||
}).catch(() => {});
|
||
}
|
||
};
|
||
|
||
// SSE 流式发送(Phase 0)——实时增量渲染 AI 回复文本与思考链(reasoning)
|
||
const streamSend = async (
|
||
convId: string,
|
||
payload: any,
|
||
tempUserMsg: Message,
|
||
assistantId: string,
|
||
): Promise<void> => {
|
||
const token = localStorage.getItem('aiflow_token');
|
||
const controller = new AbortController();
|
||
abortRef.current = controller;
|
||
const resp = await fetch(`/app/api/chat/conversations/${convId}/messages/stream`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
body: JSON.stringify(payload),
|
||
signal: controller.signal,
|
||
});
|
||
if (!resp.ok || !resp.body) {
|
||
throw new Error(`SSE 连接失败 (${resp.status})`);
|
||
}
|
||
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let accReasoning = '';
|
||
let gotDone = false;
|
||
let pendingDelta = '';
|
||
let reasoningDirty = false;
|
||
let rafId: number | null = null;
|
||
|
||
// 缺陷4:用 requestAnimationFrame 合并同一帧内的多个 delta/reasoning,避免逐 token 重渲染抖动
|
||
const flushBuffered = () => {
|
||
rafId = null;
|
||
if (!pendingDelta && !reasoningDirty) return;
|
||
const piece = pendingDelta;
|
||
pendingDelta = '';
|
||
const r = reasoningDirty ? accReasoning : null;
|
||
reasoningDirty = false;
|
||
setMessages(prev => prev.map(m => {
|
||
if (m.id !== assistantId) return m;
|
||
const patch: any = {};
|
||
if (piece) patch.content = (m.content || '') + piece;
|
||
if (r !== null) patch.reasoning = r;
|
||
return { ...m, ...patch };
|
||
}));
|
||
};
|
||
const scheduleFlush = () => {
|
||
if (rafId == null) rafId = requestAnimationFrame(flushBuffered);
|
||
};
|
||
const finalizeFlush = () => {
|
||
if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; }
|
||
flushBuffered();
|
||
};
|
||
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
let sep: number;
|
||
while ((sep = buffer.indexOf('\n\n')) >= 0) {
|
||
const raw = buffer.slice(0, sep);
|
||
buffer = buffer.slice(sep + 2);
|
||
const dataLine = raw.split('\n').find(l => l.startsWith('data: '));
|
||
if (!dataLine) continue;
|
||
let evt: any;
|
||
try { evt = JSON.parse(dataLine.slice(6)); } catch { continue; }
|
||
if (evt.type === 'delta') {
|
||
pendingDelta += evt.content || '';
|
||
scheduleFlush();
|
||
} else if (evt.type === 'reasoning') {
|
||
accReasoning += evt.content || '';
|
||
reasoningDirty = true;
|
||
scheduleFlush();
|
||
} else if (evt.type === 'step') {
|
||
// 缺陷2:实时增量过程步骤,前端随事件收拢式渲染
|
||
const step = evt.step;
|
||
const idx = typeof evt.index === 'number' ? evt.index : -1;
|
||
setMessages(prev => prev.map(m => {
|
||
if (m.id !== assistantId) return m;
|
||
const steps: any[] = (m as any).process_steps || [];
|
||
const next = steps.slice();
|
||
if (idx >= 0 && idx < next.length) next[idx] = step;
|
||
else next.push(step);
|
||
return { ...m, process_steps: next };
|
||
}));
|
||
} else if (evt.type === 'done') {
|
||
gotDone = true;
|
||
finalizeFlush();
|
||
const finalId = evt.message_id || assistantId;
|
||
setMessages(prev => prev.map(m => {
|
||
if (m.id !== assistantId) return m;
|
||
return {
|
||
...m,
|
||
id: finalId,
|
||
content: evt.content ?? m.content,
|
||
process_steps: evt.process_steps || [],
|
||
used_skills: evt.used_skills || [],
|
||
used_mcp: evt.used_mcp || [],
|
||
generating: !!evt.generating,
|
||
};
|
||
}));
|
||
setStreamingId(null);
|
||
abortRef.current = null;
|
||
if (evt.generating) startMediaPolling(convId, finalId);
|
||
setFreshMsgIds(prev => new Set(prev).add(finalId));
|
||
loadConversations();
|
||
setAttachments([]);
|
||
if (convId && evt.content) {
|
||
evolutionAPI.submitTrajectory({
|
||
conversation_id: convId,
|
||
messages: [
|
||
{ role: 'user', content: tempUserMsg.content, tokens: 0 },
|
||
{ role: 'assistant', content: evt.content, tokens: 0 },
|
||
],
|
||
}).catch(() => {});
|
||
}
|
||
} else if (evt.type === 'error') {
|
||
// 缺陷4:容错——保留已生成内容,不强删,标记中断后优雅收尾
|
||
finalizeFlush();
|
||
setMessages(prev => prev.map(m => {
|
||
if (m.id !== assistantId) return m;
|
||
const note = '\n\n_(生成中断:' + (evt.message || '未知错误') + ')_';
|
||
return { ...m, content: (m.content || '') + note };
|
||
}));
|
||
setStreamingId(null);
|
||
abortRef.current = null;
|
||
setFreshMsgIds(prev => new Set(prev).add(assistantId));
|
||
loadConversations();
|
||
gotDone = true;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
if (!gotDone) {
|
||
// 流结束但未收到 done:已累积的 delta 仍可展示,但视为异常兜底
|
||
finalizeFlush();
|
||
abortRef.current = null;
|
||
setStreamingId(null);
|
||
throw new Error('流式响应未正常结束');
|
||
}
|
||
};
|
||
|
||
// Handle send message - CALLS REAL API(Phase 0:默认 SSE 流式,失败回退非流式)
|
||
// 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
|
||
const sendText = async (content: string) => {
|
||
if (!content.trim() || sending || !selectedConversation) return;
|
||
setSending(true);
|
||
setSendingTimeout(false);
|
||
// 60s 超时警告
|
||
if (sendingTimerRef.current) clearTimeout(sendingTimerRef.current);
|
||
sendingTimerRef.current = setTimeout(() => {
|
||
setSendingTimeout(true);
|
||
}, 60000);
|
||
|
||
// Add user message optimistically
|
||
const tempUserMsg: Message = {
|
||
id: `temp_${Date.now()}`,
|
||
conversationId: selectedConversation?.id || '',
|
||
role: 'user',
|
||
content,
|
||
tokenUsed: 0,
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
setMessages(prev => [...prev, tempUserMsg]);
|
||
|
||
const payload = {
|
||
content,
|
||
model_id: selectedModelId || undefined,
|
||
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
|
||
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
|
||
knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
|
||
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,
|
||
};
|
||
|
||
// 先放一个空的助手消息占位,用于 SSE 流式增量渲染
|
||
const assistantId = `temp_assistant_${Date.now()}`;
|
||
setMessages(prev => [...prev, {
|
||
id: assistantId,
|
||
conversationId: selectedConversation!.id,
|
||
role: 'assistant',
|
||
content: '',
|
||
tokenUsed: 0,
|
||
createdAt: new Date().toISOString(),
|
||
reasoning: '',
|
||
} as Message]);
|
||
setStreamingId(assistantId);
|
||
|
||
try {
|
||
await streamSend(selectedConversation!.id, payload, tempUserMsg, assistantId);
|
||
} catch (err: any) {
|
||
// 移除流式占位,回退非流式重新发送
|
||
setMessages(prev => prev.filter(m => m.id !== assistantId && m.id !== tempUserMsg.id));
|
||
setStreamingId(null);
|
||
try {
|
||
await sendLegacy(payload, tempUserMsg);
|
||
} catch (e2: any) {
|
||
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
|
||
alert('发送失败: ' + (e2?.message || e2?.detail || '未知错误'));
|
||
}
|
||
} finally {
|
||
if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; }
|
||
setSending(false);
|
||
setSendingTimeout(false);
|
||
inputRef.current?.focus();
|
||
}
|
||
};
|
||
|
||
// 文件上传:读取文本类文件内容作为附件上下文
|
||
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) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="chat-page">
|
||
{/* Left Sidebar - Conversation List */}
|
||
<aside className="chat-sidebar">
|
||
<div className="chat-sidebar-header">
|
||
<button className="new-chat-btn" onClick={handleNewConversation}>
|
||
<IconPlus />
|
||
<span>新建对话</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="chat-sidebar-search">
|
||
<span className="search-icon-wrap">
|
||
<IconSearch />
|
||
</span>
|
||
<input
|
||
type="text"
|
||
className="search-input"
|
||
placeholder="搜索对话..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="chat-sidebar-list">
|
||
{loadingConversations ? (
|
||
<div className="conv-empty">加载中...</div>
|
||
) : (
|
||
filteredConversations.map(conv => (
|
||
<div
|
||
key={conv.id}
|
||
className={`conversation-item ${conv.id === selectedConversation?.id ? 'active' : ''} ${conv.pinned ? 'pinned' : ''}`}
|
||
onClick={() => navigate(`/chat/${conv.id}`)}
|
||
>
|
||
<div className="conv-top">
|
||
{conv.pinned && <span className="conv-pin-icon"><IconPin /></span>}
|
||
<span className="conv-title truncate">{conv.title}</span>
|
||
<Dropdown
|
||
trigger={<span className="conv-more-btn"><IconMore /></span>}
|
||
align="right"
|
||
>
|
||
<button className="dropdown-item" onClick={(e) => handlePin(conv.id, e)}>
|
||
<IconPin /> {conv.pinned ? '取消置顶' : '置顶'}
|
||
</button>
|
||
<button className="dropdown-item" onClick={(e) => handleKnowledgeGraph(conv.id, e)}>
|
||
<IconGraph /> 转知识图谱{conv.knowledgeGraphCount ? ` (${conv.knowledgeGraphCount})` : ''}
|
||
</button>
|
||
<div className="dropdown-divider" />
|
||
<button className="dropdown-item" onClick={(e) => handleRename(conv.id, e)}>
|
||
<IconEdit /> 重命名
|
||
</button>
|
||
<button className="dropdown-item" onClick={(e) => handleArchive(conv.id, e)}>
|
||
<IconArchive /> {conv.status === 'archived' ? '取消归档' : '归档'}
|
||
</button>
|
||
<div className="dropdown-divider" />
|
||
<button className="dropdown-item danger" onClick={(e) => handleDelete(conv.id, e)}>
|
||
<IconTrash /> 删除
|
||
</button>
|
||
</Dropdown>
|
||
</div>
|
||
<div className="conv-preview truncate">
|
||
{conv.lastMessage || '暂无消息'}
|
||
</div>
|
||
<div className="conv-meta">
|
||
<span className="conv-time">{formatTime(conv.updatedAt)}</span>
|
||
{conv.status === 'archived' && <span className="conv-badge">已归档</span>}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
|
||
{!loadingConversations && filteredConversations.length === 0 && (
|
||
<div className="conv-empty">
|
||
{searchQuery ? '未找到匹配的对话' : '暂无对话'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
|
||
{/* Right Main Chat Area */}
|
||
<div className="chat-main">
|
||
{selectedConversation ? (
|
||
<>
|
||
{/* Chat Header */}
|
||
<div className="chat-header">
|
||
<div className="chat-header-left">
|
||
<Dropdown
|
||
trigger={
|
||
<div className="model-selector">
|
||
<IconModel />
|
||
<span className="model-name">{selectedModel.name}</span>
|
||
<span className="model-badge">{(selectedModel.status === 'free' || selectedModel.price_per_million_tokens === 0) ? '免费' : '付费'}</span>
|
||
<IconChevronDown />
|
||
</div>
|
||
}
|
||
>
|
||
<button
|
||
key="auto"
|
||
className={`dropdown-item ${selectedModelId === 'auto' ? 'selected' : ''}`}
|
||
onClick={() => setSelectedModelId('auto')}
|
||
>
|
||
<div className="model-dropdown-item">
|
||
<span>🤖 Auto 自动</span>
|
||
<span className="model-provider-tag">按能力智能匹配</span>
|
||
<span className="model-cap-badge">智能</span>
|
||
</div>
|
||
</button>
|
||
{chatModels.filter(m => m.is_enabled !== false).map(model => (
|
||
<button
|
||
key={model.id}
|
||
className={`dropdown-item ${model.id === selectedModelId ? 'selected' : ''}`}
|
||
onClick={() => setSelectedModelId(model.id)}
|
||
>
|
||
<div className="model-dropdown-item">
|
||
<span>{model.name}</span>
|
||
<span className="model-provider-tag">{model.provider_name || model.providerName || ''}</span>
|
||
{(() => {
|
||
const caps = model.capabilities || [];
|
||
const label = caps.includes('image') && caps.includes('video') ? '图像/视频'
|
||
: caps.includes('image') ? '图像'
|
||
: caps.includes('video') ? '视频' : '';
|
||
return label ? <span className="model-cap-badge">{label}</span> : null;
|
||
})()}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</Dropdown>
|
||
<span className="chat-header-divider" />
|
||
<span className="chat-title">{selectedConversation.title}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Messages Area */}
|
||
<div className="chat-messages">
|
||
{loadingMessages ? (
|
||
<div className="chat-empty-state">
|
||
<IconEmpty />
|
||
<p className="empty-desc">加载消息中...</p>
|
||
</div>
|
||
) : messages.length === 0 ? (
|
||
<div className="chat-empty-state">
|
||
<IconEmpty />
|
||
<p className="empty-title">开始一段新对话</p>
|
||
<p className="empty-desc">
|
||
向 {selectedModel.name} 发送消息,开始对话
|
||
</p>
|
||
</div>
|
||
) : (
|
||
messages.map(msg => {
|
||
// Real used_skills from API response
|
||
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
|
||
const usedMcp: string[] = (msg.role === 'assistant' && msg.used_mcp) ? msg.used_mcp : [];
|
||
const steps: any[] = (msg as any).process_steps || [];
|
||
// 模糊创作意图确认卡片
|
||
const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
|
||
return (
|
||
<div key={msg.id} className={`message-row ${msg.role}`}>
|
||
{msg.role === 'assistant' && steps.length > 0 && (
|
||
<AiProcessBlock steps={steps} autoExpand={freshMsgIds.has(msg.id)} />
|
||
)}
|
||
{msg.role === 'assistant' && (msg as any).reasoning && (
|
||
<ReasoningBlock text={(msg as any).reasoning} fresh={freshMsgIds.has(msg.id)} />
|
||
)}
|
||
<div className={`message-bubble ${msg.role}`}>
|
||
{clarify ? (
|
||
<ClarifyCard
|
||
data={clarify}
|
||
onChoose={(opt: any) => sendText(`${opt.medium}:${clarify.prompt}`)}
|
||
/>
|
||
) : msg.role === 'assistant' && isAiError(msg.content) ? (
|
||
<div className="ai-error-bubble">
|
||
<div className="ai-error-icon">⚠️</div>
|
||
<div className="ai-error-text">
|
||
<MarkdownContent content={msg.content} />
|
||
</div>
|
||
<button className="ai-error-retry" onClick={() => {
|
||
setMessages(prev => prev.filter(m => m.id !== msg.id));
|
||
const msgsAfterFilter = messages.filter(m => m.id !== msg.id);
|
||
const lastUser = [...msgsAfterFilter].reverse().find(m => m.role === 'user');
|
||
if (lastUser) sendText(lastUser.content);
|
||
}}>🔄 重试</button>
|
||
</div>
|
||
) : msg.role === 'assistant' ? (
|
||
msg.content ? <MarkdownContent content={msg.content} /> : (streamingId === msg.id ? <span className="streaming-hint"><IconSpinner /> 生成中…</span> : null)
|
||
) : (
|
||
<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">
|
||
{usedSkills.slice(0, 3).map(skillName => (
|
||
<span
|
||
key={skillName}
|
||
className="evo-skill-tag"
|
||
onClick={() => { window.location.href = '/evolution?skill=' + encodeURIComponent(skillName); }}
|
||
title={`使用了专属技能:${skillName}`}
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||
</svg>
|
||
{skillName}
|
||
</span>
|
||
))}
|
||
{usedSkills.length > 3 && (
|
||
<span className="evo-skill-tag evo-skill-tag-more">+{usedSkills.length - 3}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{msg.role === 'assistant' && usedMcp.length > 0 && (
|
||
<div className="evo-skill-tags-row">
|
||
{usedMcp.slice(0, 3).map(mcpName => (
|
||
<span
|
||
key={mcpName}
|
||
className="evo-skill-tag evo-mcp-tag"
|
||
title={`使用了 MCP 应用:${mcpName}`}
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||
</svg>
|
||
{mcpName}
|
||
</span>
|
||
))}
|
||
{usedMcp.length > 3 && (
|
||
<span className="evo-skill-tag evo-skill-tag-more">+{usedMcp.length - 3}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className={`message-meta ${msg.role}`}>
|
||
<span className="message-time">{formatTime(msg.createdAt)}</span>
|
||
<span className="message-tokens">{msg.tokenUsed} tokens</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
{sending && !streamingId && (
|
||
<div className="message-row assistant">
|
||
<div className={`message-bubble assistant${sendingTimeout ? ' ai-sending-timeout' : ''}`} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<IconSpinner />
|
||
<span>{sendingTimeout ? '⏳ 响应时间较长,AI 仍在处理中...' : '思考中...'}</span>
|
||
{sendingTimeout && (
|
||
<button className="ai-cancel-btn" onClick={() => {
|
||
setSending(false);
|
||
setSendingTimeout(false);
|
||
if (sendingTimerRef.current) clearTimeout(sendingTimerRef.current);
|
||
}} title="取消等待" style={{ marginLeft: 12, padding: '2px 8px', fontSize: 12, cursor: 'pointer', background: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', borderRadius: 4, color: 'var(--color-text-secondary)' }}>
|
||
取消
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{/* Input Area */}
|
||
<div className="chat-input-area">
|
||
<div className="chat-input-container">
|
||
<div className="input-attachments">
|
||
<Dropdown
|
||
dropup
|
||
closeOnClick={false}
|
||
trigger={
|
||
<button className="attach-btn" title="知识库">
|
||
<IconKnowledge />
|
||
<span>{selectedKbIds.length > 0 ? `知识库 (${selectedKbIds.length})` : '知识库'}</span>
|
||
</button>
|
||
}
|
||
>
|
||
<div className="attach-dropdown-title">选择知识库(多选)</div>
|
||
{knowledgeBases.map((kb: any) => (
|
||
<button key={kb.id} className={`dropdown-item ${selectedKbIds.includes(kb.id) ? 'selected' : ''}`}
|
||
onClick={() => { setSelectedKbIds(prev => prev.includes(kb.id) ? prev.filter(id => id !== kb.id) : [...prev, kb.id]); }}>
|
||
<IconKnowledge />
|
||
<span>{kb.name}</span>
|
||
<span className="attach-count">{kb.documentCount ?? kb.document_count ?? 0} 文档</span>
|
||
{selectedKbIds.includes(kb.id) && <span style={{marginLeft:'auto',color:'var(--color-accent)'}}>✓</span>}
|
||
</button>
|
||
))}
|
||
{knowledgeBases.length > 0 && selectedKbIds.length > 0 && (
|
||
<div className="dropdown-divider" />
|
||
)}
|
||
{selectedKbIds.length > 0 && (
|
||
<button className="dropdown-item" style={{color:'var(--color-text-tertiary)'}} onClick={() => setSelectedKbIds([])}>
|
||
清除选择 ({selectedKbIds.length})
|
||
</button>
|
||
)}
|
||
{knowledgeBases.length === 0 && (
|
||
<div className="dropdown-item disabled">暂无可用知识库</div>
|
||
)}
|
||
</Dropdown>
|
||
|
||
<Dropdown
|
||
dropup
|
||
closeOnClick={false}
|
||
trigger={
|
||
<button className="attach-btn" title="Agent">
|
||
<IconAgent />
|
||
<span>{selectedAgentIds.length > 0 ? `Agent (${selectedAgentIds.length})` : 'Agent'}</span>
|
||
</button>
|
||
}
|
||
>
|
||
<div className="attach-dropdown-title">选择 Agent(多选)</div>
|
||
{agents.map((agent: any) => (
|
||
<button key={agent.id} className={`dropdown-item ${selectedAgentIds.includes(agent.id) ? 'selected' : ''}`}
|
||
onClick={() => { setSelectedAgentIds(prev => prev.includes(agent.id) ? prev.filter(id => id !== agent.id) : [...prev, agent.id]); }}>
|
||
<IconAgent />
|
||
<span>{agent.name}</span>
|
||
{selectedAgentIds.includes(agent.id) && <span style={{marginLeft:'auto',color:'var(--color-accent)'}}>✓</span>}
|
||
</button>
|
||
))}
|
||
{agents.length > 0 && selectedAgentIds.length > 0 && (
|
||
<div className="dropdown-divider" />
|
||
)}
|
||
{selectedAgentIds.length > 0 && (
|
||
<button className="dropdown-item" style={{color:'var(--color-text-tertiary)'}} onClick={() => setSelectedAgentIds([])}>
|
||
清除选择 ({selectedAgentIds.length})
|
||
</button>
|
||
)}
|
||
{agents.length === 0 && (
|
||
<div className="dropdown-item disabled">暂无可用 Agent</div>
|
||
)}
|
||
</Dropdown>
|
||
|
||
<Dropdown
|
||
dropup
|
||
closeOnClick={false}
|
||
trigger={
|
||
<button className="attach-btn" title="技能">
|
||
<IconSkill />
|
||
<span>{selectedSkillIds.length > 0 ? `技能 (${selectedSkillIds.length})` : '技能'}</span>
|
||
</button>
|
||
}
|
||
>
|
||
<div className="attach-dropdown-title">选择技能(多选)</div>
|
||
{skills.map((skill: any) => (
|
||
<button key={skill.id} className={`dropdown-item ${selectedSkillIds.includes(skill.id) ? 'selected' : ''}`}
|
||
onClick={() => { setSelectedSkillIds(prev => prev.includes(skill.id) ? prev.filter(id => id !== skill.id) : [...prev, skill.id]); }}>
|
||
<IconSkill />
|
||
<span>{skill.name}</span>
|
||
{selectedSkillIds.includes(skill.id) && <span style={{marginLeft:'auto',color:'var(--color-accent)'}}>✓</span>}
|
||
</button>
|
||
))}
|
||
{skills.length > 0 && selectedSkillIds.length > 0 && (
|
||
<div className="dropdown-divider" />
|
||
)}
|
||
{selectedSkillIds.length > 0 && (
|
||
<button className="dropdown-item" style={{color:'var(--color-text-tertiary)'}} onClick={() => setSelectedSkillIds([])}>
|
||
清除选择 ({selectedSkillIds.length})
|
||
</button>
|
||
)}
|
||
{skills.length === 0 && (
|
||
<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} 发送消息...(可点击 🎤 语音输入)`}
|
||
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={streamingId ? stopStreaming : handleSend}
|
||
disabled={!streamingId && (!inputValue.trim() || sending)}
|
||
title={streamingId ? '停止生成' : '发送 (Enter)'}
|
||
>
|
||
{streamingId ? <IconStop /> : (sending ? <IconSpinner /> : <IconSend />)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="chat-empty-full">
|
||
<IconEmpty />
|
||
<p className="empty-title">选择或创建对话</p>
|
||
<p className="empty-desc">从左侧列表选择一个对话,或点击上方按钮创建新对话</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Knowledge Graph Conversion Progress Modal */}
|
||
{kgModalOpen && (
|
||
<div className="kg-overlay" onClick={() => !kgError && kgStep !== 2 && setKgModalOpen(false)}>
|
||
<div className="kg-modal" onClick={e => e.stopPropagation()}>
|
||
<div className="kg-modal-header">
|
||
<h3>转换知识图谱</h3>
|
||
<button className="kg-modal-close" onClick={() => setKgModalOpen(false)}>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div className="kg-modal-body">
|
||
{kgError ? (
|
||
<div className="kg-error-state">
|
||
<div className="kg-error-icon">
|
||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="var(--color-error)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
|
||
</svg>
|
||
</div>
|
||
<p className="kg-error-text">{kgError}</p>
|
||
<button className="kg-btn kg-btn-secondary" onClick={() => { setKgModalOpen(false); setKgError(null); }}>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
) : kgStep === 3 && kgResult ? (
|
||
<div className="kg-success-state">
|
||
<div className="kg-success-icon">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-success)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
|
||
</svg>
|
||
</div>
|
||
<p className="kg-success-title">知识图谱生成完成</p>
|
||
<div className="kg-result-stats">
|
||
<div className="kg-stat-item">
|
||
<span className="kg-stat-value">{kgResult.nodes}</span>
|
||
<span className="kg-stat-label">节点</span>
|
||
</div>
|
||
<div className="kg-stat-divider" />
|
||
<div className="kg-stat-item">
|
||
<span className="kg-stat-value">{kgResult.edges}</span>
|
||
<span className="kg-stat-label">条边</span>
|
||
</div>
|
||
</div>
|
||
<p className="kg-result-kb">已存入知识库:{kgResult.kbName}</p>
|
||
<div className="kg-actions">
|
||
<button className="kg-btn kg-btn-primary" onClick={() => { setKgModalOpen(false); navigate('/knowledge'); }}>
|
||
前往知识库查看
|
||
</button>
|
||
<button className="kg-btn kg-btn-secondary" onClick={() => setKgModalOpen(false)}>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="kg-progress-state">
|
||
<div className="kg-steps">
|
||
<div className={`kg-step ${kgStep >= 1 ? 'active' : ''} ${kgStep > 1 ? 'done' : ''}`}>
|
||
<div className="kg-step-indicator">
|
||
{kgStep > 1 ? (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
) : kgStep === 1 ? (
|
||
<div className="kg-spinner" />
|
||
) : (
|
||
<span>1</span>
|
||
)}
|
||
</div>
|
||
<span className="kg-step-label">提取对话内容</span>
|
||
</div>
|
||
<div className="kg-step-connector">
|
||
<div className={`kg-connector-line ${kgStep > 1 ? 'done' : ''}`}>
|
||
{kgStep === 2 && <div className="kg-connector-progress" />}
|
||
</div>
|
||
</div>
|
||
<div className={`kg-step ${kgStep >= 2 ? 'active' : ''} ${kgStep > 2 ? 'done' : ''}`}>
|
||
<div className="kg-step-indicator">
|
||
{kgStep > 2 ? (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
) : kgStep === 2 ? (
|
||
<div className="kg-spinner" />
|
||
) : (
|
||
<span>2</span>
|
||
)}
|
||
</div>
|
||
<span className="kg-step-label">构建知识图谱</span>
|
||
</div>
|
||
<div className="kg-step-connector">
|
||
<div className={`kg-connector-line ${kgStep > 2 ? 'done' : ''}`} />
|
||
</div>
|
||
<div className={`kg-step ${kgStep >= 3 ? 'active' : ''} ${kgStep > 3 ? 'done' : ''}`}>
|
||
<div className="kg-step-indicator">
|
||
<span>3</span>
|
||
</div>
|
||
<span className="kg-step-label">完成</span>
|
||
</div>
|
||
</div>
|
||
<p className="kg-progress-tip">
|
||
{kgStep === 1 ? '正在提取对话内容...' : kgStep === 2 ? '正在构建知识图谱...' : '处理完成'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ChatPage;
|