diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index e9f4756..b70aaf8 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -19,6 +19,7 @@ import { DynamicReview } from './DynamicReview'; import { ImportPreflightCard } from './ImportPreflightCard'; import { LiteCodeHighlighter } from './LiteCodeHighlighter'; import { LiteMermaid } from './LiteMermaid'; +import type { ResolveImportPreflightInput } from './api'; import type { AiAttachment, AiChatMessage, @@ -189,6 +190,11 @@ export interface AiMessageContentProps { type: AiReviewSectionType, ) => AiReviewSchema | Promise | void; onOpenImportWizard?: (runId: string) => void; + onResolveImportPreflight?: ( + messageId: number | undefined, + preflight: AiImportPreflight, + input: ResolveImportPreflightInput, + ) => Promise | void; } export const AiMessageContent: React.FC = ({ @@ -202,6 +208,7 @@ export const AiMessageContent: React.FC = ({ onConfirmReviewStep, onConfirmReviewGroup, onOpenImportWizard, + onResolveImportPreflight, }) => { const streaming = status === 'loading' || status === 'updating'; const formSubmission = message.metadata?.a2uiSubmit; @@ -317,7 +324,22 @@ export const AiMessageContent: React.FC = ({ {(() => { const preflight = message.metadata?.a2uiImportPreflight; if (!preflight || typeof preflight !== 'object' || Array.isArray(preflight)) return null; - return ; + const typedPreflight = preflight as AiImportPreflight; + return ( + + onResolveImportPreflight( + typeof message.id === 'number' ? message.id : undefined, + typedPreflight, + input, + ) + : undefined + } + /> + ); })()} {(() => { const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined; diff --git a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx index 2bfce5b..6ef111a 100644 --- a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx +++ b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx @@ -1,10 +1,14 @@ -import React from 'react'; -import { TableOutlined } from '@ant-design/icons'; -import { Card, Flex, Space, Tag, Typography } from 'antd'; +import React, { useMemo, useState } from 'react'; +import { CheckOutlined, TableOutlined } from '@ant-design/icons'; +import { Alert, Button, Card, Flex, Input, Radio, Select, Space, Tag, Typography } from 'antd'; +import type { ResolveImportPreflightInput } from './api'; import type { AiImportPreflight, + AiImportPreflightQuestion, AiImportPreflightVerdict, + AiReviewSectionType, } from './types'; +import { STEP_FIELDS } from '../ImportWizard/types'; const VERDICT_META: Record< AiImportPreflightVerdict, @@ -15,14 +19,141 @@ const VERDICT_META: Record< blocked: { label: '暂无法导入', color: 'error' }, }; +interface SettingsDraft { + organization?: string | null; + updateExisting?: boolean; + duplicatePolicy?: 'error' | 'skip'; + skipUnmatched?: boolean; +} + +function defaultSettings(questions: AiImportPreflightQuestion[]): SettingsDraft { + const settings: SettingsDraft = {}; + for (const question of questions) { + if (question.type === 'update' && typeof question.default === 'boolean') { + settings.updateExisting = question.default; + } else if ( + question.type === 'duplicate' && + (question.default === 'error' || question.default === 'skip') + ) { + settings.duplicatePolicy = question.default; + } else if (question.type === 'reference' && typeof question.default === 'boolean') { + settings.skipUnmatched = question.default; + } else if (question.type === 'organization') { + settings.organization = typeof question.default === 'string' ? question.default : ''; + } + } + return settings; +} + +function missingFieldKeys( + stepKey: AiReviewSectionType, + missingLabels: string[], +): string[] { + const fields = STEP_FIELDS[stepKey] ?? []; + return missingLabels + .map((label) => fields.find((field) => field.label === label)?.key) + .filter((key): key is string => Boolean(key)); +} + +export interface ImportPreflightCardProps { + preflight: AiImportPreflight; + onResolve?: (input: ResolveImportPreflightInput) => Promise | void; +} + /** - * 上传 Excel 后的“可插入性预检报告”卡片:展示判定结论、 - * 分阶段统计、阻断原因、待确认问题与下一步建议。 + * 上传 Excel 后的“可插入性预检”交互卡:展示判定结论、分阶段统计与阻断原因, + * 并让用户直接在卡内确认列映射与导入策略,点击「生成导入向导」由服务端建任务。 + * 对应 A2UI demo 中“同一 surface 内完成用户交互 + 增量更新”的交互方式。 */ -export const ImportPreflightCard: React.FC<{ preflight: AiImportPreflight }> = ({ +export const ImportPreflightCard: React.FC = ({ preflight, + onResolve, }) => { const verdict = VERDICT_META[preflight.verdict] ?? VERDICT_META.needs_input; + const resolved = preflight.resolved === true; + const permitted = new Set(preflight.permittedSteps ?? []); + const hasStages = preflight.stages.length > 0; + const showActions = !resolved && hasStages && Boolean(onResolve); + + const [mappingDraft, setMappingDraft] = useState>>( + () => + Object.fromEntries( + preflight.stages.map((stage) => [stage.stepKey, { ...stage.mapping }]), + ), + ); + const [settingsDraft, setSettingsDraft] = useState(() => + defaultSettings(preflight.questions), + ); + const [customOrganization, setCustomOrganization] = useState(false); + const [organizationInput, setOrganizationInput] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const organizationQuestion = preflight.questions.find( + (question) => question.type === 'organization', + ); + const organizationOptions = useMemo(() => { + const options = [...(organizationQuestion?.options ?? [])]; + if (!options.some((option) => option.value === '')) { + options.push({ label: '忽略校区', value: '' }); + } + return [ + ...options, + { label: '手动输入校区', value: '__custom__' }, + ]; + }, [organizationQuestion]); + + const setMapping = (stepKey: string, field: string, header?: string) => { + setMappingDraft((prev) => { + const current = { ...prev[stepKey] }; + if (header) current[field] = header; + else delete current[field]; + return { ...prev, [stepKey]: current }; + }); + }; + + const handleResolve = async () => { + if (!onResolve) return; + setSubmitting(true); + setError(null); + const settings: SettingsDraft = { ...settingsDraft }; + if (organizationQuestion) { + if (customOrganization) { + const value = organizationInput.trim(); + if (!value) { + setError('请先输入校区名称,或选择“忽略校区”'); + setSubmitting(false); + return; + } + settings.organization = value; + } else if (settings.organization === '__custom__') { + settings.organization = ''; + } + } + try { + await onResolve({ + mapping: mappingDraft, + settings: { + ...(settings.organization ? { organization: settings.organization } : {}), + ...(settings.updateExisting !== undefined + ? { updateExisting: settings.updateExisting } + : {}), + ...(settings.duplicatePolicy ? { duplicatePolicy: settings.duplicatePolicy } : {}), + ...(settings.skipUnmatched !== undefined + ? { skipUnmatched: settings.skipUnmatched } + : {}), + }, + }); + } catch (resolveError) { + setError(resolveError instanceof Error ? resolveError.message : '导入向导生成失败'); + setSubmitting(false); + } + }; + + const nonMappingQuestions = preflight.questions.filter( + (question) => question.type !== 'mapping', + ); + return ( = ( Excel 导入预检 {verdict.label} + {resolved && ( + }> + 已生成向导 + + )} } > + {resolved && ( + + )} + {error && } + {preflight.stages.length > 0 ? ( {preflight.stages.map((stage) => ( @@ -45,6 +186,9 @@ export const ImportPreflightCard: React.FC<{ preflight: AiImportPreflight }> = ( {stage.sheetNames.join('、')} + {stage.stepKey && !permitted.has(stage.stepKey) && ( + 无提交权限 + )} 共 {stage.total} 行 @@ -76,22 +220,139 @@ export const ImportPreflightCard: React.FC<{ preflight: AiImportPreflight }> = ( )} - {preflight.questions.length > 0 && ( - - - 需要确认 - - {preflight.questions.map((question) => ( - - {question.label} - {question.description && ( - - {question.description} - + {showActions && ( + <> + {preflight.stages + .filter( + (stage) => + stage.missingRequired.length > 0 && + stage.stepKey && + permitted.has(stage.stepKey), + ) + .map((stage) => ( + + + 列映射 + + {stage.label} + + + 缺少:{stage.missingRequired.join('、')} + + + + {missingFieldKeys(stage.stepKey, stage.missingRequired).map((field) => { + const fieldMeta = STEP_FIELDS[stage.stepKey]?.find( + (item) => item.key === field, + ); + return ( + { + if (value === '__custom__') { + setCustomOrganization(true); + } else { + setCustomOrganization(false); + setSettingsDraft((prev) => ({ ...prev, organization: value })); + } + }} + /> + {customOrganization && ( + setOrganizationInput(event.target.value)} + /> + )} + + ) : ( + { + const value = event.target.value; + if (question.type === 'update') { + setSettingsDraft((prev) => ({ + ...prev, + updateExisting: value === 'true', + })); + } else if (question.type === 'duplicate') { + setSettingsDraft((prev) => ({ + ...prev, + duplicatePolicy: value as 'error' | 'skip', + })); + } else { + setSettingsDraft((prev) => ({ + ...prev, + skipUnmatched: value === 'true', + })); + } + }} + > + {(question.options ?? []).map((option) => ( + + {option.label} + + ))} + )} - + ))} - + + + + 未确认的列可在向导中继续调整 + + + + )} {preflight.nextSteps.length > 0 && ( diff --git a/apps/admin/src/components/AiChat/api.integration.test.ts b/apps/admin/src/components/AiChat/api.integration.test.ts index 94d113e..5fdeb30 100644 --- a/apps/admin/src/components/AiChat/api.integration.test.ts +++ b/apps/admin/src/components/AiChat/api.integration.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import api from '../../api'; -import { aiChatApi } from './api'; +import { + aiChatApi, + resolveImportPreflight, + type ImportPreflightResolveUpdate, +} from './api'; describe('AI chat API adapter', () => { afterEach(() => vi.restoreAllMocks()); @@ -71,4 +75,55 @@ describe('AI chat API adapter', () => { '/ai/chat/reviews/review-1/types/checkins/confirm', ); }); + + it('resolveImportPreflight 流式解析 SSE 事件并回调预检卡/向导更新', async () => { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + 'event: ui.import_preflight\ndata: {"preflight":{"verdict":"ready","resolved":true,"runId":"run-1"}}\n\n', + ), + ); + controller.enqueue( + encoder.encode( + 'event: ui.import_wizard\ndata: {"wizard":{"runId":"run-1","fileName":"students.xlsx","sheets":[],"steps":[]}}\n\n', + ), + ); + controller.enqueue(encoder.encode('event: done\ndata: {}\n\n')); + controller.close(); + }, + }); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, body }); + vi.stubGlobal('fetch', fetchMock); + + const updates: ImportPreflightResolveUpdate[] = []; + await resolveImportPreflight( + 42, + { mapping: { students: { name: '姓名' } }, settings: { updateExisting: false } }, + (update) => updates.push(update), + ); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/ai/chat/import/preflight/42/resolve/stream', + expect.objectContaining({ method: 'POST' }), + ); + expect(updates).toEqual([ + { preflight: { verdict: 'ready', resolved: true, runId: 'run-1' } }, + { wizard: { runId: 'run-1', fileName: 'students.xlsx', sheets: [], steps: [] } }, + ]); + }); + + it('resolveImportPreflight 非 2xx 时抛出服务端错误', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ message: '列映射「x」不在工作表表头中' }), + }); + vi.stubGlobal('fetch', fetchMock); + + await expect( + resolveImportPreflight(42, { mapping: {} }, () => undefined), + ).rejects.toThrow('列映射「x」不在工作表表头中'); + }); }); diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts index f336abc..c1683b9 100644 --- a/apps/admin/src/components/AiChat/api.ts +++ b/apps/admin/src/components/AiChat/api.ts @@ -1,8 +1,11 @@ import api from '../../api'; +import { useUserStore } from '../../store/user/userStore'; import type { AiApiResponse, AiAttachment, AiConversation, + AiImportPreflight, + AiImportWizard, AiMessagePage, AiReviewSchema, AiReviewSection, @@ -83,3 +86,89 @@ export const aiChatApi = { export function conversationStreamUrl(id: number): string { return `/api${basePath}/${id}/stream`; } + +export interface ResolveImportPreflightInput { + mapping?: Record>; + settings?: { + organization?: string | null; + updateExisting?: boolean; + duplicatePolicy?: 'error' | 'skip'; + skipUnmatched?: boolean; + }; +} + +export interface ImportPreflightResolveUpdate { + preflight?: AiImportPreflight; + wizard?: AiImportWizard; +} + +/** + * 用户在预检卡内确认映射与策略后,调用服务端 resolve 流直接生成导入向导。 + * 与普通对话不同:不新增用户消息,只原位更新目标消息的预检卡/向导卡。 + */ +export async function resolveImportPreflight( + messageId: number, + input: ResolveImportPreflightInput, + onUpdate: (update: ImportPreflightResolveUpdate) => void, +): Promise { + const token = useUserStore.getState().token; + const response = await fetch(`/api/ai/chat/import/preflight/${messageId}/resolve/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + clientRequestId: crypto.randomUUID(), + mapping: input.mapping ?? {}, + settings: input.settings ?? {}, + }), + }); + if (response.status === 401) { + useUserStore.getState().logout(); + window.location.href = '/login'; + throw new Error('登录已失效'); + } + if (!response.ok || !response.body) { + let message = '导入向导生成失败'; + try { + const body = (await response.json()) as { message?: string; error?: string }; + message = body?.message ?? body?.error ?? message; + } catch { + // 保留默认错误信息 + } + throw new Error(message); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split('\n\n'); + buffer = blocks.pop() ?? ''; + for (const block of blocks) { + const event = /^event:\s*(.+)$/m.exec(block)?.[1]?.trim() ?? 'message'; + const dataLine = /^data:\s*(.+)$/m.exec(block)?.[1]?.trim(); + if (!dataLine || dataLine === '[DONE]') continue; + let data: Record; + try { + data = JSON.parse(dataLine) as Record; + } catch { + continue; + } + if (event === 'error') { + const message = + typeof data.message === 'string' ? data.message : '导入向导生成失败'; + throw new Error(message); + } + if (event === 'ui.import_preflight' && data.preflight) { + onUpdate({ preflight: data.preflight as AiImportPreflight }); + } else if (event === 'ui.import_wizard' && data.wizard) { + onUpdate({ wizard: data.wizard as AiImportWizard }); + } + } + } +} diff --git a/apps/admin/src/components/AiChat/bubble.integration.test.tsx b/apps/admin/src/components/AiChat/bubble.integration.test.tsx index 5b67c9d..b7fab93 100644 --- a/apps/admin/src/components/AiChat/bubble.integration.test.tsx +++ b/apps/admin/src/components/AiChat/bubble.integration.test.tsx @@ -7,6 +7,7 @@ import { AiMessageContent } from './AiMessageContent'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; +import type { ResolveImportPreflightInput } from './api'; import type { AiChatMessage, AiChartSchema, AiReviewSchema } from './types'; let container: HTMLDivElement | null = null; @@ -532,6 +533,108 @@ describe('AI chat bubble rendering', () => { expect(openedRunId).toBe('run-1'); }); + it('preflight card resolves mapping and settings inline', async () => { + let resolvedMessageId: number | undefined; + let resolvedInput: ResolveImportPreflightInput | undefined; + const message: AiChatMessage = { + id: 42, + role: 'assistant', + content: '请在预检卡内确认列映射与策略。', + reasoningContent: '', + toolRuns: [], + attachments: [], + metadata: { + a2uiImportPreflight: { + verdict: 'blocked', + stages: [ + { + stepKey: 'students', + label: '学生档案', + sheetNames: ['学生'], + headers: ['姓名', '学号', '手机号'], + total: 2, + create: 2, + update: 0, + error: 0, + skip: 0, + mapping: { name: '姓名', studentNo: '学号' }, + missingRequired: [], + }, + { + stepKey: 'rooms', + label: '宿舍档案', + sheetNames: ['宿舍'], + headers: ['宿舍号', '楼栋', '容量'], + total: 1, + create: 1, + update: 0, + error: 0, + skip: 0, + mapping: { roomNumber: '宿舍号' }, + missingRequired: ['容量'], + }, + ], + blocks: [ + { + code: 'missing_columns', + label: '缺少必填列', + stepKeys: ['rooms'], + message: '宿舍阶段缺少容量列', + count: 1, + }, + ], + questions: [ + { + key: 'update', + type: 'update', + label: '文件中有 1 行已匹配现有记录', + options: [ + { label: '更新已有记录', value: 'true' }, + { label: '跳过已有记录', value: 'false' }, + ], + default: true, + }, + ], + nextSteps: [], + permittedSteps: ['students', 'rooms'], + resolved: false, + runId: null, + }, + }, + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + { + resolvedMessageId = messageId; + resolvedInput = input; + }} + />, + ); + }); + + expect(container.textContent).toContain('容量'); + const resolveButton = Array.from(container.querySelectorAll('button')).find((item) => + item.textContent?.includes('生成导入向导'), + ) as HTMLButtonElement | undefined; + expect(resolveButton).toBeDefined(); + await act(async () => { + resolveButton?.click(); + }); + + expect(resolvedMessageId).toBe(42); + expect(resolvedInput?.mapping).toMatchObject({ + students: { name: '姓名', studentNo: '学号' }, + rooms: { roomNumber: '宿舍号' }, + }); + expect(resolvedInput?.settings).toMatchObject({ updateExisting: true }); + }); + it('renders model retrying hint while waiting for the upstream retry', async () => { const message: AiChatMessage = { role: 'assistant', diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index 8987091..ac2fc59 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -121,6 +121,8 @@ export interface AiImportPreflightStage { stepKey: AiReviewSectionType; label: string; sheetNames: string[]; + /** 该阶段所有工作表的表头并集,供预检卡列映射选择。 */ + headers?: string[]; total: number; create: number; update: number; @@ -161,6 +163,11 @@ export interface AiImportPreflight { blocks: AiImportPreflightBlock[]; questions: AiImportPreflightQuestion[]; nextSteps: AiImportPreflightNextStep[]; + attachmentId?: number; + headerRow?: number; + permittedSteps?: AiReviewSectionType[]; + resolved?: boolean; + runId?: string | null; errorSamples?: Array<{ code: string; stepKey: AiReviewSectionType; diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index 552cb4c..a6607f9 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -6,7 +6,7 @@ import { App } from 'antd'; import type { UploadFile, UploadProps } from 'antd'; import { message } from '../../ui/app-message'; import { useSettingsStore } from '../../store/settings/settingsStore'; -import { aiChatApi } from './api'; +import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api'; import { AiMessageContent } from './AiMessageContent'; import { mapHistoryMessage } from './message-mappers'; import { GongxueAiChatProvider } from './provider'; @@ -24,6 +24,7 @@ import type { AiChatMessage, AiChatMessageStatus, AiFormSchema, + AiImportPreflight, AiReviewSchema, AiReviewSection, AiReviewSectionType, @@ -409,6 +410,31 @@ export function useAiChatMessageActions({ [provider, setMessage], ); + const resolvePreflight = useCallback( + async ( + messageId: number | undefined, + _preflight: AiImportPreflight, + input: ResolveImportPreflightInput, + ): Promise => { + if (typeof messageId !== 'number') { + throw new Error('消息尚未完成生成,请稍后再试'); + } + await resolveImportPreflight(messageId, input, (update) => { + setMessage(messageId, (info) => ({ + message: { + ...info.message, + metadata: { + ...info.message.metadata, + ...(update.preflight ? { a2uiImportPreflight: update.preflight } : {}), + ...(update.wizard ? { a2uiImportWizard: update.wizard } : {}), + }, + }, + })); + }); + }, + [setMessage], + ); + const customUpload = useCallback>(async (options) => { const file = options.file as File; if (attachmentsRef.current.length >= 5) { @@ -524,6 +550,7 @@ export function useAiChatMessageActions({ onConfirmReviewStep={confirmReviewStep} onConfirmReviewGroup={confirmReviewGroup} onOpenImportWizard={setImportWizardRunId} + onResolveImportPreflight={resolvePreflight} /> ), })), @@ -537,6 +564,7 @@ export function useAiChatMessageActions({ isRequesting, messages, reloadMessage, + resolvePreflight, setImportWizardRunId, submitForm, submitReview,