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 = { 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' : 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(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} {safeUrl(item.url) && 查看页面文字} {safeUrl(item.image_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' && safeUrl(action.result?.download_url) && 下载导出文件} {action.status === 'failed' && {action.result?.error}} ; } 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
safeUrl(url) || ''} components={{ a: ({ href, children }) => safeUrl(href) ? {children} : {children}, img: ({ alt, src }) => safeUrl(src) ? {alt || '查看图片'} : {alt} }}> {text || ''}
; } function CitationCards({ items }: { items: any[] }) { return
{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
{name}{place && {place}}
{quote && (quote.length > 320 ?
{quote.slice(0, 240)}… 展开原文

{quote}

:

{quote}

)} {sourceHref && {sourceLabel(sourceHref)}} {scoreHref && scoreHref !== sourceHref && 查看评分} {imageHref && 查看图像}
; })}
; } function Citations({ items }: { items: any[] }) { const seen = new Set(); 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
引用 · {unique.length}
; } const argumentLabels: Record = { 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 = 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
工具调用 · {calls.length} {active > 0 && {active} 项进行中}{failed > 0 && {failed} 项失败}
{calls.map((call, index) =>
{call.name || '未知工具'} {call.status === 'running' ? '进行中' : call.status === 'failed' ? '失败' : call.status === 'skipped' ? '已跳过' : '成功'} {call.status === 'running' ? '正在执行' : call.error ? String(call.error).slice(0, 80) : call.summary || (typeof call.result === 'string' ? call.result : '已完成')}
调用参数
{JSON.stringify(visibleArguments(call), null, 2)}
{call.error ? '错误' : '调用结果'}
{call.error || (typeof call.result === 'string' ? call.result : JSON.stringify(call.result ?? '运行中', null, 2))}
)}
; } export default function Chat() { const [conversations, setConversations] = useState([]); const [currentId, setCurrentId] = useState(); const [token, setToken] = useState(newToken); const [scope, setScope] = useState({}); const [details, setDetails] = useState>({}); const [composers, setComposers] = useState>({}); const [pending, setPending] = useState>({}); const [scopeSaving, setScopeSaving] = useState(false); const [deleteId, setDeleteId] = useState(); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(''); const [versions, setVersions] = useState([]); const [batches, setBatches] = useState([]); const [projects, setProjects] = useState([]); const selection = useRef(`draft-${token}`); const sending = useRef>(new Set()); const deletedIds = useRef>(new Set()); const detailSequence = useRef>({}); const subscriptions = useRef>(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 <>会话可直接提问;写入操作会先显示确认卡片。首次发送前可调整限定范围。 新建}> { event.stopPropagation(); setDeleteError(''); setDeleteId(item.id); }}>删除]} onClick={() => open(item.id)}> {item.title}#{item.id} · {item.batch_id ? `批次 ${item.batch_id}` : item.course_version_id ? `课程版本 ${item.course_version_id}` : '未限定范围'}{item.running && 生成中} } /> 范围已锁定}> {!currentId && 草稿只在当前页面保存,发送第一条消息后才创建会话。}
!currentScope.course_version_id || b.course_version_id === currentScope.course_version_id).map(b => ({ value: b.id, label: `#${b.id} · ${b.status}` }))} onChange={changeBatch} />