diff --git a/frontend/nginx.conf b/frontend/nginx.conf index ad1e579..5ab4692 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,5 +4,14 @@ server { index index.html; location = /health { add_header Content-Type text/plain; return 200 'ok'; } location /api/ { proxy_pass http://api:8000; proxy_set_header Host $host; client_max_body_size 1m; } + location ~ ^/api/conversations/[0-9]+/messages$ { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 900s; + proxy_send_timeout 900s; + client_max_body_size 1m; + } location / { try_files $uri /index.html; } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9a7e1e6..fa81bd1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT/PDF' }; const nav = [ + { key: 'chat', icon: , label: '对话' }, { key: 'dashboard', icon: , label: '仪表盘' }, { key: 'courses', icon: , label: '课程与规则' }, { key: 'knowledge', icon: , label: '知识库资料' }, @@ -40,6 +42,7 @@ function Courses() { const [courses, setCourses] = useState([]), [courseId, setCourseId] = useState(), [versions, setVersions] = useState([]), [versionId, setVersionId] = useState(), [draft, setDraft] = useState(), [rubrics, setRubrics] = useState([]), [rubricId, setRubricId] = useState(), [rubric, setRubric] = useState(); 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([]), [versions, setVersions] = useState([]), [courseId, setCourseId] = useState(), [versionId, setVersionId] = useState(), [docs, setDocs] = useState([]); 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 <>公共课程知识库 `v${v.version} ${v.title}`)} onChange={setVersionId} />; @@ -90,9 +94,11 @@ function Import() { } function Projects({ openScores }: { openScores: (id: number) => void }) { - const [projects, setProjects] = useState([]), [selected, setSelected] = useState(); + const [projects, setProjects] = useState([]), [selected, setSelected] = useState(), [indexTasks, setIndexTasks] = useState([]); useEffect(() => { load('/projects', setProjects); }, []); - return <>学生项目
p.missing.length ? p.missing.map((c: string) => {categories[c]}) : 齐全 }, { title: '解析异常', render: (_, p) => p.parse_errors.length ? : '—' }, { title: '操作', render: (_, p) => }]} /> setSelected(undefined)} title={selected?.topic} width={700}>{selected && <> rowKey="id" size="small" dataSource={selected.files} columns={[{ title: '材料', render: (_, f) => categories[f.category] }, { title: '文件', dataIndex: 'relative_path' }, { title: '解析', render: (_, f) => {f.parse_status} }, { title: '错误', dataIndex: 'parse_error' }]} />抽取事实与一致性
{JSON.stringify(selected.facts, null, 2)}
}
; + 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 <>学生项目
p.missing.length ? p.missing.map((c: string) => {categories[c]}) : 齐全 }, { title: '解析异常', render: (_, p) => p.parse_errors.length ? : '—' }, { title: '操作', render: (_, p) => }]} /> setSelected(undefined)} title={selected?.topic} width={700}>{selected && <> rowKey="id" size="small" dataSource={selected.files} columns={[{ title: '材料', render: (_, f) => categories[f.category] }, { title: '文件', dataIndex: 'relative_path' }, { title: '解析', render: (_, f) => {f.parse_status} }, { title: '错误', dataIndex: 'parse_error' }]} />解析与索引任务 rowKey="id" size="small" pagination={false} dataSource={indexTasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '材料 ID', dataIndex: 'submission_file_id' }, { title: '状态', render: (_, task) => {task.status} }, { title: '错误', dataIndex: 'error' }, { title: '操作', render: (_, task) => task.status === 'failed' && }]} />抽取事实与一致性
{JSON.stringify(selected.facts, null, 2)}
}
; } 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 <>评分批次与进度 v.published), v => `v${v.version} ${v.title}`)} onChange={setVersionId} />
`${b.course_version_id} / ${b.rubric_version_id}` }, { title: '状态', render: (_, b) => {b.status} }, { title: '进度', render: (_, b) => }, { title: '异常', dataIndex: 'error' }, { title: '操作', render: (_, b) => }]} />; + return <>评分批次与进度 v.published), v => `v${v.version} ${v.title}`)} onChange={setVersionId} />
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) => {b.status} }, { title: '进度', render: (_, b) => }, { title: '异常', dataIndex: 'error' }, { title: '操作', render: (_, b) => }]} />; } function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) { @@ -119,8 +125,9 @@ function Exports() { const [tasks, setTasks] = useState([]), [batches, setBatches] = useState([]), [projects, setProjects] = useState([]), [batchId, setBatchId] = useState(), [projectId, setProjectId] = useState(); 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 <>成绩与反馈导出 `${p.student_no} ${p.name}`)} onChange={setProjectId} />
task.status === 'completed' ? 下载文件 : '—' }]} />; + return <>成绩与反馈导出 `${p.student_no} ${p.name}`)} onChange={setProjectId} />
task.status === 'completed' ? 下载文件 : '—' }]} />; } function Settings() { @@ -130,9 +137,20 @@ function Settings() { } export default function App() { - const [page, setPage] = useState('dashboard'), [batchId, setBatchId] = useState(), [projectId, setProjectId] = useState(); - const openScores = (id: number) => { setProjectId(id); setPage('scores'); }; - const openBatch = (id: number) => { setBatchId(id); setPage('scores'); }; - const pages: Record = { dashboard: , courses: , knowledge: , import: , projects: , batches: , scores: , exports: , settings: }; - return
智
WiseGrade教师工作台
setPage(key)} />
智评自动化专业综合实践
单机教师端
{pages[page]}
; + const params = new URLSearchParams(window.location.search); + const [page, setPage] = useState(params.get('page') || 'chat'); + const [batchId, setBatchId] = useState(Number(params.get('batch_id')) || undefined); + const [projectId, setProjectId] = useState(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 = { chat: , dashboard: , courses: , knowledge: , import: , projects: , batches: , scores: , exports: , settings: }; + return
智
WiseGrade教师工作台
navigate(key)} />
智评自动化专业综合实践
单机教师端
{pages[page] || pages.chat}
; } diff --git a/frontend/src/Chat.tsx b/frontend/src/Chat.tsx new file mode 100644 index 0000000..51ff4d4 --- /dev/null +++ b/frontend/src/Chat.tsx @@ -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 = { import: '导入学生材料', course_ingest: '课程资料入库', create_batch: '创建评分批次', start_batch: '启动/重试评分批次', review: '教师复核', export: '导出成绩或反馈', index_retry: '重试索引任务' }; +const categories: Record = { 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(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
+ {(action.preview.projects || []).map((project: any, pi: number) => + +
学号 editProject(pi, 'student_no', e.target.value)} /> + 姓名 editProject(pi, 'name', e.target.value)} /> + 选题 editProject(pi, 'topic', e.target.value)} /> + + 选题方向 editProject(pi, 'direction', e.target.value)} /> + {project.missing?.map((item: string) => 缺少{categories[item] || item})}{project.warnings?.map((item: string) => {item})} + + {file.relative_path.split('/').pop()} + {file.status} + editFile(pi, fi, 'status', value)} /> + {file.error && {file.error}} + } /> + )} + + ; +} + +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 {kinds[action.kind] || action.kind}{action.status}} + extra={action.status === 'pending' && }> + ({ key, label: key, children: String(value ?? '—') }))} /> + {action.kind === 'import' && <> + } + {action.kind === 'review' && <>批次 #{preview.batch_id} · {preview.student_no} {preview.student_name} · {preview.criterion_name}({preview.criterion_id}) + 原状态:{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)}`} + {preview.evidence?.map((item: any, index: number) => {item.document} · 第 {item.page} 页{item.quote}{item.url && 查看页面})}} + {action.kind === 'create_batch' && 固定项目清单:{preview.project_ids?.join('、') || '无项目'}({preview.project_ids?.length || 0} 个)} + {action.kind === 'course_ingest' && <>入库资料:{preview.files?.map((item: any) => item[0]).join('、') || '无'}。{preview.missing_files?.length > 0 && }} + {action.kind === 'start_batch' && 项目范围:{preview.project_ids?.join('、') || '旧批次历史清单未知'};当前状态:{preview.status}} + {action.kind === 'export' && preview.incomplete_projects?.length > 0 && } + {action.status === 'completed' && action.result?.download_url && 下载导出文件} + {action.status === 'failed' && {action.result?.error}} + ; +} + +export default function Chat() { + const [conversations, setConversations] = useState([]); + const [currentId, setCurrentId] = useState(); + const [detail, setDetail] = useState(); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const [events, setEvents] = useState([]); + const [error, setError] = useState(''); + const [newOpen, setNewOpen] = useState(false); + const [newTitle, setNewTitle] = useState('新对话'); + const [courseVersionId, setCourseVersionId] = useState(); + const [batchId, setBatchId] = useState(); + const [projectId, setProjectId] = useState(); + const [versions, setVersions] = useState([]); + const [batches, setBatches] = useState([]); + const [projects, setProjects] = useState([]); + const [indexTasks, setIndexTasks] = useState([]); + 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 <>对话查询课程、项目与评分;写入操作会先显示确认卡片。 + + setNewOpen(true)}>新建}> + { setCurrentId(item.id); setEvents([]); setError(''); }} className={item.id === currentId ? 'chat-selected' : 'chat-session'}> + {item.title}#{item.id} · {item.batch_id ? `批次 ${item.batch_id}` : item.course_version_id ? `课程版本 ${item.course_version_id}` : '未限定范围'} + } /> + + {detail ? <> + 课程版本 {detail.course_version_id ?? '未指定'}批次 {detail.batch_id ?? '未指定'}学生项目 {detail.project_id ?? '未指定'} +
{detail.messages?.map((item: any) => + {item.content} + {item.tool_calls?.length > 0 && {item.tool_calls.map((call: any, index: number) => {call.name})}} + {item.citations?.length > 0 && <>引用 + + } />} + )} + {events.map((item, index) => {item.kind === 'tool' ? `${item.name}:${item.status}` : item.content}{item.error && {item.error}})} +
+ {detail.actions?.map((action: any) => reload(detail.id)} />)} + {(indexTasks.length > 0 || batches.length > 0) && {indexTasks.slice(0, 5).map(task => 索引 #{task.id} {task.status}{task.error ? `:${task.error}` : ''})}{batches.slice(0, 3).map(batch => 批次 #{batch.id} {batch.status} {batch.progress_done}/{batch.progress_total})}} + {error && } + setDraft(e.target.value)} onPressEnter={e => { if (!e.shiftKey) { e.preventDefault(); send(); } }} /> + : 新建会话后即可提问。} + + setNewOpen(false)} onOk={create} okText="创建"> + setNewTitle(e.target.value)} placeholder="会话名称" /> + ({ value: b.id, label: `#${b.id} · ${b.status}` }))} onChange={setBatchId} /> +