Files
qihuaiflow/frontend/src/pages/front/ChatPage.tsx
T
QihuAI Design 256d9f33e6 chore(align): sync Git with running container as authoritative source
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).
2026-07-24 18:25:15 +08:00

1937 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;