feat: add conversation workspace and confirmation cards
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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';
|
||||
|
||||
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' : '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>{item.url && <a href={item.url} target="_blank" rel="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 === 'failed' && <Text type="danger">{action.result?.error}</Text>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
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 [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); }
|
||||
};
|
||||
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);
|
||||
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);
|
||||
}, []);
|
||||
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 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>
|
||||
</>;
|
||||
}
|
||||
Reference in New Issue
Block a user