287 lines
27 KiB
TypeScript
287 lines
27 KiB
TypeScript
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></>;
|
||
}
|