Files
qihuaiflow/frontend/src/pages/front/ChatPage.tsx
T
QihuAI Design dc577eb0f0 feat(chat): 增强AI回复渲染 — 代码高亮/KaTeX数学/删除线/URL自动链接
新增渲染能力:
1. highlight.js 代码语法高亮 + 复制按钮 (hover显示)
2. KaTeX 数学公式渲染 ($$...$$块级 + $...$行内)
3. 删除线 ~~text~~ → <del>
4. 裸URL自动转可点击链接

依赖: npm install highlight.js katex mermaid
2026-07-16 16:25:41 +08:00

1696 lines
75 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 type { Conversation, Message } from '@/types';
import './ChatPage.css';
// ===== 增强渲染依赖 =====
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import katex from 'katex';
import 'katex/dist/katex.min.css';
// ===== Enhanced Markdown Renderer =====
// 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项
interface MediaInfo { type: 'img' | 'video'; src: string; alt?: string; }
// 全屏灯箱预览
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 }) => {
const nodes = useMemo(() => {
let text = content;
// 1. Extract code blocks first (protect from other replacements)
const codeBlocks: string[] = [];
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => {
const idx = codeBlocks.length;
codeBlocks.push('<pre class="md-code-block"><code class="md-code">' + escapeHtml(code.trim()) + '</code></pre>');
return '\x00CODEBLOCK' + idx + '\x00';
});
// 2. Extract inline code
const inlineCodes: string[] = [];
text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
const idx = inlineCodes.length;
inlineCodes.push('<code class="md-inline-code">' + escapeHtml(code) + '</code>');
return '\x00INLINE' + idx + '\x00';
});
// 2.5 Extract media blocks (<img>/<video>) → 结构化,后续渲染为 React 组件
const mediaBlocks: MediaInfo[] = [];
text = text.replace(/<(img|video)([^>]*?)\/?>/gi, (_m: string, tag: string, attrs: string) => {
const type = tag.toLowerCase() === 'img' ? 'img' : 'video';
const srcM = attrs.match(/src=["']([^"']+)["']/i);
const altM = attrs.match(/alt=["']([^"']*)["']/i);
mediaBlocks.push({ type, src: srcM ? srcM[1] : '', alt: altM ? altM[1] : '' });
return '\x00MEDIA' + (mediaBlocks.length - 1) + '\x00';
});
// 2.7 Extract Markdown tables: | h1 | h2 |\n|---|---| ... |
// 把整段表格替换为占位符,表格内容(已转义)放入 tableBlocks 数组
const tableBlocks: string[] = [];
text = text.replace(
/((?:^\|.+\|\r?\n)+^\|[\s\-:|]+\|\r?\n(?:^\|.+\|\r?\n?)+)/gm,
(_m: string, block: string) => {
const lines = block.trim().split(/\r?\n/).filter((l: string) => l.trim());
if (lines.length < 2) return block;
const headerCells = lines[0].split('|').slice(1, -1).map((c: string) => c.trim());
const alignCells = lines[1].split('|').slice(1, -1).map((c: string) => {
const t = c.trim();
const left = t.startsWith(':');
const right = t.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
if (left) return 'left';
return 'left';
});
const bodyRows = lines.slice(2).map((row: string) =>
row.split('|').slice(1, -1).map((c: string) => c.trim())
);
const thead = headerCells.map((c: string, i: number) =>
`<th style="text-align:${alignCells[i] || 'left'}">${c}</th>`
).join('');
const tbody = bodyRows.map((r: string[]) =>
'<tr>' + r.map((c: string, i: number) =>
`<td style="text-align:${alignCells[i] || 'left'}">${c}</td>`
).join('') + '</tr>'
).join('');
const html = `<table class="md-table"><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`;
const idx = tableBlocks.length;
tableBlocks.push(html);
return '\x00TABLE' + idx + '\x00';
}
);
// 2.8 LaTeX 数学公式:$$...$$(块级)和 $...$(行内)
// 提取为占位符,渲染后用 KaTeX 处理
const mathBlocks: string[] = [];
text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_m: string, formula: string) => {
try {
const rendered = katex.renderToString(formula.trim(), { displayMode: true, throwOnError: false });
const idx = mathBlocks.length;
mathBlocks.push(`<div class="md-math-block">${rendered}</div>`);
return '\x00MATHBLOCK' + idx + '\x00';
} catch { return _m; }
});
text = text.replace(/\$([^\s$][^$]*[^\s$])\$/g, (_m: string, formula: string) => {
try {
const rendered = katex.renderToString(formula.trim(), { displayMode: false, throwOnError: false });
const idx = mathBlocks.length;
mathBlocks.push(`<span class="md-math-inline">${rendered}</span>`);
return '\x00MATHBLOCK' + idx + '\x00';
} catch { return _m; }
});
// 2.9 Strikethrough: ~~text~~ → <del>text</del>
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 2.10 Auto-link bare URLs: http(s)://... → <a>
text = text.replace(/(?<![="'>])https?:\/\/[^\s<>[\]()]+/g, (url: string) =>
`<a class="md-link" href="${url}" target="_blank" rel="noopener">${url}</a>`
);
// 3. Headers
text = text.replace(/^#### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
text = text.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
text = text.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>');
text = text.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>');
// 4. Horizontal rule
text = text.replace(/^---+$/gm, '<hr class="md-hr">');
// 5. Blockquotes
text = text.replace(/^> (.+)$/gm, '<blockquote class="md-blockquote">$1</blockquote>');
// 6. Links [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a class="md-link" href="$2" target="_blank" rel="noopener">$1</a>');
// 7. Bold and italic
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/(?<!\w)\*(?!\*)(.+?)(?<!\*)\*(?!\w)/g, '<em>$1</em>');
// 8. Lists
text = text.replace(/^\s*[-*] (.+)$/gm, '<li class="md-li">$1</li>');
text = text.replace(/^\s*\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
// Wrap consecutive <li> in <ul>
text = text.replace(/((?:<li class="md-li">[\s\S]*?<\/li>\n?)+)/g, '<ul class="md-ul">$1</ul>');
// 9. Convert double newlines to paragraph breaks, single to <br/>
text = text.replace(/\n\n/g, '</p><p class="md-p">');
text = text.replace(/\n/g, '<br/>');
// 10. Restore code blocks and inline codes
text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
text = text.replace(/\x00TABLE(\d+)\x00/g, (_m: string, idx: string) => tableBlocks[parseInt(idx)] || '');
text = text.replace(/\x00MATHBLOCK(\d+)\x00/g, (_m: string, idx: string) => mathBlocks[parseInt(idx)] || '');
// 10.5 Apply syntax highlighting to code blocks + add copy button
text = text.replace(
/<pre class="md-code-block"><code class="md-code">([\s\S]*?)<\/code><\/pre>/g,
(_m: string, code: string) => {
const decoded = code.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
let highlighted: string;
try { highlighted = hljs.highlightAuto(decoded).value; } catch { highlighted = code; }
return `<div class="md-code-wrapper">
<button class="md-code-copy" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('code').textContent);this.textContent='已复制';setTimeout(()=>this.textContent='复制',2000)">复制</button>
<pre class="md-code-block"><code class="md-code hljs">${highlighted}</code></pre>
</div>`;
}
);
// 11. Wrap in paragraph and clean up
text = '<p class="md-p">' + text + '</p>';
text = text.replace(/<p class="md-p"><\/p>/g, '');
text = text.replace(/<p class="md-p">(<h[234][\s\S]*?<\/h[234]>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<pre[\s\S]*?<\/pre>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<ul[\s\S]*?<\/ul>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<hr[\s\S]*?)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<blockquote[\s\S]*?<\/blockquote>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<table[\s\S]*?<\/table>)<\/p>/g, '$1');
// 12. 拆分为文本片段与媒体组件
const parts = text.split(/(\x00MEDIA\d+\x00)/g);
return parts.map((part: string, i: number) => {
const m = part.match(/^\x00MEDIA(\d+)\x00$/);
if (m) {
const info = mediaBlocks[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 }} />;
});
}, [content]);
return <div className="markdown-body">{nodes}</div>;
};
// 解析后端返回的「模糊创作意图」确认指令:<<CLARIFY>>{json}<<CLARIFY>>
function parseClarify(content: string): { prompt: string; options: any[] } | null {
if (!content || typeof content !== 'string') return null;
const m = content.match(/^<<CLARIFY>>([\s\S]+)<<CLARIFY>>$/);
if (!m) return null;
try {
const obj = JSON.parse(m[1]);
if (obj && Array.isArray(obj.options)) return obj;
} catch { /* ignore */ }
return null;
}
// 模糊创作意图确认卡片:让用户选择产出方向(文案/图片/视频/其他)
const ClarifyCard: React.FC<{ data: { prompt: string; options: any[] }; onChoose: (opt: any) => void }> = ({ data, onChoose }) => {
return (
<div className="chat-clarify-card">
<div className="chat-clarify-title">🤔 这个请求的方向还不太明确,您希望以哪种形式呈现?</div>
<div className="chat-clarify-sub">「{data.prompt}」</div>
<div className="chat-clarify-options">
{data.options.map((opt: any) => (
<button key={opt.key} className="chat-clarify-option" onClick={() => onChoose(opt)}>
<span className="chat-clarify-opt-label">{opt.label}</span>
<span className="chat-clarify-opt-desc">{opt.desc}</span>
</button>
))}
</div>
</div>
);
};
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ===== Time Formatting =====
function formatTime(iso: string): string {
const date = new Date(iso);
const now = new Date();
const diff = now.getTime() - date.getTime();
const oneDay = 24 * 60 * 60 * 1000;
if (diff < oneDay) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
} else if (diff < 7 * oneDay) {
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return days[date.getDay()];
} else if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}/${date.getDate()}`;
} else {
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
}
}
// ===== 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>
);
};
// ===== 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 [conversations, setConversations] = useState<Conversation[]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const [loadingConversations, setLoadingConversations] = useState(true);
const [loadingMessages, setLoadingMessages] = useState(false);
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,
} : 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 || '转换失败,请稍后重试');
}
};
// Handle send message - CALLS REAL API
// 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
const sendText = async (content: string) => {
if (!content.trim() || sending || !selectedConversation) return;
setSending(true);
// 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]);
try {
const res = await chatAPI.sendMessage(selectedConversation!.id, {
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,
});
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 || [],
});
}
return [...filtered, ...newMsgs];
});
// 媒体异步生成:启动轮询,待后端回写后自动刷新
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(() => {});
}
} catch (err: any) {
// Remove optimistic message on error
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
alert('发送失败: ' + (err.message || err.detail || '未知错误'));
} finally {
setSending(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 clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
return (
<div key={msg.id} className={`message-row ${msg.role}`}>
<div className={`message-bubble ${msg.role}`}>
{clarify ? (
<ClarifyCard
data={clarify}
onChoose={(opt: any) => sendText(`${opt.medium}:${clarify.prompt}`)}
/>
) : msg.role === 'assistant' ? (
<MarkdownContent content={msg.content} />
) : (
<span>{msg.content}</span>
)}
</div>
{!clarify && msg.role === 'assistant' && msg.content && (
<button
className="msg-tts-btn"
title="朗读回复"
onClick={() => speakText(msg.content)}
>
<IconVolume />
</button>
)}
{/* Skill usage tags */}
{msg.role === 'assistant' && usedSkills.length > 0 && (
<div className="evo-skill-tags-row">
{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>
)}
<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 && (
<div className="message-row assistant">
<div className="message-bubble assistant" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<IconSpinner />
<span>思考中...</span>
</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={handleSend}
disabled={!inputValue.trim() || sending}
title="发送 (Enter)"
>
{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;