Files
WiseGrade/frontend/src/Chat.tsx
T

287 lines
27 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 { useEffect, useRef, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Alert, Button, Card, Col, Descriptions, Input, List, Modal, Row, Select, Space, Tag, Typography, message } from 'antd';
import { api, post, put, subscribeRun } from './api';
const { Title, Text, Paragraph } = Typography;
const kinds: Record<string, string> = { import: '导入学生材料', course_ingest: '课程资料入库', create_batch: '创建评分批次', start_batch: '启动/重试评分批次', review: '教师复核', export: '导出成绩或反馈', index_retry: '重试索引任务' };
const categories: Record<string, string> = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT/PDF' };
const statusColor = (status: string) => status === 'completed' || status === 'ready' ? 'green' : status === 'failed' || status === 'stale' ? 'red' : status === 'skipped' ? 'default' : 'orange';
const reportError = (error: unknown) => message.error(error instanceof Error ? error.message : String(error));
function ImportEditor({ action, conversationId, reload }: { action: any; conversationId: number; reload: () => void }) {
const [draft, setDraft] = useState<any>(action.payload);
useEffect(() => {
const next = structuredClone(action.payload);
for (const project of action.preview.projects || []) {
const selected = next.projects.find((item: any) => item.relative_dir === project.relative_dir);
if (!selected) continue;
selected.files = project.files.map((file: any) => selected.files.find((item: any) => item.relative_path === file.relative_path) || {
relative_path: file.relative_path, category: file.category, sha256: file.sha256, status: 'ignored',
});
}
setDraft(next);
}, [action.id]);
const editProject = (index: number, key: string, value: string) => {
const next = structuredClone(draft); next.projects[index][key] = value; setDraft(next);
};
const editFile = (pi: number, fi: number, key: string, value: string) => {
const next = structuredClone(draft); next.projects[pi].files[fi][key] = value; setDraft(next);
};
return <div className="import-editor">
{(action.preview.projects || []).map((project: any, pi: number) => <Card size="small" className="top-gap" key={project.relative_dir} title={project.relative_dir}>
<Row gutter={8}>
<Col xs={24} md={7}><Text>学号</Text><Input value={draft.projects[pi]?.student_no} onChange={e => editProject(pi, 'student_no', e.target.value)} /></Col>
<Col xs={24} md={7}><Text>姓名</Text><Input value={draft.projects[pi]?.name} onChange={e => editProject(pi, 'name', e.target.value)} /></Col>
<Col xs={24} md={10}><Text>选题</Text><Input value={draft.projects[pi]?.topic} onChange={e => editProject(pi, 'topic', e.target.value)} /></Col>
</Row>
<Text>选题方向</Text><Input value={draft.projects[pi]?.direction || ''} onChange={e => editProject(pi, 'direction', e.target.value)} />
<Space wrap className="top-gap">{project.missing?.map((item: string) => <Tag color="red" key={item}>缺少{categories[item] || item}</Tag>)}{project.warnings?.map((item: string) => <Tag color="orange" key={item}>{item}</Tag>)}</Space>
<List size="small" dataSource={project.files} renderItem={(file: any, fi: number) => <List.Item key={file.relative_path}>
<Space wrap><Text style={{ maxWidth: 300, overflowWrap: 'anywhere' }}>{file.relative_path.split('/').pop()}</Text>
<Tag color={file.status === 'recognized' ? 'green' : 'orange'}>{file.status}</Tag>
<Select style={{ width: 150 }} value={draft.projects[pi]?.files[fi]?.category} options={[{ value: 'unknown', label: '未识别' }, ...Object.entries(categories).map(([value, label]) => ({ value, label }))]} onChange={value => editFile(pi, fi, 'category', value)} />
<Select style={{ width: 95 }} value={draft.projects[pi]?.files[fi]?.status} options={[{ value: 'recognized', label: '导入' }, { value: 'ignored', label: '忽略' }]} onChange={value => editFile(pi, fi, 'status', value)} />
{file.error && <Text type="danger">{file.error}</Text>}</Space>
</List.Item>} />
</Card>)}
<Button className="top-gap" onClick={async () => { try { await post(`/conversations/${conversationId}/actions/${action.id}/edit-import`, { payload: draft }); reload(); message.success('已更新导入预览,请核对新确认卡片'); } catch (error) { reportError(error); } }}>更新预览与文件选择</Button>
</div>;
}
function ActionCard({ action, conversationId, reload }: { action: any; conversationId: number; reload: () => void }) {
const [busy, setBusy] = useState(false);
const operate = async (verb: 'confirm' | 'cancel') => {
setBusy(true);
try { const result = await post(`/conversations/${conversationId}/actions/${action.id}/${verb}`); reload();
message.success(verb === 'confirm' ? `已执行:${JSON.stringify(result)}` : '已取消');
} catch (error) { reportError(error); reload(); } finally { setBusy(false); }
};
const preview = action.preview || {};
const details = Object.entries(preview).filter(([key, value]) => !['projects', 'selection'].includes(key) && !Array.isArray(value) && typeof value !== 'object');
return <Card className="top-gap" size="small" title={<Space>{kinds[action.kind] || action.kind}<Tag color={statusColor(action.status)}>{action.status}</Tag></Space>}
extra={action.status === 'pending' && <Space><Button loading={busy} onClick={() => operate('cancel')}>取消</Button><Button type="primary" loading={busy} disabled={!!preview.requires_edit} onClick={() => operate('confirm')}>确认执行</Button></Space>}>
<Descriptions column={1} size="small" items={details.map(([key, value]) => ({ key, label: key, children: String(value ?? '—') }))} />
{action.kind === 'import' && <><Alert type={preview.requires_edit ? 'warning' : 'info'} message={preview.requires_edit ? '存在识别异常或重复文件;请修正并更新预览' : '确认后将自动解析、OCR 并建立项目索引'} />
<ImportEditor action={action} conversationId={conversationId} reload={reload} /></>}
{action.kind === 'review' && <><Paragraph>批次 #{preview.batch_id} · {preview.student_no} {preview.student_name} · {preview.criterion_name}({preview.criterion_id})</Paragraph>
<Paragraph>原状态:{preview.old_status};模型原始分:{preview.old_model_score ?? '无'};教师原分:{preview.old_teacher_score ?? '无'};新分:{preview.new_score};新评语:{preview.comment};修改原因:{preview.reason}。{preview.evidence_warning && ` 证据/异常:${JSON.stringify(preview.evidence_warning)}`}</Paragraph>
{preview.evidence?.map((item: any, index: number) => <Card size="small" key={index} className="evidence"><Text strong>{item.document} · 第 {item.page} 页</Text><Paragraph>{item.quote}</Paragraph>
<Space wrap>{safeUrl(item.url) && <a href={safeUrl(item.url)} target="_blank" rel="noopener noreferrer">查看页面文字</a>}
{safeUrl(item.image_url) && <a href={safeUrl(item.image_url)} target="_blank" rel="noopener noreferrer">查看图像</a>}</Space></Card>)}</>}
{action.kind === 'create_batch' && <Paragraph>固定项目清单:{preview.project_ids?.join('、') || '无项目'}({preview.project_ids?.length || 0} 个)</Paragraph>}
{action.kind === 'course_ingest' && <><Paragraph>入库资料:{preview.files?.map((item: any) => item[0]).join('、') || '无'}。</Paragraph>{preview.missing_files?.length > 0 && <Alert type="warning" message={`资料文件缺失:${preview.missing_files.join('、')}`} />}</>}
{action.kind === 'start_batch' && <Paragraph>项目范围:{preview.project_ids?.join('、') || '旧批次历史清单未知'};当前状态:{preview.status}</Paragraph>}
{action.kind === 'export' && preview.incomplete_projects?.length > 0 && <Alert type="warning" message={`包含 ${preview.incomplete_projects.length} 个未完成项目;未完成成绩留空`} />}
{action.status === 'completed' && safeUrl(action.result?.download_url) && <a href={safeUrl(action.result.download_url)}>下载导出文件</a>}
{action.status === 'failed' && <Text type="danger">{action.result?.error}</Text>}
</Card>;
}
type Scope = { course_version_id?: number; batch_id?: number; project_id?: number };
const newToken = () => crypto.randomUUID();
const safeUrl = (raw: unknown) => {
if (typeof raw !== 'string' || !raw.trim()) return undefined;
try { const url = new URL(raw, window.location.origin); return ['http:', 'https:'].includes(url.protocol) ? url.href : undefined; }
catch { return undefined; }
};
const isImageUrl = (href: string) => {
const path = new URL(href).pathname;
return /^\/api\/assets\/\d+\/\d+\/?$/.test(path) ||
/^\/api\/knowledge\/documents\/\d+\/pages\/\d+\/image\/?$/.test(path) ||
/\.(?:png|jpe?g|gif|webp|avif|svg)$/i.test(path);
};
const sourceLabel = (href: string) => {
const url = new URL(href);
if (isImageUrl(href)) return '查看图像';
if (/^\/api\/knowledge\/documents\/\d+\/pages\/\d+\/?$/.test(url.pathname)) return '查看页面文字';
if (url.pathname === '/' && url.searchParams.get('page') === 'scores') return '查看评分';
return '打开来源';
};
function Answer({ text }: { text: string }) {
return <div className="chat-markdown"><ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={url => safeUrl(url) || ''}
components={{ a: ({ href, children }) => safeUrl(href) ? <a href={safeUrl(href)} target="_blank" rel="noopener noreferrer">{children}</a> : <span>{children}</span>,
img: ({ alt, src }) => safeUrl(src) ? <a href={safeUrl(src)} target="_blank" rel="noopener noreferrer">{alt || '查看图片'}</a> : <span>{alt}</span> }}>
{text || ''}</ReactMarkdown></div>;
}
function CitationCards({ items }: { items: any[] }) {
return <div className="citation-grid">{items.map((item: any, index) => { const name = String(item.title || item.document || '未命名资料');
const page = item.page ?? item.slide; const place = page == null ? '' : `第 ${page} ${/\.pptx?$/i.test(name) ? '张幻灯片' : '页'}`;
const quote = typeof item.quote === 'string' ? item.quote.trim() : '';
const sourceHref = safeUrl(item.url);
const scoreHref = safeUrl(item.score_url);
const imageHref = safeUrl(item.image_url);
return <div className="citation-card" key={`${name}-${page}-${index}`}><div className="citation-heading"><Text strong>{name}</Text>{place && <Tag>{place}</Tag>}</div>
{quote && (quote.length > 320 ? <details><summary>{quote.slice(0, 240)}… 展开原文</summary><p>{quote}</p></details> : <p>{quote}</p>)}
<Space wrap size="small">{sourceHref && <a href={sourceHref} target="_blank" rel="noopener noreferrer">{sourceLabel(sourceHref)}</a>}
{scoreHref && scoreHref !== sourceHref && <a href={scoreHref} target="_blank" rel="noopener noreferrer">查看评分</a>}
{imageHref && <a href={imageHref} target="_blank" rel="noopener noreferrer">查看图像</a>}</Space></div>;
})}</div>;
}
function Citations({ items }: { items: any[] }) {
const seen = new Set<string>();
const unique = (items || []).filter(item => { if (!item || typeof item !== 'object') return false;
const key = JSON.stringify([item.document_id, item.title || item.document, item.page, item.quote,
item.url, item.score_url, item.image_url]);
if (seen.has(key)) return false; seen.add(key); return true; });
if (!unique.length) return null;
return <section className="chat-citations" aria-label="引用资料"><details className="chat-citation-group"><summary><Text strong>引用 · {unique.length}</Text></summary><CitationCards items={unique} /></details></section>;
}
const argumentLabels: Record<string, string> = {
query: '查询词 (query)', course_version_id: '课程版本 ID', batch_id: '评分批次 ID', project_id: '学生项目 ID',
score_id: '评分 ID', task_id: '任务 ID', rubric_version_id: '量规版本 ID', criterion_id: '评分项 ID',
project_offset: '项目偏移量', criterion_offset: '评分项偏移量', offset: '偏移量', limit: '数量上限',
status: '状态筛选', student_no: '学号', relative_dir: '目录', score: '分数', format: '格式',
};
function visibleArguments(call: any) {
const args: Record<string, unknown> = call.arguments && typeof call.arguments === 'object' && !Array.isArray(call.arguments)
? { ...call.arguments } : {};
let missing = Number(args.omitted_text_fields || args.omitted_fields || 0);
delete args.omitted_text_fields;
delete args.omitted_fields;
// Older score calls kept their effective offsets in the result but dropped them from arguments.
if (call.name === 'get_scores') for (const field of ['project_offset', 'criterion_offset']) {
if (!(field in args) && typeof call.result?.[field] === 'number') {
args[field] = call.result[field];
missing = Math.max(0, missing - 1);
}
}
if (['search_course_material', 'search_project_material'].includes(call.name) && !('query' in args) && missing > 0) {
args.query = '历史调用未保存查询词';
missing -= 1;
}
if (missing > 0) args['历史未保存参数'] = `${missing} 项`;
return Object.fromEntries(Object.entries(args).map(([field, value]) => [argumentLabels[field] || field, value]));
}
function Tools({ calls }: { calls: any[] }) {
if (!calls?.length) return null;
const active = calls.filter(call => call.status === 'running').length;
const failed = calls.filter(call => call.status === 'failed').length;
return <section className="chat-tools" aria-label="工具调用"><details className="chat-tool-group"><summary><Text strong>工具调用 · {calls.length}</Text>
{active > 0 && <Tag color="processing">{active} 项进行中</Tag>}{failed > 0 && <Tag color="red">{failed} 项失败</Tag>}</summary>
<div className="chat-tool-list">{calls.map((call, index) => <details key={call.id || index} className="chat-tool"><summary><Space wrap size="small"><Text code>{call.name || '未知工具'}</Text>
<Tag color={statusColor(call.status || 'completed')}>{call.status === 'running' ? '进行中' : call.status === 'failed' ? '失败' : call.status === 'skipped' ? '已跳过' : '成功'}</Tag>
<Text type="secondary">{call.status === 'running' ? '正在执行' : call.error ? String(call.error).slice(0, 80) : call.summary || (typeof call.result === 'string' ? call.result : '已完成')}</Text></Space></summary>
<div className="tool-detail"><Text strong>调用参数</Text><pre>{JSON.stringify(visibleArguments(call), null, 2)}</pre>
<Text strong>{call.error ? '错误' : '调用结果'}</Text><pre>{call.error || (typeof call.result === 'string' ? call.result : JSON.stringify(call.result ?? '运行中', null, 2))}</pre></div>
</details>)}</div></details></section>;
}
export default function Chat() {
const [conversations, setConversations] = useState<any[]>([]);
const [currentId, setCurrentId] = useState<number>();
const [token, setToken] = useState(newToken);
const [scope, setScope] = useState<Scope>({});
const [details, setDetails] = useState<Record<number, any>>({});
const [composers, setComposers] = useState<Record<string, string>>({});
const [pending, setPending] = useState<Record<string, boolean>>({});
const [scopeSaving, setScopeSaving] = useState(false);
const [deleteId, setDeleteId] = useState<number>();
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState('');
const [versions, setVersions] = useState<any[]>([]);
const [batches, setBatches] = useState<any[]>([]);
const [projects, setProjects] = useState<any[]>([]);
const selection = useRef<string>(`draft-${token}`);
const sending = useRef<Set<string>>(new Set());
const deletedIds = useRef<Set<number>>(new Set());
const detailSequence = useRef<Record<number, number>>({});
const subscriptions = useRef<Map<number, { runId: number; controller: AbortController }>>(new Map());
const detail = currentId ? details[currentId] : undefined;
const key = currentId ? String(currentId) : `draft-${token}`;
const input = composers[key] || '';
const currentScope: Scope = currentId && detail ? {
course_version_id: detail.course_version_id ?? undefined,
batch_id: detail.batch_id ?? undefined,
project_id: detail.project_id ?? undefined,
} : scope;
const running = !!(currentId && (detail?.runs?.[0]?.status === 'running' || conversations.some(c => c.id === currentId && c.running)));
const loadDetail = async (id: number) => {
const sequence = (detailSequence.current[id] || 0) + 1;
detailSequence.current[id] = sequence;
try { const data = await api(`/conversations/${id}`); if (!deletedIds.current.has(id) && detailSequence.current[id] === sequence) setDetails(old => ({ ...old, [id]: data })); return data; }
catch (err) { if (!deletedIds.current.has(id)) throw err; }
};
const refresh = async () => { try { const list = await api('/conversations'); setConversations(list.filter((item: any) => !deletedIds.current.has(item.id)));
const selected = Number(selection.current); if (selected && !deletedIds.current.has(selected)) await loadDetail(selected); } catch (err) { reportError(err); } };
useEffect(() => { localStorage.removeItem('wisegrade_conversation_id'); refresh();
api('/courses').then(async (courses: any[]) => setVersions((await Promise.all(courses.map(course => api(`/courses/${course.id}/versions`)))).flat())).catch(reportError);
api('/scoring-batches').then(setBatches).catch(reportError); api('/projects').then(setProjects).catch(reportError);
const timer = window.setInterval(refresh, 1800); return () => { window.clearInterval(timer); subscriptions.current.forEach(item => item.controller.abort()); subscriptions.current.clear(); };
}, []);
const watchRun = (id: number, runId: number) => {
const old = subscriptions.current.get(id);
if (old?.runId === runId) return;
old?.controller.abort();
const controller = new AbortController();
subscriptions.current.set(id, { runId, controller });
void subscribeRun(id, runId, (event) => {
if (event === 'tool' || event === 'message' || event === 'error' || event === 'done') void loadDetail(id).catch(reportError);
if (event === 'error' || event === 'done') void refresh();
}, controller.signal).catch(err => { if (err?.name !== 'AbortError') void refresh(); }).finally(() => {
if (subscriptions.current.get(id)?.controller === controller) subscriptions.current.delete(id);
});
};
const open = (id: number) => { selection.current = String(id); setCurrentId(id);
loadDetail(id).then(data => { const active = data?.runs?.[0]; if (active?.status === 'running') watchRun(id, active.id); }).catch(reportError); };
const newDraft = () => { const next = newToken(); selection.current = `draft-${next}`; setToken(next); setCurrentId(undefined); setScope({}); };
const updateScope = async (next: Scope) => { if (!currentId) { setScope(next); return; } if (scopeSaving) return;
setScopeSaving(true); try { const data = await put(`/conversations/${currentId}/scope`, next); setDetails(old => ({ ...old, [currentId]: data })); await refresh(); }
catch (err) { reportError(err); await loadDetail(currentId); } finally { setScopeSaving(false); } };
const changeVersion = (id?: number) => { if (!id) { updateScope({}); return; }
updateScope({ course_version_id: id,
batch_id: batches.find(b => b.id === currentScope.batch_id)?.course_version_id === id ? currentScope.batch_id : undefined,
project_id: projects.find(p => p.id === currentScope.project_id)?.course_version_id === id ? currentScope.project_id : undefined }); };
const changeBatch = (id?: number) => { if (!id) { updateScope({ ...currentScope, batch_id: undefined }); return; }
const batch = batches.find(b => b.id === id); if (!batch) return;
const project = projects.find(p => p.id === currentScope.project_id);
updateScope({ course_version_id: batch.course_version_id, batch_id: id,
project_id: project?.course_version_id === batch.course_version_id && (!batch.project_ids || batch.project_ids.includes(project.id)) ? project.id : undefined }); };
const changeProject = (id?: number) => { if (!id) { updateScope({ ...currentScope, project_id: undefined }); return; }
const project = projects.find(p => p.id === id); if (!project) return;
const batch = batches.find(b => b.id === currentScope.batch_id);
updateScope({ course_version_id: project.course_version_id, project_id: id,
batch_id: batch?.course_version_id === project.course_version_id && (!batch.project_ids || batch.project_ids.includes(id)) ? batch.id : undefined }); };
const send = async () => { const content = input.trim(); if (!content || running || sending.current.has(key)) return;
sending.current.add(key); setPending(old => ({ ...old, [key]: true })); setComposers(old => ({ ...old, [key]: '' }));
try { const started = currentId ? await post(`/conversations/${currentId}/messages/start`, { content }) :
await post('/conversations/first-message', { content, request_id: token, ...scope });
if (!currentId && selection.current === key) { selection.current = String(started.conversation_id); setCurrentId(started.conversation_id); }
watchRun(started.conversation_id, started.run_id);
await refresh(); await loadDetail(started.conversation_id).catch(reportError);
} catch (err) { reportError(err); setComposers(old => ({ ...old, [key]: content })); }
finally { sending.current.delete(key); setPending(old => ({ ...old, [key]: false })); } };
const remove = async () => { if (!deleteId) return; const id = deleteId; setDeleting(true);
try { await api(`/conversations/${id}`, 'DELETE'); deletedIds.current.add(id); subscriptions.current.get(id)?.controller.abort(); subscriptions.current.delete(id); setConversations(old => old.filter(c => c.id !== id));
setDetails(old => { const next = { ...old }; delete next[id]; return next; });
setComposers(old => { const next = { ...old }; delete next[String(id)]; return next; });
setPending(old => { const next = { ...old }; delete next[String(id)]; return next; });
if (selection.current === String(id)) newDraft(); setDeleteId(undefined); message.success('会话已删除'); }
catch (err) { setDeleteError(err instanceof Error ? err.message : String(err)); } finally { setDeleting(false); } };
const locked = !!(currentId && detail?.scope_locked);
return <><Title level={2}>会话</Title><Paragraph>可直接提问;写入操作会先显示确认卡片。首次发送前可调整限定范围。</Paragraph>
<Row gutter={18}><Col xs={24} lg={6}><Card size="small" title="会话列表" extra={<Button size="small" type="primary" onClick={newDraft}>新建</Button>}>
<List dataSource={conversations} locale={{ emptyText: '还没有已发送消息的会话' }} renderItem={(item: any) => <List.Item className={item.id === currentId ? 'chat-selected' : 'chat-session'}
actions={[<Button key="delete" size="small" danger onClick={event => { event.stopPropagation(); setDeleteError(''); setDeleteId(item.id); }}>删除</Button>]} onClick={() => open(item.id)}>
<Space direction="vertical" size={0}><Text strong={item.id === currentId}>{item.title}</Text><Text type="secondary">#{item.id} · {item.batch_id ? `批次 ${item.batch_id}` : item.course_version_id ? `课程版本 ${item.course_version_id}` : '未限定范围'}</Text>{item.running && <Tag color="processing">生成中</Tag>}</Space>
</List.Item>} /></Card></Col><Col xs={24} lg={18}>
<Card size="small" title={currentId ? (detail?.title || `会话 #${currentId}`) : '新会话草稿'} extra={locked && <Tag>范围已锁定</Tag>}>
{!currentId && <Text type="secondary">草稿只在当前页面保存,发送第一条消息后才创建会话。</Text>}
<div className="chat-scope"><Select aria-label="课程版本" allowClear disabled={locked || scopeSaving || running} placeholder="课程版本(可选)" value={currentScope.course_version_id} options={versions.map(v => ({ value: v.id, label: `#${v.id} · v${v.version} ${v.title}` }))} onChange={changeVersion} />
<Select aria-label="评分批次" allowClear disabled={locked || scopeSaving || running} placeholder="评分批次(可选)" value={currentScope.batch_id} options={batches.filter(b => !currentScope.course_version_id || b.course_version_id === currentScope.course_version_id).map(b => ({ value: b.id, label: `#${b.id} · ${b.status}` }))} onChange={changeBatch} />
<Select aria-label="学生项目" allowClear disabled={locked || scopeSaving || running} placeholder="学生项目(可选)" value={currentScope.project_id} options={projects.filter(p => (!currentScope.course_version_id || p.course_version_id === currentScope.course_version_id) && (!currentScope.batch_id || !batches.find(b => b.id === currentScope.batch_id)?.project_ids || batches.find(b => b.id === currentScope.batch_id).project_ids.includes(p.id))).map(p => ({ value: p.id, label: `${p.student_no} ${p.name}` }))} onChange={changeProject} /></div>
{locked && <Text type="secondary">首条消息已提交,课程版本、批次和学生项目范围不可修改。</Text>}
</Card><div className="chat-messages">{detail?.messages?.map((item: any) => <Card key={item.id} size="small" className={`chat-message chat-${item.role}`} title={item.role === 'user' ? '教师' : '智评'}>
{item.role === 'assistant' ? <><Tools calls={item.tool_calls} /><Answer text={item.content} /><Citations items={item.citations} /></> : <Paragraph className="chat-content">{item.content}</Paragraph>}
</Card>)}
{detail?.runs?.filter((run: any) => run.status !== 'completed' && (run.tool_calls?.length || run.error || run.status === 'running')).map((run: any) => <Card key={`run-${run.id}`} size="small" className="chat-message chat-assistant" title={<Space>智评 <Tag color={statusColor(run.status)}>{run.status === 'running' ? '生成中' : run.status === 'failed' ? '失败' : '已中断'}</Tag></Space>}>
<Tools calls={run.tool_calls} />{run.error && <Alert type="error" message={run.error} />}</Card>)}
{!currentId && <Card size="small" className="chat-message"><Text type="secondary">输入问题,开始新的会话。</Text></Card>}</div>
{detail?.actions?.map((action: any) => <ActionCard key={action.id} action={action} conversationId={detail.id} reload={() => loadDetail(detail.id).catch(reportError)} />)}
<Space.Compact className="chat-compose"><Input.TextArea value={input} rows={3} disabled={running || !!pending[key]} placeholder="例如:查看批次 3 的未完成评分项;给评分项 42 打 8 分,评语……" onChange={e => setComposers(old => ({ ...old, [key]: e.target.value }))} onPressEnter={e => { if (!e.shiftKey) { e.preventDefault(); send(); } }} /><Button type="primary" loading={!!pending[key]} disabled={running || !input.trim()} onClick={send}>发送</Button></Space.Compact>
</Col></Row><Modal open={deleteId !== undefined} title="删除会话?" okText="删除会话" okButtonProps={{ danger: true, loading: deleting }} cancelText="取消" onOk={remove} onCancel={() => { setDeleteError(''); setDeleteId(undefined); }}>
<Paragraph>会话消息、工具记录和待确认卡片将被删除。已确认的业务操作与审计记录仍保留。</Paragraph>
{deleteError && <Alert type="error" message={deleteError} />}
</Modal></>;
}