diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index b70aaf8..f9e10de 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -56,6 +56,9 @@ const toolLabels: Record = { search_classrooms: '查询教室', search_classroom_rentals: '查询教室租用', get_sync_status: '查询同步状态', + get_business_context: '读取业务流程', + get_entity_schema: '读取实体字典', + get_pending_tasks: '查询业务待办', }; const markdownComponents = { diff --git a/apps/admin/src/components/AiChat/DynamicForm.tsx b/apps/admin/src/components/AiChat/DynamicForm.tsx index 4de1f7d..8148f58 100644 --- a/apps/admin/src/components/AiChat/DynamicForm.tsx +++ b/apps/admin/src/components/AiChat/DynamicForm.tsx @@ -84,6 +84,7 @@ const FormPreview: React.FC = ({ form, disabled, onAction }) = ); if (!form) return null; const finished = Boolean(form.submitted) || form.status === 'submitted'; + const expired = form.status === 'expired'; const handleFinish = (values: Record) => { onAction?.('form:submit', { values: normalizeValues(form.fields, values) }); @@ -97,7 +98,9 @@ const FormPreview: React.FC = ({ form, disabled, onAction }) = {form.description} )} - {finished ? ( + {expired ? ( + + ) : finished ? ( ) : (
{ expect(container.textContent).toContain('已提交'); }); + it('renders an expired A2UI form as disabled without submit action', async () => { + let submitted = false; + const el = document.createElement('div'); + container = el; + document.body.appendChild(el); + root = createRoot(el); + + await act(async () => { + root?.render( + { + submitted = true; + }} + />, + ); + }); + + expect(el.textContent).toContain('已失效'); + expect(el.querySelector('button[type="submit"]')).toBeNull(); + await act(async () => { + const buttons = Array.from(el.querySelectorAll('button')); + buttons.forEach((button) => button.click()); + }); + expect(submitted).toBe(false); + }); + it('renders an A2UI review card and submits via the confirm button', async () => { let submittedId: string | null = null; const review: AiReviewSchema = { diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index 60ea6ee..baf9410 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -78,6 +78,52 @@ describe('AI chat history mapper', () => { expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' }); }); + it('restores uiArtifacts from message metadata and derives legacy lists', () => { + const mapped = mapHistoryMessage({ + id: 8, + role: 'assistant', + content: '请处理', + reasoningContent: null, + status: 'completed', + errorCode: null, + createdAt: '2026-07-23T00:00:00.000Z', + metadata: { + uiArtifacts: [ + { + id: 'form-10', + type: 'form', + status: 'submitted', + messageId: 8, + conversationId: 3, + payload: { + id: 'form-10', + title: '新增学生', + status: 'submitted', + fields: [], + }, + }, + { + id: 'review-10', + type: 'review', + status: 'expired', + messageId: 8, + conversationId: 3, + payload: { + id: 'review-10', + title: '旧预览', + status: 'expired', + sections: [], + }, + }, + ], + }, + }); + + expect(mapped.message.uiArtifacts).toHaveLength(2); + expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-10', status: 'submitted' }); + expect(mapped.message.reviews?.[0]).toMatchObject({ id: 'review-10', status: 'expired' }); + }); + it('restores a persisted A2UI review from message metadata', () => { const mapped = mapHistoryMessage({ id: 6, diff --git a/apps/admin/src/components/AiChat/message-mappers.ts b/apps/admin/src/components/AiChat/message-mappers.ts index e3a1390..68688a8 100644 --- a/apps/admin/src/components/AiChat/message-mappers.ts +++ b/apps/admin/src/components/AiChat/message-mappers.ts @@ -3,11 +3,13 @@ import type { AiChatMessage, AiChatMessageStatus, AiChartSchema, + AiArtifactSchema, AiFormSchema, AiMessageRecord, AiReviewSchema, AiToolRun, } from './types'; +import { mergeArtifactIntoMessage } from './provider'; function mapStatus(record: AiMessageRecord): AiChatMessageStatus { if (record.status === 'pending') return 'loading'; @@ -50,24 +52,40 @@ function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined { return [a2uiChart as AiChartSchema]; } +function historyArtifacts(record: AiMessageRecord) { + const artifacts = record.metadata?.uiArtifacts; + if (!Array.isArray(artifacts)) return undefined; + return artifacts.filter( + (item): item is AiArtifactSchema => + Boolean(item) && typeof item === 'object' && typeof (item as AiArtifactSchema).id === 'string', + ); +} + export function mapHistoryMessage(record: AiMessageRecord): MessageInfo { + const artifacts = historyArtifacts(record); + const baseMessage = { + id: record.id, + role: record.role, + content: record.content || '', + reasoningContent: record.reasoningContent || '', + toolRuns: (record.toolRuns || []).map(normalizeToolRun), + attachments: record.attachments ?? [], + forms: historyForms(record), + reviews: historyReviews(record), + charts: historyCharts(record), + replyToMessageId: record.replyToMessageId, + metadata: record.metadata, + error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined, + cancelled: record.status === 'cancelled', + }; + if (artifacts) { + for (const artifact of artifacts) { + mergeArtifactIntoMessage(baseMessage as AiChatMessage, artifact); + } + } return { id: record.id, status: mapStatus(record), - message: { - id: record.id, - role: record.role, - content: record.content || '', - reasoningContent: record.reasoningContent || '', - toolRuns: (record.toolRuns || []).map(normalizeToolRun), - attachments: record.attachments ?? [], - forms: historyForms(record), - reviews: historyReviews(record), - charts: historyCharts(record), - replyToMessageId: record.replyToMessageId, - metadata: record.metadata, - error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined, - cancelled: record.status === 'cancelled', - }, + message: baseMessage as AiChatMessage, }; } diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index d1f1f43..c552dfe 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -154,6 +154,41 @@ describe('AI chat SSE message reducer', () => { expect(message.forms?.[1]).toMatchObject({ id: 'form-2' }); }); + it('merges ui.artifact events into uiArtifacts and legacy lists by id', () => { + let message = reduceAiSseMessage(undefined, { + event: 'ui.artifact', + data: JSON.stringify({ + messageId: 12, + artifact: { + id: 'form-1', + type: 'form', + status: 'pending', + messageId: 12, + conversationId: 3, + payload: { id: 'form-1', title: '新增学生', fields: [] }, + }, + }), + }); + message = reduceAiSseMessage(message, { + event: 'ui.artifact', + data: JSON.stringify({ + messageId: 12, + artifact: { + id: 'review-1', + type: 'review', + status: 'expired', + messageId: 12, + conversationId: 3, + payload: { id: 'review-1', title: '旧导入预览', status: 'expired', sections: [] }, + }, + }), + }); + + expect(message.uiArtifacts).toHaveLength(2); + expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' }); + expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'expired' }); + }); + it('stores ui.import_preflight in message metadata and restores it from completed message', () => { const preflight = { verdict: 'needs_input', diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index e8f7309..970fdef 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -8,6 +8,7 @@ import { usePermissionStore } from '../../store/permission/permissionStore'; import { useUserStore } from '../../store/user/userStore'; import type { AiAttachment, + AiArtifactSchema, AiChatInput, AiChatMessage, AiChartSchema, @@ -34,6 +35,7 @@ interface AiSsePayload { durationMs?: number | null; attachment?: AiAttachment; form?: AiFormSchema; + artifact?: AiArtifactSchema; review?: AiReviewSchema; chart?: AiChartSchema; preflight?: AiImportPreflight; @@ -62,6 +64,7 @@ function emptyAssistant(): AiChatMessage { toolRuns: [], attachments: [], forms: [], + uiArtifacts: [], }; } @@ -165,10 +168,50 @@ function applyMessagePayload( message.charts, (nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, ); + const artifacts = nested.metadata?.uiArtifacts; + if (Array.isArray(artifacts)) { + for (const artifact of artifacts) { + if (artifact && typeof artifact === 'object' && typeof artifact.id === 'string') { + mergeArtifactIntoMessage(message, artifact as AiArtifactSchema); + } + } + } message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId; message.metadata = nested.metadata ?? message.metadata; } +/** + * 将统一 artifact 归入 uiArtifacts,并按类型派发到 legacy 列表。 + */ +export function mergeArtifactIntoMessage( + message: AiChatMessage, + artifact: AiArtifactSchema, +): AiChatMessage { + message.uiArtifacts = mergeById(message.uiArtifacts, artifact); + const payload = + artifact.payload && typeof artifact.payload === 'object' + ? (artifact.payload as Record) + : {}; + if (artifact.type === 'form') { + message.forms = mergeForms(message.forms, payload as unknown as AiFormSchema); + } else if (artifact.type === 'review') { + message.reviews = mergeById( + message.reviews, + payload as unknown as AiReviewSchema, + ); + } else if (artifact.type === 'chart') { + message.charts = mergeById( + message.charts, + payload as unknown as AiChartSchema, + ); + } else if (artifact.type === 'import_preflight') { + message.metadata = { ...message.metadata, a2uiImportPreflight: payload }; + } else if (artifact.type === 'import_wizard') { + message.metadata = { ...message.metadata, a2uiImportWizard: payload }; + } + return message; +} + export function reduceAiSseMessage( originMessage: AiChatMessage | undefined, chunk?: AiSseChunk, @@ -198,6 +241,8 @@ export function reduceAiSseMessage( message.reviews = mergeById(message.reviews, payload.review); } else if (event === 'ui.chart' && payload.chart) { message.charts = mergeById(message.charts, payload.chart); + } else if (event === 'ui.artifact' && payload.artifact) { + mergeArtifactIntoMessage(message, payload.artifact); } else if (event === 'ui.import_preflight' && payload.preflight) { message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight }; } else if (event === 'ui.import_wizard' && payload.wizard) { @@ -319,6 +364,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider< > { /** Routes events that target another (already streamed) message. */ onExternalReview?: (messageId: number, review: AiReviewSchema) => void; + onExternalArtifact?: (messageId: number, artifact: AiArtifactSchema) => void; constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) { super({ @@ -404,6 +450,30 @@ export class GongxueAiChatProvider extends AbstractChatProvider< transformMessage(info: TransformMessage): AiChatMessage { const { event, payload } = parseSsePayload(info.chunk); + if ( + event === 'ui.artifact' && + payload.artifact && + typeof payload.messageId === 'number' && + info.originMessage?.id !== payload.messageId + ) { + this.onExternalArtifact?.(payload.messageId, payload.artifact); + return info.originMessage ?? emptyAssistant(); + } + if ( + event === 'ui.form' && + payload.form && + typeof payload.messageId === 'number' && + info.originMessage?.id !== payload.messageId + ) { + this.onExternalArtifact?.(payload.messageId, { + id: payload.form.id, + type: 'form', + status: payload.form.status ?? 'pending', + messageId: payload.messageId, + payload: payload.form, + }); + return info.originMessage ?? emptyAssistant(); + } if ( event === 'ui.review' && payload.review && diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index ac2fc59..31bac56 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -52,7 +52,7 @@ export interface AiFormSchema { description?: string | null; submitLabel?: string; fields: AiFormField[]; - status?: 'pending' | 'submitted'; + status?: 'pending' | 'submitted' | 'expired'; } export interface AiReviewColumn { @@ -98,6 +98,27 @@ export interface AiChartSchema { rows: AiReviewRow[]; } +export type AiArtifactType = + | 'form' + | 'review' + | 'chart' + | 'import_preflight' + | 'import_wizard'; + +export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled'; + +export interface AiArtifactSchema { + id: string; + type: AiArtifactType; + status: AiArtifactStatus; + messageId: number; + conversationId?: number; + payload: T; + createdAt?: string | null; + submittedAt?: string | null; + supersededBy?: string | null; +} + export interface AiImportWizard { runId: string; fileName: string; @@ -216,6 +237,7 @@ export interface AiChatMessage { forms?: AiFormSchema[]; reviews?: AiReviewSchema[]; charts?: AiChartSchema[]; + uiArtifacts?: AiArtifactSchema[]; replyToMessageId?: number | null; metadata?: Record | null; retrying?: AiModelRetryInfo | null; diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index a6607f9..91a42dc 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -9,7 +9,7 @@ import { useSettingsStore } from '../../store/settings/settingsStore'; import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api'; import { AiMessageContent } from './AiMessageContent'; import { mapHistoryMessage } from './message-mappers'; -import { GongxueAiChatProvider } from './provider'; +import { GongxueAiChatProvider, mergeArtifactIntoMessage } from './provider'; import { emptyAssistant, MessageHoverActions, @@ -111,6 +111,11 @@ export function useAiChatMessageActions({ }, })); }; + provider.onExternalArtifact = (messageId, artifact) => { + setMessage(messageId, (info) => ({ + message: mergeArtifactIntoMessage(info.message, artifact), + })); + }; }, [provider, setMessage]); requestingRef.current = isRequesting; diff --git a/apps/server/src/agent-context/business-context.registry.ts b/apps/server/src/agent-context/business-context.registry.ts new file mode 100644 index 0000000..c6a8939 --- /dev/null +++ b/apps/server/src/agent-context/business-context.registry.ts @@ -0,0 +1,318 @@ +import type { + BusinessEntity, + BusinessWorkflow, +} from './business-context.types'; + +/** + * 恭学系统业务上下文(代码内维护)。 + * + * v1 覆盖三大闭环:学生教学、住宿计费、教室租赁。实体字段字典用于 + * render_form 生成正确表单,工作流阶段/前置依赖/下一步建议用于引导 + * Agent 感知业务流程。 + */ +export const BUSINESS_ENTITIES: readonly BusinessEntity[] = [ + { + key: 'student', + name: '学生档案', + description: '学生基础档案(姓名、学号、手机号、性别等),是分班、入住、账单的前置数据。', + searchTool: 'search_students', + requiredPermissions: ['student:view'], + fields: [ + { key: 'name', label: '姓名', type: 'string', required: true }, + { key: 'studentNo', label: '学号', type: 'string' }, + { key: 'phone', label: '手机号', type: 'string' }, + { key: 'gender', label: '性别', type: 'enum', enumFrom: 'student.gender' }, + { key: 'idNumber', label: '身份证号', type: 'string' }, + ], + relations: [ + { entityKey: 'class', via: 'class_student', requiredFor: ['class'] }, + { entityKey: 'occupancy', via: 'occupancy', requiredFor: ['checkin'] }, + { entityKey: 'bill', via: 'bill', requiredFor: ['bill'] }, + { entityKey: 'exam', via: 'exam_score', requiredFor: ['exam'] }, + ], + }, + { + key: 'class', + name: '班级', + description: '班级档案与在读分班关系,排课、考勤、考试依赖班级。', + searchTool: 'search_classes', + requiredPermissions: ['class:view'], + fields: [ + { key: 'name', label: '班级名称', type: 'string', required: true }, + { key: 'grade', label: '年级', type: 'string' }, + { key: 'headTeacher', label: '班主任', type: 'string' }, + ], + relations: [ + { entityKey: 'student', via: 'class_student', requiredFor: ['class'] }, + { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + ], + }, + { + key: 'schedule', + name: '排课/日程', + description: '班级与教室的课程安排,考勤和教室日程依赖排课。', + searchTool: 'search_schedules', + requiredPermissions: ['schedule:view'], + fields: [ + { key: 'classId', label: '班级', type: 'number' }, + { key: 'classroomId', label: '教室', type: 'number' }, + { key: 'weekDay', label: '星期', type: 'number' }, + { key: 'startTime', label: '开始时间', type: 'string' }, + { key: 'endTime', label: '结束时间', type: 'string' }, + ], + relations: [ + { entityKey: 'class', via: 'class_schedule', requiredFor: ['schedule'] }, + { entityKey: 'classroom', via: 'class_schedule', requiredFor: ['schedule'] }, + ], + }, + { + key: 'attendance', + name: '考勤', + description: '按班级与日期的考勤记录(出勤/迟到/缺勤/请假)。', + searchTool: 'get_attendance_summary', + requiredPermissions: ['attendance:view'], + fields: [ + { key: 'studentId', label: '学生', type: 'number', required: true }, + { key: 'date', label: '日期', type: 'date', required: true }, + { key: 'status', label: '状态', type: 'enum', enumFrom: 'attendance.status' }, + ], + relations: [ + { entityKey: 'class', via: 'class_schedule', requiredFor: ['attendance'] }, + { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['attendance'] }, + ], + }, + { + key: 'exam', + name: '考试/成绩', + description: '考试安排与成绩记录,依赖班级与学生档案。', + searchTool: 'search_exams', + requiredPermissions: ['exam:view'], + fields: [ + { key: 'name', label: '考试名称', type: 'string', required: true }, + { key: 'date', label: '考试日期', type: 'date' }, + { key: 'subject', label: '科目', type: 'string' }, + ], + relations: [ + { entityKey: 'class', via: 'exam_score', requiredFor: ['exam'] }, + { entityKey: 'student', via: 'exam_score', requiredFor: ['exam'] }, + ], + }, + { + key: 'room', + name: '宿舍档案', + description: '宿舍/床位基础档案,入住登记的前置数据。', + searchTool: 'search_rooms', + requiredPermissions: ['room:view'], + fields: [ + { key: 'roomNumber', label: '宿舍号', type: 'string', required: true }, + { key: 'building', label: '楼栋', type: 'string' }, + { key: 'floor', label: '楼层', type: 'number' }, + { key: 'capacity', label: '容量', type: 'number', required: true }, + { key: 'roomType', label: '房型', type: 'string' }, + { key: 'monthlyRate', label: '月租金', type: 'number' }, + ], + relations: [ + { entityKey: 'occupancy', via: 'occupancy', requiredFor: ['checkin'] }, + ], + }, + { + key: 'occupancy', + name: '入住记录', + description: '学生入住/换宿/退宿记录,费用与账单依赖入住状态。', + searchTool: 'get_room_occupancy_summary', + requiredPermissions: ['occupancy:view'], + fields: [ + { key: 'studentId', label: '学生', type: 'number', required: true }, + { key: 'roomId', label: '宿舍', type: 'number', required: true }, + { key: 'checkInDate', label: '入住日期', type: 'date', required: true }, + { key: 'billingStartDate', label: '计费开始日期', type: 'date', required: true }, + { key: 'stayType', label: '住宿类型', type: 'enum', enumFrom: 'occupancy.stayType' }, + ], + relations: [ + { entityKey: 'student', via: 'occupancy', requiredFor: ['checkin'] }, + { entityKey: 'room', via: 'occupancy', requiredFor: ['checkin'] }, + { entityKey: 'bill', via: 'bill', requiredFor: ['bill'] }, + ], + }, + { + key: 'expense', + name: '费用', + description: '公共费用与个人费用,是生成账单的基础。', + searchTool: 'search_expenses', + requiredPermissions: ['expense:view'], + fields: [ + { key: 'type', label: '费用类型', type: 'string', required: true }, + { key: 'amount', label: '金额', type: 'number', required: true }, + { key: 'periodStart', label: '费用开始日期', type: 'date' }, + { key: 'periodEnd', label: '费用结束日期', type: 'date' }, + ], + relations: [ + { entityKey: 'occupancy', via: 'expense', requiredFor: ['expense'] }, + { entityKey: 'bill', via: 'bill_item', requiredFor: ['bill'] }, + ], + }, + { + key: 'bill', + name: '账单', + description: '按学生与账期生成的账单(公共+个人费用分摊),支持确认与付款。', + searchTool: 'search_bills', + requiredPermissions: ['bill:view'], + fields: [ + { key: 'studentId', label: '学生', type: 'number', required: true }, + { key: 'periodStart', label: '账期开始', type: 'date', required: true }, + { key: 'periodEnd', label: '账期结束', type: 'date', required: true }, + { key: 'totalAmount', label: '总金额', type: 'number', required: true }, + { key: 'status', label: '状态', type: 'enum', enumFrom: 'bill.status' }, + ], + relations: [ + { entityKey: 'student', via: 'bill', requiredFor: ['bill'] }, + { entityKey: 'occupancy', via: 'bill', requiredFor: ['bill'] }, + { entityKey: 'deposit', via: 'deposit', requiredFor: ['deposit'] }, + ], + }, + { + key: 'deposit', + name: '押金', + description: '押金收取与退还记录,通常在账单确认后处理。', + searchTool: 'search_deposits', + requiredPermissions: ['deposit:view'], + fields: [ + { key: 'studentId', label: '学生', type: 'number', required: true }, + { key: 'amount', label: '金额', type: 'number', required: true }, + { key: 'status', label: '状态', type: 'enum', enumFrom: 'deposit.status' }, + ], + relations: [ + { entityKey: 'student', via: 'deposit', requiredFor: ['deposit'] }, + { entityKey: 'bill', via: 'deposit', requiredFor: ['deposit'] }, + ], + }, + { + key: 'classroom', + name: '教室档案', + description: '教室基础档案,租赁与教室日程的前置数据。', + searchTool: 'search_classrooms', + requiredPermissions: ['classroom:view'], + fields: [ + { key: 'name', label: '教室名称', type: 'string', required: true }, + { key: 'building', label: '楼栋', type: 'string' }, + { key: 'capacity', label: '容量', type: 'number' }, + ], + relations: [ + { entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] }, + { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + ], + }, + { + key: 'organization', + name: '组织/校区', + description: '校区与组织归属,租赁双方与档案归属依赖组织。', + requiredPermissions: ['organization:view'], + fields: [ + { key: 'name', label: '名称', type: 'string', required: true }, + { key: 'code', label: '编码', type: 'string' }, + { key: 'isHost', label: '是否本部', type: 'boolean' }, + ], + relations: [ + { entityKey: 'student', via: 'organization', requiredFor: ['profile'] }, + { entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] }, + ], + }, + { + key: 'rental', + name: '教室租赁', + description: '教室租赁订单与合同(合同字段在租赁记录上),依赖教室与组织。', + searchTool: 'search_classroom_rentals', + requiredPermissions: ['rental:view'], + fields: [ + { key: 'classroomId', label: '教室', type: 'number', required: true }, + { key: 'lesseeOrganizationId', label: '承租方', type: 'number' }, + { key: 'startDate', label: '开始日期', type: 'date', required: true }, + { key: 'endDate', label: '结束日期', type: 'date', required: true }, + { key: 'dailyRate', label: '日租金', type: 'number' }, + { key: 'contractPath', label: '合同文件', type: 'string' }, + ], + relations: [ + { entityKey: 'classroom', via: 'classroom_rental', requiredFor: ['rental'] }, + { entityKey: 'organization', via: 'classroom_rental', requiredFor: ['rental'] }, + { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + ], + }, +]; + +export const BUSINESS_WORKFLOWS: readonly BusinessWorkflow[] = [ + { + key: 'student_teaching', + name: '学生教学闭环', + description: '学生档案 → 分班 → 排课 → 考勤 → 考试/成绩。', + requiredPermissions: ['student:view'], + stages: [ + { key: 'profile', label: '学生档案', entities: ['student'] }, + { key: 'class', label: '分班', entities: ['class', 'student'] }, + { key: 'schedule', label: '排课', entities: ['schedule', 'class'] }, + { key: 'attendance', label: '考勤', entities: ['attendance', 'schedule', 'class'] }, + { key: 'exam', label: '考试/成绩', entities: ['exam', 'class', 'student'] }, + ], + prerequisites: { + class: ['profile'], + schedule: ['class'], + attendance: ['schedule'], + exam: ['class'], + }, + nextSteps: [ + { key: 'after_profile', label: '学生档案完成 → 建议分班', after: ['profile'] }, + { key: 'after_class', label: '分班完成 → 建议排课', after: ['class'] }, + { key: 'after_attendance', label: '考勤稳定 → 建议记录考试成绩', after: ['attendance'] }, + ], + }, + { + key: 'dormitory_billing', + name: '住宿计费闭环', + description: '学生/宿舍档案 → 入住 → 费用录入 → 生成账单 → 押金。', + requiredPermissions: ['occupancy:view'], + stages: [ + { key: 'profile', label: '学生档案', entities: ['student'] }, + { key: 'room', label: '宿舍档案', entities: ['room'] }, + { key: 'checkin', label: '入住/换宿/退宿', entities: ['occupancy', 'student', 'room'] }, + { key: 'expense', label: '费用录入', entities: ['expense', 'occupancy'] }, + { key: 'bill', label: '生成账单', entities: ['bill', 'occupancy', 'expense'] }, + { key: 'deposit', label: '押金', entities: ['deposit', 'bill', 'student'] }, + ], + prerequisites: { + checkin: ['profile', 'room'], + expense: ['checkin'], + bill: ['checkin', 'expense'], + deposit: ['bill'], + }, + nextSteps: [ + { key: 'after_checkin', label: '入住完成 → 建议录入本月公共/个人费用', after: ['checkin'] }, + { key: 'after_expense', label: '费用录入完成 → 建议生成并确认账单', after: ['expense'] }, + { key: 'after_bill', label: '账单确认 → 建议标记已付并处理押金', after: ['bill'] }, + ], + }, + { + key: 'classroom_rental', + name: '教室租赁闭环', + description: '教室/组织档案 → 租赁订单 → 合同 → 教室日程。', + requiredPermissions: ['rental:view'], + stages: [ + { key: 'classroom', label: '教室档案', entities: ['classroom'] }, + { key: 'organization', label: '组织/校区', entities: ['organization'] }, + { key: 'rental', label: '租赁订单', entities: ['rental', 'classroom', 'organization'] }, + { key: 'contract', label: '合同', entities: ['rental'] }, + { key: 'schedule', label: '教室日程', entities: ['schedule', 'classroom', 'rental'] }, + ], + prerequisites: { + rental: ['classroom', 'organization'], + contract: ['rental'], + schedule: ['rental'], + }, + nextSteps: [ + { key: 'after_rental', label: '租赁订单生成 → 建议补充合同', after: ['rental'] }, + { key: 'after_contract', label: '合同归档 → 建议排定教室日程', after: ['contract'] }, + ], + }, +]; + +export const BUSINESS_WORKFLOW_KEYS: readonly string[] = BUSINESS_WORKFLOWS.map( + (workflow) => workflow.key, +); diff --git a/apps/server/src/agent-context/business-context.service.spec.ts b/apps/server/src/agent-context/business-context.service.spec.ts new file mode 100644 index 0000000..b8ecc3c --- /dev/null +++ b/apps/server/src/agent-context/business-context.service.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from '@jest/globals'; +import { BusinessContextService } from './business-context.service'; + +function principal(permissions: string[] = [], isSuperAdmin = false) { + return { permissions, isSuperAdmin }; +} + +describe('BusinessContextService', () => { + it('registry 中所有工作流/实体/权限引用都有效', () => { + const service = new BusinessContextService(); + expect(service.validateRegistry()).toEqual([]); + expect(() => service.assertValidRegistry()).not.toThrow(); + }); + + it('超级管理员可查看全部工作流与实体', () => { + const service = new BusinessContextService(); + const result = service.getBusinessContext(principal([], true)); + expect(result.workflows.length).toBeGreaterThanOrEqual(3); + expect(result.entities.length).toBeGreaterThanOrEqual(10); + expect(result.workflows.map((item) => item.key)).toEqual( + expect.arrayContaining(['student_teaching', 'dormitory_billing', 'classroom_rental']), + ); + }); + + it('按权限过滤实体,且只暴露有权限的工作流', () => { + const service = new BusinessContextService(); + const result = service.getBusinessContext(principal(['student:view', 'class:view'])); + const entityKeys = result.entities.map((item) => item.key); + expect(entityKeys).toContain('student'); + expect(entityKeys).toContain('class'); + expect(entityKeys).not.toContain('bill'); + expect(result.workflows.map((item) => item.key)).toEqual(['student_teaching']); + }); + + it('workflowKey 聚焦时只返回对应工作流与实体', () => { + const service = new BusinessContextService(); + const result = service.getBusinessContext( + principal(['room:view', 'occupancy:view', 'expense:view', 'bill:view', 'student:view'], false), + 'dormitory_billing', + ); + expect(result.workflows.map((item) => item.key)).toEqual(['dormitory_billing']); + expect(result.entities.map((item) => item.key)).toEqual( + expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'student']), + ); + }); + + it('未知或不可见 workflowKey 返回空结果', () => { + const service = new BusinessContextService(); + expect(service.getBusinessContext(principal([], false), 'unknown_loop').workflows).toEqual([]); + expect( + service.getBusinessContext(principal(['student:view'], false), 'dormitory_billing').workflows, + ).toEqual([]); + }); + + it('getEntitySchema 返回字段字典与关系,未知/无权限实体返回 null', () => { + const service = new BusinessContextService(); + const student = service.getEntitySchema(principal(['student:view']), 'student'); + expect(student?.fields.map((field) => field.key)).toEqual( + expect.arrayContaining(['name', 'phone', 'gender', 'studentNo']), + ); + expect(student?.relations.some((relation) => relation.entityKey === 'class')).toBe(true); + expect(service.getEntitySchema(principal(['student:view']), 'bill')).toBeNull(); + expect(service.getEntitySchema(principal(['student:view']), 'unknown')).toBeNull(); + }); + + it('suggestNextSteps 只返回已完成阶段对应的下一步建议', () => { + const service = new BusinessContextService(); + const steps = service.suggestNextSteps(principal([], true), ['profile', 'checkin']); + const labels = steps.map((item) => item.label); + expect(labels).toEqual( + expect.arrayContaining([expect.stringContaining('费用')]), + ); + expect(labels.some((item) => item.includes('账单'))).toBe(false); + expect(service.suggestNextSteps(principal([], true), ['profile']).some((item) => item.after.includes('checkin'))).toBe(false); + const afterExpense = service.suggestNextSteps(principal([], true), [ + 'profile', + 'room', + 'checkin', + 'expense', + ]); + expect(afterExpense.map((item) => item.label)).toEqual( + expect.arrayContaining([expect.stringContaining('账单')]), + ); + }); +}); diff --git a/apps/server/src/agent-context/business-context.service.ts b/apps/server/src/agent-context/business-context.service.ts new file mode 100644 index 0000000..803e9d6 --- /dev/null +++ b/apps/server/src/agent-context/business-context.service.ts @@ -0,0 +1,180 @@ +import { Injectable } from '@nestjs/common'; +import { + BUSINESS_ENTITIES, + BUSINESS_WORKFLOWS, +} from './business-context.registry'; +import type { + BusinessContextPrincipal, + BusinessContextResult, + BusinessEntity, + BusinessWorkflow, + BusinessWorkflowNextStep, +} from './business-context.types'; + +function hasPermission( + principal: BusinessContextPrincipal, + requiredPermissions: readonly string[], +): boolean { + if (principal.isSuperAdmin) return true; + return requiredPermissions.some((permission) => principal.permissions.includes(permission)); +} + +/** + * 根据已完成阶段返回后续建议(按权限过滤),供 A2UI 提交回灌等场景直接使用。 + */ +export function suggestNextStepsForPrincipal( + principal: BusinessContextPrincipal, + completedStageKeys: readonly string[], +): BusinessWorkflowNextStep[] { + const completed = new Set(completedStageKeys); + const steps: BusinessWorkflowNextStep[] = []; + for (const workflow of BUSINESS_WORKFLOWS) { + if (!hasPermission(principal, workflow.requiredPermissions)) continue; + for (const step of workflow.nextSteps) { + if (!step.after.every((stageKey) => completed.has(stageKey))) continue; + if (step.permission && !hasPermission(principal, [step.permission])) continue; + steps.push(step); + } + } + return steps; +} + +/** + * 业务上下文服务:把代码内维护的实体字典与工作流元数据按权限暴露给 + * Agent(纯逻辑,不访问数据库)。 + */ +@Injectable() +export class BusinessContextService { + /** + * 校验注册表引用完整性,返回所有问题;空数组表示合法。 + */ + validateRegistry(): string[] { + const problems: string[] = []; + const entityKeys = new Set(BUSINESS_ENTITIES.map((entity) => entity.key)); + const stageKeys = new Set( + BUSINESS_WORKFLOWS.flatMap((workflow) => workflow.stages.map((stage) => stage.key)), + ); + + const duplicateEntities = BUSINESS_ENTITIES + .map((entity) => entity.key) + .filter((key, index, all) => all.indexOf(key) !== index); + if (duplicateEntities.length) { + problems.push(`实体 key 重复: ${duplicateEntities.join(', ')}`); + } + + for (const entity of BUSINESS_ENTITIES) { + if (!entity.name || !entity.description) { + problems.push(`实体 ${entity.key} 缺少 name/description`); + } + if (entity.requiredPermissions.length === 0) { + problems.push(`实体 ${entity.key} 缺少权限点`); + } + const fieldKeys = new Set(); + for (const field of entity.fields) { + if (fieldKeys.has(field.key)) { + problems.push(`实体 ${entity.key} 字段重复: ${field.key}`); + } + fieldKeys.add(field.key); + if (field.type === 'enum' && !field.enumFrom && !field.options) { + problems.push(`实体 ${entity.key} 枚举字段 ${field.key} 缺少 enumFrom/options`); + } + } + for (const relation of entity.relations) { + if (!entityKeys.has(relation.entityKey)) { + problems.push(`实体 ${entity.key} 关系引用未知实体: ${relation.entityKey}`); + } + for (const stageKey of relation.requiredFor) { + if (!stageKeys.has(stageKey)) { + problems.push(`实体 ${entity.key} 关系 ${relation.via} 引用未知阶段: ${stageKey}`); + } + } + } + } + + for (const workflow of BUSINESS_WORKFLOWS) { + if (workflow.requiredPermissions.length === 0) { + problems.push(`工作流 ${workflow.key} 缺少权限点`); + } + const workflowStageKeys = new Set(workflow.stages.map((stage) => stage.key)); + if (workflowStageKeys.size !== workflow.stages.length) { + problems.push(`工作流 ${workflow.key} 存在重复阶段`); + } + for (const stage of workflow.stages) { + for (const entityKey of stage.entities) { + if (!entityKeys.has(entityKey)) { + problems.push(`工作流 ${workflow.key} 阶段 ${stage.key} 引用未知实体: ${entityKey}`); + } + } + } + for (const [stageKey, prereqs] of Object.entries(workflow.prerequisites)) { + if (!workflowStageKeys.has(stageKey)) { + problems.push(`工作流 ${workflow.key} prerequisites 引用未知阶段: ${stageKey}`); + } + for (const prereq of prereqs) { + if (!workflowStageKeys.has(prereq)) { + problems.push(`工作流 ${workflow.key} 阶段 ${stageKey} 前置引用未知阶段: ${prereq}`); + } + } + } + for (const step of workflow.nextSteps) { + for (const afterKey of step.after) { + if (!workflowStageKeys.has(afterKey)) { + problems.push(`工作流 ${workflow.key} 下一步 ${step.key} 引用未知阶段: ${afterKey}`); + } + } + } + } + + return [...new Set(problems)]; + } + + assertValidRegistry(): void { + const problems = this.validateRegistry(); + if (problems.length > 0) { + throw new Error(`业务上下文注册表无效: ${problems.join('; ')}`); + } + } + + /** + * 返回当前主体可见的工作流,以及这些工作流阶段引用的实体集合。 + * 传 workflowKey 时只返回该工作流(不可见则返回空)。 + */ + getBusinessContext( + principal: BusinessContextPrincipal, + workflowKey?: string, + ): BusinessContextResult { + let workflows = BUSINESS_WORKFLOWS.filter((workflow) => + hasPermission(principal, workflow.requiredPermissions), + ); + if (workflowKey) { + workflows = workflows.filter((workflow) => workflow.key === workflowKey); + } + const entityKeys = new Set( + workflows.flatMap((workflow) => workflow.stages.flatMap((stage) => stage.entities)), + ); + const entities = BUSINESS_ENTITIES.filter( + (entity) => entityKeys.has(entity.key) && hasPermission(principal, entity.requiredPermissions), + ); + return { workflows, entities }; + } + + /** 返回指定实体的字段字典与关系;无权限或未知返回 null。 */ + getEntitySchema(principal: BusinessContextPrincipal, entityKey: string): BusinessEntity | null { + const entity = BUSINESS_ENTITIES.find((item) => item.key === entityKey); + if (!entity || !hasPermission(principal, entity.requiredPermissions)) return null; + return entity; + } + + /** + * 根据已完成阶段返回后续建议(按权限过滤)。 + */ + suggestNextSteps( + principal: BusinessContextPrincipal, + completedStageKeys: readonly string[], + ): BusinessWorkflowNextStep[] { + return suggestNextStepsForPrincipal(principal, completedStageKeys); + } +} + +export { BUSINESS_ENTITIES, BUSINESS_WORKFLOWS }; +export type { BusinessContextPrincipal, BusinessWorkflow }; diff --git a/apps/server/src/agent-context/business-context.tools.spec.ts b/apps/server/src/agent-context/business-context.tools.spec.ts new file mode 100644 index 0000000..df3c6cb --- /dev/null +++ b/apps/server/src/agent-context/business-context.tools.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import type { AgentToolContext } from '../agent-tools/agent-tool.types'; +import { AgentToolContextFactory } from '../agent-tools/agent-tool.types'; +import type { AuthenticatedUser } from '../authorization'; +import type { BusinessContextService } from './business-context.service'; +import { GetBusinessContextTool, GetEntitySchemaTool } from './get-business-context.tool'; +import { GetPendingTasksTool } from './get-pending-tasks.tool'; + +function context(permissions: string[] = []): AgentToolContext { + const user: AuthenticatedUser = { + id: 7, + username: 'ops', + permissions, + isSuperAdmin: false, + roles: [], + }; + return AgentToolContextFactory.fromAuthenticatedUser(user); +} + +describe('agent business context tools', () => { + it('get_business_context 校验 workflowKey 并透传主体与过滤条件', async () => { + const service = { + getBusinessContext: jest.fn().mockReturnValue({ workflows: [], entities: [] }), + } as unknown as BusinessContextService; + const tool = new GetBusinessContextTool(service); + expect(tool.requiredPermission).toBe('ai:chat:use'); + expect(tool.validate({ workflowKey: 123 }).ok).toBe(false); + expect(tool.validate({ workflowKey: 'x'.repeat(51) }).ok).toBe(false); + expect(tool.validate({ debug: true }).ok).toBe(false); + const parsed = tool.validate({ workflowKey: 'dormitory_billing' }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + const ctx = context(['occupancy:view']); + await tool.execute(parsed.value, ctx); + expect(service.getBusinessContext).toHaveBeenCalledWith( + { permissions: ['occupancy:view'], isSuperAdmin: false }, + 'dormitory_billing', + ); + }); + + it('get_entity_schema 必须提供存在的 entityKey', async () => { + const service = { + getEntitySchema: jest.fn().mockReturnValue({ key: 'student', name: '学生档案' }), + } as unknown as BusinessContextService; + const tool = new GetEntitySchemaTool(service); + expect(tool.validate({}).ok).toBe(false); + expect(tool.validate({ entityKey: '' }).ok).toBe(false); + expect(tool.validate({ entityKey: 'student', extra: 1 }).ok).toBe(false); + const parsed = tool.validate({ entityKey: 'student' }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + await tool.execute(parsed.value, context()); + expect(service.getEntitySchema).toHaveBeenCalledWith( + { permissions: [], isSuperAdmin: false }, + 'student', + ); + }); + + it('get_pending_tasks 只接受已知工作流 key', async () => { + const service = { + getPendingTasks: jest.fn().mockResolvedValue([]), + }; + const tool = new GetPendingTasksTool(service as never); + expect(tool.requiredPermission).toBe('ai:chat:use'); + expect(tool.validate({ workflowKey: 'unknown_loop' }).ok).toBe(false); + expect(tool.validate({ workflowKey: 'dormitory_billing', limit: 1 }).ok).toBe(false); + const parsed = tool.validate({ workflowKey: 'dormitory_billing' }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + await tool.execute(parsed.value, context(['bill:view'])); + expect(service.getPendingTasks).toHaveBeenCalledWith(expect.anything(), 'dormitory_billing'); + }); +}); diff --git a/apps/server/src/agent-context/business-context.types.ts b/apps/server/src/agent-context/business-context.types.ts new file mode 100644 index 0000000..3b0b419 --- /dev/null +++ b/apps/server/src/agent-context/business-context.types.ts @@ -0,0 +1,66 @@ +/** 调用方主体验证(来自可信的 AgentToolContext / AuthenticatedUser)。 */ +export interface BusinessContextPrincipal { + readonly permissions: readonly string[]; + readonly isSuperAdmin: boolean; +} + +export type BusinessEntityFieldType = 'string' | 'number' | 'boolean' | 'date' | 'enum'; + +export interface BusinessEntityField { + key: string; + label: string; + type: BusinessEntityFieldType; + required?: boolean; + /** 枚举值来源(如 gender、room.status),供 render_form 生成 options。 */ + enumFrom?: string; + options?: Array<{ label: string; value: string }>; + description?: string; +} + +export interface BusinessEntityRelation { + entityKey: string; + via: string; + /** 需要该关系已建立的工作流阶段 key。 */ + requiredFor: string[]; +} + +export interface BusinessEntity { + key: string; + name: string; + description: string; + searchTool?: string; + requiredPermissions: readonly string[]; + fields: readonly BusinessEntityField[]; + relations: readonly BusinessEntityRelation[]; +} + +export interface BusinessWorkflowStage { + key: string; + label: string; + entities: readonly string[]; +} + +export interface BusinessWorkflowNextStep { + key: string; + label: string; + /** 满足这些阶段完成后才建议该步骤。 */ + after: readonly string[]; + /** 可选权限点,未授权用户不返回该建议。 */ + permission?: string; +} + +export interface BusinessWorkflow { + key: string; + name: string; + description: string; + requiredPermissions: readonly string[]; + stages: readonly BusinessWorkflowStage[]; + /** stageKey -> 前置阶段 key 列表。 */ + prerequisites: Readonly>; + nextSteps: readonly BusinessWorkflowNextStep[]; +} + +export interface BusinessContextResult { + workflows: readonly BusinessWorkflow[]; + entities: readonly BusinessEntity[]; +} diff --git a/apps/server/src/agent-context/get-business-context.tool.ts b/apps/server/src/agent-context/get-business-context.tool.ts new file mode 100644 index 0000000..0a256fe --- /dev/null +++ b/apps/server/src/agent-context/get-business-context.tool.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tools/agent-tool.types'; +import { rejectUnknownKeys, optionalString } from '../agent-tools/tools/tool-input'; +import type { BusinessContextService } from './business-context.service'; + +interface GetBusinessContextInput { + workflowKey?: string; +} + +/** + * 让 Agent 获取当前角色可见的业务流程、实体字典与依赖规则。 + */ +@Injectable() +export class GetBusinessContextTool implements ToolDef { + readonly name = 'get_business_context'; + readonly skillKey = 'assistant'; + readonly requiredPermission = 'ai:chat:use'; + readonly description = + '获取当前账号可见的业务流程(学生教学/住宿计费/教室租赁)、实体字典、阶段依赖与下一步建议。写入或导入前先调用本工具确认前置数据要求。'; + readonly inputSchema = { + type: 'object', + properties: { + workflowKey: { + type: 'string', + description: '可选:聚焦某个闭环(student_teaching / dormitory_billing / classroom_rental)', + maxLength: 50, + }, + }, + additionalProperties: false, + }; + + constructor(private readonly service: BusinessContextService) {} + + validate(input: Record): ToolInputResult { + const invalid = rejectUnknownKeys(input, ['workflowKey']); + if (invalid) return invalid; + const workflowKey = optionalString(input.workflowKey, 'workflowKey', 50); + if (!workflowKey.ok) return workflowKey; + return { ok: true, value: { workflowKey: workflowKey.value } }; + } + + async execute(input: GetBusinessContextInput, context: AgentToolContext): Promise { + return this.service.getBusinessContext( + { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, + input.workflowKey, + ); + } +} + +/** + * 让 Agent 获取指定业务实体的字段字典与关系,用于生成准确表单。 + */ +@Injectable() +export class GetEntitySchemaTool implements ToolDef<{ entityKey: string }> { + readonly name = 'get_entity_schema'; + readonly skillKey = 'assistant'; + readonly requiredPermission = 'ai:chat:use'; + readonly description = + '获取指定业务实体(student/class/room/occupancy/expense/bill/deposit/classroom/organization/rental 等)的字段字典、枚举来源与关系,render_form 前可按需调用以生成正确字段。'; + readonly inputSchema = { + type: 'object', + properties: { + entityKey: { type: 'string', minLength: 1, maxLength: 50 }, + }, + required: ['entityKey'], + additionalProperties: false, + }; + + constructor(private readonly service: BusinessContextService) {} + + validate(input: Record): ToolInputResult<{ entityKey: string }> { + const invalid = rejectUnknownKeys(input, ['entityKey']); + if (invalid) return invalid; + if (typeof input.entityKey !== 'string' || !input.entityKey.trim()) { + return { ok: false, error: 'entityKey 必须是字符串' }; + } + const entityKey = input.entityKey.trim(); + if (entityKey.length > 50) { + return { ok: false, error: 'entityKey 长度不能超过 50' }; + } + return { ok: true, value: { entityKey } }; + } + + async execute(input: { entityKey: string }, context: AgentToolContext): Promise { + return this.service.getEntitySchema( + { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, + input.entityKey, + ); + } +} diff --git a/apps/server/src/agent-context/get-pending-tasks.tool.ts b/apps/server/src/agent-context/get-pending-tasks.tool.ts new file mode 100644 index 0000000..1f68b97 --- /dev/null +++ b/apps/server/src/agent-context/get-pending-tasks.tool.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tools/agent-tool.types'; +import { rejectUnknownKeys } from '../agent-tools/tools/tool-input'; +import { BUSINESS_WORKFLOW_KEYS } from './business-context.registry'; +import type { PendingTasksService } from './pending-tasks.service'; + +interface GetPendingTasksInput { + workflowKey?: string; +} + +/** + * 让 Agent 查询业务完成度(未分班、未入住、未出账单、临期租赁等), + * 用于核实前置数据与给出有数据支撑的下一步建议。 + */ +@Injectable() +export class GetPendingTasksTool implements ToolDef { + readonly name = 'get_pending_tasks'; + readonly skillKey = 'overview'; + readonly requiredPermission = 'ai:chat:use'; + readonly description = + '获取当前账号可见范围内的业务待办计数(未分班学生、已建档未入住学生、在住未生成账单、租赁缺合同/临期到期等)。写入或导入前用它核实前置数据是否齐备,完成后用它判断后续待办。'; + readonly inputSchema = { + type: 'object', + properties: { + workflowKey: { + type: 'string', + description: '可选:聚焦某个闭环(student_teaching / dormitory_billing / classroom_rental)', + enum: [...BUSINESS_WORKFLOW_KEYS], + }, + }, + additionalProperties: false, + }; + + constructor(private readonly service: PendingTasksService) {} + + validate(input: Record): ToolInputResult { + const invalid = rejectUnknownKeys(input, ['workflowKey']); + if (invalid) return invalid; + if (input.workflowKey === undefined) return { ok: true, value: {} }; + if ( + typeof input.workflowKey !== 'string' || + !(BUSINESS_WORKFLOW_KEYS as readonly string[]).includes(input.workflowKey) + ) { + return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` }; + } + return { ok: true, value: { workflowKey: input.workflowKey } }; + } + + execute(input: GetPendingTasksInput, context: AgentToolContext): Promise { + return this.service.getPendingTasks(context, input.workflowKey); + } +} diff --git a/apps/server/src/agent-context/index.ts b/apps/server/src/agent-context/index.ts new file mode 100644 index 0000000..88564af --- /dev/null +++ b/apps/server/src/agent-context/index.ts @@ -0,0 +1,12 @@ +export { BusinessContextService } from './business-context.service'; +export { BUSINESS_ENTITIES, BUSINESS_WORKFLOWS } from './business-context.registry'; +export type { + BusinessContextPrincipal, + BusinessContextResult, + BusinessEntity, + BusinessEntityField, + BusinessEntityRelation, + BusinessWorkflow, + BusinessWorkflowNextStep, + BusinessWorkflowStage, +} from './business-context.types'; diff --git a/apps/server/src/agent-context/pending-tasks.service.spec.ts b/apps/server/src/agent-context/pending-tasks.service.spec.ts new file mode 100644 index 0000000..fa54f15 --- /dev/null +++ b/apps/server/src/agent-context/pending-tasks.service.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import type { DataSource } from 'typeorm'; +import { AgentToolContextFactory, type AgentToolContext } from '../agent-tools/agent-tool.types'; +import type { AuthenticatedUser } from '../authorization'; +import type { StudentAccessScopeFactory } from '../students/student-access-scope.factory'; +import { PendingTasksService } from './pending-tasks.service'; + +function context(permissions: string[], isSuperAdmin = false): AgentToolContext { + const user: AuthenticatedUser = { + id: 7, + username: 'ops', + permissions, + isSuperAdmin, + roles: [], + }; + return AgentToolContextFactory.fromAuthenticatedUser(user); +} + +function createService( + scopeType: 'manageAll' | 'teacher', + queryMock: jest.Mock>>>, +) { + const dataSource = { query: queryMock } as unknown as DataSource; + const scopeFactory = { + buildScope: jest.fn().mockReturnValue( + scopeType === 'manageAll' + ? { type: 'manageAll' } + : { type: 'teacher', userId: 7 }, + ), + } as unknown as StudentAccessScopeFactory; + return new PendingTasksService(dataSource, scopeFactory); +} + +const ROW = (cnt: unknown) => [{ cnt }] as Record[]; + +describe('PendingTasksService', () => { + it('manageAll 权限下返回全部待办计数', async () => { + const query = jest.fn().mockImplementation(async (sql: string) => { + if (sql.includes('NOT EXISTS') && sql.includes('class_student')) return ROW('3'); + if (sql.includes('LEFT JOIN occupancies o')) return ROW('5'); + if (sql.includes('FROM occupancies o')) return ROW('8'); + if (sql.includes('classroom_rentals r') && sql.includes('contract_path IS NULL')) return ROW('2'); + if (sql.includes('classroom_rentals r') && sql.includes('end_date BETWEEN')) return ROW('4'); + return ROW('0'); + }); + const service = createService('manageAll', query); + const tasks = await service.getPendingTasks( + context([ + 'student:view', + 'class:view', + 'occupancy:view', + 'bill:view', + 'rental:view', + ]), + ); + const byKey = Object.fromEntries(tasks.map((task) => [task.key, task])); + expect(byKey.students_without_class?.count).toBe(3); + expect(byKey.students_without_checkin?.count).toBe(5); + expect(byKey.occupancies_without_bill?.count).toBe(8); + expect(byKey.rentals_without_contract?.count).toBe(2); + expect(byKey.rentals_ending_soon?.count).toBe(4); + expect(byKey.students_without_class?.restricted).toBeUndefined(); + }); + + it('teacher 范围只统计本人班级学生,且未分班任务标记 restricted', async () => { + const query = jest.fn().mockImplementation(async (sql: string, params: unknown[]) => { + expect(sql).toContain('cs.class_id IN'); + expect(params).toEqual([7]); + if (sql.includes('LEFT JOIN occupancies o')) return ROW('2'); + if (sql.includes('FROM occupancies o')) return ROW('1'); + return ROW('0'); + }); + const service = createService('teacher', query); + const tasks = await service.getPendingTasks( + context(['student:view', 'occupancy:view', 'bill:view']), + ); + const byKey = Object.fromEntries(tasks.map((task) => [task.key, task])); + expect(byKey.students_without_class).toMatchObject({ count: null, restricted: true }); + expect(byKey.students_without_checkin?.count).toBe(2); + expect(byKey.occupancies_without_bill?.count).toBe(1); + expect(byKey.rentals_without_contract).toBeUndefined(); + }); + + it('workflowKey 只返回该闭环的待办', async () => { + const query = jest.fn().mockResolvedValue(ROW('0')); + const service = createService('manageAll', query); + const tasks = await service.getPendingTasks( + context([], true), + 'classroom_rental', + ); + expect(tasks.map((task) => task.key)).toEqual([ + 'rentals_without_contract', + 'rentals_ending_soon', + ]); + }); + + it('缺少权限的任务不返回', async () => { + const query = jest.fn().mockResolvedValue(ROW('0')); + const service = createService('manageAll', query); + const tasks = await service.getPendingTasks(context(['student:view'])); + expect(tasks.map((task) => task.key)).toEqual(['students_without_class']); + }); +}); diff --git a/apps/server/src/agent-context/pending-tasks.service.ts b/apps/server/src/agent-context/pending-tasks.service.ts new file mode 100644 index 0000000..41db622 --- /dev/null +++ b/apps/server/src/agent-context/pending-tasks.service.ts @@ -0,0 +1,183 @@ +import { Injectable } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; +import type { AgentToolContext } from '../agent-tools/agent-tool.types'; +import type { StudentAccessScope } from '../students/student-access-scope'; +import { StudentAccessScopeFactory } from '../students/student-access-scope.factory'; + +export interface PendingTask { + key: string; + label: string; + entity: string; + permission: string; + workflowKeys: string[]; + count: number | null; + restricted?: boolean; + detail?: string; +} + +interface TaskDefinition { + key: string; + label: string; + entity: string; + permission: string; + workflowKeys: string[]; + teacherRestricted?: boolean; + sql: (scope: StudentAccessScope) => { sql: string; params: unknown[] }; +} + +function can(context: AgentToolContext, permission: string): boolean { + return context.isSuperAdmin || context.permissions.includes(permission); +} + +function today(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function inDays(days: number): string { + const date = new Date(); + date.setDate(date.getDate() + days); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +const TASKS: readonly TaskDefinition[] = [ + { + key: 'students_without_class', + label: '未分班学生', + entity: 'student', + permission: 'student:view', + workflowKeys: ['student_teaching'], + teacherRestricted: true, + sql: () => ({ + sql: ` + SELECT COUNT(*) AS cnt FROM students s + WHERE s.status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM class_student cs + WHERE cs.student_id = s.id AND cs.status = 'active' + ) + `, + params: [], + }), + }, + { + key: 'students_without_checkin', + label: '已建档未入住学生', + entity: 'occupancy', + permission: 'occupancy:view', + workflowKeys: ['dormitory_billing'], + sql: (scope) => ({ + sql: ` + SELECT COUNT(DISTINCT s.id) AS cnt FROM students s + INNER JOIN class_student cs ON cs.student_id = s.id AND cs.status = 'active' + LEFT JOIN occupancies o ON o.student_id = s.id AND o.status = 'active' + WHERE s.status = 'active' + ${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''} + AND o.id IS NULL + `, + params: scope.type === 'teacher' ? [scope.userId] : [], + }), + }, + { + key: 'occupancies_without_bill', + label: '在住但未生成账单', + entity: 'bill', + permission: 'bill:view', + workflowKeys: ['dormitory_billing'], + sql: (scope) => ({ + sql: ` + SELECT COUNT(DISTINCT o.id) AS cnt FROM occupancies o + ${scope.type === 'teacher' ? 'INNER JOIN class_student cs ON cs.student_id = o.student_id AND cs.status = \'active\'' : ''} + LEFT JOIN bills b ON b.student_id = o.student_id + WHERE o.status = 'active' + ${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''} + AND b.id IS NULL + `, + params: scope.type === 'teacher' ? [scope.userId] : [], + }), + }, + { + key: 'rentals_without_contract', + label: '租赁未归档合同', + entity: 'rental', + permission: 'rental:view', + workflowKeys: ['classroom_rental'], + sql: () => ({ + sql: ` + SELECT COUNT(*) AS cnt FROM classroom_rentals r + WHERE r.status = 'active' AND r.contract_path IS NULL + `, + params: [], + }), + }, + { + key: 'rentals_ending_soon', + label: '七天内到期租赁', + entity: 'rental', + permission: 'rental:view', + workflowKeys: ['classroom_rental'], + sql: () => ({ + sql: ` + SELECT COUNT(*) AS cnt FROM classroom_rentals r + WHERE r.status = 'active' AND r.end_date BETWEEN ? AND ? + `, + params: [today(), inDays(7)], + }), + }, +]; + +/** + * 业务完成度查询:按权限与数据范围返回“待办”计数,供 Agent 在 + * 写入/导入前核实前置数据、完成后给出有数据支撑的下一步建议。 + */ +@Injectable() +export class PendingTasksService { + constructor( + private readonly dataSource: DataSource, + private readonly scopeFactory: StudentAccessScopeFactory, + ) {} + + async getPendingTasks( + context: AgentToolContext, + workflowKey?: string, + ): Promise { + const scope = this.scopeFactory.buildScope(context); + const tasks: PendingTask[] = []; + for (const definition of TASKS) { + if (!can(context, definition.permission)) continue; + if (workflowKey && !definition.workflowKeys.includes(workflowKey)) continue; + if (definition.teacherRestricted && scope.type === 'teacher') { + tasks.push({ + key: definition.key, + label: definition.label, + entity: definition.entity, + permission: definition.permission, + workflowKeys: definition.workflowKeys, + count: null, + restricted: true, + detail: '当前角色数据范围不适合统计该待办,建议由教务/运营角色处理', + }); + continue; + } + const { sql, params } = definition.sql(scope); + const rows = await this.dataSource.query(sql, params); + const count = Number((rows as Array>)[0]?.cnt ?? 0); + tasks.push({ + key: definition.key, + label: definition.label, + entity: definition.entity, + permission: definition.permission, + workflowKeys: definition.workflowKeys, + count, + }); + } + return tasks; + } + +} diff --git a/apps/server/src/agent-tools/agent-skill.catalog.ts b/apps/server/src/agent-tools/agent-skill.catalog.ts index dbeab4b..a956ec1 100644 --- a/apps/server/src/agent-tools/agent-skill.catalog.ts +++ b/apps/server/src/agent-tools/agent-skill.catalog.ts @@ -4,8 +4,8 @@ export const AGENT_SKILLS: readonly Omit[] = [ { key: 'overview', name: '经营总览', - description: '查看当前权限范围内的学生、班级和今日考勤概览。', - examples: ['今天整体运营情况怎么样?', '帮我汇总当前学生和班级数量'], + description: '查看当前权限范围内的学生、班级和今日考勤概览,以及业务待办(未分班、未入住、未出账单、临期租赁等)。', + examples: ['今天整体运营情况怎么样?', '有哪些业务待办需要处理?', '帮我汇总当前学生和班级数量'], }, { key: 'student', @@ -22,20 +22,20 @@ export const AGENT_SKILLS: readonly Omit[] = [ { key: 'dormitory', name: '宿舍管理', - description: '查询宿舍、入住数量和空余床位。', - examples: ['哪些房间还有空床?', '汇总当前宿舍入住情况'], + description: '查询宿舍、入住数量和空余床位,并按“学生/宿舍档案 → 入住 → 费用 → 账单 → 押金”闭环引导。', + examples: ['哪些房间还有空床?', '汇总当前宿舍入住情况', '我可以按 先学生、再入住、后账单 帮你完成'], }, { key: 'billing', name: '账单查询', - description: '查询账单编号、账期、金额和状态。', - examples: ['查找本月未支付账单', '查询张同学最近的账单'], + description: '查询账单编号、账期、金额和状态,并在入住/费用完成后建议生成与确认账单。', + examples: ['查找本月未支付账单', '查询张同学最近的账单', '哪些在住学生还没生成账单?'], }, { key: 'classroom', name: '教室与租用', - description: '查询教室信息、占用状态和租赁订单。', - examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?'], + description: '查询教室信息、占用状态和租赁订单,并按“教室/组织档案 → 租赁 → 合同 → 日程”闭环引导。', + examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?', '哪些租赁还没归档合同?'], }, { key: 'sync', diff --git a/apps/server/src/agent-tools/agent-tools.module.ts b/apps/server/src/agent-tools/agent-tools.module.ts index 9afde4f..872a740 100644 --- a/apps/server/src/agent-tools/agent-tools.module.ts +++ b/apps/server/src/agent-tools/agent-tools.module.ts @@ -32,6 +32,10 @@ import { ExpensesModule } from '../expenses/expenses.module'; import { ClassroomsModule } from '../classrooms/classrooms.module'; import { ClassroomRentalsModule } from '../classroom-rentals/classroom-rentals.module'; import { SyncModule } from '../sync/sync.module'; +import { BusinessContextService } from '../agent-context/business-context.service'; +import { PendingTasksService } from '../agent-context/pending-tasks.service'; +import { GetBusinessContextTool, GetEntitySchemaTool } from '../agent-context/get-business-context.tool'; +import { GetPendingTasksTool } from '../agent-context/get-pending-tasks.tool'; /** * Agent Tools feature module. @@ -66,6 +70,11 @@ import { SyncModule } from '../sync/sync.module'; providers: [ AgentToolRegistry, AgentToolExecutor, + BusinessContextService, + PendingTasksService, + GetBusinessContextTool, + GetEntitySchemaTool, + GetPendingTasksTool, SearchStudentsTool, GetStudentBasicTool, AgentBusinessScopeFactory, @@ -107,9 +116,15 @@ export class AgentToolsModule implements OnModuleInit { private readonly searchClassroomsTool: SearchClassroomsTool, private readonly searchClassroomRentalsTool: SearchClassroomRentalsTool, private readonly getSyncStatusTool: GetSyncStatusTool, + private readonly businessContextTool: GetBusinessContextTool, + private readonly entitySchemaTool: GetEntitySchemaTool, + private readonly pendingTasksTool: GetPendingTasksTool, ) {} onModuleInit(): void { + this.registry.register(this.businessContextTool); + this.registry.register(this.entitySchemaTool); + this.registry.register(this.pendingTasksTool); this.registry.register(this.searchTool); this.registry.register(this.getTool); this.registry.register(this.searchClassesTool); diff --git a/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts b/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts new file mode 100644 index 0000000..560439f --- /dev/null +++ b/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; + +function createService(overrides: Record = {}) { + const repo = { + findOne: jest.fn(), + save: jest.fn(async (value) => value), + create: jest.fn((value) => value), + ...overrides, + }; + return { service: new A2uiSubmissionsService(repo as never), repo }; +} + +const submission = { + id: 1, + artifactId: 'form-1', + clientRequestId: '4d5c1c2a-1111-4222-8333-444455556666', + status: 'created', + resultJson: '{"ok":true}', + createdAt: new Date(), +}; + +describe('A2uiSubmissionsService', () => { + it('recordSubmission 首次提交创建记录并标记 created', async () => { + const { service, repo } = createService({ + findOne: jest.fn().mockResolvedValue(null), + }); + const result = await service.recordSubmission({ + artifactId: submission.artifactId, + clientRequestId: submission.clientRequestId, + status: 'created', + resultJson: submission.resultJson, + }); + expect(result.created).toBe(true); + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ + artifactId: submission.artifactId, + clientRequestId: submission.clientRequestId, + }), + ); + }); + + it('recordSubmission 同一 clientRequestId 重复提交返回既有记录且不重复创建', async () => { + const { service, repo } = createService({ + findOne: jest.fn().mockResolvedValue(submission), + }); + const result = await service.recordSubmission({ + artifactId: submission.artifactId, + clientRequestId: submission.clientRequestId, + status: 'created', + resultJson: '{"ok":false}', + }); + expect(result.created).toBe(false); + expect(result.submission).toEqual(submission); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('findSubmission 透传查询条件', async () => { + const { service, repo } = createService({ + findOne: jest.fn().mockResolvedValue(submission), + }); + await expect( + service.findSubmission(submission.artifactId, submission.clientRequestId), + ).resolves.toEqual(submission); + expect(repo.findOne).toHaveBeenCalledWith({ + where: { artifactId: submission.artifactId, clientRequestId: submission.clientRequestId }, + }); + }); +}); diff --git a/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts b/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts new file mode 100644 index 0000000..ad2a706 --- /dev/null +++ b/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AiA2uiSubmission } from './entities/ai-a2ui-submission.entity'; + +export interface RecordSubmissionInput { + artifactId: string; + clientRequestId: string; + status: string; + resultJson?: string | null; +} + +/** + * A2UI 提交幂等服务。 + */ +@Injectable() +export class A2uiSubmissionsService { + constructor( + @InjectRepository(AiA2uiSubmission) + private readonly submissions: Repository, + ) {} + + async recordSubmission(input: RecordSubmissionInput): Promise<{ + created: boolean; + submission: AiA2uiSubmission; + }> { + const existing = await this.findSubmission(input.artifactId, input.clientRequestId); + if (existing) return { created: false, submission: existing }; + const submission = await this.submissions.save( + this.submissions.create({ + artifactId: input.artifactId, + clientRequestId: input.clientRequestId, + status: input.status, + resultJson: input.resultJson ?? null, + }), + ); + return { created: true, submission }; + } + + async findSubmission( + artifactId: string, + clientRequestId: string, + ): Promise { + return this.submissions.findOne({ + where: { artifactId, clientRequestId }, + }); + } +} diff --git a/apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts b/apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts new file mode 100644 index 0000000..daf460f --- /dev/null +++ b/apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from '@jest/globals'; +import { buildA2uiArtifact } from './ai-a2ui.artifact'; + +describe('buildA2uiArtifact', () => { + it('构造统一 artifact 载荷(id/type/status/messageId/conversationId/payload)', () => { + const artifact = buildA2uiArtifact({ + type: 'form', + id: 'form-1', + status: 'pending', + messageId: 12, + conversationId: 3, + payload: { title: '新增学生' }, + }); + expect(artifact).toEqual({ + id: 'form-1', + type: 'form', + status: 'pending', + messageId: 12, + conversationId: 3, + payload: { title: '新增学生' }, + }); + }); + + it('未知类型被拒绝', () => { + expect(() => + buildA2uiArtifact({ + type: 'hacker' as never, + id: 'x', + status: 'pending', + messageId: 1, + conversationId: 1, + payload: {}, + }), + ).toThrow(); + }); +}); diff --git a/apps/server/src/ai-chat/ai-a2ui.artifact.ts b/apps/server/src/ai-chat/ai-a2ui.artifact.ts new file mode 100644 index 0000000..11689fd --- /dev/null +++ b/apps/server/src/ai-chat/ai-a2ui.artifact.ts @@ -0,0 +1,58 @@ +export type A2uiArtifactType = + | 'form' + | 'review' + | 'chart' + | 'import_preflight' + | 'import_wizard'; + +export type A2uiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled'; + +export interface A2uiArtifact { + id: string; + type: A2uiArtifactType; + status: A2uiArtifactStatus; + messageId: number; + conversationId: number; + payload: T; + createdAt?: string | null; + submittedAt?: string | null; + supersededBy?: string | null; +} + +const A2UI_ARTIFACT_TYPES = new Set([ + 'form', + 'review', + 'chart', + 'import_preflight', + 'import_wizard', +]); + +/** + * 构造统一的 A2UI artifact 载荷,供 `ui.artifact` SSE 事件与前端归一化。 + */ +export function buildA2uiArtifact(input: { + type: A2uiArtifactType; + id: string; + status: A2uiArtifactStatus; + messageId: number; + conversationId: number; + payload: T; + createdAt?: string | null; + submittedAt?: string | null; + supersededBy?: string | null; +}): A2uiArtifact { + if (!A2UI_ARTIFACT_TYPES.has(input.type)) { + throw new Error(`未知 A2UI artifact 类型: ${String(input.type)}`); + } + return { + id: input.id, + type: input.type, + status: input.status, + messageId: input.messageId, + conversationId: input.conversationId, + payload: input.payload, + ...(input.createdAt !== undefined ? { createdAt: input.createdAt } : {}), + ...(input.submittedAt !== undefined ? { submittedAt: input.submittedAt } : {}), + ...(input.supersededBy !== undefined ? { supersededBy: input.supersededBy } : {}), + }; +} diff --git a/apps/server/src/ai-chat/ai-chat.constants.ts b/apps/server/src/ai-chat/ai-chat.constants.ts index 746366f..5afa881 100644 --- a/apps/server/src/ai-chat/ai-chat.constants.ts +++ b/apps/server/src/ai-chat/ai-chat.constants.ts @@ -107,7 +107,7 @@ export const A2UI_TOOL_SCHEMAS = [ function: { name: 'render_form', description: - '生成一个确认表单显示给用户填写。当用户需要新增或修改业务数据、或需要用户输入/确认信息时调用;用户提交表单后才能执行写操作。', + '生成一个确认表单显示给用户填写。当用户需要新增或修改业务数据、或需要用户输入/确认信息时调用;用户提交表单后才能执行写操作。需要准确字段/枚举时,可先调用 get_entity_schema 获取实体字段字典。', parameters: { type: 'object', properties: { @@ -211,9 +211,10 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须 当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。 上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。 业务工作流引导(重要): -- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。 -- 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。 -- 导入或录入完成后,主动给出下一步建议(例如:入住导入完成 → 建议录入本月公共费用 → 生成并确认账单;学生导入完成 → 建议分班或排课)。 +- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织,包含三大闭环:学生教学(学生→分班→排课→考勤→考试)、住宿计费(学生/宿舍→入住→费用→账单→押金)、教室租赁(教室/组织→租赁→合同→日程)。 +- 不确定当前角色可用哪些业务流程与实体时,先调用 get_business_context 获取权限范围内的闭环、阶段依赖与实体字典;编写 render_form 字段前可按需调用 get_entity_schema。 +- 执行任何写入或导入前,先调用 get_pending_tasks 或现有查询工具核实前置数据是否已存在:入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。 +- 导入或录入完成后,根据完成阶段主动给出下一步建议(例如:入住完成 → 建议录入本月公共费用 → 生成并确认账单;学生档案完成 → 建议分班;租赁订单生成 → 建议补充合同),可用 get_pending_tasks 获取有数据支撑的待办。 - 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再引导用户在预检卡内确认并生成导入向导,按依赖顺序执行。 - 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。 不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`; diff --git a/apps/server/src/ai-chat/ai-chat.module.ts b/apps/server/src/ai-chat/ai-chat.module.ts index b2319b3..3578137 100644 --- a/apps/server/src/ai-chat/ai-chat.module.ts +++ b/apps/server/src/ai-chat/ai-chat.module.ts @@ -8,11 +8,13 @@ import { AiAttachmentService } from './ai-attachment.service'; import { AiChartService } from './ai-chart.service'; import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; +import { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiReviewService } from './ai-review.service'; import { AiChatService } from './ai-chat.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { AiAttachment, + AiA2uiSubmission, AiConversation, AiForm, AiMessage, @@ -24,6 +26,7 @@ import { imports: [ TypeOrmModule.forFeature([ AiAttachment, + AiA2uiSubmission, AiConversation, AiForm, AiMessage, @@ -40,6 +43,7 @@ import { AiChartService, AiExcelReaderService, AiFormService, + A2uiSubmissionsService, AiReviewService, AiChatService, AiModelStreamService, diff --git a/apps/server/src/ai-chat/ai-chat.service-base.ts b/apps/server/src/ai-chat/ai-chat.service-base.ts index 20b633b..33bdd5f 100644 --- a/apps/server/src/ai-chat/ai-chat.service-base.ts +++ b/apps/server/src/ai-chat/ai-chat.service-base.ts @@ -12,6 +12,7 @@ import { AiChartService } from './ai-chart.service'; import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; +import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { @@ -114,6 +115,7 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { readonly chartService: AiChartService, readonly abilityFactory: CaslAbilityFactory, readonly authorization: AuthorizationService, + readonly a2uiSubmissions?: A2uiSubmissionsService, readonly excelReader?: AiExcelReaderService, readonly importsService?: ImportsService, readonly opLog?: OperationLogsService, @@ -127,7 +129,12 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { return a2uiReviewSubmitInfo(metadata); } - buildFormSubmitModelContent(submit: { title: string; values: Record }): string { + buildFormSubmitModelContent(submit: { + title: string; + values: Record; + submissionId?: string; + fieldErrors?: Array<{ field: string; message: string }>; + }): string { return buildFormSubmitModelContent(submit); } @@ -135,6 +142,8 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { reviewId: string; reviewTitle: string; resultMessage: string; + submissionId?: string; + nextSteps?: Array<{ key: string; label: string }>; }): string { return buildReviewSubmitModelContent(submit); } diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index 99c7449..9b2fb64 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -53,6 +53,7 @@ function createService( {} as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -65,6 +66,7 @@ function createService( } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); return { service, conversations, reviewService }; } @@ -295,6 +297,7 @@ describe('AiChatService', () => { } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -314,6 +317,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; const run = service.streamMessage( @@ -456,6 +460,7 @@ describe('AiChatService', () => { } as never, { createForm: jest.fn().mockResolvedValue(formShape), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -475,6 +480,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; @@ -600,6 +606,7 @@ describe('AiChatService', () => { } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -619,6 +626,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; @@ -705,6 +713,7 @@ describe('AiChatService', () => { } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -727,6 +736,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission, canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const emitted: Array<{ event: string }> = []; @@ -842,6 +852,7 @@ describe('AiChatService', () => { } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -864,6 +875,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission, canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const order: string[] = []; const emitted: Array<{ event: string; data: Record }> = []; @@ -1114,6 +1126,7 @@ describe('AiChatService', () => { { removeOrphans } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -1133,6 +1146,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); await expect(service.deleteMessage(7, 3, 10)).resolves.toEqual({ deletedIds: [10, 11] }); @@ -1154,6 +1168,7 @@ describe('AiChatService', () => { {} as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -1173,6 +1188,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); (service as unknown as { activeConversations: Set }).activeConversations.add(3); await expect(service.deleteMessage(7, 3, 10)).rejects.toBeInstanceOf(ConflictException); @@ -1238,6 +1254,7 @@ describe('AiChatService', () => { { removeOrphans } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -1257,6 +1274,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; diff --git a/apps/server/src/ai-chat/ai-chat.service.ts b/apps/server/src/ai-chat/ai-chat.service.ts index 3899efa..8f71896 100644 --- a/apps/server/src/ai-chat/ai-chat.service.ts +++ b/apps/server/src/ai-chat/ai-chat.service.ts @@ -14,6 +14,7 @@ import { AiChartService } from './ai-chart.service'; import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; +import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AiConversation, AiMessage, AiToolRun } from './entities'; @@ -66,6 +67,7 @@ export class AiChatService extends AiChatServiceBase { chartService: AiChartService, abilityFactory: CaslAbilityFactory, authorization: AuthorizationService, + a2uiSubmissions?: A2uiSubmissionsService, excelReader?: AiExcelReaderService, importsService?: ImportsService, opLog?: OperationLogsService, @@ -84,6 +86,7 @@ export class AiChatService extends AiChatServiceBase { chartService, abilityFactory, authorization, + a2uiSubmissions, excelReader, importsService, opLog, diff --git a/apps/server/src/ai-chat/ai-chat.submissions.flow.ts b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts index 1251cd3..a0e743f 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.flow.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts @@ -1,9 +1,22 @@ import type { AiChatServiceContext, AiSseEmitter } from './ai-chat.types'; import { DEFAULT_TITLE } from './ai-chat.types'; import type { AuthenticatedUser } from '../authorization'; +import { suggestNextStepsForPrincipal } from '../agent-context/business-context.service'; +import { buildA2uiArtifact } from './ai-a2ui.artifact'; +import { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; +import { AiA2uiSubmission } from './entities'; import { assertReviewImportPermissions } from './ai-chat.review-confirm'; import { persistExchange, runGenerationAndRelease } from './ai-chat.submissions.runtime'; +function submissionsService( + context: AiChatServiceContext, +): A2uiSubmissionsService { + return ( + context.a2uiSubmissions ?? + new A2uiSubmissionsService(context.dataSource.getRepository(AiA2uiSubmission)) + ); +} + export async function submitForm( context: AiChatServiceContext, user: AuthenticatedUser, @@ -19,6 +32,18 @@ export async function submitForm( const effectiveSkillKey = conversation.lockedSkillKey ?? null; context.assertSkillAvailable(user, effectiveSkillKey); + const submissions = submissionsService(context); + const recorded = await submissions.recordSubmission({ + artifactId: formId, + clientRequestId: dto.clientRequestId, + status: 'created', + }); + if (!recorded.created) { + emit('error', { message: '该表单已提交过,请勿重复提交' }); + emit('done', {}); + return; + } + await context.acquireConversation(conversation.id); try { const summary = `已提交表单「${form.title}」`; @@ -31,7 +56,15 @@ export async function submitForm( summary, dto.clientRequestId, effectiveSkillKey, - { a2uiSubmit: { formId: form.id, formTitle: form.title, values } }, + { + a2uiSubmit: { + formId: form.id, + formTitle: form.title, + values, + submissionId: String(recorded.submission.id), + fieldErrors: [], + }, + }, undefined, conversation.title === DEFAULT_TITLE ? form.title.slice(0, 30) : undefined, ), @@ -39,6 +72,17 @@ export async function submitForm( await context.formService.markSubmitted(form, values); await context.markFormSubmittedOnMessage(form.assistantMessageId, conversation.id); + emit('ui.artifact', { + messageId: form.assistantMessageId, + artifact: buildA2uiArtifact({ + type: 'form', + id: form.id, + status: 'submitted', + messageId: form.assistantMessageId, + conversationId: conversation.id, + payload: { ...context.formService.serialize(form), status: 'submitted' }, + }), + }); await runGenerationAndRelease(context, { user, @@ -73,12 +117,43 @@ export async function submitReview( context.assertSkillAvailable(user, effectiveSkillKey); assertReviewImportPermissions(context, user, review); + const submissions = submissionsService(context); + const recorded = await submissions.recordSubmission({ + artifactId: reviewId, + clientRequestId: dto.clientRequestId, + status: 'created', + }); + if (!recorded.created) { + emit('error', { message: '该导入预览已确认过,请勿重复提交' }); + emit('done', {}); + return; + } + await context.acquireConversation(conversation.id); try { const { review: updatedReview, result } = await context.reviewService.submitAll( review.id, user.id, ); + const sectionTypes = context.reviewService + .parseSections(review.sectionsJson) + .map((section) => section.type) + .filter(Boolean); + const completedStageKeys = [ + ...new Set( + sectionTypes.map((type) => + type === 'students' + ? 'profile' + : type === 'rooms' + ? 'room' + : 'checkin', + ), + ), + ]; + const nextSteps = suggestNextStepsForPrincipal( + { permissions: user.permissions, isSuperAdmin: user.isSuperAdmin }, + completedStageKeys, + ).map((step) => ({ key: step.key, label: step.label })); const summary = `已确认导入「${review.title}」:${result.message}`; await context.opLog?.log({ userId: user.id, @@ -103,6 +178,8 @@ export async function submitReview( reviewId: review.id, reviewTitle: review.title, resultMessage: result.message, + submissionId: String(recorded.submission.id), + nextSteps, }, }, undefined, @@ -117,6 +194,17 @@ export async function submitReview( messageId: updatedReview.assistantMessageId, review: serialized, }); + emit('ui.artifact', { + messageId: updatedReview.assistantMessageId, + artifact: buildA2uiArtifact({ + type: 'review', + id: review.id, + status: 'submitted', + messageId: updatedReview.assistantMessageId, + conversationId: conversation.id, + payload: serialized, + }), + }); await context.markReviewSubmittedOnMessage( updatedReview.assistantMessageId, conversation.id, diff --git a/apps/server/src/ai-chat/ai-chat.submit-content.spec.ts b/apps/server/src/ai-chat/ai-chat.submit-content.spec.ts new file mode 100644 index 0000000..d7d1b12 --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.submit-content.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from '@jest/globals'; +import { + a2uiReviewSubmitInfo, + a2uiSubmitInfo, + buildFormSubmitModelContent, + buildReviewSubmitModelContent, +} from './ai-chat.submit-content'; + +describe('a2ui submit model content', () => { + it('表单提交信息解析 submissionId 与 fieldErrors', () => { + const info = a2uiSubmitInfo({ + a2uiSubmit: { + formId: 'form-1', + formTitle: '新增学生', + values: { name: '张三' }, + submissionId: 'sub-1', + fieldErrors: [{ field: 'phone', message: '手机号格式错误' }], + }, + }); + expect(info).toEqual({ + formId: 'form-1', + title: '新增学生', + values: { name: '张三' }, + submissionId: 'sub-1', + fieldErrors: [{ field: 'phone', message: '手机号格式错误' }], + }); + }); + + it('表单回灌内容包含提交 ID 与结构化错误', () => { + const content = buildFormSubmitModelContent({ + title: '新增学生', + values: { name: '张三' }, + submissionId: 'sub-1', + fieldErrors: [], + }); + expect(content).toContain('sub-1'); + expect(content).toContain('"name":"张三"'); + expect(content).toContain('fieldErrors'); + }); + + it('审阅提交信息解析 submissionId 与下一步建议', () => { + const info = a2uiReviewSubmitInfo({ + a2uiReviewSubmit: { + reviewId: 'review-1', + reviewTitle: '批量入住导入', + resultMessage: '已导入 12 条', + submissionId: 'sub-2', + nextSteps: [{ key: 'after_checkin', label: '入住完成 → 建议录入费用' }], + }, + }); + expect(info?.submissionId).toBe('sub-2'); + expect(info?.nextSteps).toHaveLength(1); + }); + + it('审阅回灌内容包含提交 ID 与下一步建议', () => { + const content = buildReviewSubmitModelContent({ + reviewId: 'review-1', + reviewTitle: '批量入住导入', + resultMessage: '已导入 12 条', + submissionId: 'sub-2', + nextSteps: [{ key: 'after_checkin', label: '入住完成 → 建议录入费用' }], + }); + expect(content).toContain('sub-2'); + expect(content).toContain('建议录入费用'); + }); +}); diff --git a/apps/server/src/ai-chat/ai-chat.submit-content.ts b/apps/server/src/ai-chat/ai-chat.submit-content.ts index b21668a..5fd291b 100644 --- a/apps/server/src/ai-chat/ai-chat.submit-content.ts +++ b/apps/server/src/ai-chat/ai-chat.submit-content.ts @@ -3,21 +3,35 @@ import type { AiChatServiceContext } from './ai-chat.types'; export function a2uiSubmitInfo( metadata: Record | null, -): { title: string; values: Record } | null { +): { + formId: string; + title: string; + values: Record; + submissionId?: string; + fieldErrors?: Array<{ field: string; message: string }>; +} | null { const submit = metadata?.a2uiSubmit; if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; const record = submit as Record; + const formId = typeof record.formId === 'string' ? record.formId : ''; const title = typeof record.formTitle === 'string' ? record.formTitle : '表单'; const values = record.values && typeof record.values === 'object' && !Array.isArray(record.values) ? (record.values as Record) : {}; - return { title, values }; + const submissionId = + typeof record.submissionId === 'string' ? record.submissionId : undefined; + const fieldErrors = Array.isArray(record.fieldErrors) + ? (record.fieldErrors as Array<{ field: string; message: string }>) + : []; + return { formId, title, values, submissionId, fieldErrors }; } export function buildFormSubmitModelContent(submit: { title: string; values: Record; + submissionId?: string; + fieldErrors?: Array<{ field: string; message: string }>; }): string { let json: string; try { @@ -25,7 +39,11 @@ export function buildFormSubmitModelContent(submit: { } catch { json = '[无法序列化]'; } - return `【表单提交:${submit.title}】\n提交值(JSON):${json.slice(0, 32 * 1024)}\n用户已在表单中确认,你可以执行允许的写操作工具。`; + const submissionId = submit.submissionId ? `\n提交ID:${submit.submissionId}` : ''; + const fieldErrors = submit.fieldErrors?.length + ? `\nfieldErrors: ${JSON.stringify(submit.fieldErrors)}` + : '\nfieldErrors: []'; + return `【表单提交:${submit.title}】${submissionId}\n提交值(JSON):${json.slice(0, 32 * 1024)}${fieldErrors}\n用户已在表单中确认,你可以执行允许的写操作工具。`; } export async function markFormSubmittedOnMessage( @@ -48,15 +66,28 @@ export async function markFormSubmittedOnMessage( export function a2uiReviewSubmitInfo( metadata: Record | null, -): { reviewId: string; reviewTitle: string; resultMessage: string } | null { +): { + reviewId: string; + reviewTitle: string; + resultMessage: string; + submissionId?: string; + nextSteps?: Array<{ key: string; label: string }>; +} | null { const submit = metadata?.a2uiReviewSubmit; if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; const record = submit as Record; if (typeof record.reviewId !== 'string') return null; + const submissionId = + typeof record.submissionId === 'string' ? record.submissionId : undefined; + const nextSteps = Array.isArray(record.nextSteps) + ? (record.nextSteps as Array<{ key: string; label: string }>) + : []; return { reviewId: record.reviewId, reviewTitle: typeof record.reviewTitle === 'string' ? record.reviewTitle : '批量导入', resultMessage: typeof record.resultMessage === 'string' ? record.resultMessage : '导入已完成', + submissionId, + nextSteps, }; } @@ -64,8 +95,14 @@ export function buildReviewSubmitModelContent(submit: { reviewId: string; reviewTitle: string; resultMessage: string; + submissionId?: string; + nextSteps?: Array<{ key: string; label: string }>; }): string { - return `【批量导入已确认:${submit.reviewTitle}】\n${submit.resultMessage}\n数据已由系统入库,不要再次调用写入工具,直接向用户汇报导入结果即可。`; + const submissionId = submit.submissionId ? `\n提交ID:${submit.submissionId}` : ''; + const nextSteps = submit.nextSteps?.length + ? `\n下一步建议:${submit.nextSteps.map((step) => step.label).join(';')}` + : ''; + return `【批量导入已确认:${submit.reviewTitle}】${submissionId}\n${submit.resultMessage}${nextSteps}\n数据已由系统入库,不要再次调用写入工具,直接向用户汇报导入结果即可。`; } export async function markReviewSubmittedOnMessage( diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts index f37665d..25067d9 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts @@ -8,6 +8,7 @@ import { permittedStepKeys } from '../imports/imports.access'; import { expandStageSheets } from '../imports/imports.mapping'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AgentToolContext } from './ai-chat.tools'; +import { buildA2uiArtifact } from './ai-a2ui.artifact'; import { AiMessage } from './entities'; import { finishToolRun, startToolRun } from './ai-chat.tools'; import { @@ -155,6 +156,17 @@ export const executePreflightImport = makeImportToolExecutor( .join('、') || '未识别到可导入阶段'}`, }, emit); emit('ui.import_preflight', { messageId, preflight: preflightCard }); + emit('ui.artifact', { + messageId, + artifact: buildA2uiArtifact({ + type: 'import_preflight', + id: `preflight-${attachment.id}`, + status: 'pending', + messageId, + conversationId: assistant.conversationId, + payload: preflightCard, + }), + }); return preflightModelPayload(preflight, permittedSteps); }); @@ -241,6 +253,17 @@ export const executeStartImportWizard = makeImportToolExecutor( }); } emit('ui.import_wizard', { messageId, wizard }); + emit('ui.artifact', { + messageId, + artifact: buildA2uiArtifact({ + type: 'import_wizard', + id: `wizard-${detail.id}`, + status: 'pending', + messageId, + conversationId: assistant.conversationId, + payload: wizard, + }), + }); return JSON.stringify({ status: 'success', runId: detail.id, diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index f3a5dec..7b8fe3d 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -1,5 +1,6 @@ import { AiReview } from './entities/ai-review.entity'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; +import { buildA2uiArtifact } from './ai-a2ui.artifact'; import { finishToolRun, startToolRun } from './ai-chat.tools'; export async function executeRenderForm( context: AiChatServiceContext, @@ -20,6 +21,39 @@ export async function executeRenderForm( { userId, conversationId: assistant.conversationId, assistantMessageId: messageId }, parsedArgs, ); + const expiredForms = await context.formService.expirePreviousForms( + userId, + assistant.conversationId, + form.id, + ); + await Promise.all( + expiredForms.map(async (expired) => { + const oldAssistant = await context.messages.findOne({ + where: { id: expired.assistantMessageId, conversationId: assistant.conversationId }, + }); + const oldA2ui = oldAssistant?.metadata?.a2uiForm; + if (oldAssistant && oldA2ui && typeof oldA2ui === 'object' && !Array.isArray(oldA2ui)) { + oldAssistant.metadata = { + ...oldAssistant.metadata, + a2uiForm: context.formService.serialize(expired), + }; + await context.messages.save(oldAssistant); + } + const expiredPayload = context.formService.serialize(expired); + emit('ui.form', { messageId: expired.assistantMessageId, form: expiredPayload }); + emit('ui.artifact', { + messageId: expired.assistantMessageId, + artifact: buildA2uiArtifact({ + type: 'form', + id: expired.id, + status: 'expired', + messageId: expired.assistantMessageId, + conversationId: assistant.conversationId, + payload: expiredPayload, + }), + }); + }), + ); assistant.metadata = { ...assistant.metadata, a2uiForm: context.formService.serialize(form), @@ -31,6 +65,17 @@ export async function executeRenderForm( messageId, form: context.formService.serialize(form), }); + emit('ui.artifact', { + messageId, + artifact: buildA2uiArtifact({ + type: 'form', + id: form.id, + status: form.status === 'submitted' ? 'submitted' : 'pending', + messageId, + conversationId: assistant.conversationId, + payload: context.formService.serialize(form), + }), + }); return JSON.stringify({ status: 'success', formId: form.id, @@ -119,6 +164,17 @@ export async function executeRenderReview( messageId: expired.assistantMessageId, review: context.reviewService.serialize(expired), }); + emit('ui.artifact', { + messageId: expired.assistantMessageId, + artifact: buildA2uiArtifact({ + type: 'review', + id: expired.id, + status: expired.status === 'submitted' ? 'submitted' : 'expired', + messageId: expired.assistantMessageId, + conversationId: assistant.conversationId, + payload: context.reviewService.serialize(expired), + }), + }); }), ); assistant.metadata = { @@ -132,6 +188,17 @@ export async function executeRenderReview( messageId, review: context.reviewService.serialize(review), }); + emit('ui.artifact', { + messageId, + artifact: buildA2uiArtifact({ + type: 'review', + id: review.id, + status: review.status === 'submitted' ? 'submitted' : 'pending', + messageId, + conversationId: assistant.conversationId, + payload: context.reviewService.serialize(review), + }), + }); return JSON.stringify({ status: 'success', reviewId: review.id, @@ -179,6 +246,17 @@ export async function executeRenderChart( messageId, chart: context.chartService.serialize(chart), }); + emit('ui.artifact', { + messageId, + artifact: buildA2uiArtifact({ + type: 'chart', + id: chart.id, + status: 'pending', + messageId, + conversationId: assistant.conversationId, + payload: context.chartService.serialize(chart), + }), + }); return JSON.stringify({ status: 'success', chartId: chart.id, diff --git a/apps/server/src/ai-chat/ai-chat.types.ts b/apps/server/src/ai-chat/ai-chat.types.ts index b1108b1..e51cd7e 100644 --- a/apps/server/src/ai-chat/ai-chat.types.ts +++ b/apps/server/src/ai-chat/ai-chat.types.ts @@ -10,6 +10,7 @@ import { AiChartService } from './ai-chart.service'; import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; +import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { @@ -95,6 +96,7 @@ export interface AiChatServiceContext { readonly excelReader?: AiExcelReaderService; readonly importsService?: ImportsService; readonly opLog?: OperationLogsService; + readonly a2uiSubmissions?: A2uiSubmissionsService; listSkills(user: AuthenticatedUser): ReturnType; serializeMessage(message: AiMessage): Record; redactText(value: string): string; @@ -114,11 +116,18 @@ export interface AiChatServiceContext { reviewTitle: string; resultMessage: string; } | null; - buildFormSubmitModelContent(submit: { title: string; values: Record }): string; + buildFormSubmitModelContent(submit: { + title: string; + values: Record; + submissionId?: string; + fieldErrors?: Array<{ field: string; message: string }>; + }): string; buildReviewSubmitModelContent(submit: { reviewId: string; reviewTitle: string; resultMessage: string; + submissionId?: string; + nextSteps?: Array<{ key: string; label: string }>; }): string; markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise; markReviewSubmittedOnMessage( @@ -175,6 +184,7 @@ export type AiSseEventName = | 'ui.form' | 'ui.review' | 'ui.chart' + | 'ui.artifact' | 'ui.import_preflight' | 'ui.import_wizard' | 'attachment.processed' diff --git a/apps/server/src/ai-chat/ai-form.service.spec.ts b/apps/server/src/ai-chat/ai-form.service.spec.ts index 1763b49..ffd97b3 100644 --- a/apps/server/src/ai-chat/ai-form.service.spec.ts +++ b/apps/server/src/ai-chat/ai-form.service.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { In } from 'typeorm'; import { AiFormService } from './ai-form.service'; function createService(overrides: Record = {}) { @@ -140,6 +141,35 @@ describe('AiFormService', () => { }); }); + describe('expirePreviousForms', () => { + it('过期同会话其他 pending 表单并返回过期记录', async () => { + const pending = [ + { id: 'form-a', userId: 7, conversationId: 3, status: 'pending' }, + { id: 'form-b', userId: 7, conversationId: 3, status: 'pending' }, + ]; + const { service, forms } = createService({ + find: jest.fn().mockResolvedValue(pending), + update: jest.fn().mockResolvedValue({}), + }); + const expired = await service.expirePreviousForms(7, 3, 'form-b'); + expect(expired.map((item) => item.id)).toEqual(['form-a']); + expect(expired[0].status).toBe('expired'); + expect(forms.update).toHaveBeenCalledWith( + { id: In(['form-a']) }, + { status: 'expired' }, + ); + }); + + it('没有其他 pending 表单时返回空数组且不更新', async () => { + const { service, forms } = createService({ + find: jest.fn().mockResolvedValue([{ id: 'form-b' }]), + update: jest.fn(), + }); + await expect(service.expirePreviousForms(7, 3, 'form-b')).resolves.toEqual([]); + expect(forms.update).not.toHaveBeenCalled(); + }); + }); + describe('validateValues', () => { const form = { fieldsJson: JSON.stringify(validSchema.fields), diff --git a/apps/server/src/ai-chat/ai-form.service.ts b/apps/server/src/ai-chat/ai-form.service.ts index 205c72e..a72488f 100644 --- a/apps/server/src/ai-chat/ai-form.service.ts +++ b/apps/server/src/ai-chat/ai-form.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { uuidV7 } from '../common/uuid-v7'; import { AiForm, type AiFormField } from './entities/ai-form.entity'; @@ -108,10 +108,28 @@ export class AiFormService { async findOwnedPending(formId: string, userId: number): Promise { const form = await this.forms.findOne({ where: { id: formId, userId, status: 'pending' } }); - if (!form) throw new NotFoundException('表单不存在或已提交'); + if (!form) throw new NotFoundException('表单不存在、已提交或已失效'); return form; } + /** + * 过期同会话其他 pending 表单(新表单渲染后旧表单不可再提交)。 + */ + async expirePreviousForms( + userId: number, + conversationId: number, + exceptFormId: string, + ): Promise { + const pending = await this.forms.find({ + where: { userId, conversationId, status: 'pending' }, + }); + const expired = pending.filter((form) => form.id !== exceptFormId); + if (expired.length === 0) return []; + const ids = expired.map((form) => form.id); + await this.forms.update({ id: In(ids) }, { status: 'expired' }); + return expired.map((form) => ({ ...form, status: 'expired' as const })); + } + async markSubmitted(form: AiForm, values: Record): Promise { form.status = 'submitted'; form.submittedValuesJson = JSON.stringify(values); diff --git a/apps/server/src/ai-chat/entities/ai-a2ui-submission.entity.ts b/apps/server/src/ai-chat/entities/ai-a2ui-submission.entity.ts new file mode 100644 index 0000000..9568b50 --- /dev/null +++ b/apps/server/src/ai-chat/entities/ai-a2ui-submission.entity.ts @@ -0,0 +1,35 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * A2UI 提交幂等记录:同一 artifact + clientRequestId 只允许执行一次, + * 重复提交返回既有结果,避免双击/重试导致二次写入或二次生成。 + */ +@Entity('ai_a2ui_submissions') +@Index('uk_ai_a2ui_submissions_artifact_client', ['artifactId', 'clientRequestId'], { + unique: true, +}) +export class AiA2uiSubmission { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'artifact_id', type: 'varchar', length: 100 }) + artifactId: string; + + @Column({ name: 'client_request_id', type: 'varchar', length: 36 }) + clientRequestId: string; + + @Column({ type: 'varchar', length: 20 }) + status: string; + + @Column({ name: 'result_json', type: 'text', nullable: true }) + resultJson: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'datetime' }) + createdAt: Date; +} diff --git a/apps/server/src/ai-chat/entities/ai-form.entity.ts b/apps/server/src/ai-chat/entities/ai-form.entity.ts index d77dc88..7226788 100644 --- a/apps/server/src/ai-chat/entities/ai-form.entity.ts +++ b/apps/server/src/ai-chat/entities/ai-form.entity.ts @@ -10,7 +10,7 @@ import { } from 'typeorm'; import { AiMessage } from './ai-message.entity'; -export type AiFormStatus = 'pending' | 'submitted'; +export type AiFormStatus = 'pending' | 'submitted' | 'expired'; export interface AiFormField { name: string; diff --git a/apps/server/src/ai-chat/entities/index.ts b/apps/server/src/ai-chat/entities/index.ts index 164aeb1..075580f 100644 --- a/apps/server/src/ai-chat/entities/index.ts +++ b/apps/server/src/ai-chat/entities/index.ts @@ -4,3 +4,4 @@ export * from './ai-tool-run.entity'; export * from './ai-attachment.entity'; export * from './ai-form.entity'; export * from './ai-review.entity'; +export * from './ai-a2ui-submission.entity'; diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 8816293..66dad62 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -19,6 +19,7 @@ import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiR import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns'; import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback'; import { AddImportRunSettings1784930000000 } from './migrations/1784930000000-AddImportRunSettings'; +import { AddA2UiSubmissions1786001000000 } from './migrations/1786001000000-AddA2UiSubmissions'; const allMigrations = [ InitialSchema1784520727860, AddExamManagement1784600000000, @@ -31,6 +32,7 @@ const allMigrations = [ AddImportRuns1784910000000, DropAiMessageFeedback1784920000000, AddImportRunSettings1784930000000, + AddA2UiSubmissions1786001000000, ]; import { AuthorizationModule } from './authorization'; import { RbacModule } from './rbac/rbac.module'; @@ -149,6 +151,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; Entities.AiAttachment, Entities.AiForm, Entities.AiReview, + Entities.AiA2uiSubmission, Entities.ImportRun, Entities.ImportStep, Entities.ImportRow, diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index ed8af6e..1eb8118 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -53,6 +53,7 @@ export { AiToolRun, AiForm, AiReview, + AiA2uiSubmission, } from '../ai-chat/entities'; export { ImportRun } from '../imports/entities/import-run.entity'; export { ImportStep } from '../imports/entities/import-step.entity'; diff --git a/apps/server/src/migrations/1786001000000-AddA2UiSubmissions.ts b/apps/server/src/migrations/1786001000000-AddA2UiSubmissions.ts new file mode 100644 index 0000000..64d810b --- /dev/null +++ b/apps/server/src/migrations/1786001000000-AddA2UiSubmissions.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * A2UI 提交幂等记录:同一 artifact + clientRequestId 只允许执行一次。 + */ +export class AddA2UiSubmissions1786001000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('ai_a2ui_submissions')) return; + await queryRunner.createTable( + new Table({ + name: 'ai_a2ui_submissions', + columns: [ + { name: 'id', type: 'int', isPrimary: true, isGenerated: true, generationStrategy: 'increment' }, + { name: 'artifact_id', type: 'varchar', length: '100' }, + { name: 'client_request_id', type: 'varchar', length: '36' }, + { name: 'status', type: 'varchar', length: '20' }, + { name: 'result_json', type: 'text', isNullable: true }, + { name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + ], + }), + ); + await queryRunner.createIndex( + 'ai_a2ui_submissions', + new TableIndex({ + name: 'uk_ai_a2ui_submissions_artifact_client', + columnNames: ['artifact_id', 'client_request_id'], + isUnique: true, + }), + ); + } + + async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('ai_a2ui_submissions')) { + await queryRunner.dropTable('ai_a2ui_submissions'); + } + } +}