新增渲染能力: 1. highlight.js 代码语法高亮 + 复制按钮 (hover显示) 2. KaTeX 数学公式渲染 ($$...$$块级 + $...$行内) 3. 删除线 ~~text~~ → <del> 4. 裸URL自动转可点击链接 依赖: npm install highlight.js katex mermaid
1696 lines
75 KiB
TypeScript
1696 lines
75 KiB
TypeScript
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
|
||
import 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(/</g, '<').replace(/>/g, '>').replace(/&/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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ===== 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;
|