feat: complete chat sessions and production startup

This commit is contained in:
2026-09-27 11:38:57 +08:00
parent d755a16d5f
commit 09d730058c
23 changed files with 2458 additions and 152 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ const { Header, Sider, Content } = Layout;
const { Title, Text, Paragraph } = Typography;
const categories: Record<string, string> = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT/PDF' };
const nav = [
{ key: 'chat', icon: <MessageOutlined />, label: '对话' },
{ key: 'chat', icon: <MessageOutlined />, label: '会话' },
{ key: 'dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
{ key: 'courses', icon: <BookOutlined />, label: '课程与规则' },
{ key: 'knowledge', icon: <FileSearchOutlined />, label: '知识库资料' },
+156 -81
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Card, Col, Descriptions, Divider, Input, Layout, List, Modal, Row, Select, Space, Tag, Typography, message } from 'antd';
import { api, post, streamChat } from './api';
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: '重试索引任务' };
@@ -65,100 +67,173 @@ function ActionCard({ action, conversationId, reload }: { action: any; conversat
<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>{item.url && <a href={item.url} target="_blank" rel="noreferrer">查看页面</a>}</Card>)}</>}
{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>{safeUrl(item.url) && <a href={safeUrl(item.url)} target="_blank" rel="noopener noreferrer">查看页面</a>}</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' && action.result?.download_url && <a href={action.result.download_url}>下载导出文件</a>}
{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; }
};
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 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.title || item.document, item.page, item.quote, item.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="引用资料"><Text strong>引用 · {unique.length}</Text><div className="citation-grid">
{unique.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() : '';
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">{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></div>;
})}</div></section>;
}
function Tools({ calls }: { calls: any[] }) {
if (!calls?.length) return null;
return <section className="chat-tools" aria-label="工具调用"><Text type="secondary">工具调用 · {calls.length}</Text>
{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' ? '失败' : '成功'}</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(call.arguments || {}, 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>)}</section>;
}
export default function Chat() {
const [conversations, setConversations] = useState<any[]>([]);
const [currentId, setCurrentId] = useState<number | undefined>();
const [detail, setDetail] = useState<any>();
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [events, setEvents] = useState<any[]>([]);
const [error, setError] = useState('');
const [newOpen, setNewOpen] = useState(false);
const [newTitle, setNewTitle] = useState('新对话');
const [courseVersionId, setCourseVersionId] = useState<number>();
const [batchId, setBatchId] = useState<number>();
const [projectId, setProjectId] = useState<number>();
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 [indexTasks, setIndexTasks] = useState<any[]>([]);
const reload = async (id = currentId) => {
try {
const list = await api('/conversations'); setConversations(list);
if (id) { setDetail(await api(`/conversations/${id}`)); setIndexTasks(await api('/index-tasks')); }
} catch (err) { reportError(err); }
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; }
};
useEffect(() => {
api('/conversations').then((list: any[]) => {
setConversations(list);
const stored = Number(localStorage.getItem('wisegrade_conversation_id'));
const id = list.find(item => item.id === stored)?.id || list[0]?.id;
if (id) setCurrentId(id);
}).catch(reportError);
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);
api('/scoring-batches').then(setBatches).catch(reportError); api('/projects').then(setProjects).catch(reportError);
api('/index-tasks').then(setIndexTasks).catch(reportError);
const timer = window.setInterval(refresh, 1800); return () => { window.clearInterval(timer); subscriptions.current.forEach(item => item.controller.abort()); subscriptions.current.clear(); };
}, []);
useEffect(() => { if (currentId) { localStorage.setItem('wisegrade_conversation_id', String(currentId)); reload(currentId); } }, [currentId]);
useEffect(() => { if (!currentId) return; const id = window.setInterval(() => reload(currentId), 10000); return () => clearInterval(id); }, [currentId]);
const create = async () => {
try {
const created = await post('/conversations', { title: newTitle, course_version_id: courseVersionId, batch_id: batchId, project_id: projectId });
setCurrentId(created.id); setNewOpen(false); setDraft(''); setEvents([]); await reload(created.id);
} catch (err) { reportError(err); }
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 send = async () => {
if (!currentId || !draft.trim() || sending) return;
const content = draft.trim(); setDraft(''); setSending(true); setError(''); setEvents([{ kind: 'user', content }]);
try {
await streamChat(currentId, content, (event, data) => {
if (event === 'error') setError(data.detail);
else if (event === 'tool') setEvents(items => [...items, { kind: 'tool', ...data }]);
else if (event === 'message') setEvents(items => [...items, { kind: 'assistant', ...data }]);
});
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
finally { setSending(false); setEvents([]); reload(currentId); }
};
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={() => setNewOpen(true)}>新建</Button>}>
<List dataSource={conversations} renderItem={(item: any) => <List.Item onClick={() => { setCurrentId(item.id); setEvents([]); setError(''); }} className={item.id === currentId ? 'chat-selected' : 'chat-session'}>
<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></Space>
</List.Item>} />
</Card></Col>
<Col xs={24} lg={18}>{detail ? <>
<Card size="small"><Space wrap><Tag>课程版本 {detail.course_version_id ?? '未指定'}</Tag><Tag>批次 {detail.batch_id ?? '未指定'}</Tag><Tag>学生项目 {detail.project_id ?? '未指定'}</Tag></Space></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' ? '教师' : '智评'}>
<Paragraph className="chat-content">{item.content}</Paragraph>
{item.tool_calls?.length > 0 && <Space wrap>{item.tool_calls.map((call: any, index: number) => <Tag key={index}>{call.name}</Tag>)}</Space>}
{item.citations?.length > 0 && <><Divider /><Text strong>引用</Text><List size="small" dataSource={item.citations} renderItem={(citation: any, index: number) => <List.Item key={index}>
<div><a href={citation.url} target="_blank" rel="noreferrer">{citation.title || citation.document} · 第 {citation.page} {String(citation.title || '').toLowerCase().endsWith('.pptx') ? '张幻灯片' : '页'}</a><Paragraph className="chat-quote">{citation.quote}</Paragraph>{citation.image_url && <a href={citation.image_url} target="_blank" rel="noreferrer">查看页面图像</a>}</div>
</List.Item>} /></>}
</Card>)}
{events.map((item, index) => <Card size="small" key={index} className="chat-message"><Text>{item.kind === 'tool' ? `${item.name}:${item.status}` : item.content}</Text>{item.error && <Text type="danger">{item.error}</Text>}</Card>)}
</div>
{detail.actions?.map((action: any) => <ActionCard key={action.id} action={action} conversationId={detail.id} reload={() => reload(detail.id)} />)}
{(indexTasks.length > 0 || batches.length > 0) && <Card size="small" className="top-gap" title="后台进度"><Space wrap>{indexTasks.slice(0, 5).map(task => <Tag key={`i${task.id}`} color={statusColor(task.status)}>索引 #{task.id} {task.status}{task.error ? `:${task.error}` : ''}</Tag>)}{batches.slice(0, 3).map(batch => <Tag key={`b${batch.id}`} color={statusColor(batch.status)}>批次 #{batch.id} {batch.status} {batch.progress_done}/{batch.progress_total}</Tag>)}</Space></Card>}
{error && <Alert className="top-gap" type="error" message={error} />}
<Space.Compact className="chat-compose"><Input.TextArea value={draft} rows={3} placeholder="例如:查看批次 3 的未完成评分项;给评分项 42 打 8 分,评语……" onChange={e => setDraft(e.target.value)} onPressEnter={e => { if (!e.shiftKey) { e.preventDefault(); send(); } }} /><Button type="primary" loading={sending} onClick={send}>发送</Button></Space.Compact>
</> : <Card><Paragraph>新建会话后即可提问。</Paragraph><Button type="primary" onClick={() => setNewOpen(true)}>新建会话</Button></Card>}</Col>
</Row>
<Modal open={newOpen} title="新建对话" onCancel={() => setNewOpen(false)} onOk={create} okText="创建">
<Space direction="vertical" style={{ width: '100%' }}><Input value={newTitle} onChange={e => setNewTitle(e.target.value)} placeholder="会话名称" />
<Select allowClear placeholder="课程版本(可选)" value={courseVersionId} options={versions.map(v => ({ value: v.id, label: `#${v.id} · v${v.version} ${v.title}` }))} onChange={setCourseVersionId} />
<Select allowClear placeholder="评分批次(可选)" value={batchId} options={batches.map(b => ({ value: b.id, label: `#${b.id} · ${b.status}` }))} onChange={setBatchId} />
<Select allowClear placeholder="学生项目(可选)" value={projectId} options={projects.map(p => ({ value: p.id, label: `${p.student_no} ${p.name}` }))} onChange={setProjectId} />
<Text type="secondary">选择范围后,工具只能读取该课程、批次或学生项目。</Text></Space>
</Modal>
</>;
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' ? <Answer text={item.content} /> : <Paragraph className="chat-content">{item.content}</Paragraph>}
{item.role === 'assistant' && <><Citations items={item.citations} /><Tools calls={item.tool_calls} /></>}
</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)} />)}
{(indexTasks.length > 0 || batches.length > 0) && currentId && <Card size="small" className="top-gap" title="后台进度"><Space wrap>{indexTasks.slice(0, 5).map(task => <Tag key={`i${task.id}`} color={statusColor(task.status)}>索引 #{task.id} {task.status}{task.error ? `:${task.error}` : ''}</Tag>)}{batches.slice(0, 3).map(batch => <Tag key={`b${batch.id}`} color={statusColor(batch.status)}>批次 #{batch.id} {batch.status} {batch.progress_done}/{batch.progress_total}</Tag>)}</Space></Card>}
<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></>;
}
+11 -1
View File
@@ -19,7 +19,17 @@ export async function streamChat(conversationId: number, content: string, onEven
try { detail = (await response.json()).detail || detail; } catch { /* no JSON */ }
throw new Error(String(detail));
}
if (!response.body) throw new Error('服务器未提供对话事件流');
return readEvents(response, onEvent);
}
export async function subscribeRun(conversationId: number, runId: number, onEvent: (event: string, data: any) => void, signal: AbortSignal) {
const response = await fetch(`/api/conversations/${conversationId}/runs/${runId}/events`, { signal });
if (!response.ok) throw new Error(`订阅会话运行失败(${response.status})`);
return readEvents(response, onEvent);
}
async function readEvents(response: Response, onEvent: (event: string, data: any) => void) {
if (!response.body) throw new Error('服务器未提供会话事件流');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
+28
View File
@@ -30,3 +30,31 @@ body { margin: 0; }
.import-editor .ant-list-item { justify-content: flex-start; }
.linked-row td { background: #eaf3ff !important; }
@media (max-width: 700px) { .content { padding: 14px; } .header { padding: 0 15px; } .header-sub { display: none; } }
.chat-scope { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
.chat-scope .ant-select { min-width: 0; }
.chat-messages { max-height: 58vh; }
.chat-message { min-width: 0; overflow: hidden; }
.chat-markdown { line-height: 1.65; overflow-wrap: anywhere; }
.chat-markdown > :first-child { margin-top: 0; }
.chat-markdown > :last-child { margin-bottom: 0; }
.chat-markdown p, .chat-markdown ul, .chat-markdown ol, .chat-markdown blockquote { margin: .45em 0 .7em; }
.chat-markdown h1, .chat-markdown h2, .chat-markdown h3 { line-height: 1.35; margin: .8em 0 .35em; }
.chat-markdown h1 { font-size: 1.4em; } .chat-markdown h2 { font-size: 1.2em; } .chat-markdown h3 { font-size: 1.08em; }
.chat-markdown blockquote { border-left: 3px solid #9db7d8; padding-left: 12px; color: #526982; }
.chat-markdown pre, .tool-detail pre { max-width: 100%; overflow-x: auto; white-space: pre; background: #f4f6fa; border-radius: 6px; padding: 10px; font-size: 12px; }
.chat-markdown :not(pre) > code { overflow-wrap: anywhere; background: #f4f6fa; padding: 1px 4px; border-radius: 3px; }
.chat-markdown table { display: block; width: max-content; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 10px 0; }
.chat-markdown th, .chat-markdown td { border: 1px solid #d8e1eb; padding: 6px 9px; vertical-align: top; }
.chat-citations { margin-top: 14px; padding-top: 10px; border-top: 1px solid #e2eaf3; }
.citation-grid { display: grid; gap: 8px; margin-top: 7px; }
.citation-card { border-left: 3px solid #5b91d9; background: #f8fbff; padding: 8px 10px; min-width: 0; overflow-wrap: anywhere; }
.citation-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; }
.citation-card p { margin: 5px 0; color: #44566d; white-space: pre-wrap; }
.citation-card summary { cursor: pointer; color: #44566d; overflow-wrap: anywhere; }
.chat-tools { margin-top: 12px; }
.chat-tool { border: 1px solid #e2e8ef; border-radius: 6px; margin-top: 5px; padding: 6px 9px; }
.chat-tool summary { cursor: pointer; list-style-position: inside; overflow-wrap: anywhere; }
.tool-detail { margin-top: 8px; padding-left: 12px; }
.chat-compose { gap: 8px; }
.chat-compose .ant-input { min-width: 0; }
@media (max-width: 700px) { .chat-scope { grid-template-columns: 1fr; } .chat-user, .chat-assistant { margin-left: 0; margin-right: 0; } .chat-messages { max-height: none; } .chat-compose { display: flex; } .chat-compose .ant-btn { height: auto; } }