feat: add conversation workspace and confirmation cards
This commit is contained in:
+28
-10
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Alert, Badge, Button, Card, Col, Collapse, Descriptions, Divider, Drawer, Form, Input, InputNumber, Layout, Menu, Modal, Progress, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd';
|
||||
import { DashboardOutlined, BookOutlined, FileSearchOutlined, FolderOpenOutlined, TeamOutlined, ThunderboltOutlined, AuditOutlined, ExportOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { DashboardOutlined, BookOutlined, FileSearchOutlined, FolderOpenOutlined, TeamOutlined, ThunderboltOutlined, AuditOutlined, ExportOutlined, SettingOutlined, MessageOutlined } from '@ant-design/icons';
|
||||
import { api, post, put } from './api';
|
||||
import Chat from './Chat';
|
||||
|
||||
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: 'dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
|
||||
{ key: 'courses', icon: <BookOutlined />, label: '课程与规则' },
|
||||
{ key: 'knowledge', icon: <FileSearchOutlined />, label: '知识库资料' },
|
||||
@@ -40,6 +42,7 @@ function Courses() {
|
||||
const [courses, setCourses] = useState<any[]>([]), [courseId, setCourseId] = useState<number>(), [versions, setVersions] = useState<any[]>([]), [versionId, setVersionId] = useState<number>(), [draft, setDraft] = useState<any>(), [rubrics, setRubrics] = useState<any[]>([]), [rubricId, setRubricId] = useState<number>(), [rubric, setRubric] = useState<any>();
|
||||
const refreshCourses = () => load('/courses', setCourses);
|
||||
useEffect(() => { refreshCourses(); }, []);
|
||||
useEffect(() => { const id = Number(new URLSearchParams(window.location.search).get('course_version_id')); if (id) api(`/course-versions/${id}`).then(v => { setCourseId(v.course_id); setVersionId(id); }).catch(fail); }, []);
|
||||
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]);
|
||||
useEffect(() => { setDraft(versions.find(v => v.id === versionId)); if (versionId) load(`/course-versions/${versionId}/rubrics`, setRubrics); }, [versions, versionId]);
|
||||
useEffect(() => { setRubric(rubrics.find(r => r.id === rubricId)); }, [rubrics, rubricId]);
|
||||
@@ -71,6 +74,7 @@ function Courses() {
|
||||
function Knowledge() {
|
||||
const [courses, setCourses] = useState<any[]>([]), [versions, setVersions] = useState<any[]>([]), [courseId, setCourseId] = useState<number>(), [versionId, setVersionId] = useState<number>(), [docs, setDocs] = useState<any[]>([]);
|
||||
useEffect(() => { load('/courses', setCourses); }, []);
|
||||
useEffect(() => { const id = Number(new URLSearchParams(window.location.search).get('course_version_id')); if (id) api(`/course-versions/${id}`).then(v => { setCourseId(v.course_id); setVersionId(id); }).catch(fail); }, []);
|
||||
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]);
|
||||
useEffect(() => { if (versionId) load(`/knowledge?course_version_id=${versionId}`, setDocs); }, [versionId]);
|
||||
return <><Title level={2}>公共课程知识库</Title><Space><Select placeholder="课程" style={{ width: 260 }} options={options(courses, c => c.name)} onChange={setCourseId} /><Select placeholder="课程版本" style={{ width: 220 }} options={options(versions, v => `v${v.version} ${v.title}`)} onChange={setVersionId} /><Button type="primary" disabled={!versionId} onClick={async () => { try { const result = await post(`/knowledge/ingest-course/${versionId}`); message.info(`入库 ${result.completed.length} 份;失败 ${Object.keys(result.errors).length} 份`); load(`/knowledge?course_version_id=${versionId}`, setDocs); } catch (e) { fail(e); } }}>入库课程资料</Button></Space><Table className="top-gap" rowKey="id" dataSource={docs} columns={[{ title: '资料', dataIndex: 'title' }, { title: '类型', dataIndex: 'type' }, { title: '块数', dataIndex: 'chunks' }, { title: '状态', dataIndex: 'status' }]} /></>;
|
||||
@@ -90,9 +94,11 @@ function Import() {
|
||||
}
|
||||
|
||||
function Projects({ openScores }: { openScores: (id: number) => void }) {
|
||||
const [projects, setProjects] = useState<any[]>([]), [selected, setSelected] = useState<any>();
|
||||
const [projects, setProjects] = useState<any[]>([]), [selected, setSelected] = useState<any>(), [indexTasks, setIndexTasks] = useState<any[]>([]);
|
||||
useEffect(() => { load('/projects', setProjects); }, []);
|
||||
return <><Title level={2}>学生项目</Title><Table rowKey="id" dataSource={projects} columns={[{ title: '学号', dataIndex: 'student_no' }, { title: '姓名', dataIndex: 'name' }, { title: '选题', dataIndex: 'topic' }, { title: '缺失材料', render: (_, p) => p.missing.length ? p.missing.map((c: string) => <Tag color="red" key={c}>{categories[c]}</Tag>) : <Tag color="green">齐全</Tag> }, { title: '解析异常', render: (_, p) => p.parse_errors.length ? <Badge status="error" text={p.parse_errors.length} /> : '—' }, { title: '操作', render: (_, p) => <Space><Button onClick={() => load(`/projects/${p.id}`, setSelected)}>材料详情</Button><Button type="link" onClick={() => openScores(p.id)}>查看评分</Button></Space> }]} /><Drawer open={!!selected} onClose={() => setSelected(undefined)} title={selected?.topic} width={700}>{selected && <><Descriptions column={1} items={[{ key: 'no', label: '学号', children: selected.student_no }, { key: 'name', label: '姓名', children: selected.name }, { key: 'direction', label: '选题方向', children: selected.direction || '未指定' }]} /><Table<any> rowKey="id" size="small" dataSource={selected.files} columns={[{ title: '材料', render: (_, f) => categories[f.category] }, { title: '文件', dataIndex: 'relative_path' }, { title: '解析', render: (_, f) => <Tag color={f.parse_status === 'ready' ? 'green' : 'orange'}>{f.parse_status}</Tag> }, { title: '错误', dataIndex: 'parse_error' }]} /><Title level={5}>抽取事实与一致性</Title><pre className="json-view">{JSON.stringify(selected.facts, null, 2)}</pre></>}</Drawer></>;
|
||||
useEffect(() => { const id = Number(new URLSearchParams(window.location.search).get('project_id')); if (id) load(`/projects/${id}`, setSelected); }, []);
|
||||
useEffect(() => { if (selected?.id) load(`/index-tasks?project_id=${selected.id}`, setIndexTasks); }, [selected?.id]);
|
||||
return <><Title level={2}>学生项目</Title><Table rowKey="id" dataSource={projects} columns={[{ title: '学号', dataIndex: 'student_no' }, { title: '姓名', dataIndex: 'name' }, { title: '选题', dataIndex: 'topic' }, { title: '缺失材料', render: (_, p) => p.missing.length ? p.missing.map((c: string) => <Tag color="red" key={c}>{categories[c]}</Tag>) : <Tag color="green">齐全</Tag> }, { title: '解析异常', render: (_, p) => p.parse_errors.length ? <Badge status="error" text={p.parse_errors.length} /> : '—' }, { title: '操作', render: (_, p) => <Space><Button onClick={() => load(`/projects/${p.id}`, setSelected)}>材料详情</Button><Button type="link" onClick={() => openScores(p.id)}>查看评分</Button></Space> }]} /><Drawer open={!!selected} onClose={() => setSelected(undefined)} title={selected?.topic} width={700}>{selected && <><Descriptions column={1} items={[{ key: 'no', label: '学号', children: selected.student_no }, { key: 'name', label: '姓名', children: selected.name }, { key: 'direction', label: '选题方向', children: selected.direction || '未指定' }]} /><Table<any> rowKey="id" size="small" dataSource={selected.files} columns={[{ title: '材料', render: (_, f) => categories[f.category] }, { title: '文件', dataIndex: 'relative_path' }, { title: '解析', render: (_, f) => <Tag color={f.parse_status === 'ready' ? 'green' : 'orange'}>{f.parse_status}</Tag> }, { title: '错误', dataIndex: 'parse_error' }]} /><Title level={5}>解析与索引任务</Title><Table<any> rowKey="id" size="small" pagination={false} dataSource={indexTasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '材料 ID', dataIndex: 'submission_file_id' }, { title: '状态', render: (_, task) => <Tag color={task.status === 'completed' ? 'green' : task.status === 'failed' ? 'red' : 'orange'}>{task.status}</Tag> }, { title: '错误', dataIndex: 'error' }, { title: '操作', render: (_, task) => task.status === 'failed' && <Button size="small" onClick={async () => { try { await post(`/index-tasks/${task.id}/retry`); load(`/index-tasks?project_id=${selected.id}`, setIndexTasks); } catch (e) { fail(e); } }}>重试</Button> }]} /><Title level={5}>抽取事实与一致性</Title><pre className="json-view">{JSON.stringify(selected.facts, null, 2)}</pre></>}</Drawer></>;
|
||||
}
|
||||
|
||||
function Batches({ openBatch }: { openBatch: (id: number) => void }) {
|
||||
@@ -101,7 +107,7 @@ function Batches({ openBatch }: { openBatch: (id: number) => void }) {
|
||||
useEffect(() => { refresh(); load('/courses', setCourses); const id = window.setInterval(refresh, 5000); return () => clearInterval(id); }, []);
|
||||
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]);
|
||||
useEffect(() => { if (versionId) load(`/course-versions/${versionId}/rubrics`, setRubrics); }, [versionId]);
|
||||
return <><Title level={2}>评分批次与进度</Title><Card title="创建独立批次"><Space wrap><Select style={{ width: 250 }} placeholder="课程" options={options(courses, c => c.name)} onChange={setCourseId} /><Select style={{ width: 250 }} placeholder="已发布课程版本" options={options(versions.filter(v => v.published), v => `v${v.version} ${v.title}`)} onChange={setVersionId} /><Select style={{ width: 230 }} placeholder="已发布规则版本" options={options(rubrics.filter(r => r.published), r => `v${r.version} · ${r.criteria.length}项`)} onChange={setRubricId} /><Button type="primary" disabled={!versionId || !rubricId} onClick={async () => { try { await post('/scoring-batches', { course_version_id: versionId, rubric_version_id: rubricId }); refresh(); message.success('批次已创建'); } catch (e) { fail(e); } }}>创建批次</Button></Space></Card><Table className="top-gap" rowKey="id" dataSource={batches} columns={[{ title: '批次', dataIndex: 'id' }, { title: '课程/规则版本', render: (_, b) => `${b.course_version_id} / ${b.rubric_version_id}` }, { title: '状态', render: (_, b) => <Tag color={b.status === 'completed' ? 'green' : b.status === 'partial' || b.status === 'failed' ? 'orange' : 'blue'}>{b.status}</Tag> }, { title: '进度', render: (_, b) => <Progress percent={b.progress_total ? Math.round(b.progress_done / b.progress_total * 100) : 0} size="small" /> }, { title: '异常', dataIndex: 'error' }, { title: '操作', render: (_, b) => <Space><Button onClick={async () => { try { await post(`/scoring-batches/${b.id}/start`); refresh(); } catch (e) { fail(e); } }}>启动/重试</Button><Button type="link" onClick={() => openBatch(b.id)}>查看评分</Button></Space> }]} /></>;
|
||||
return <><Title level={2}>评分批次与进度</Title><Card title="创建独立批次"><Space wrap><Select style={{ width: 250 }} placeholder="课程" options={options(courses, c => c.name)} onChange={setCourseId} /><Select style={{ width: 250 }} placeholder="已发布课程版本" options={options(versions.filter(v => v.published), v => `v${v.version} ${v.title}`)} onChange={setVersionId} /><Select style={{ width: 230 }} placeholder="已发布规则版本" options={options(rubrics.filter(r => r.published), r => `v${r.version} · ${r.criteria.length}项`)} onChange={setRubricId} /><Button type="primary" disabled={!versionId || !rubricId} onClick={async () => { try { await post('/scoring-batches', { course_version_id: versionId, rubric_version_id: rubricId }); refresh(); message.success('批次已创建'); } catch (e) { fail(e); } }}>创建批次</Button></Space></Card><Table className="top-gap" rowKey="id" rowClassName={(b: any) => Number(new URLSearchParams(window.location.search).get("batch_id")) === b.id ? "linked-row" : ""} dataSource={batches} columns={[{ title: '批次', dataIndex: 'id' }, { title: '课程/规则版本', render: (_, b) => `${b.course_version_id} / ${b.rubric_version_id}` }, { title: '状态', render: (_, b) => <Tag color={b.status === 'completed' ? 'green' : b.status === 'partial' || b.status === 'failed' ? 'orange' : 'blue'}>{b.status}</Tag> }, { title: '进度', render: (_, b) => <Progress percent={b.progress_total ? Math.round(b.progress_done / b.progress_total * 100) : 0} size="small" /> }, { title: '异常', dataIndex: 'error' }, { title: '操作', render: (_, b) => <Space><Button disabled={!['created', 'failed', 'partial'].includes(b.status)} onClick={async () => { try { await post(`/scoring-batches/${b.id}/start`); refresh(); } catch (e) { fail(e); } }}>启动/重试</Button><Button type="link" onClick={() => openBatch(b.id)}>查看评分</Button></Space> }]} /></>;
|
||||
}
|
||||
|
||||
function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) {
|
||||
@@ -119,8 +125,9 @@ function Exports() {
|
||||
const [tasks, setTasks] = useState<any[]>([]), [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]), [batchId, setBatchId] = useState<number>(), [projectId, setProjectId] = useState<number>();
|
||||
const refresh = () => load('/exports', setTasks);
|
||||
useEffect(() => { refresh(); load('/scoring-batches', setBatches); load('/projects', setProjects); }, []);
|
||||
useEffect(() => { const id = Number(new URLSearchParams(window.location.search).get('batch_id')); if (id) setBatchId(id); }, []);
|
||||
const create = async (kind: 'excel' | 'project-pdf') => { try { await post(kind === 'excel' ? '/exports/excel' : '/exports/project-pdf', { batch_id: batchId, project_id: projectId }); refresh(); message.success('导出完成'); } catch (e) { fail(e); } };
|
||||
return <><Title level={2}>成绩与反馈导出</Title><Alert type="info" message="未完成或无法评分项目在Excel中显示为空,并标记待处理;每个批次独立导出。" /><Space className="top-gap" wrap><Select style={{ width: 210 }} placeholder="选择批次" options={options(batches, b => `批次 #${b.id} · ${b.status}`)} onChange={setBatchId} /><Button disabled={!batchId} onClick={() => create('excel')}>导出Excel成绩表</Button><Select style={{ width: 300 }} placeholder="选择学生" options={options(projects, p => `${p.student_no} ${p.name}`)} onChange={setProjectId} /><Button disabled={!batchId || !projectId} onClick={() => create('project-pdf')}>导出学生PDF反馈</Button></Space><Table className="top-gap" rowKey="id" dataSource={tasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '批次', dataIndex: 'batch_id' }, { title: '项目', dataIndex: 'project_id' }, { title: '格式', dataIndex: 'kind' }, { title: '状态', dataIndex: 'status' }, { title: '异常', dataIndex: 'error' }, { title: '下载', render: (_, task) => task.status === 'completed' ? <a href={`/api/exports/${task.id}/download`}>下载文件</a> : '—' }]} /></>;
|
||||
return <><Title level={2}>成绩与反馈导出</Title><Alert type="info" message="未完成或无法评分项目在Excel中显示为空,并标记待处理;每个批次独立导出。" /><Space className="top-gap" wrap><Select style={{ width: 210 }} placeholder="选择批次" value={batchId} options={options(batches, b => `批次 #${b.id} · ${b.status}`)} onChange={setBatchId} /><Button disabled={!batchId} onClick={() => create('excel')}>导出Excel成绩表</Button><Select style={{ width: 300 }} placeholder="选择学生" options={options(projects, p => `${p.student_no} ${p.name}`)} onChange={setProjectId} /><Button disabled={!batchId || !projectId} onClick={() => create('project-pdf')}>导出学生PDF反馈</Button></Space><Table className="top-gap" rowKey="id" dataSource={tasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '批次', dataIndex: 'batch_id' }, { title: '项目', dataIndex: 'project_id' }, { title: '格式', dataIndex: 'kind' }, { title: '状态', dataIndex: 'status' }, { title: '异常', dataIndex: 'error' }, { title: '下载', render: (_, task) => task.status === 'completed' ? <a href={`/api/exports/${task.id}/download`}>下载文件</a> : '—' }]} /></>;
|
||||
}
|
||||
|
||||
function Settings() {
|
||||
@@ -130,9 +137,20 @@ function Settings() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState('dashboard'), [batchId, setBatchId] = useState<number>(), [projectId, setProjectId] = useState<number>();
|
||||
const openScores = (id: number) => { setProjectId(id); setPage('scores'); };
|
||||
const openBatch = (id: number) => { setBatchId(id); setPage('scores'); };
|
||||
const pages: Record<string, ReactNode> = { dashboard: <Dashboard />, courses: <Courses />, knowledge: <Knowledge />, import: <Import />, projects: <Projects openScores={openScores} />, batches: <Batches openBatch={openBatch} />, scores: <Scores key={`${batchId}-${projectId}`} batchId={batchId} projectId={projectId} />, exports: <Exports />, settings: <Settings /> };
|
||||
return <Layout className="shell"><Sider breakpoint="lg" collapsedWidth="0" width={230} theme="dark"><div className="brand"><span className="brand-icon">智</span><div><strong>WiseGrade</strong><small>教师工作台</small></div></div><Menu theme="dark" mode="inline" selectedKeys={[page]} items={nav} onClick={({ key }) => setPage(key)} /></Sider><Layout><Header className="header"><div><strong>智评</strong><span className="header-sub">自动化专业综合实践</span></div><Tag color="blue">单机教师端</Tag></Header><Content className="content"><div className="content-inner">{pages[page]}</div></Content></Layout></Layout>;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [page, setPage] = useState(params.get('page') || 'chat');
|
||||
const [batchId, setBatchId] = useState<number | undefined>(Number(params.get('batch_id')) || undefined);
|
||||
const [projectId, setProjectId] = useState<number | undefined>(Number(params.get('project_id')) || undefined);
|
||||
const navigate = (next: string, batch?: number, project?: number) => {
|
||||
setPage(next); setBatchId(batch); setProjectId(project);
|
||||
const query = new URLSearchParams({ page: next });
|
||||
if (batch) query.set('batch_id', String(batch));
|
||||
if (project) query.set('project_id', String(project));
|
||||
window.history.pushState({}, '', `/?${query.toString()}`);
|
||||
};
|
||||
useEffect(() => { const onPop = () => { const query = new URLSearchParams(window.location.search); setPage(query.get('page') || 'chat'); setBatchId(Number(query.get('batch_id')) || undefined); setProjectId(Number(query.get('project_id')) || undefined); }; window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, []);
|
||||
const openScores = (id: number) => navigate('scores', batchId, id);
|
||||
const openBatch = (id: number) => navigate('scores', id, projectId);
|
||||
const pages: Record<string, ReactNode> = { chat: <Chat />, dashboard: <Dashboard />, courses: <Courses />, knowledge: <Knowledge />, import: <Import />, projects: <Projects openScores={openScores} />, batches: <Batches openBatch={openBatch} />, scores: <Scores key={`${batchId}-${projectId}`} batchId={batchId} projectId={projectId} />, exports: <Exports />, settings: <Settings /> };
|
||||
return <Layout className="shell"><Sider breakpoint="lg" collapsedWidth="0" width={230} theme="dark"><div className="brand"><span className="brand-icon">智</span><div><strong>WiseGrade</strong><small>教师工作台</small></div></div><Menu theme="dark" mode="inline" selectedKeys={[page]} items={nav} onClick={({ key }) => navigate(key)} /></Sider><Layout><Header className="header"><div><strong>智评</strong><span className="header-sub">自动化专业综合实践</span></div><Tag color="blue">单机教师端</Tag></Header><Content className="content"><div className="content-inner">{pages[page] || pages.chat}</div></Content></Layout></Layout>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
@@ -9,3 +9,31 @@ export async function api(path: string, method = 'GET', body?: unknown): Promise
|
||||
}
|
||||
export const post = (path: string, body?: unknown) => api(path, 'POST', body);
|
||||
export const put = (path: string, body: unknown) => api(path, 'PUT', body);
|
||||
|
||||
export async function streamChat(conversationId: number, content: string, onEvent: (event: string, data: any) => void) {
|
||||
const response = await fetch(`/api/conversations/${conversationId}/messages`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText;
|
||||
try { detail = (await response.json()).detail || detail; } catch { /* no JSON */ }
|
||||
throw new Error(String(detail));
|
||||
}
|
||||
if (!response.body) throw new Error('服务器未提供对话事件流');
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let boundary: number;
|
||||
while ((boundary = buffer.indexOf('\n\n')) >= 0) {
|
||||
const frame = buffer.slice(0, boundary).replace(/\r/g, '');
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
const event = frame.split('\n').find(line => line.startsWith('event: '))?.slice(7);
|
||||
const data = frame.split('\n').find(line => line.startsWith('data: '))?.slice(6);
|
||||
if (event && data) onEvent(event, JSON.parse(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,16 @@ body { margin: 0; }
|
||||
.folder-list button { height: auto; min-height: 40px; text-align: left; }
|
||||
.evidence { margin: 9px 0; border-left: 3px solid #4e91ed; }
|
||||
.json-view { white-space: pre-wrap; overflow: auto; background: #f5f7fa; padding: 12px; border-radius: 6px; }
|
||||
.chat-selected, .chat-session { cursor: pointer; padding: 9px 8px !important; border-radius: 6px; }
|
||||
.chat-selected { background: #eaf3ff; }
|
||||
.chat-messages { max-height: 56vh; overflow-y: auto; margin-top: 14px; }
|
||||
.chat-message { margin-bottom: 10px; }
|
||||
.chat-user { margin-left: 10%; background: #f4f8ff; }
|
||||
.chat-assistant { margin-right: 7%; }
|
||||
.chat-content { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.chat-quote { margin: 4px 0 !important; white-space: pre-wrap; max-width: 640px; }
|
||||
.chat-compose { display: flex; width: 100%; margin-top: 16px; }
|
||||
.chat-compose textarea { flex: 1; }
|
||||
.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; } }
|
||||
|
||||
Reference in New Issue
Block a user