diff --git a/apps/admin/src/components/AiChat/message-mappers.ts b/apps/admin/src/components/AiChat/message-mappers.ts index 68688a8..ed7d0b0 100644 --- a/apps/admin/src/components/AiChat/message-mappers.ts +++ b/apps/admin/src/components/AiChat/message-mappers.ts @@ -9,7 +9,7 @@ import type { AiReviewSchema, AiToolRun, } from './types'; -import { mergeArtifactIntoMessage } from './provider'; +import { mergeArtifactIntoMessage } from './uiArtifacts'; function mapStatus(record: AiMessageRecord): AiChatMessageStatus { if (record.status === 'pending') return 'loading'; diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index 970fdef..69b22f6 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -6,280 +6,10 @@ import { } from '@ant-design/x-sdk'; import { usePermissionStore } from '../../store/permission/permissionStore'; import { useUserStore } from '../../store/user/userStore'; -import type { - AiAttachment, - AiArtifactSchema, - AiChatInput, - AiChatMessage, - AiChartSchema, - AiFormSchema, - AiImportPreflight, - AiModelRetryInfo, - AiReviewSchema, - AiSseChunk, - AiToolRun, -} from './types'; +import type { AiArtifactSchema, AiChatInput, AiChatMessage, AiReviewSchema, AiSseChunk } from './types'; +import { emptyAssistant, parseSsePayload, reduceAiSseMessage } from './sseReducer'; -interface AiSsePayload { - messageId?: number; - userMessageId?: number; - assistantMessageId?: number; - delta?: string; - content?: string; - reasoningContent?: string | null; - toolCallId?: string; - toolName?: string; - skillKey?: string | null; - status?: string; - summary?: string | null; - durationMs?: number | null; - attachment?: AiAttachment; - form?: AiFormSchema; - artifact?: AiArtifactSchema; - review?: AiReviewSchema; - chart?: AiChartSchema; - preflight?: AiImportPreflight; - wizard?: unknown; - retry?: AiModelRetryInfo; - message?: - | string - | { - id?: number; - content?: string; - reasoningContent?: string | null; - status?: string; - toolRuns?: AiToolRun[]; - attachments?: AiAttachment[]; - replyToMessageId?: number | null; - metadata?: Record | null; - }; - error?: string; -} - -function emptyAssistant(): AiChatMessage { - return { - role: 'assistant', - content: '', - reasoningContent: '', - toolRuns: [], - attachments: [], - forms: [], - uiArtifacts: [], - }; -} - -function mergeForms( - current: AiFormSchema[] | undefined, - incoming: AiFormSchema | AiFormSchema[] | undefined, -): AiFormSchema[] { - const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; - if (!items.length) return current ?? []; - const next = [...(current ?? [])]; - for (const item of items) { - if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) { - next.push(item); - } - } - return next; -} - -function mergeById( - current: T[] | undefined, - incoming: T | T[] | undefined, -): T[] { - const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; - if (!items.length) return current ?? []; - const next = [...(current ?? [])]; - for (const item of items) { - if (!item || typeof item !== 'object') continue; - const index = next.findIndex((existing) => existing.id === item.id); - if (index === -1) { - next.push(item); - } else { - next[index] = item; - } - } - return next; -} - -export function parseSsePayload(chunk?: AiSseChunk): { - event: string; - payload: AiSsePayload; -} { - if (!chunk) return { event: '', payload: {} }; - const event = chunk.event?.trim() || 'message'; - if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} }; - try { - const parsed: unknown = JSON.parse(chunk.data); - return { - event, - payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {}, - }; - } catch { - return { event, payload: { delta: chunk.data } }; - } -} - -function upsertToolRun( - toolRuns: AiToolRun[], - payload: AiSsePayload, - fallbackStatus: AiToolRun['status'], -): AiToolRun[] { - const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`; - const next: AiToolRun = { - toolCallId, - toolName: payload.toolName || '查询工具', - skillKey: payload.skillKey, - status: (payload.status as AiToolRun['status']) || fallbackStatus, - summary: payload.summary, - resultSummary: fallbackStatus === 'running' ? undefined : payload.summary, - argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined, - durationMs: payload.durationMs, - }; - const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId); - if (index === -1) return [...toolRuns, next]; - return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item)); -} - -function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] { - if (!toolRuns) return fallback; - return toolRuns.map((tool) => ({ - ...tool, - status: tool.status === 'error' ? 'failed' : tool.status, - summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary, - })); -} - -function applyMessagePayload( - message: AiChatMessage, - nested: AiSsePayload['message'], - payload: AiSsePayload, -): void { - if (typeof nested !== 'object' || nested === null) return; - message.forms = mergeForms( - message.forms, - (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, - ); - message.reviews = mergeById( - message.reviews, - (nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, - ); - message.charts = mergeById( - 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, -): AiChatMessage { - const message = originMessage ? { ...originMessage } : emptyAssistant(); - const { event, payload } = parseSsePayload(chunk); - - if (event === 'message.created') { - const nested = typeof payload.message === 'object' ? payload.message : undefined; - message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id; - message.content = nested?.content ?? message.content; - message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; - message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); - message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); - } else if (event === 'reasoning.delta') { - message.retrying = null; - message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; - } else if (event === 'content.delta') { - message.retrying = null; - message.content += payload.delta ?? payload.content ?? ''; - } else if (event === 'model.retrying' && payload.retry) { - message.retrying = payload.retry; - } else if (event === 'ui.form' && payload.form) { - message.forms = mergeForms(message.forms, payload.form); - } else if (event === 'ui.review' && payload.review) { - 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) { - message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; - } else if (event === 'tool.started') { - message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running'); - } else if (event === 'tool.completed') { - message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success'); - } else if (event === 'tool.failed') { - message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed'); - } else if (event === 'attachment.processed' && payload.attachment) { - if (!message.attachments.some((item) => item.id === payload.attachment?.id)) { - message.attachments = [...message.attachments, payload.attachment]; - } - } else if (event === 'message.completed') { - const nested = typeof payload.message === 'object' ? payload.message : undefined; - message.id = nested?.id ?? payload.messageId ?? message.id; - message.content = nested?.content ?? payload.content ?? message.content; - message.reasoningContent = - nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; - message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); - message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); - message.retrying = null; - } else if (event === 'message.cancelled') { - message.id = payload.messageId ?? message.id; - message.cancelled = true; - message.retrying = null; - } else if (event === 'error') { - message.retrying = null; - message.error = - (typeof payload.message === 'string' ? payload.message : undefined) || - payload.error || - 'AI 回答生成失败'; - } - return message; -} +export { parseSsePayload, reduceAiSseMessage }; export async function authenticatedFetch( input: RequestInfo | URL, diff --git a/apps/admin/src/components/AiChat/sseReducer.ts b/apps/admin/src/components/AiChat/sseReducer.ts new file mode 100644 index 0000000..6b1ac2e --- /dev/null +++ b/apps/admin/src/components/AiChat/sseReducer.ts @@ -0,0 +1,208 @@ +import type { + AiArtifactSchema, + AiAttachment, + AiChartSchema, + AiChatMessage, + AiFormSchema, + AiImportPreflight, + AiModelRetryInfo, + AiReviewSchema, + AiSseChunk, + AiToolRun, +} from './types'; +import { mergeArtifactIntoMessage, mergeById, mergeForms } from './uiArtifacts'; + +export interface AiSsePayload { + messageId?: number; + userMessageId?: number; + assistantMessageId?: number; + delta?: string; + content?: string; + reasoningContent?: string | null; + toolCallId?: string; + toolName?: string; + skillKey?: string | null; + status?: string; + summary?: string | null; + durationMs?: number | null; + attachment?: AiAttachment; + form?: AiFormSchema; + artifact?: AiArtifactSchema; + review?: AiReviewSchema; + chart?: AiChartSchema; + preflight?: AiImportPreflight; + wizard?: unknown; + retry?: AiModelRetryInfo; + message?: + | string + | { + id?: number; + content?: string; + reasoningContent?: string | null; + status?: string; + toolRuns?: AiToolRun[]; + attachments?: AiAttachment[]; + replyToMessageId?: number | null; + metadata?: Record | null; + }; + error?: string; +} + +export function emptyAssistant(): AiChatMessage { + return { + role: 'assistant', + content: '', + reasoningContent: '', + toolRuns: [], + attachments: [], + forms: [], + uiArtifacts: [], + }; +} + +export function parseSsePayload(chunk?: AiSseChunk): { + event: string; + payload: AiSsePayload; +} { + if (!chunk) return { event: '', payload: {} }; + const event = chunk.event?.trim() || 'message'; + if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} }; + try { + const parsed: unknown = JSON.parse(chunk.data); + return { + event, + payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {}, + }; + } catch { + return { event, payload: { delta: chunk.data } }; + } +} + +function upsertToolRun( + toolRuns: AiToolRun[], + payload: AiSsePayload, + fallbackStatus: AiToolRun['status'], +): AiToolRun[] { + const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`; + const next: AiToolRun = { + toolCallId, + toolName: payload.toolName || '查询工具', + skillKey: payload.skillKey, + status: (payload.status as AiToolRun['status']) || fallbackStatus, + summary: payload.summary, + resultSummary: fallbackStatus === 'running' ? undefined : payload.summary, + argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined, + durationMs: payload.durationMs, + }; + const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId); + if (index === -1) return [...toolRuns, next]; + return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item)); +} + +function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] { + if (!toolRuns) return fallback; + return toolRuns.map((tool) => ({ + ...tool, + status: tool.status === 'error' ? 'failed' : tool.status, + summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary, + })); +} + +function applyMessagePayload( + message: AiChatMessage, + nested: AiSsePayload['message'], + payload: AiSsePayload, +): void { + if (typeof nested !== 'object' || nested === null) return; + message.forms = mergeForms( + message.forms, + (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, + ); + message.reviews = mergeById( + message.reviews, + (nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, + ); + message.charts = mergeById( + 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; +} + +export function reduceAiSseMessage( + originMessage: AiChatMessage | undefined, + chunk?: AiSseChunk, +): AiChatMessage { + const message = originMessage ? { ...originMessage } : emptyAssistant(); + const { event, payload } = parseSsePayload(chunk); + + if (event === 'message.created') { + const nested = typeof payload.message === 'object' ? payload.message : undefined; + message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id; + message.content = nested?.content ?? message.content; + message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; + message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); + message.attachments = nested?.attachments ?? message.attachments; + applyMessagePayload(message, nested, payload); + } else if (event === 'reasoning.delta') { + message.retrying = null; + message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; + } else if (event === 'content.delta') { + message.retrying = null; + message.content += payload.delta ?? payload.content ?? ''; + } else if (event === 'model.retrying' && payload.retry) { + message.retrying = payload.retry; + } else if (event === 'ui.form' && payload.form) { + message.forms = mergeForms(message.forms, payload.form); + } else if (event === 'ui.review' && payload.review) { + 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) { + message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; + } else if (event === 'tool.started') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running'); + } else if (event === 'tool.completed') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success'); + } else if (event === 'tool.failed') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed'); + } else if (event === 'attachment.processed' && payload.attachment) { + if (!message.attachments.some((item) => item.id === payload.attachment?.id)) { + message.attachments = [...message.attachments, payload.attachment]; + } + } else if (event === 'message.completed') { + const nested = typeof payload.message === 'object' ? payload.message : undefined; + message.id = nested?.id ?? payload.messageId ?? message.id; + message.content = nested?.content ?? payload.content ?? message.content; + message.reasoningContent = + nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; + message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); + message.attachments = nested?.attachments ?? message.attachments; + applyMessagePayload(message, nested, payload); + message.retrying = null; + } else if (event === 'message.cancelled') { + message.id = payload.messageId ?? message.id; + message.cancelled = true; + message.retrying = null; + } else if (event === 'error') { + message.retrying = null; + message.error = + (typeof payload.message === 'string' ? payload.message : undefined) || + payload.error || + 'AI 回答生成失败'; + } + return message; +} diff --git a/apps/admin/src/components/AiChat/uiArtifacts.ts b/apps/admin/src/components/AiChat/uiArtifacts.ts new file mode 100644 index 0000000..abb20e2 --- /dev/null +++ b/apps/admin/src/components/AiChat/uiArtifacts.ts @@ -0,0 +1,69 @@ +import type { + AiArtifactSchema, + AiChartSchema, + AiChatMessage, + AiFormSchema, + AiReviewSchema, +} from './types'; + +export function mergeById( + current: T[] | undefined, + incoming: T | T[] | undefined, +): T[] { + const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; + if (!items.length) return current ?? []; + const next = [...(current ?? [])]; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const index = next.findIndex((existing) => existing.id === item.id); + if (index === -1) { + next.push(item); + } else { + next[index] = item; + } + } + return next; +} + +export function mergeForms( + current: AiFormSchema[] | undefined, + incoming: AiFormSchema | AiFormSchema[] | undefined, +): AiFormSchema[] { + const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; + if (!items.length) return current ?? []; + const next = [...(current ?? [])]; + for (const item of items) { + if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) { + next.push(item); + } + } + return next; +} + +function payloadOf(artifact: AiArtifactSchema): unknown { + return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {}; +} + +/** + * 将统一 artifact 归入 uiArtifacts,并按类型派发到 legacy 列表。 + * payload 来自服务端契约(表单/审阅/图表/预检/向导),按类型做单次断言。 + */ +export function mergeArtifactIntoMessage( + message: AiChatMessage, + artifact: AiArtifactSchema, +): AiChatMessage { + message.uiArtifacts = mergeById(message.uiArtifacts, artifact); + const payload = payloadOf(artifact); + if (artifact.type === 'form') { + message.forms = mergeForms(message.forms, payload as AiFormSchema); + } else if (artifact.type === 'review') { + message.reviews = mergeById(message.reviews, payload as AiReviewSchema); + } else if (artifact.type === 'chart') { + message.charts = mergeById(message.charts, payload 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; +} diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index 91a42dc..ad0b032 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -9,7 +9,8 @@ import { useSettingsStore } from '../../store/settings/settingsStore'; import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api'; import { AiMessageContent } from './AiMessageContent'; import { mapHistoryMessage } from './message-mappers'; -import { GongxueAiChatProvider, mergeArtifactIntoMessage } from './provider'; +import { GongxueAiChatProvider } from './provider'; +import { mergeArtifactIntoMessage } from './uiArtifacts'; import { emptyAssistant, MessageHoverActions, diff --git a/apps/server/src/agent-context/business-context.registry.ts b/apps/server/src/agent-context/business-context.registry.ts index c6a8939..cf15d17 100644 --- a/apps/server/src/agent-context/business-context.registry.ts +++ b/apps/server/src/agent-context/business-context.registry.ts @@ -1,8 +1,49 @@ import type { BusinessEntity, + BusinessEntityField, + BusinessEntityRelation, BusinessWorkflow, } from './business-context.types'; +function field( + key: string, + label: string, + type: BusinessEntityField['type'], + extra: Partial> = {}, +): BusinessEntityField { + return { key, label, type, ...extra }; +} + +function relation( + entityKey: string, + via: string, + requiredFor: readonly string[], +): BusinessEntityRelation { + return { entityKey, via, requiredFor }; +} + +function entity( + key: string, + name: string, + description: string, + options: { + searchTool?: string; + requiredPermissions: readonly string[]; + fields: readonly BusinessEntityField[]; + relations?: readonly BusinessEntityRelation[]; + }, +): BusinessEntity { + return { + key, + name, + description, + ...(options.searchTool ? { searchTool: options.searchTool } : {}), + requiredPermissions: options.requiredPermissions, + fields: options.fields, + relations: options.relations ?? [], + }; +} + /** * 恭学系统业务上下文(代码内维护)。 * @@ -11,232 +52,191 @@ import type { * Agent 感知业务流程。 */ export const BUSINESS_ENTITIES: readonly BusinessEntity[] = [ - { - key: 'student', - name: '学生档案', - description: '学生基础档案(姓名、学号、手机号、性别等),是分班、入住、账单的前置数据。', + entity('student', '学生档案', '学生基础档案(姓名、学号、手机号、性别等),是分班、入住、账单的前置数据。', { 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' }, + field('name', '姓名', 'string', { required: true }), + field('studentNo', '学号', 'string'), + field('phone', '手机号', 'string'), + field('gender', '性别', 'enum', { enumFrom: 'student.gender' }), + field('idNumber', '身份证号', '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'] }, + relation('class', 'class_student', ['class']), + relation('occupancy', 'occupancy', ['checkin']), + relation('bill', 'bill', ['bill']), + relation('exam', 'exam_score', ['exam']), ], - }, - { - key: 'class', - name: '班级', - description: '班级档案与在读分班关系,排课、考勤、考试依赖班级。', + }), + entity('class', '班级', '班级档案与在读分班关系,排课、考勤、考试依赖班级。', { searchTool: 'search_classes', requiredPermissions: ['class:view'], fields: [ - { key: 'name', label: '班级名称', type: 'string', required: true }, - { key: 'grade', label: '年级', type: 'string' }, - { key: 'headTeacher', label: '班主任', type: 'string' }, + field('name', '班级名称', 'string', { required: true }), + field('grade', '年级', 'string'), + field('headTeacher', '班主任', 'string'), ], relations: [ - { entityKey: 'student', via: 'class_student', requiredFor: ['class'] }, - { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + relation('student', 'class_student', ['class']), + relation('schedule', 'class_schedule', ['schedule']), ], - }, - { - key: 'schedule', - name: '排课/日程', - description: '班级与教室的课程安排,考勤和教室日程依赖排课。', + }), + entity('schedule', '排课/日程', '班级与教室的课程安排,考勤和教室日程依赖排课。', { 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' }, + field('classId', '班级', 'number'), + field('classroomId', '教室', 'number'), + field('weekDay', '星期', 'number'), + field('startTime', '开始时间', 'string'), + field('endTime', '结束时间', 'string'), ], relations: [ - { entityKey: 'class', via: 'class_schedule', requiredFor: ['schedule'] }, - { entityKey: 'classroom', via: 'class_schedule', requiredFor: ['schedule'] }, + relation('class', 'class_schedule', ['schedule']), + relation('classroom', 'class_schedule', ['schedule']), ], - }, - { - key: 'attendance', - name: '考勤', - description: '按班级与日期的考勤记录(出勤/迟到/缺勤/请假)。', + }), + entity('attendance', '考勤', '按班级与日期的考勤记录(出勤/迟到/缺勤/请假)。', { 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' }, + field('studentId', '学生', 'number', { required: true }), + field('date', '日期', 'date', { required: true }), + field('status', '状态', 'enum', { enumFrom: 'attendance.status' }), ], relations: [ - { entityKey: 'class', via: 'class_schedule', requiredFor: ['attendance'] }, - { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['attendance'] }, + relation('class', 'class_schedule', ['attendance']), + relation('schedule', 'class_schedule', ['attendance']), ], - }, - { - key: 'exam', - name: '考试/成绩', - description: '考试安排与成绩记录,依赖班级与学生档案。', + }), + entity('exam', '考试/成绩', '考试安排与成绩记录,依赖班级与学生档案。', { searchTool: 'search_exams', requiredPermissions: ['exam:view'], fields: [ - { key: 'name', label: '考试名称', type: 'string', required: true }, - { key: 'date', label: '考试日期', type: 'date' }, - { key: 'subject', label: '科目', type: 'string' }, + field('name', '考试名称', 'string', { required: true }), + field('date', '考试日期', 'date'), + field('subject', '科目', 'string'), ], relations: [ - { entityKey: 'class', via: 'exam_score', requiredFor: ['exam'] }, - { entityKey: 'student', via: 'exam_score', requiredFor: ['exam'] }, + relation('class', 'exam_score', ['exam']), + relation('student', 'exam_score', ['exam']), ], - }, - { - key: 'room', - name: '宿舍档案', - description: '宿舍/床位基础档案,入住登记的前置数据。', + }), + entity('room', '宿舍档案', '宿舍/床位基础档案,入住登记的前置数据。', { 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' }, + field('roomNumber', '宿舍号', 'string', { required: true }), + field('building', '楼栋', 'string'), + field('floor', '楼层', 'number'), + field('capacity', '容量', 'number', { required: true }), + field('roomType', '房型', 'string'), + field('monthlyRate', '月租金', 'number'), ], - relations: [ - { entityKey: 'occupancy', via: 'occupancy', requiredFor: ['checkin'] }, - ], - }, - { - key: 'occupancy', - name: '入住记录', - description: '学生入住/换宿/退宿记录,费用与账单依赖入住状态。', + relations: [relation('occupancy', 'occupancy', ['checkin'])], + }), + entity('occupancy', '入住记录', '学生入住/换宿/退宿记录,费用与账单依赖入住状态。', { 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' }, + field('studentId', '学生', 'number', { required: true }), + field('roomId', '宿舍', 'number', { required: true }), + field('checkInDate', '入住日期', 'date', { required: true }), + field('billingStartDate', '计费开始日期', 'date', { required: true }), + field('stayType', '住宿类型', 'enum', { enumFrom: 'occupancy.stayType' }), ], relations: [ - { entityKey: 'student', via: 'occupancy', requiredFor: ['checkin'] }, - { entityKey: 'room', via: 'occupancy', requiredFor: ['checkin'] }, - { entityKey: 'bill', via: 'bill', requiredFor: ['bill'] }, + relation('student', 'occupancy', ['checkin']), + relation('room', 'occupancy', ['checkin']), + relation('bill', 'bill', ['bill']), ], - }, - { - key: 'expense', - name: '费用', - description: '公共费用与个人费用,是生成账单的基础。', + }), + entity('expense', '费用', '公共费用与个人费用,是生成账单的基础。', { 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' }, + field('type', '费用类型', 'string', { required: true }), + field('amount', '金额', 'number', { required: true }), + field('periodStart', '费用开始日期', 'date'), + field('periodEnd', '费用结束日期', 'date'), ], relations: [ - { entityKey: 'occupancy', via: 'expense', requiredFor: ['expense'] }, - { entityKey: 'bill', via: 'bill_item', requiredFor: ['bill'] }, + relation('occupancy', 'expense', ['expense']), + relation('bill', 'bill_item', ['bill']), ], - }, - { - key: 'bill', - name: '账单', - description: '按学生与账期生成的账单(公共+个人费用分摊),支持确认与付款。', + }), + entity('bill', '账单', '按学生与账期生成的账单(公共+个人费用分摊),支持确认与付款。', { 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' }, + field('studentId', '学生', 'number', { required: true }), + field('periodStart', '账期开始', 'date', { required: true }), + field('periodEnd', '账期结束', 'date', { required: true }), + field('totalAmount', '总金额', 'number', { required: true }), + field('status', '状态', 'enum', { enumFrom: 'bill.status' }), ], relations: [ - { entityKey: 'student', via: 'bill', requiredFor: ['bill'] }, - { entityKey: 'occupancy', via: 'bill', requiredFor: ['bill'] }, - { entityKey: 'deposit', via: 'deposit', requiredFor: ['deposit'] }, + relation('student', 'bill', ['bill']), + relation('occupancy', 'bill', ['bill']), + relation('deposit', 'deposit', ['deposit']), ], - }, - { - key: 'deposit', - name: '押金', - description: '押金收取与退还记录,通常在账单确认后处理。', + }), + entity('deposit', '押金', '押金收取与退还记录,通常在账单确认后处理。', { 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' }, + field('studentId', '学生', 'number', { required: true }), + field('amount', '金额', 'number', { required: true }), + field('status', '状态', 'enum', { enumFrom: 'deposit.status' }), ], relations: [ - { entityKey: 'student', via: 'deposit', requiredFor: ['deposit'] }, - { entityKey: 'bill', via: 'deposit', requiredFor: ['deposit'] }, + relation('student', 'deposit', ['deposit']), + relation('bill', 'deposit', ['deposit']), ], - }, - { - key: 'classroom', - name: '教室档案', - description: '教室基础档案,租赁与教室日程的前置数据。', + }), + entity('classroom', '教室档案', '教室基础档案,租赁与教室日程的前置数据。', { searchTool: 'search_classrooms', requiredPermissions: ['classroom:view'], fields: [ - { key: 'name', label: '教室名称', type: 'string', required: true }, - { key: 'building', label: '楼栋', type: 'string' }, - { key: 'capacity', label: '容量', type: 'number' }, + field('name', '教室名称', 'string', { required: true }), + field('building', '楼栋', 'string'), + field('capacity', '容量', 'number'), ], relations: [ - { entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] }, - { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + relation('rental', 'classroom_rental', ['rental']), + relation('schedule', 'class_schedule', ['schedule']), ], - }, - { - key: 'organization', - name: '组织/校区', - description: '校区与组织归属,租赁双方与档案归属依赖组织。', + }), + entity('organization', '组织/校区', '校区与组织归属,租赁双方与档案归属依赖组织。', { requiredPermissions: ['organization:view'], fields: [ - { key: 'name', label: '名称', type: 'string', required: true }, - { key: 'code', label: '编码', type: 'string' }, - { key: 'isHost', label: '是否本部', type: 'boolean' }, + field('name', '名称', 'string', { required: true }), + field('code', '编码', 'string'), + field('isHost', '是否本部', 'boolean'), ], relations: [ - { entityKey: 'student', via: 'organization', requiredFor: ['profile'] }, - { entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] }, + relation('student', 'organization', ['profile']), + relation('rental', 'classroom_rental', ['rental']), ], - }, - { - key: 'rental', - name: '教室租赁', - description: '教室租赁订单与合同(合同字段在租赁记录上),依赖教室与组织。', + }), + entity('rental', '教室租赁', '教室租赁订单与合同(合同字段在租赁记录上),依赖教室与组织。', { 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' }, + field('classroomId', '教室', 'number', { required: true }), + field('lesseeOrganizationId', '承租方', 'number'), + field('startDate', '开始日期', 'date', { required: true }), + field('endDate', '结束日期', 'date', { required: true }), + field('dailyRate', '日租金', 'number'), + field('contractPath', '合同文件', 'string'), ], relations: [ - { entityKey: 'classroom', via: 'classroom_rental', requiredFor: ['rental'] }, - { entityKey: 'organization', via: 'classroom_rental', requiredFor: ['rental'] }, - { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + relation('classroom', 'classroom_rental', ['rental']), + relation('organization', 'classroom_rental', ['rental']), + relation('schedule', 'class_schedule', ['schedule']), ], - }, + }), ]; export const BUSINESS_WORKFLOWS: readonly BusinessWorkflow[] = [ diff --git a/apps/server/src/agent-context/business-context.types.ts b/apps/server/src/agent-context/business-context.types.ts index 3b0b419..3b68eb5 100644 --- a/apps/server/src/agent-context/business-context.types.ts +++ b/apps/server/src/agent-context/business-context.types.ts @@ -21,7 +21,7 @@ export interface BusinessEntityRelation { entityKey: string; via: string; /** 需要该关系已建立的工作流阶段 key。 */ - requiredFor: string[]; + requiredFor: readonly string[]; } export interface BusinessEntity {