feat(ai): 业务上下文感知与 A2UI 链路统一
- 新增代码内业务上下文元数据层(实体字典 + 三大闭环工作流) - 新增 get_business_context / get_entity_schema / get_pending_tasks 运行时工具 - SYSTEM_PROMPT 与技能目录改为先查业务流程/待办再执行 - A2UI 增加 ai_a2ui_submissions 幂等表、表单过期、ui.artifact 事件 - 提交回灌携带 submissionId / fieldErrors / 下一步建议 - 前端 uiArtifacts 归一化与过期表单禁用
This commit is contained in:
@@ -56,6 +56,9 @@ const toolLabels: Record<string, string> = {
|
|||||||
search_classrooms: '查询教室',
|
search_classrooms: '查询教室',
|
||||||
search_classroom_rentals: '查询教室租用',
|
search_classroom_rentals: '查询教室租用',
|
||||||
get_sync_status: '查询同步状态',
|
get_sync_status: '查询同步状态',
|
||||||
|
get_business_context: '读取业务流程',
|
||||||
|
get_entity_schema: '读取实体字典',
|
||||||
|
get_pending_tasks: '查询业务待办',
|
||||||
};
|
};
|
||||||
|
|
||||||
const markdownComponents = {
|
const markdownComponents = {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
|
|||||||
);
|
);
|
||||||
if (!form) return null;
|
if (!form) return null;
|
||||||
const finished = Boolean(form.submitted) || form.status === 'submitted';
|
const finished = Boolean(form.submitted) || form.status === 'submitted';
|
||||||
|
const expired = form.status === 'expired';
|
||||||
|
|
||||||
const handleFinish = (values: Record<string, unknown>) => {
|
const handleFinish = (values: Record<string, unknown>) => {
|
||||||
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
|
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
|
||||||
@@ -97,7 +98,9 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
|
|||||||
{form.description}
|
{form.description}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
{finished ? (
|
{expired ? (
|
||||||
|
<Alert type="warning" showIcon title="表单已失效" description="此表单已被新的请求替代,请让助手重新生成。" />
|
||||||
|
) : finished ? (
|
||||||
<Alert type="success" showIcon title="已提交,AI 正在处理…" />
|
<Alert type="success" showIcon title="已提交,AI 正在处理…" />
|
||||||
) : (
|
) : (
|
||||||
<Form
|
<Form
|
||||||
|
|||||||
@@ -103,6 +103,38 @@ describe('AI chat bubble rendering', () => {
|
|||||||
expect(container.textContent).toContain('已提交');
|
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(
|
||||||
|
<DynamicForm
|
||||||
|
form={{
|
||||||
|
id: 'form-expired',
|
||||||
|
title: '已失效的表单',
|
||||||
|
status: 'expired',
|
||||||
|
fields: [{ name: 'name', label: '姓名', type: 'input', required: true }],
|
||||||
|
}}
|
||||||
|
onSubmit={() => {
|
||||||
|
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 () => {
|
it('renders an A2UI review card and submits via the confirm button', async () => {
|
||||||
let submittedId: string | null = null;
|
let submittedId: string | null = null;
|
||||||
const review: AiReviewSchema = {
|
const review: AiReviewSchema = {
|
||||||
|
|||||||
@@ -78,6 +78,52 @@ describe('AI chat history mapper', () => {
|
|||||||
expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' });
|
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', () => {
|
it('restores a persisted A2UI review from message metadata', () => {
|
||||||
const mapped = mapHistoryMessage({
|
const mapped = mapHistoryMessage({
|
||||||
id: 6,
|
id: 6,
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import type {
|
|||||||
AiChatMessage,
|
AiChatMessage,
|
||||||
AiChatMessageStatus,
|
AiChatMessageStatus,
|
||||||
AiChartSchema,
|
AiChartSchema,
|
||||||
|
AiArtifactSchema,
|
||||||
AiFormSchema,
|
AiFormSchema,
|
||||||
AiMessageRecord,
|
AiMessageRecord,
|
||||||
AiReviewSchema,
|
AiReviewSchema,
|
||||||
AiToolRun,
|
AiToolRun,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { mergeArtifactIntoMessage } from './provider';
|
||||||
|
|
||||||
function mapStatus(record: AiMessageRecord): AiChatMessageStatus {
|
function mapStatus(record: AiMessageRecord): AiChatMessageStatus {
|
||||||
if (record.status === 'pending') return 'loading';
|
if (record.status === 'pending') return 'loading';
|
||||||
@@ -50,24 +52,40 @@ function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined {
|
|||||||
return [a2uiChart as AiChartSchema];
|
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<AiChatMessage> {
|
export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMessage> {
|
||||||
|
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 {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
status: mapStatus(record),
|
status: mapStatus(record),
|
||||||
message: {
|
message: baseMessage as AiChatMessage,
|
||||||
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',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,41 @@ describe('AI chat SSE message reducer', () => {
|
|||||||
expect(message.forms?.[1]).toMatchObject({ id: 'form-2' });
|
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', () => {
|
it('stores ui.import_preflight in message metadata and restores it from completed message', () => {
|
||||||
const preflight = {
|
const preflight = {
|
||||||
verdict: 'needs_input',
|
verdict: 'needs_input',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { usePermissionStore } from '../../store/permission/permissionStore';
|
|||||||
import { useUserStore } from '../../store/user/userStore';
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
import type {
|
import type {
|
||||||
AiAttachment,
|
AiAttachment,
|
||||||
|
AiArtifactSchema,
|
||||||
AiChatInput,
|
AiChatInput,
|
||||||
AiChatMessage,
|
AiChatMessage,
|
||||||
AiChartSchema,
|
AiChartSchema,
|
||||||
@@ -34,6 +35,7 @@ interface AiSsePayload {
|
|||||||
durationMs?: number | null;
|
durationMs?: number | null;
|
||||||
attachment?: AiAttachment;
|
attachment?: AiAttachment;
|
||||||
form?: AiFormSchema;
|
form?: AiFormSchema;
|
||||||
|
artifact?: AiArtifactSchema;
|
||||||
review?: AiReviewSchema;
|
review?: AiReviewSchema;
|
||||||
chart?: AiChartSchema;
|
chart?: AiChartSchema;
|
||||||
preflight?: AiImportPreflight;
|
preflight?: AiImportPreflight;
|
||||||
@@ -62,6 +64,7 @@ function emptyAssistant(): AiChatMessage {
|
|||||||
toolRuns: [],
|
toolRuns: [],
|
||||||
attachments: [],
|
attachments: [],
|
||||||
forms: [],
|
forms: [],
|
||||||
|
uiArtifacts: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,10 +168,50 @@ function applyMessagePayload(
|
|||||||
message.charts,
|
message.charts,
|
||||||
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
|
(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.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
|
||||||
message.metadata = nested.metadata ?? message.metadata;
|
message.metadata = nested.metadata ?? message.metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将统一 artifact 归入 uiArtifacts,并按类型派发到 legacy 列表。
|
||||||
|
*/
|
||||||
|
export function mergeArtifactIntoMessage(
|
||||||
|
message: AiChatMessage,
|
||||||
|
artifact: AiArtifactSchema,
|
||||||
|
): AiChatMessage {
|
||||||
|
message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);
|
||||||
|
const payload =
|
||||||
|
artifact.payload && typeof artifact.payload === 'object'
|
||||||
|
? (artifact.payload as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
if (artifact.type === 'form') {
|
||||||
|
message.forms = mergeForms(message.forms, payload as unknown as AiFormSchema);
|
||||||
|
} else if (artifact.type === 'review') {
|
||||||
|
message.reviews = mergeById<AiReviewSchema>(
|
||||||
|
message.reviews,
|
||||||
|
payload as unknown as AiReviewSchema,
|
||||||
|
);
|
||||||
|
} else if (artifact.type === 'chart') {
|
||||||
|
message.charts = mergeById<AiChartSchema>(
|
||||||
|
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(
|
export function reduceAiSseMessage(
|
||||||
originMessage: AiChatMessage | undefined,
|
originMessage: AiChatMessage | undefined,
|
||||||
chunk?: AiSseChunk,
|
chunk?: AiSseChunk,
|
||||||
@@ -198,6 +241,8 @@ export function reduceAiSseMessage(
|
|||||||
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
|
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
|
||||||
} else if (event === 'ui.chart' && payload.chart) {
|
} else if (event === 'ui.chart' && payload.chart) {
|
||||||
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
|
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
|
||||||
|
} else if (event === 'ui.artifact' && payload.artifact) {
|
||||||
|
mergeArtifactIntoMessage(message, payload.artifact);
|
||||||
} else if (event === 'ui.import_preflight' && payload.preflight) {
|
} else if (event === 'ui.import_preflight' && payload.preflight) {
|
||||||
message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight };
|
message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight };
|
||||||
} else if (event === 'ui.import_wizard' && payload.wizard) {
|
} 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. */
|
/** Routes events that target another (already streamed) message. */
|
||||||
onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
|
onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
|
||||||
|
onExternalArtifact?: (messageId: number, artifact: AiArtifactSchema) => void;
|
||||||
|
|
||||||
constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) {
|
constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) {
|
||||||
super({
|
super({
|
||||||
@@ -404,6 +450,30 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
|
|||||||
|
|
||||||
transformMessage(info: TransformMessage<AiChatMessage, AiSseChunk>): AiChatMessage {
|
transformMessage(info: TransformMessage<AiChatMessage, AiSseChunk>): AiChatMessage {
|
||||||
const { event, payload } = parseSsePayload(info.chunk);
|
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 (
|
if (
|
||||||
event === 'ui.review' &&
|
event === 'ui.review' &&
|
||||||
payload.review &&
|
payload.review &&
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export interface AiFormSchema {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
fields: AiFormField[];
|
fields: AiFormField[];
|
||||||
status?: 'pending' | 'submitted';
|
status?: 'pending' | 'submitted' | 'expired';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AiReviewColumn {
|
export interface AiReviewColumn {
|
||||||
@@ -98,6 +98,27 @@ export interface AiChartSchema {
|
|||||||
rows: AiReviewRow[];
|
rows: AiReviewRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AiArtifactType =
|
||||||
|
| 'form'
|
||||||
|
| 'review'
|
||||||
|
| 'chart'
|
||||||
|
| 'import_preflight'
|
||||||
|
| 'import_wizard';
|
||||||
|
|
||||||
|
export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled';
|
||||||
|
|
||||||
|
export interface AiArtifactSchema<T = unknown> {
|
||||||
|
id: string;
|
||||||
|
type: AiArtifactType;
|
||||||
|
status: AiArtifactStatus;
|
||||||
|
messageId: number;
|
||||||
|
conversationId?: number;
|
||||||
|
payload: T;
|
||||||
|
createdAt?: string | null;
|
||||||
|
submittedAt?: string | null;
|
||||||
|
supersededBy?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AiImportWizard {
|
export interface AiImportWizard {
|
||||||
runId: string;
|
runId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -216,6 +237,7 @@ export interface AiChatMessage {
|
|||||||
forms?: AiFormSchema[];
|
forms?: AiFormSchema[];
|
||||||
reviews?: AiReviewSchema[];
|
reviews?: AiReviewSchema[];
|
||||||
charts?: AiChartSchema[];
|
charts?: AiChartSchema[];
|
||||||
|
uiArtifacts?: AiArtifactSchema[];
|
||||||
replyToMessageId?: number | null;
|
replyToMessageId?: number | null;
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
retrying?: AiModelRetryInfo | null;
|
retrying?: AiModelRetryInfo | null;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useSettingsStore } from '../../store/settings/settingsStore';
|
|||||||
import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api';
|
import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api';
|
||||||
import { AiMessageContent } from './AiMessageContent';
|
import { AiMessageContent } from './AiMessageContent';
|
||||||
import { mapHistoryMessage } from './message-mappers';
|
import { mapHistoryMessage } from './message-mappers';
|
||||||
import { GongxueAiChatProvider } from './provider';
|
import { GongxueAiChatProvider, mergeArtifactIntoMessage } from './provider';
|
||||||
import {
|
import {
|
||||||
emptyAssistant,
|
emptyAssistant,
|
||||||
MessageHoverActions,
|
MessageHoverActions,
|
||||||
@@ -111,6 +111,11 @@ export function useAiChatMessageActions({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
provider.onExternalArtifact = (messageId, artifact) => {
|
||||||
|
setMessage(messageId, (info) => ({
|
||||||
|
message: mergeArtifactIntoMessage(info.message, artifact),
|
||||||
|
}));
|
||||||
|
};
|
||||||
}, [provider, setMessage]);
|
}, [provider, setMessage]);
|
||||||
|
|
||||||
requestingRef.current = isRequesting;
|
requestingRef.current = isRequesting;
|
||||||
|
|||||||
318
apps/server/src/agent-context/business-context.registry.ts
Normal file
318
apps/server/src/agent-context/business-context.registry.ts
Normal file
@@ -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,
|
||||||
|
);
|
||||||
@@ -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('账单')]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
180
apps/server/src/agent-context/business-context.service.ts
Normal file
180
apps/server/src/agent-context/business-context.service.ts
Normal file
@@ -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<string>();
|
||||||
|
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 };
|
||||||
73
apps/server/src/agent-context/business-context.tools.spec.ts
Normal file
73
apps/server/src/agent-context/business-context.tools.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
apps/server/src/agent-context/business-context.types.ts
Normal file
66
apps/server/src/agent-context/business-context.types.ts
Normal file
@@ -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<Record<string, readonly string[]>>;
|
||||||
|
nextSteps: readonly BusinessWorkflowNextStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessContextResult {
|
||||||
|
workflows: readonly BusinessWorkflow[];
|
||||||
|
entities: readonly BusinessEntity[];
|
||||||
|
}
|
||||||
90
apps/server/src/agent-context/get-business-context.tool.ts
Normal file
90
apps/server/src/agent-context/get-business-context.tool.ts
Normal file
@@ -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<GetBusinessContextInput> {
|
||||||
|
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<string, unknown>): ToolInputResult<GetBusinessContextInput> {
|
||||||
|
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<unknown> {
|
||||||
|
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<string, unknown>): 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<unknown> {
|
||||||
|
return this.service.getEntitySchema(
|
||||||
|
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
|
||||||
|
input.entityKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/server/src/agent-context/get-pending-tasks.tool.ts
Normal file
52
apps/server/src/agent-context/get-pending-tasks.tool.ts
Normal file
@@ -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<GetPendingTasksInput> {
|
||||||
|
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<string, unknown>): ToolInputResult<GetPendingTasksInput> {
|
||||||
|
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<unknown> {
|
||||||
|
return this.service.getPendingTasks(context, input.workflowKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/server/src/agent-context/index.ts
Normal file
12
apps/server/src/agent-context/index.ts
Normal file
@@ -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';
|
||||||
103
apps/server/src/agent-context/pending-tasks.service.spec.ts
Normal file
103
apps/server/src/agent-context/pending-tasks.service.spec.ts
Normal file
@@ -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<Promise<Array<Record<string, unknown>>>>,
|
||||||
|
) {
|
||||||
|
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<string, unknown>[];
|
||||||
|
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
183
apps/server/src/agent-context/pending-tasks.service.ts
Normal file
183
apps/server/src/agent-context/pending-tasks.service.ts
Normal file
@@ -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<PendingTask[]> {
|
||||||
|
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<Record<string, unknown>>)[0]?.cnt ?? 0);
|
||||||
|
tasks.push({
|
||||||
|
key: definition.key,
|
||||||
|
label: definition.label,
|
||||||
|
entity: definition.entity,
|
||||||
|
permission: definition.permission,
|
||||||
|
workflowKeys: definition.workflowKeys,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ export const AGENT_SKILLS: readonly Omit<AgentSkillDescriptor, 'tools'>[] = [
|
|||||||
{
|
{
|
||||||
key: 'overview',
|
key: 'overview',
|
||||||
name: '经营总览',
|
name: '经营总览',
|
||||||
description: '查看当前权限范围内的学生、班级和今日考勤概览。',
|
description: '查看当前权限范围内的学生、班级和今日考勤概览,以及业务待办(未分班、未入住、未出账单、临期租赁等)。',
|
||||||
examples: ['今天整体运营情况怎么样?', '帮我汇总当前学生和班级数量'],
|
examples: ['今天整体运营情况怎么样?', '有哪些业务待办需要处理?', '帮我汇总当前学生和班级数量'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'student',
|
key: 'student',
|
||||||
@@ -22,20 +22,20 @@ export const AGENT_SKILLS: readonly Omit<AgentSkillDescriptor, 'tools'>[] = [
|
|||||||
{
|
{
|
||||||
key: 'dormitory',
|
key: 'dormitory',
|
||||||
name: '宿舍管理',
|
name: '宿舍管理',
|
||||||
description: '查询宿舍、入住数量和空余床位。',
|
description: '查询宿舍、入住数量和空余床位,并按“学生/宿舍档案 → 入住 → 费用 → 账单 → 押金”闭环引导。',
|
||||||
examples: ['哪些房间还有空床?', '汇总当前宿舍入住情况'],
|
examples: ['哪些房间还有空床?', '汇总当前宿舍入住情况', '我可以按 先学生、再入住、后账单 帮你完成'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'billing',
|
key: 'billing',
|
||||||
name: '账单查询',
|
name: '账单查询',
|
||||||
description: '查询账单编号、账期、金额和状态。',
|
description: '查询账单编号、账期、金额和状态,并在入住/费用完成后建议生成与确认账单。',
|
||||||
examples: ['查找本月未支付账单', '查询张同学最近的账单'],
|
examples: ['查找本月未支付账单', '查询张同学最近的账单', '哪些在住学生还没生成账单?'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'classroom',
|
key: 'classroom',
|
||||||
name: '教室与租用',
|
name: '教室与租用',
|
||||||
description: '查询教室信息、占用状态和租赁订单。',
|
description: '查询教室信息、占用状态和租赁订单,并按“教室/组织档案 → 租赁 → 合同 → 日程”闭环引导。',
|
||||||
examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?'],
|
examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?', '哪些租赁还没归档合同?'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'sync',
|
key: 'sync',
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ import { ExpensesModule } from '../expenses/expenses.module';
|
|||||||
import { ClassroomsModule } from '../classrooms/classrooms.module';
|
import { ClassroomsModule } from '../classrooms/classrooms.module';
|
||||||
import { ClassroomRentalsModule } from '../classroom-rentals/classroom-rentals.module';
|
import { ClassroomRentalsModule } from '../classroom-rentals/classroom-rentals.module';
|
||||||
import { SyncModule } from '../sync/sync.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.
|
* Agent Tools feature module.
|
||||||
@@ -66,6 +70,11 @@ import { SyncModule } from '../sync/sync.module';
|
|||||||
providers: [
|
providers: [
|
||||||
AgentToolRegistry,
|
AgentToolRegistry,
|
||||||
AgentToolExecutor,
|
AgentToolExecutor,
|
||||||
|
BusinessContextService,
|
||||||
|
PendingTasksService,
|
||||||
|
GetBusinessContextTool,
|
||||||
|
GetEntitySchemaTool,
|
||||||
|
GetPendingTasksTool,
|
||||||
SearchStudentsTool,
|
SearchStudentsTool,
|
||||||
GetStudentBasicTool,
|
GetStudentBasicTool,
|
||||||
AgentBusinessScopeFactory,
|
AgentBusinessScopeFactory,
|
||||||
@@ -107,9 +116,15 @@ export class AgentToolsModule implements OnModuleInit {
|
|||||||
private readonly searchClassroomsTool: SearchClassroomsTool,
|
private readonly searchClassroomsTool: SearchClassroomsTool,
|
||||||
private readonly searchClassroomRentalsTool: SearchClassroomRentalsTool,
|
private readonly searchClassroomRentalsTool: SearchClassroomRentalsTool,
|
||||||
private readonly getSyncStatusTool: GetSyncStatusTool,
|
private readonly getSyncStatusTool: GetSyncStatusTool,
|
||||||
|
private readonly businessContextTool: GetBusinessContextTool,
|
||||||
|
private readonly entitySchemaTool: GetEntitySchemaTool,
|
||||||
|
private readonly pendingTasksTool: GetPendingTasksTool,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
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.searchTool);
|
||||||
this.registry.register(this.getTool);
|
this.registry.register(this.getTool);
|
||||||
this.registry.register(this.searchClassesTool);
|
this.registry.register(this.searchClassesTool);
|
||||||
|
|||||||
69
apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts
Normal file
69
apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it, jest } from '@jest/globals';
|
||||||
|
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
|
||||||
|
|
||||||
|
function createService(overrides: Record<string, unknown> = {}) {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
48
apps/server/src/ai-chat/ai-a2ui-submissions.service.ts
Normal file
48
apps/server/src/ai-chat/ai-a2ui-submissions.service.ts
Normal file
@@ -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<AiA2uiSubmission>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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<AiA2uiSubmission | null> {
|
||||||
|
return this.submissions.findOne({
|
||||||
|
where: { artifactId, clientRequestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts
Normal file
36
apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
58
apps/server/src/ai-chat/ai-a2ui.artifact.ts
Normal file
58
apps/server/src/ai-chat/ai-a2ui.artifact.ts
Normal file
@@ -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<T = unknown> {
|
||||||
|
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<A2uiArtifactType>([
|
||||||
|
'form',
|
||||||
|
'review',
|
||||||
|
'chart',
|
||||||
|
'import_preflight',
|
||||||
|
'import_wizard',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造统一的 A2UI artifact 载荷,供 `ui.artifact` SSE 事件与前端归一化。
|
||||||
|
*/
|
||||||
|
export function buildA2uiArtifact<T>(input: {
|
||||||
|
type: A2uiArtifactType;
|
||||||
|
id: string;
|
||||||
|
status: A2uiArtifactStatus;
|
||||||
|
messageId: number;
|
||||||
|
conversationId: number;
|
||||||
|
payload: T;
|
||||||
|
createdAt?: string | null;
|
||||||
|
submittedAt?: string | null;
|
||||||
|
supersededBy?: string | null;
|
||||||
|
}): A2uiArtifact<T> {
|
||||||
|
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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ export const A2UI_TOOL_SCHEMAS = [
|
|||||||
function: {
|
function: {
|
||||||
name: 'render_form',
|
name: 'render_form',
|
||||||
description:
|
description:
|
||||||
'生成一个确认表单显示给用户填写。当用户需要新增或修改业务数据、或需要用户输入/确认信息时调用;用户提交表单后才能执行写操作。',
|
'生成一个确认表单显示给用户填写。当用户需要新增或修改业务数据、或需要用户输入/确认信息时调用;用户提交表单后才能执行写操作。需要准确字段/枚举时,可先调用 get_entity_schema 获取实体字段字典。',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -211,9 +211,10 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须
|
|||||||
当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。
|
当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。
|
||||||
上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。
|
上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。
|
||||||
业务工作流引导(重要):
|
业务工作流引导(重要):
|
||||||
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。
|
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织,包含三大闭环:学生教学(学生→分班→排课→考勤→考试)、住宿计费(学生/宿舍→入住→费用→账单→押金)、教室租赁(教室/组织→租赁→合同→日程)。
|
||||||
- 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。
|
- 不确定当前角色可用哪些业务流程与实体时,先调用 get_business_context 获取权限范围内的闭环、阶段依赖与实体字典;编写 render_form 字段前可按需调用 get_entity_schema。
|
||||||
- 导入或录入完成后,主动给出下一步建议(例如:入住导入完成 → 建议录入本月公共费用 → 生成并确认账单;学生导入完成 → 建议分班或排课)。
|
- 执行任何写入或导入前,先调用 get_pending_tasks 或现有查询工具核实前置数据是否已存在:入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。
|
||||||
|
- 导入或录入完成后,根据完成阶段主动给出下一步建议(例如:入住完成 → 建议录入本月公共费用 → 生成并确认账单;学生档案完成 → 建议分班;租赁订单生成 → 建议补充合同),可用 get_pending_tasks 获取有数据支撑的待办。
|
||||||
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再引导用户在预检卡内确认并生成导入向导,按依赖顺序执行。
|
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再引导用户在预检卡内确认并生成导入向导,按依赖顺序执行。
|
||||||
- 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。
|
- 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。
|
||||||
不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`;
|
不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`;
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { AiAttachmentService } from './ai-attachment.service';
|
|||||||
import { AiChartService } from './ai-chart.service';
|
import { AiChartService } from './ai-chart.service';
|
||||||
import { AiExcelReaderService } from './ai-excel-reader.service';
|
import { AiExcelReaderService } from './ai-excel-reader.service';
|
||||||
import { AiFormService } from './ai-form.service';
|
import { AiFormService } from './ai-form.service';
|
||||||
|
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
|
||||||
import { AiReviewService } from './ai-review.service';
|
import { AiReviewService } from './ai-review.service';
|
||||||
import { AiChatService } from './ai-chat.service';
|
import { AiChatService } from './ai-chat.service';
|
||||||
import { AiModelStreamService } from './ai-model-stream.service';
|
import { AiModelStreamService } from './ai-model-stream.service';
|
||||||
import {
|
import {
|
||||||
AiAttachment,
|
AiAttachment,
|
||||||
|
AiA2uiSubmission,
|
||||||
AiConversation,
|
AiConversation,
|
||||||
AiForm,
|
AiForm,
|
||||||
AiMessage,
|
AiMessage,
|
||||||
@@ -24,6 +26,7 @@ import {
|
|||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
AiAttachment,
|
AiAttachment,
|
||||||
|
AiA2uiSubmission,
|
||||||
AiConversation,
|
AiConversation,
|
||||||
AiForm,
|
AiForm,
|
||||||
AiMessage,
|
AiMessage,
|
||||||
@@ -40,6 +43,7 @@ import {
|
|||||||
AiChartService,
|
AiChartService,
|
||||||
AiExcelReaderService,
|
AiExcelReaderService,
|
||||||
AiFormService,
|
AiFormService,
|
||||||
|
A2uiSubmissionsService,
|
||||||
AiReviewService,
|
AiReviewService,
|
||||||
AiChatService,
|
AiChatService,
|
||||||
AiModelStreamService,
|
AiModelStreamService,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AiChartService } from './ai-chart.service';
|
|||||||
import { AiExcelReaderService } from './ai-excel-reader.service';
|
import { AiExcelReaderService } from './ai-excel-reader.service';
|
||||||
import { AiFormService } from './ai-form.service';
|
import { AiFormService } from './ai-form.service';
|
||||||
import { AiReviewService } from './ai-review.service';
|
import { AiReviewService } from './ai-review.service';
|
||||||
|
import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
|
||||||
import { AiModelStreamService } from './ai-model-stream.service';
|
import { AiModelStreamService } from './ai-model-stream.service';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import {
|
import {
|
||||||
@@ -114,6 +115,7 @@ export abstract class AiChatServiceBase implements AiChatServiceContext {
|
|||||||
readonly chartService: AiChartService,
|
readonly chartService: AiChartService,
|
||||||
readonly abilityFactory: CaslAbilityFactory,
|
readonly abilityFactory: CaslAbilityFactory,
|
||||||
readonly authorization: AuthorizationService,
|
readonly authorization: AuthorizationService,
|
||||||
|
readonly a2uiSubmissions?: A2uiSubmissionsService,
|
||||||
readonly excelReader?: AiExcelReaderService,
|
readonly excelReader?: AiExcelReaderService,
|
||||||
readonly importsService?: ImportsService,
|
readonly importsService?: ImportsService,
|
||||||
readonly opLog?: OperationLogsService,
|
readonly opLog?: OperationLogsService,
|
||||||
@@ -127,7 +129,12 @@ export abstract class AiChatServiceBase implements AiChatServiceContext {
|
|||||||
return a2uiReviewSubmitInfo(metadata);
|
return a2uiReviewSubmitInfo(metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFormSubmitModelContent(submit: { title: string; values: Record<string, unknown> }): string {
|
buildFormSubmitModelContent(submit: {
|
||||||
|
title: string;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
submissionId?: string;
|
||||||
|
fieldErrors?: Array<{ field: string; message: string }>;
|
||||||
|
}): string {
|
||||||
return buildFormSubmitModelContent(submit);
|
return buildFormSubmitModelContent(submit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +142,8 @@ export abstract class AiChatServiceBase implements AiChatServiceContext {
|
|||||||
reviewId: string;
|
reviewId: string;
|
||||||
reviewTitle: string;
|
reviewTitle: string;
|
||||||
resultMessage: string;
|
resultMessage: string;
|
||||||
|
submissionId?: string;
|
||||||
|
nextSteps?: Array<{ key: string; label: string }>;
|
||||||
}): string {
|
}): string {
|
||||||
return buildReviewSubmitModelContent(submit);
|
return buildReviewSubmitModelContent(submit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function createService(
|
|||||||
{} as never,
|
{} as never,
|
||||||
{
|
{
|
||||||
createForm: jest.fn(),
|
createForm: jest.fn(),
|
||||||
|
expirePreviousForms: jest.fn().mockResolvedValue([]),
|
||||||
findOwnedPending: jest.fn(),
|
findOwnedPending: jest.fn(),
|
||||||
validateValues: jest.fn(),
|
validateValues: jest.fn(),
|
||||||
markSubmitted: jest.fn(),
|
markSubmitted: jest.fn(),
|
||||||
@@ -65,6 +66,7 @@ function createService(
|
|||||||
} as never,
|
} as never,
|
||||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } 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 };
|
return { service, conversations, reviewService };
|
||||||
}
|
}
|
||||||
@@ -295,6 +297,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{
|
{
|
||||||
createForm: jest.fn(),
|
createForm: jest.fn(),
|
||||||
|
expirePreviousForms: jest.fn().mockResolvedValue([]),
|
||||||
findOwnedPending: jest.fn(),
|
findOwnedPending: jest.fn(),
|
||||||
validateValues: jest.fn(),
|
validateValues: jest.fn(),
|
||||||
markSubmitted: jest.fn(),
|
markSubmitted: jest.fn(),
|
||||||
@@ -314,6 +317,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } 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<string, unknown> }> = [];
|
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
const run = service.streamMessage(
|
const run = service.streamMessage(
|
||||||
@@ -456,6 +460,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{
|
{
|
||||||
createForm: jest.fn().mockResolvedValue(formShape),
|
createForm: jest.fn().mockResolvedValue(formShape),
|
||||||
|
expirePreviousForms: jest.fn().mockResolvedValue([]),
|
||||||
findOwnedPending: jest.fn(),
|
findOwnedPending: jest.fn(),
|
||||||
validateValues: jest.fn(),
|
validateValues: jest.fn(),
|
||||||
markSubmitted: jest.fn(),
|
markSubmitted: jest.fn(),
|
||||||
@@ -475,6 +480,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } 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<string, unknown> }> = [];
|
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
@@ -600,6 +606,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{
|
{
|
||||||
createForm: jest.fn(),
|
createForm: jest.fn(),
|
||||||
|
expirePreviousForms: jest.fn().mockResolvedValue([]),
|
||||||
findOwnedPending: jest.fn(),
|
findOwnedPending: jest.fn(),
|
||||||
validateValues: jest.fn(),
|
validateValues: jest.fn(),
|
||||||
markSubmitted: jest.fn(),
|
markSubmitted: jest.fn(),
|
||||||
@@ -619,6 +626,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } 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<string, unknown> }> = [];
|
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
@@ -705,6 +713,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{
|
{
|
||||||
createForm: jest.fn(),
|
createForm: jest.fn(),
|
||||||
|
expirePreviousForms: jest.fn().mockResolvedValue([]),
|
||||||
findOwnedPending: jest.fn(),
|
findOwnedPending: jest.fn(),
|
||||||
validateValues: jest.fn(),
|
validateValues: jest.fn(),
|
||||||
markSubmitted: jest.fn(),
|
markSubmitted: jest.fn(),
|
||||||
@@ -727,6 +736,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||||
{ assertPermission, canPermission: jest.fn() } 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 }> = [];
|
const emitted: Array<{ event: string }> = [];
|
||||||
|
|
||||||
@@ -842,6 +852,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{
|
{
|
||||||
createForm: jest.fn(),
|
createForm: jest.fn(),
|
||||||
|
expirePreviousForms: jest.fn().mockResolvedValue([]),
|
||||||
findOwnedPending: jest.fn(),
|
findOwnedPending: jest.fn(),
|
||||||
validateValues: jest.fn(),
|
validateValues: jest.fn(),
|
||||||
markSubmitted: jest.fn(),
|
markSubmitted: jest.fn(),
|
||||||
@@ -864,6 +875,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||||
{ assertPermission, canPermission: jest.fn() } 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 order: string[] = [];
|
||||||
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
@@ -1114,6 +1126,7 @@ describe('AiChatService', () => {
|
|||||||
{ removeOrphans } as never,
|
{ removeOrphans } as never,
|
||||||
{
|
{
|
||||||
createForm: jest.fn(),
|
createForm: jest.fn(),
|
||||||
|
expirePreviousForms: jest.fn().mockResolvedValue([]),
|
||||||
findOwnedPending: jest.fn(),
|
findOwnedPending: jest.fn(),
|
||||||
validateValues: jest.fn(),
|
validateValues: jest.fn(),
|
||||||
markSubmitted: jest.fn(),
|
markSubmitted: jest.fn(),
|
||||||
@@ -1133,6 +1146,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } 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] });
|
await expect(service.deleteMessage(7, 3, 10)).resolves.toEqual({ deletedIds: [10, 11] });
|
||||||
@@ -1154,6 +1168,7 @@ describe('AiChatService', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{
|
{
|
||||||
createForm: jest.fn(),
|
createForm: jest.fn(),
|
||||||
|
expirePreviousForms: jest.fn().mockResolvedValue([]),
|
||||||
findOwnedPending: jest.fn(),
|
findOwnedPending: jest.fn(),
|
||||||
validateValues: jest.fn(),
|
validateValues: jest.fn(),
|
||||||
markSubmitted: jest.fn(),
|
markSubmitted: jest.fn(),
|
||||||
@@ -1173,6 +1188,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } 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<number> }).activeConversations.add(3);
|
(service as unknown as { activeConversations: Set<number> }).activeConversations.add(3);
|
||||||
await expect(service.deleteMessage(7, 3, 10)).rejects.toBeInstanceOf(ConflictException);
|
await expect(service.deleteMessage(7, 3, 10)).rejects.toBeInstanceOf(ConflictException);
|
||||||
@@ -1238,6 +1254,7 @@ describe('AiChatService', () => {
|
|||||||
{ removeOrphans } as never,
|
{ removeOrphans } as never,
|
||||||
{
|
{
|
||||||
createForm: jest.fn(),
|
createForm: jest.fn(),
|
||||||
|
expirePreviousForms: jest.fn().mockResolvedValue([]),
|
||||||
findOwnedPending: jest.fn(),
|
findOwnedPending: jest.fn(),
|
||||||
validateValues: jest.fn(),
|
validateValues: jest.fn(),
|
||||||
markSubmitted: jest.fn(),
|
markSubmitted: jest.fn(),
|
||||||
@@ -1257,6 +1274,7 @@ describe('AiChatService', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
{ createForUser: jest.fn().mockReturnValue({}) } as never,
|
||||||
{ assertPermission: jest.fn(), canPermission: jest.fn() } 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<string, unknown> }> = [];
|
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { AiChartService } from './ai-chart.service';
|
|||||||
import { AiExcelReaderService } from './ai-excel-reader.service';
|
import { AiExcelReaderService } from './ai-excel-reader.service';
|
||||||
import { AiFormService } from './ai-form.service';
|
import { AiFormService } from './ai-form.service';
|
||||||
import { AiReviewService } from './ai-review.service';
|
import { AiReviewService } from './ai-review.service';
|
||||||
|
import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
|
||||||
import { AiModelStreamService } from './ai-model-stream.service';
|
import { AiModelStreamService } from './ai-model-stream.service';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { AiConversation, AiMessage, AiToolRun } from './entities';
|
import { AiConversation, AiMessage, AiToolRun } from './entities';
|
||||||
@@ -66,6 +67,7 @@ export class AiChatService extends AiChatServiceBase {
|
|||||||
chartService: AiChartService,
|
chartService: AiChartService,
|
||||||
abilityFactory: CaslAbilityFactory,
|
abilityFactory: CaslAbilityFactory,
|
||||||
authorization: AuthorizationService,
|
authorization: AuthorizationService,
|
||||||
|
a2uiSubmissions?: A2uiSubmissionsService,
|
||||||
excelReader?: AiExcelReaderService,
|
excelReader?: AiExcelReaderService,
|
||||||
importsService?: ImportsService,
|
importsService?: ImportsService,
|
||||||
opLog?: OperationLogsService,
|
opLog?: OperationLogsService,
|
||||||
@@ -84,6 +86,7 @@ export class AiChatService extends AiChatServiceBase {
|
|||||||
chartService,
|
chartService,
|
||||||
abilityFactory,
|
abilityFactory,
|
||||||
authorization,
|
authorization,
|
||||||
|
a2uiSubmissions,
|
||||||
excelReader,
|
excelReader,
|
||||||
importsService,
|
importsService,
|
||||||
opLog,
|
opLog,
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import type { AiChatServiceContext, AiSseEmitter } from './ai-chat.types';
|
import type { AiChatServiceContext, AiSseEmitter } from './ai-chat.types';
|
||||||
import { DEFAULT_TITLE } from './ai-chat.types';
|
import { DEFAULT_TITLE } from './ai-chat.types';
|
||||||
import type { AuthenticatedUser } from '../authorization';
|
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 { assertReviewImportPermissions } from './ai-chat.review-confirm';
|
||||||
import { persistExchange, runGenerationAndRelease } from './ai-chat.submissions.runtime';
|
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(
|
export async function submitForm(
|
||||||
context: AiChatServiceContext,
|
context: AiChatServiceContext,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
@@ -19,6 +32,18 @@ export async function submitForm(
|
|||||||
const effectiveSkillKey = conversation.lockedSkillKey ?? null;
|
const effectiveSkillKey = conversation.lockedSkillKey ?? null;
|
||||||
context.assertSkillAvailable(user, effectiveSkillKey);
|
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);
|
await context.acquireConversation(conversation.id);
|
||||||
try {
|
try {
|
||||||
const summary = `已提交表单「${form.title}」`;
|
const summary = `已提交表单「${form.title}」`;
|
||||||
@@ -31,7 +56,15 @@ export async function submitForm(
|
|||||||
summary,
|
summary,
|
||||||
dto.clientRequestId,
|
dto.clientRequestId,
|
||||||
effectiveSkillKey,
|
effectiveSkillKey,
|
||||||
{ a2uiSubmit: { formId: form.id, formTitle: form.title, values } },
|
{
|
||||||
|
a2uiSubmit: {
|
||||||
|
formId: form.id,
|
||||||
|
formTitle: form.title,
|
||||||
|
values,
|
||||||
|
submissionId: String(recorded.submission.id),
|
||||||
|
fieldErrors: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
undefined,
|
undefined,
|
||||||
conversation.title === DEFAULT_TITLE ? form.title.slice(0, 30) : 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.formService.markSubmitted(form, values);
|
||||||
await context.markFormSubmittedOnMessage(form.assistantMessageId, conversation.id);
|
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, {
|
await runGenerationAndRelease(context, {
|
||||||
user,
|
user,
|
||||||
@@ -73,12 +117,43 @@ export async function submitReview(
|
|||||||
context.assertSkillAvailable(user, effectiveSkillKey);
|
context.assertSkillAvailable(user, effectiveSkillKey);
|
||||||
assertReviewImportPermissions(context, user, review);
|
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);
|
await context.acquireConversation(conversation.id);
|
||||||
try {
|
try {
|
||||||
const { review: updatedReview, result } = await context.reviewService.submitAll(
|
const { review: updatedReview, result } = await context.reviewService.submitAll(
|
||||||
review.id,
|
review.id,
|
||||||
user.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}`;
|
const summary = `已确认导入「${review.title}」:${result.message}`;
|
||||||
await context.opLog?.log({
|
await context.opLog?.log({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -103,6 +178,8 @@ export async function submitReview(
|
|||||||
reviewId: review.id,
|
reviewId: review.id,
|
||||||
reviewTitle: review.title,
|
reviewTitle: review.title,
|
||||||
resultMessage: result.message,
|
resultMessage: result.message,
|
||||||
|
submissionId: String(recorded.submission.id),
|
||||||
|
nextSteps,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -117,6 +194,17 @@ export async function submitReview(
|
|||||||
messageId: updatedReview.assistantMessageId,
|
messageId: updatedReview.assistantMessageId,
|
||||||
review: serialized,
|
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(
|
await context.markReviewSubmittedOnMessage(
|
||||||
updatedReview.assistantMessageId,
|
updatedReview.assistantMessageId,
|
||||||
conversation.id,
|
conversation.id,
|
||||||
|
|||||||
66
apps/server/src/ai-chat/ai-chat.submit-content.spec.ts
Normal file
66
apps/server/src/ai-chat/ai-chat.submit-content.spec.ts
Normal file
@@ -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('建议录入费用');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,21 +3,35 @@ import type { AiChatServiceContext } from './ai-chat.types';
|
|||||||
|
|
||||||
export function a2uiSubmitInfo(
|
export function a2uiSubmitInfo(
|
||||||
metadata: Record<string, unknown> | null,
|
metadata: Record<string, unknown> | null,
|
||||||
): { title: string; values: Record<string, unknown> } | null {
|
): {
|
||||||
|
formId: string;
|
||||||
|
title: string;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
submissionId?: string;
|
||||||
|
fieldErrors?: Array<{ field: string; message: string }>;
|
||||||
|
} | null {
|
||||||
const submit = metadata?.a2uiSubmit;
|
const submit = metadata?.a2uiSubmit;
|
||||||
if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null;
|
if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null;
|
||||||
const record = submit as Record<string, unknown>;
|
const record = submit as Record<string, unknown>;
|
||||||
|
const formId = typeof record.formId === 'string' ? record.formId : '';
|
||||||
const title = typeof record.formTitle === 'string' ? record.formTitle : '表单';
|
const title = typeof record.formTitle === 'string' ? record.formTitle : '表单';
|
||||||
const values =
|
const values =
|
||||||
record.values && typeof record.values === 'object' && !Array.isArray(record.values)
|
record.values && typeof record.values === 'object' && !Array.isArray(record.values)
|
||||||
? (record.values as Record<string, unknown>)
|
? (record.values as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
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: {
|
export function buildFormSubmitModelContent(submit: {
|
||||||
title: string;
|
title: string;
|
||||||
values: Record<string, unknown>;
|
values: Record<string, unknown>;
|
||||||
|
submissionId?: string;
|
||||||
|
fieldErrors?: Array<{ field: string; message: string }>;
|
||||||
}): string {
|
}): string {
|
||||||
let json: string;
|
let json: string;
|
||||||
try {
|
try {
|
||||||
@@ -25,7 +39,11 @@ export function buildFormSubmitModelContent(submit: {
|
|||||||
} catch {
|
} catch {
|
||||||
json = '[无法序列化]';
|
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(
|
export async function markFormSubmittedOnMessage(
|
||||||
@@ -48,15 +66,28 @@ export async function markFormSubmittedOnMessage(
|
|||||||
|
|
||||||
export function a2uiReviewSubmitInfo(
|
export function a2uiReviewSubmitInfo(
|
||||||
metadata: Record<string, unknown> | null,
|
metadata: Record<string, unknown> | 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;
|
const submit = metadata?.a2uiReviewSubmit;
|
||||||
if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null;
|
if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null;
|
||||||
const record = submit as Record<string, unknown>;
|
const record = submit as Record<string, unknown>;
|
||||||
if (typeof record.reviewId !== 'string') return null;
|
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 {
|
return {
|
||||||
reviewId: record.reviewId,
|
reviewId: record.reviewId,
|
||||||
reviewTitle: typeof record.reviewTitle === 'string' ? record.reviewTitle : '批量导入',
|
reviewTitle: typeof record.reviewTitle === 'string' ? record.reviewTitle : '批量导入',
|
||||||
resultMessage: typeof record.resultMessage === 'string' ? record.resultMessage : '导入已完成',
|
resultMessage: typeof record.resultMessage === 'string' ? record.resultMessage : '导入已完成',
|
||||||
|
submissionId,
|
||||||
|
nextSteps,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,8 +95,14 @@ export function buildReviewSubmitModelContent(submit: {
|
|||||||
reviewId: string;
|
reviewId: string;
|
||||||
reviewTitle: string;
|
reviewTitle: string;
|
||||||
resultMessage: string;
|
resultMessage: string;
|
||||||
|
submissionId?: string;
|
||||||
|
nextSteps?: Array<{ key: string; label: string }>;
|
||||||
}): 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(
|
export async function markReviewSubmittedOnMessage(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { permittedStepKeys } from '../imports/imports.access';
|
|||||||
import { expandStageSheets } from '../imports/imports.mapping';
|
import { expandStageSheets } from '../imports/imports.mapping';
|
||||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||||
import type { AgentToolContext } from './ai-chat.tools';
|
import type { AgentToolContext } from './ai-chat.tools';
|
||||||
|
import { buildA2uiArtifact } from './ai-a2ui.artifact';
|
||||||
import { AiMessage } from './entities';
|
import { AiMessage } from './entities';
|
||||||
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
||||||
import {
|
import {
|
||||||
@@ -155,6 +156,17 @@ export const executePreflightImport = makeImportToolExecutor(
|
|||||||
.join('、') || '未识别到可导入阶段'}`,
|
.join('、') || '未识别到可导入阶段'}`,
|
||||||
}, emit);
|
}, emit);
|
||||||
emit('ui.import_preflight', { messageId, preflight: preflightCard });
|
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);
|
return preflightModelPayload(preflight, permittedSteps);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,6 +253,17 @@ export const executeStartImportWizard = makeImportToolExecutor(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
emit('ui.import_wizard', { messageId, wizard });
|
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({
|
return JSON.stringify({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
runId: detail.id,
|
runId: detail.id,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AiReview } from './entities/ai-review.entity';
|
import { AiReview } from './entities/ai-review.entity';
|
||||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||||
|
import { buildA2uiArtifact } from './ai-a2ui.artifact';
|
||||||
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
||||||
export async function executeRenderForm(
|
export async function executeRenderForm(
|
||||||
context: AiChatServiceContext,
|
context: AiChatServiceContext,
|
||||||
@@ -20,6 +21,39 @@ export async function executeRenderForm(
|
|||||||
{ userId, conversationId: assistant.conversationId, assistantMessageId: messageId },
|
{ userId, conversationId: assistant.conversationId, assistantMessageId: messageId },
|
||||||
parsedArgs,
|
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 = {
|
||||||
...assistant.metadata,
|
...assistant.metadata,
|
||||||
a2uiForm: context.formService.serialize(form),
|
a2uiForm: context.formService.serialize(form),
|
||||||
@@ -31,6 +65,17 @@ export async function executeRenderForm(
|
|||||||
messageId,
|
messageId,
|
||||||
form: context.formService.serialize(form),
|
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({
|
return JSON.stringify({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
formId: form.id,
|
formId: form.id,
|
||||||
@@ -119,6 +164,17 @@ export async function executeRenderReview(
|
|||||||
messageId: expired.assistantMessageId,
|
messageId: expired.assistantMessageId,
|
||||||
review: context.reviewService.serialize(expired),
|
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 = {
|
assistant.metadata = {
|
||||||
@@ -132,6 +188,17 @@ export async function executeRenderReview(
|
|||||||
messageId,
|
messageId,
|
||||||
review: context.reviewService.serialize(review),
|
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({
|
return JSON.stringify({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
reviewId: review.id,
|
reviewId: review.id,
|
||||||
@@ -179,6 +246,17 @@ export async function executeRenderChart(
|
|||||||
messageId,
|
messageId,
|
||||||
chart: context.chartService.serialize(chart),
|
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({
|
return JSON.stringify({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
chartId: chart.id,
|
chartId: chart.id,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AiChartService } from './ai-chart.service';
|
|||||||
import { AiExcelReaderService } from './ai-excel-reader.service';
|
import { AiExcelReaderService } from './ai-excel-reader.service';
|
||||||
import { AiFormService } from './ai-form.service';
|
import { AiFormService } from './ai-form.service';
|
||||||
import { AiReviewService } from './ai-review.service';
|
import { AiReviewService } from './ai-review.service';
|
||||||
|
import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
|
||||||
import { AiModelStreamService } from './ai-model-stream.service';
|
import { AiModelStreamService } from './ai-model-stream.service';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import {
|
import {
|
||||||
@@ -95,6 +96,7 @@ export interface AiChatServiceContext {
|
|||||||
readonly excelReader?: AiExcelReaderService;
|
readonly excelReader?: AiExcelReaderService;
|
||||||
readonly importsService?: ImportsService;
|
readonly importsService?: ImportsService;
|
||||||
readonly opLog?: OperationLogsService;
|
readonly opLog?: OperationLogsService;
|
||||||
|
readonly a2uiSubmissions?: A2uiSubmissionsService;
|
||||||
listSkills(user: AuthenticatedUser): ReturnType<AgentToolExecutor['listSkills']>;
|
listSkills(user: AuthenticatedUser): ReturnType<AgentToolExecutor['listSkills']>;
|
||||||
serializeMessage(message: AiMessage): Record<string, unknown>;
|
serializeMessage(message: AiMessage): Record<string, unknown>;
|
||||||
redactText(value: string): string;
|
redactText(value: string): string;
|
||||||
@@ -114,11 +116,18 @@ export interface AiChatServiceContext {
|
|||||||
reviewTitle: string;
|
reviewTitle: string;
|
||||||
resultMessage: string;
|
resultMessage: string;
|
||||||
} | null;
|
} | null;
|
||||||
buildFormSubmitModelContent(submit: { title: string; values: Record<string, unknown> }): string;
|
buildFormSubmitModelContent(submit: {
|
||||||
|
title: string;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
submissionId?: string;
|
||||||
|
fieldErrors?: Array<{ field: string; message: string }>;
|
||||||
|
}): string;
|
||||||
buildReviewSubmitModelContent(submit: {
|
buildReviewSubmitModelContent(submit: {
|
||||||
reviewId: string;
|
reviewId: string;
|
||||||
reviewTitle: string;
|
reviewTitle: string;
|
||||||
resultMessage: string;
|
resultMessage: string;
|
||||||
|
submissionId?: string;
|
||||||
|
nextSteps?: Array<{ key: string; label: string }>;
|
||||||
}): string;
|
}): string;
|
||||||
markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise<void>;
|
markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise<void>;
|
||||||
markReviewSubmittedOnMessage(
|
markReviewSubmittedOnMessage(
|
||||||
@@ -175,6 +184,7 @@ export type AiSseEventName =
|
|||||||
| 'ui.form'
|
| 'ui.form'
|
||||||
| 'ui.review'
|
| 'ui.review'
|
||||||
| 'ui.chart'
|
| 'ui.chart'
|
||||||
|
| 'ui.artifact'
|
||||||
| 'ui.import_preflight'
|
| 'ui.import_preflight'
|
||||||
| 'ui.import_wizard'
|
| 'ui.import_wizard'
|
||||||
| 'attachment.processed'
|
| 'attachment.processed'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { In } from 'typeorm';
|
||||||
import { AiFormService } from './ai-form.service';
|
import { AiFormService } from './ai-form.service';
|
||||||
|
|
||||||
function createService(overrides: Record<string, unknown> = {}) {
|
function createService(overrides: Record<string, unknown> = {}) {
|
||||||
@@ -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', () => {
|
describe('validateValues', () => {
|
||||||
const form = {
|
const form = {
|
||||||
fieldsJson: JSON.stringify(validSchema.fields),
|
fieldsJson: JSON.stringify(validSchema.fields),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { In, Repository } from 'typeorm';
|
||||||
import { uuidV7 } from '../common/uuid-v7';
|
import { uuidV7 } from '../common/uuid-v7';
|
||||||
import { AiForm, type AiFormField } from './entities/ai-form.entity';
|
import { AiForm, type AiFormField } from './entities/ai-form.entity';
|
||||||
|
|
||||||
@@ -108,10 +108,28 @@ export class AiFormService {
|
|||||||
|
|
||||||
async findOwnedPending(formId: string, userId: number): Promise<AiForm> {
|
async findOwnedPending(formId: string, userId: number): Promise<AiForm> {
|
||||||
const form = await this.forms.findOne({ where: { id: formId, userId, status: 'pending' } });
|
const form = await this.forms.findOne({ where: { id: formId, userId, status: 'pending' } });
|
||||||
if (!form) throw new NotFoundException('表单不存在或已提交');
|
if (!form) throw new NotFoundException('表单不存在、已提交或已失效');
|
||||||
return form;
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过期同会话其他 pending 表单(新表单渲染后旧表单不可再提交)。
|
||||||
|
*/
|
||||||
|
async expirePreviousForms(
|
||||||
|
userId: number,
|
||||||
|
conversationId: number,
|
||||||
|
exceptFormId: string,
|
||||||
|
): Promise<AiForm[]> {
|
||||||
|
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<string, unknown>): Promise<AiForm> {
|
async markSubmitted(form: AiForm, values: Record<string, unknown>): Promise<AiForm> {
|
||||||
form.status = 'submitted';
|
form.status = 'submitted';
|
||||||
form.submittedValuesJson = JSON.stringify(values);
|
form.submittedValuesJson = JSON.stringify(values);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { AiMessage } from './ai-message.entity';
|
import { AiMessage } from './ai-message.entity';
|
||||||
|
|
||||||
export type AiFormStatus = 'pending' | 'submitted';
|
export type AiFormStatus = 'pending' | 'submitted' | 'expired';
|
||||||
|
|
||||||
export interface AiFormField {
|
export interface AiFormField {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from './ai-tool-run.entity';
|
|||||||
export * from './ai-attachment.entity';
|
export * from './ai-attachment.entity';
|
||||||
export * from './ai-form.entity';
|
export * from './ai-form.entity';
|
||||||
export * from './ai-review.entity';
|
export * from './ai-review.entity';
|
||||||
|
export * from './ai-a2ui-submission.entity';
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiR
|
|||||||
import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns';
|
import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns';
|
||||||
import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback';
|
import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback';
|
||||||
import { AddImportRunSettings1784930000000 } from './migrations/1784930000000-AddImportRunSettings';
|
import { AddImportRunSettings1784930000000 } from './migrations/1784930000000-AddImportRunSettings';
|
||||||
|
import { AddA2UiSubmissions1786001000000 } from './migrations/1786001000000-AddA2UiSubmissions';
|
||||||
const allMigrations = [
|
const allMigrations = [
|
||||||
InitialSchema1784520727860,
|
InitialSchema1784520727860,
|
||||||
AddExamManagement1784600000000,
|
AddExamManagement1784600000000,
|
||||||
@@ -31,6 +32,7 @@ const allMigrations = [
|
|||||||
AddImportRuns1784910000000,
|
AddImportRuns1784910000000,
|
||||||
DropAiMessageFeedback1784920000000,
|
DropAiMessageFeedback1784920000000,
|
||||||
AddImportRunSettings1784930000000,
|
AddImportRunSettings1784930000000,
|
||||||
|
AddA2UiSubmissions1786001000000,
|
||||||
];
|
];
|
||||||
import { AuthorizationModule } from './authorization';
|
import { AuthorizationModule } from './authorization';
|
||||||
import { RbacModule } from './rbac/rbac.module';
|
import { RbacModule } from './rbac/rbac.module';
|
||||||
@@ -149,6 +151,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
|||||||
Entities.AiAttachment,
|
Entities.AiAttachment,
|
||||||
Entities.AiForm,
|
Entities.AiForm,
|
||||||
Entities.AiReview,
|
Entities.AiReview,
|
||||||
|
Entities.AiA2uiSubmission,
|
||||||
Entities.ImportRun,
|
Entities.ImportRun,
|
||||||
Entities.ImportStep,
|
Entities.ImportStep,
|
||||||
Entities.ImportRow,
|
Entities.ImportRow,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export {
|
|||||||
AiToolRun,
|
AiToolRun,
|
||||||
AiForm,
|
AiForm,
|
||||||
AiReview,
|
AiReview,
|
||||||
|
AiA2uiSubmission,
|
||||||
} from '../ai-chat/entities';
|
} from '../ai-chat/entities';
|
||||||
export { ImportRun } from '../imports/entities/import-run.entity';
|
export { ImportRun } from '../imports/entities/import-run.entity';
|
||||||
export { ImportStep } from '../imports/entities/import-step.entity';
|
export { ImportStep } from '../imports/entities/import-step.entity';
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A2UI 提交幂等记录:同一 artifact + clientRequestId 只允许执行一次。
|
||||||
|
*/
|
||||||
|
export class AddA2UiSubmissions1786001000000 implements MigrationInterface {
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
if (await queryRunner.hasTable('ai_a2ui_submissions')) {
|
||||||
|
await queryRunner.dropTable('ai_a2ui_submissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user