node-resource-utils 0.0.0-stage → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -2
- package/lib/sync.js +121 -0
- package/package.json +14 -4
- package/resources/.cache/ref.b64 +1 -0
- package/resources/README.md +97 -0
- package/resources/_.env +6 -0
- package/resources/_.env.example +25 -0
- package/resources/_.gitignore +18 -0
- package/resources/_.lintstagedrc.json +9 -0
- package/resources/_.npmrc +5 -0
- package/resources/_.prettierrc +7 -0
- package/resources/commitlint.config.mjs +1 -0
- package/resources/content/docs/README.md +63 -0
- package/resources/content/docs/faq.md +107 -0
- package/resources/content/docs/features/_meta.json +5 -0
- package/resources/content/docs/features/ai.md +71 -0
- package/resources/content/docs/features/appearance.md +47 -0
- package/resources/content/docs/features/feedback.md +79 -0
- package/resources/content/docs/features/files.md +74 -0
- package/resources/content/docs/features/graph.md +66 -0
- package/resources/content/docs/features/inbox.md +61 -0
- package/resources/content/docs/guide/_meta.json +5 -0
- package/resources/content/docs/guide/account.md +76 -0
- package/resources/content/docs/guide/data.md +87 -0
- package/resources/content/docs/guide/navigation.md +81 -0
- package/resources/content/docs/guide/permissions.md +67 -0
- package/resources/content/docs/guide/system.md +92 -0
- package/resources/content/docs/quick-start.md +70 -0
- package/resources/content/docs/shortcuts.md +69 -0
- package/resources/eslint.config.mjs +17 -0
- package/resources/next-env.d.ts +6 -0
- package/resources/next.config.ts +13 -0
- package/resources/postcss.config.mjs +7 -0
- package/resources/prisma/org.config.ts +98 -0
- package/resources/prisma/schema.prisma +336 -0
- package/resources/prisma/seed.ts +721 -0
- package/resources/prisma/seedSafe.ts +347 -0
- package/resources/prisma.config.ts +14 -0
- package/resources/scripts/check-config.mjs +773 -0
- package/resources/scripts/lib/schema-graph.mjs +410 -0
- package/resources/scripts/patch-next-trace.mjs +86 -0
- package/resources/scripts/suggest-relations.mjs +105 -0
- package/resources/src/app/(admin)/ai/page.tsx +469 -0
- package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
- package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
- package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
- package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
- package/resources/src/app/(admin)/error.tsx +96 -0
- package/resources/src/app/(admin)/feedback/page.tsx +573 -0
- package/resources/src/app/(admin)/graph/page.tsx +1224 -0
- package/resources/src/app/(admin)/inbox/page.tsx +31 -0
- package/resources/src/app/(admin)/layout.tsx +58 -0
- package/resources/src/app/(admin)/loading.tsx +31 -0
- package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
- package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
- package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
- package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
- package/resources/src/app/(admin)/system/users/page.tsx +620 -0
- package/resources/src/app/api/account-recovery/route.ts +132 -0
- package/resources/src/app/api/ai/act/route.ts +70 -0
- package/resources/src/app/api/ai/ask/route.ts +82 -0
- package/resources/src/app/api/ai/chat/route.ts +404 -0
- package/resources/src/app/api/ai/report/route.ts +164 -0
- package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
- package/resources/src/app/api/auth/change-password/route.ts +57 -0
- package/resources/src/app/api/auth/forgot/route.ts +98 -0
- package/resources/src/app/api/auth/register/route.ts +95 -0
- package/resources/src/app/api/auth/register-config/route.ts +51 -0
- package/resources/src/app/api/badges/route.ts +39 -0
- package/resources/src/app/api/depts/[id]/route.ts +145 -0
- package/resources/src/app/api/depts/route.ts +126 -0
- package/resources/src/app/api/docs/export/route.ts +87 -0
- package/resources/src/app/api/docs/parse/route.ts +57 -0
- package/resources/src/app/api/docs/search/route.ts +22 -0
- package/resources/src/app/api/employees/[id]/route.ts +147 -0
- package/resources/src/app/api/employees/export/route.ts +115 -0
- package/resources/src/app/api/employees/import/route.ts +205 -0
- package/resources/src/app/api/employees/route.ts +149 -0
- package/resources/src/app/api/export/route.ts +254 -0
- package/resources/src/app/api/favorites/route.ts +56 -0
- package/resources/src/app/api/feedback/ai/route.ts +31 -0
- package/resources/src/app/api/feedback/approve/route.ts +67 -0
- package/resources/src/app/api/feedback/report/route.ts +213 -0
- package/resources/src/app/api/feedback/route.ts +246 -0
- package/resources/src/app/api/files/[name]/route.ts +82 -0
- package/resources/src/app/api/graph/focus/route.ts +75 -0
- package/resources/src/app/api/graph/node/move/route.ts +48 -0
- package/resources/src/app/api/graph/node/route.ts +274 -0
- package/resources/src/app/api/graph/route.ts +272 -0
- package/resources/src/app/api/health/route.ts +11 -0
- package/resources/src/app/api/inbox/read/route.ts +36 -0
- package/resources/src/app/api/inbox/summary/route.ts +15 -0
- package/resources/src/app/api/logs/route.ts +82 -0
- package/resources/src/app/api/menus/[id]/route.ts +132 -0
- package/resources/src/app/api/menus/mine/route.ts +23 -0
- package/resources/src/app/api/menus/route.ts +89 -0
- package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
- package/resources/src/app/api/roles/[id]/route.ts +96 -0
- package/resources/src/app/api/roles/route.ts +114 -0
- package/resources/src/app/api/search/route.ts +22 -0
- package/resources/src/app/api/stats/route.ts +92 -0
- package/resources/src/app/api/upload/route.ts +38 -0
- package/resources/src/app/api/users/[id]/password/route.ts +55 -0
- package/resources/src/app/api/users/[id]/route.ts +145 -0
- package/resources/src/app/api/users/route.ts +153 -0
- package/resources/src/app/api/visits/route.ts +48 -0
- package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
- package/resources/src/app/change-password/page.tsx +19 -0
- package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
- package/resources/src/app/docs/layout.tsx +34 -0
- package/resources/src/app/docs/loading.tsx +19 -0
- package/resources/src/app/docs/not-found.tsx +25 -0
- package/resources/src/app/forgot/page.tsx +392 -0
- package/resources/src/app/global-error.tsx +75 -0
- package/resources/src/app/globals.css +235 -0
- package/resources/src/app/layout.tsx +44 -0
- package/resources/src/app/login/page.tsx +267 -0
- package/resources/src/app/page.tsx +14 -0
- package/resources/src/app/register/page.tsx +237 -0
- package/resources/src/auth.ts +215 -0
- package/resources/src/components/AiPanel.tsx +970 -0
- package/resources/src/components/AuthContext.tsx +82 -0
- package/resources/src/components/DataTable.tsx +459 -0
- package/resources/src/components/EChart.tsx +140 -0
- package/resources/src/components/FeedbackAiModal.tsx +476 -0
- package/resources/src/components/FeedbackReportModal.tsx +479 -0
- package/resources/src/components/Markdown.tsx +532 -0
- package/resources/src/components/OntologyGraph.tsx +1196 -0
- package/resources/src/components/Providers.tsx +99 -0
- package/resources/src/components/VisitTracker.tsx +89 -0
- package/resources/src/components/admin/AdminShell.tsx +749 -0
- package/resources/src/components/admin/CountBadge.tsx +67 -0
- package/resources/src/components/admin/GlobalSearch.tsx +509 -0
- package/resources/src/components/admin/InboxBell.tsx +78 -0
- package/resources/src/components/admin/VisitStats.tsx +145 -0
- package/resources/src/components/admin/menu-icons.tsx +56 -0
- package/resources/src/components/ai/AiInputForm.tsx +351 -0
- package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
- package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
- package/resources/src/components/docs/DocArticle.tsx +106 -0
- package/resources/src/components/docs/DocToc.tsx +100 -0
- package/resources/src/components/docs/DocsChrome.tsx +259 -0
- package/resources/src/components/docs/DocsHome.tsx +209 -0
- package/resources/src/components/docs/DocsSearch.tsx +365 -0
- package/resources/src/components/docs/docs.css +1458 -0
- package/resources/src/components/feedback/AsyncButton.tsx +108 -0
- package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
- package/resources/src/components/feedback/PressableCard.tsx +92 -0
- package/resources/src/components/feedback/README.md +97 -0
- package/resources/src/components/feedback/RouteProgress.tsx +108 -0
- package/resources/src/components/feedback/Skeleton.tsx +249 -0
- package/resources/src/components/feedback/StatCard.tsx +70 -0
- package/resources/src/components/feedback/TaskHint.tsx +153 -0
- package/resources/src/components/feedback/index.ts +93 -0
- package/resources/src/components/feedback/motion.css +204 -0
- package/resources/src/components/feedback/motion.ts +63 -0
- package/resources/src/components/feedback/useCountUp.ts +110 -0
- package/resources/src/components/feedback/usePending.ts +209 -0
- package/resources/src/components/feedback/useRowFlash.ts +40 -0
- package/resources/src/components/inbox/InboxView.tsx +409 -0
- package/resources/src/components/ui/button.tsx +35 -0
- package/resources/src/components/ui/card.tsx +37 -0
- package/resources/src/components/ui/input.tsx +19 -0
- package/resources/src/lib/ai.ts +310 -0
- package/resources/src/lib/aiContext.ts +357 -0
- package/resources/src/lib/aiForm.test.ts +191 -0
- package/resources/src/lib/aiForm.ts +532 -0
- package/resources/src/lib/aiFormTypes.ts +83 -0
- package/resources/src/lib/aiToken.ts +72 -0
- package/resources/src/lib/aiTools.ts +823 -0
- package/resources/src/lib/api.ts +160 -0
- package/resources/src/lib/appInfo.ts +128 -0
- package/resources/src/lib/appearance.ts +643 -0
- package/resources/src/lib/badge-count.ts +146 -0
- package/resources/src/lib/badges.test.ts +58 -0
- package/resources/src/lib/badges.ts +79 -0
- package/resources/src/lib/brand-mark.test.ts +87 -0
- package/resources/src/lib/brand-mark.ts +45 -0
- package/resources/src/lib/docs-shared.test.ts +148 -0
- package/resources/src/lib/docs-shared.ts +422 -0
- package/resources/src/lib/docs.ts +381 -0
- package/resources/src/lib/err-text.ts +21 -0
- package/resources/src/lib/excel.ts +152 -0
- package/resources/src/lib/favorite-path.ts +60 -0
- package/resources/src/lib/favorites.ts +59 -0
- package/resources/src/lib/features.ts +69 -0
- package/resources/src/lib/feedback-access.ts +17 -0
- package/resources/src/lib/feedbackAi.ts +1621 -0
- package/resources/src/lib/formRules.ts +76 -0
- package/resources/src/lib/format.ts +52 -0
- package/resources/src/lib/graph-cascade.ts +304 -0
- package/resources/src/lib/graph-focus.ts +301 -0
- package/resources/src/lib/graph-link.ts +17 -0
- package/resources/src/lib/graph-node-write.ts +150 -0
- package/resources/src/lib/graph-schema.ts +475 -0
- package/resources/src/lib/graph-scope.test.ts +46 -0
- package/resources/src/lib/graph-scope.ts +28 -0
- package/resources/src/lib/http.ts +157 -0
- package/resources/src/lib/inbox-types.ts +57 -0
- package/resources/src/lib/inbox.ts +55 -0
- package/resources/src/lib/menu-service.ts +204 -0
- package/resources/src/lib/notifications.ts +113 -0
- package/resources/src/lib/office.ts +531 -0
- package/resources/src/lib/ontology.ts +277 -0
- package/resources/src/lib/openaiCompat.ts +257 -0
- package/resources/src/lib/pending.ts +98 -0
- package/resources/src/lib/prisma.ts +30 -0
- package/resources/src/lib/rbac.test.ts +106 -0
- package/resources/src/lib/rbac.ts +185 -0
- package/resources/src/lib/search-data.ts +158 -0
- package/resources/src/lib/security-questions.ts +20 -0
- package/resources/src/lib/skin-theme.ts +155 -0
- package/resources/src/lib/system-config.ts +55 -0
- package/resources/src/lib/theme.ts +96 -0
- package/resources/src/lib/todos/feedback.ts +86 -0
- package/resources/src/lib/todos/index.ts +73 -0
- package/resources/src/lib/todos/types.ts +33 -0
- package/resources/src/lib/upload.ts +106 -0
- package/resources/src/lib/utils.test.ts +9 -0
- package/resources/src/lib/utils.ts +6 -0
- package/resources/src/lib/validation.test.ts +297 -0
- package/resources/src/lib/validation.ts +658 -0
- package/resources/src/lib/visit-stats.ts +109 -0
- package/resources/src/lib/visits.ts +111 -0
- package/resources/src/lib/zodField.ts +44 -0
- package/resources/tsconfig.json +41 -0
- package/resources/vitest.config.ts +16 -0
- package/resources/vitest.setup.ts +1 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import { prisma } from '@/lib/prisma';
|
|
2
|
+
import ONTOLOGY from '@/lib/ontology';
|
|
3
|
+
import { readPayload, signPayload, TOKEN_TTL_MS } from '@/lib/aiToken';
|
|
4
|
+
import { coercePayload, coerceValue, fieldsOfModel, hasSoftDelete, type FieldMeta } from '@/lib/graph-schema';
|
|
5
|
+
import { inferValidationType, validateField } from '@/lib/validation';
|
|
6
|
+
import { deptAllowed, deptScope, type ScopeUser } from '@/lib/rbac';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AI 工具层 —— 让助手能真正操作业务数据,而不是只会聊天。
|
|
10
|
+
*
|
|
11
|
+
* ★ 核心设计:**工具不写死,从配置和 schema 自动生成。**
|
|
12
|
+
* 1. 有哪些工具 → 由 `ontology.ts` 的 entities 决定(配了哪些本体,就能操作哪些表)
|
|
13
|
+
* 2. 每个工具有什么参数 → 由 `prisma/schema.prisma` 反推(字段名/类型/枚举/外键/必填)
|
|
14
|
+
* 3. 参数怎么落库 → 复用 `graph-schema.ts` 的 coercePayload(和知识图谱面板同一套规则)
|
|
15
|
+
*
|
|
16
|
+
* 于是换业务时**一行工具代码都不用改**:改完 ontology 和 schema,工具自动跟着变。
|
|
17
|
+
* 这和知识图谱是同一个思想(配置驱动),只是把"画出来"换成了"能操作"。
|
|
18
|
+
*
|
|
19
|
+
* ★ 安全模型(三层,任何一层都不能省):
|
|
20
|
+
* 1. **读工具**(query_*)随便调:只读、软件删过滤、字段白名单
|
|
21
|
+
* 2. **写工具**(create_*)绝不直接执行:
|
|
22
|
+
* 模型只能"提出"一个动作,服务端签名成 token 推给前端,
|
|
23
|
+
* 用户在确认卡上点「确认」才真正写库 —— 前端改不了参数(token 里有 HMAC)
|
|
24
|
+
* 3. 落库后写 audit 日志,module 记为「AI 助手」,事后可追
|
|
25
|
+
*
|
|
26
|
+
* 为什么写工具不直接执行:小模型会误解指令,一句"帮我加一条记录"可能填出
|
|
27
|
+
* 明天的日期 + 错误的事由。让人看一眼的成本远低于删脏数据的成本。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/* ============================ 1. 工具清单 ============================ */
|
|
31
|
+
|
|
32
|
+
/** 永远不暴露给 AI 的系统表:角色/菜单/关联表/审计日志 */
|
|
33
|
+
const BLOCKED_MODELS = new Set(['Role', 'Menu', 'UserRole', 'RoleMenu', 'OperationLog']);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 只读模型:能查,但**不生成 create_**。
|
|
37
|
+
*
|
|
38
|
+
* User 往往是业务里的"人员"(有些系统直接拿它当员工表),查询很有用;
|
|
39
|
+
* 但新建账号必须走系统管理页 —— 要密码、要角色、要部门,
|
|
40
|
+
* 让模型凭空建一个没有密码、没有角色的用户,只会制造脏数据。
|
|
41
|
+
*/
|
|
42
|
+
const READ_ONLY_MODELS = new Set(['User']);
|
|
43
|
+
|
|
44
|
+
/** 模型名 → 工具名里的标识(DispatchRecord → dispatch_record) */
|
|
45
|
+
function snake(s: string): string {
|
|
46
|
+
return s
|
|
47
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
48
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
49
|
+
.toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface BusinessEntity {
|
|
53
|
+
id: string;
|
|
54
|
+
label: string;
|
|
55
|
+
model: string;
|
|
56
|
+
titleField: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 参与工具生成的业务对象。
|
|
61
|
+
*
|
|
62
|
+
* ★ 唯一来源是 ontology.ts —— 这既是知识图谱的本体,也是 AI 的能力边界。
|
|
63
|
+
* 没写进 ontology 的表,AI 既画不出来也操作不了,语义一致。
|
|
64
|
+
*/
|
|
65
|
+
function businessEntities(): BusinessEntity[] {
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
const out: BusinessEntity[] = [];
|
|
68
|
+
for (const e of ONTOLOGY.entities) {
|
|
69
|
+
if (!e?.model || seen.has(e.model) || BLOCKED_MODELS.has(e.model)) continue;
|
|
70
|
+
seen.add(e.model);
|
|
71
|
+
out.push({ id: e.id, label: e.label, model: e.model, titleField: e.titleField });
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 该模型的日期字段,用来做默认排序 */
|
|
77
|
+
function dateFieldOf(model: string): string | null {
|
|
78
|
+
const fs = fieldsOfModel(model);
|
|
79
|
+
if (fs.some((f) => f.name === 'createdAt')) return 'createdAt';
|
|
80
|
+
const hit = fs.find((f) => f.kind === 'datetime');
|
|
81
|
+
return hit?.name ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 可写字段(必填的排前面,让模型先看必填项) */
|
|
85
|
+
export function writableFields(model: string): FieldMeta[] {
|
|
86
|
+
return [...fieldsOfModel(model)].sort((a, b) => Number(b.required) - Number(a.required));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 单个字段 → JSON Schema 片段 */
|
|
90
|
+
function propSchema(f: FieldMeta): Record<string, unknown> {
|
|
91
|
+
const p: Record<string, unknown> = { description: f.label };
|
|
92
|
+
switch (f.kind) {
|
|
93
|
+
case 'number':
|
|
94
|
+
p.type = 'number';
|
|
95
|
+
break;
|
|
96
|
+
case 'boolean':
|
|
97
|
+
p.type = 'boolean';
|
|
98
|
+
break;
|
|
99
|
+
case 'datetime':
|
|
100
|
+
p.type = 'string';
|
|
101
|
+
p.description = `${f.label}(日期或 ISO 时间,如 2026-10-20)`;
|
|
102
|
+
break;
|
|
103
|
+
case 'enum': {
|
|
104
|
+
const values = (f.options ?? []).map((o) => o.value);
|
|
105
|
+
p.type = values.some((v) => typeof v === 'number') ? 'number' : 'string';
|
|
106
|
+
if (values.length) p.enum = values;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'fk':
|
|
110
|
+
p.type = 'string';
|
|
111
|
+
p.description = `${f.label}(填 ${f.refModel ?? '关联表'} 的主键 id,先用对应的 query 工具查出来,不要编造)`;
|
|
112
|
+
break;
|
|
113
|
+
default:
|
|
114
|
+
p.type = 'string';
|
|
115
|
+
}
|
|
116
|
+
return p;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface AiToolSchema {
|
|
120
|
+
type: 'function';
|
|
121
|
+
function: {
|
|
122
|
+
name: string;
|
|
123
|
+
description: string;
|
|
124
|
+
parameters: {
|
|
125
|
+
type: 'object';
|
|
126
|
+
properties: Record<string, unknown>;
|
|
127
|
+
required?: string[];
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 查询工具的筛选参数(固定形状,字段名服务端再校验) */
|
|
133
|
+
function queryParameters(model: string): AiToolSchema['function']['parameters'] {
|
|
134
|
+
const fs = fieldsOfModel(model);
|
|
135
|
+
const textFields = fs.filter((f) => f.kind === 'text' || f.kind === 'longtext').map((f) => f.name);
|
|
136
|
+
const groupCandidates = fs
|
|
137
|
+
.filter((f) => ['enum', 'text', 'boolean', 'fk'].includes(f.kind))
|
|
138
|
+
.map((f) => f.name);
|
|
139
|
+
|
|
140
|
+
const props: Record<string, unknown> = {
|
|
141
|
+
filters: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
description:
|
|
144
|
+
'按字段筛选,键必须是本表字段名。等值直接写值;范围用 {gte,lte,gt,lt};文本包含用 {contains:"xx"}。例:{"status":1} 或 {"startDate":{"gte":"2026-10-01"}}',
|
|
145
|
+
},
|
|
146
|
+
take: { type: 'number', description: '返回条数,默认 10,最大 30' },
|
|
147
|
+
};
|
|
148
|
+
if (textFields.length) {
|
|
149
|
+
props.keyword = { type: 'string', description: `关键字模糊搜索(匹配 ${textFields.slice(0, 4).join(' / ')})` };
|
|
150
|
+
}
|
|
151
|
+
if (groupCandidates.length) {
|
|
152
|
+
props.groupBy = {
|
|
153
|
+
type: 'string',
|
|
154
|
+
enum: groupCandidates,
|
|
155
|
+
description: '按这个字段分组统计条数(问"哪个部门最多""各状态各几条"时用)',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const df = dateFieldOf(model);
|
|
159
|
+
if (df) {
|
|
160
|
+
props.orderBy = { type: 'string', description: `排序字段,默认 ${df} 倒序` };
|
|
161
|
+
}
|
|
162
|
+
return { type: 'object', properties: props };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 新增工具的参数:直接列字段,必填项进 required */
|
|
166
|
+
function createParameters(model: string): AiToolSchema['function']['parameters'] {
|
|
167
|
+
const fs = writableFields(model);
|
|
168
|
+
const props: Record<string, unknown> = {};
|
|
169
|
+
for (const f of fs) props[f.name] = propSchema(f);
|
|
170
|
+
const required = fs.filter((f) => f.required).map((f) => f.name);
|
|
171
|
+
return { type: 'object', properties: props, ...(required.length ? { required } : {}) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 生成全部工具定义。
|
|
176
|
+
*
|
|
177
|
+
* 每个业务对象两个工具:
|
|
178
|
+
* query_<x> 只读查询 / 分组统计
|
|
179
|
+
* create_<x> 新增一条(需用户确认)
|
|
180
|
+
*
|
|
181
|
+
* 故意不生成 update / delete:
|
|
182
|
+
* - 改和删的影响面比新建大得多,模型填错一个 id 就改错人
|
|
183
|
+
* - 骨架的删除本来就要先算影响清单,让它走原页面更安全
|
|
184
|
+
* 真需要时在下面 TOOLS 里手工追加一条,并保持 kind:'write'
|
|
185
|
+
*/
|
|
186
|
+
export function buildAiTools(): AiToolSchema[] {
|
|
187
|
+
const out: AiToolSchema[] = [];
|
|
188
|
+
for (const e of businessEntities()) {
|
|
189
|
+
const fs = fieldsOfModel(e.model);
|
|
190
|
+
if (!fs.length) continue;
|
|
191
|
+
const label = e.label;
|
|
192
|
+
const required = fs.filter((f) => f.required).map((f) => f.label);
|
|
193
|
+
|
|
194
|
+
out.push({
|
|
195
|
+
type: 'function',
|
|
196
|
+
function: {
|
|
197
|
+
name: `query_${snake(e.model)}`,
|
|
198
|
+
description: `查询「${label}」数据(只读)。可按字段筛选、关键字搜索、分组统计,返回总条数与明细。问数量、排名、明细、占比都用它。只能查到当前用户有权查看的数据。`,
|
|
199
|
+
parameters: queryParameters(e.model),
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (READ_ONLY_MODELS.has(e.model)) continue;
|
|
204
|
+
|
|
205
|
+
out.push({
|
|
206
|
+
type: 'function',
|
|
207
|
+
function: {
|
|
208
|
+
name: `create_${snake(e.model)}`,
|
|
209
|
+
description:
|
|
210
|
+
`新建一条「${label}」记录。` +
|
|
211
|
+
(required.length ? `必填:${required.join('、')}。` : '') +
|
|
212
|
+
'调用后不会立刻写库,会先请用户确认。信息不全时先问用户,不要编造。',
|
|
213
|
+
parameters: createParameters(e.model),
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 手写工具(审批流转、批量操作这类多步业务)追加在后面
|
|
219
|
+
for (const t of EXTRA_TOOLS) {
|
|
220
|
+
out.push({
|
|
221
|
+
type: 'function',
|
|
222
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 询问用户的统一出口:模型自己决定"要问什么",前端渲染成结构化表单。
|
|
227
|
+
// 这是"文字追问"的替代品 —— 文字追问会丢字段、格式乱、模型还要再解析一遍。
|
|
228
|
+
out.push({
|
|
229
|
+
type: 'function',
|
|
230
|
+
function: {
|
|
231
|
+
name: FORM_TOOL_NAME,
|
|
232
|
+
description:
|
|
233
|
+
'需要用户补充信息时调用它,界面会弹出一张结构化表单,用户填完直接提交。' +
|
|
234
|
+
'只要还缺必填信息,就用这个工具,**不要用纯文字一条条追问**。' +
|
|
235
|
+
'如果是在为某个新建操作用收集信息,把 tool 填成对应的 create_<表> 工具名,' +
|
|
236
|
+
'系统会用该表的字段定义生成表单(含下拉选项、日期选择),用户提交后直接生成确认卡。',
|
|
237
|
+
parameters: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {
|
|
240
|
+
title: { type: 'string', description: '表单标题,例如「补全请假申请信息」' },
|
|
241
|
+
question: { type: 'string', description: '一句话说明为什么要这些信息(可选)' },
|
|
242
|
+
tool: {
|
|
243
|
+
type: 'string',
|
|
244
|
+
description:
|
|
245
|
+
'要补全的写工具名,例如 create_leave_request。字段来自该工具时一定要填,填了才会用正确的控件和选项。',
|
|
246
|
+
},
|
|
247
|
+
fields: {
|
|
248
|
+
type: 'array',
|
|
249
|
+
items: { type: 'string' },
|
|
250
|
+
description:
|
|
251
|
+
'要用户填的字段名(用 schema 里的字段名,如 startDate、type)。不要写中文标签。',
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
required: ['title', 'fields'],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 询问用户工具的名字 —— 它不执行、不写库,只负责弹表单 */
|
|
263
|
+
export const FORM_TOOL_NAME = 'ask_user';
|
|
264
|
+
|
|
265
|
+
export function isFormTool(name: string): boolean {
|
|
266
|
+
return name === FORM_TOOL_NAME;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* ============================ 1.5 手写工具扩展点 ============================ */
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 自动生成的工具只覆盖两件事:**单表通用查询** 和 **单表新增**。
|
|
273
|
+
*
|
|
274
|
+
* 审批流转、批量操作、跨表结算这类多步业务,靠 schema 硬凑是凑不出来的,
|
|
275
|
+
* 在这里追加一条即可。规则:
|
|
276
|
+
* - `kind:'write'` 的工具自动走确认卡流程(/api/ai/act),不需要自己写确认逻辑
|
|
277
|
+
* - `kind:'read'` 的工具直接执行
|
|
278
|
+
* - `run` 里自己校验业务规则,返回 ToolResult(ok / summary / content)
|
|
279
|
+
* - 写操作的 `run` 只会在用户点「确认」之后被调用,参数来自签名令牌
|
|
280
|
+
*
|
|
281
|
+
* 例子(取消注释并改写成你的业务):
|
|
282
|
+
*
|
|
283
|
+
* {
|
|
284
|
+
* name: 'approve_leave',
|
|
285
|
+
* description: '审批一条申请单(同意或驳回)',
|
|
286
|
+
* parameters: {
|
|
287
|
+
* type: 'object',
|
|
288
|
+
* properties: {
|
|
289
|
+
* id: { type: 'string', description: '申请单 id' },
|
|
290
|
+
* agree: { type: 'boolean', description: 'true 同意,false 驳回' },
|
|
291
|
+
* },
|
|
292
|
+
* required: ['id', 'agree'],
|
|
293
|
+
* },
|
|
294
|
+
* kind: 'write',
|
|
295
|
+
* run: async (args, ctx) => {
|
|
296
|
+
* // 这里用 prisma 改状态 + 写流转记录,ctx.userId 是当前操作人
|
|
297
|
+
* return { ok: true, summary: '已审批', content: '审批完成' };
|
|
298
|
+
* },
|
|
299
|
+
* },
|
|
300
|
+
*/
|
|
301
|
+
export interface HandTool {
|
|
302
|
+
name: string;
|
|
303
|
+
description: string;
|
|
304
|
+
parameters: AiToolSchema['function']['parameters'];
|
|
305
|
+
kind: 'read' | 'write';
|
|
306
|
+
run: (args: Record<string, unknown>, ctx: { userId: string }) => Promise<ToolResult>;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export const EXTRA_TOOLS: HandTool[] = [];
|
|
310
|
+
|
|
311
|
+
function extraOf(name: string): HandTool | undefined {
|
|
312
|
+
return EXTRA_TOOLS.find((t) => t.name === name);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 工具名 → 模型 + 动作 */
|
|
316
|
+
function parseTool(name: string): { action: 'query' | 'create'; model: string; entity?: BusinessEntity } | null {
|
|
317
|
+
const hit = /^(query|create)_(.+)$/.exec(name);
|
|
318
|
+
if (!hit) return null;
|
|
319
|
+
const action = hit[1] as 'query' | 'create';
|
|
320
|
+
const slug = hit[2];
|
|
321
|
+
const entity = businessEntities().find((e) => snake(e.model) === slug);
|
|
322
|
+
if (!entity) return null;
|
|
323
|
+
return { action, model: entity.model, entity };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function isWriteTool(name: string): boolean {
|
|
327
|
+
const extra = extraOf(name);
|
|
328
|
+
if (extra) return extra.kind === 'write';
|
|
329
|
+
return parseTool(name)?.action === 'create';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** 写工具的元信息:自动生成的(按 schema)还是手写的(按 JSON Schema) */
|
|
333
|
+
export type WriteToolInfo =
|
|
334
|
+
| { kind: 'model'; model: string; entity?: BusinessEntity }
|
|
335
|
+
| { kind: 'hand'; tool: HandTool };
|
|
336
|
+
|
|
337
|
+
export function writeToolInfo(name: string): WriteToolInfo | null {
|
|
338
|
+
const extra = extraOf(name);
|
|
339
|
+
if (extra) return extra.kind === 'write' ? { kind: 'hand', tool: extra } : null;
|
|
340
|
+
const p = parseTool(name);
|
|
341
|
+
if (!p || p.action !== 'create') return null;
|
|
342
|
+
return { kind: 'model', model: p.model, entity: p.entity };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 空值判定:表单提交时用户清空一个预填字段,也算"没填" */
|
|
346
|
+
export function isBlank(v: unknown): boolean {
|
|
347
|
+
if (v === undefined || v === null) return true;
|
|
348
|
+
if (typeof v === 'string') return v.trim() === '';
|
|
349
|
+
if (Array.isArray(v)) return v.length === 0;
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 手写工具的必填校验。
|
|
355
|
+
*
|
|
356
|
+
* 自动工具能从 schema 反推必填,手写工具只有 JSON Schema 的 required 数组可用。
|
|
357
|
+
* 取 description 的第一个分句当字段中文名,拼出来的话才像人话:
|
|
358
|
+
* 「还缺这些必填信息:开始日期、结束日期」而不是「startDate、endDate」。
|
|
359
|
+
*/
|
|
360
|
+
export function missingHandParams(tool: HandTool, args: Record<string, unknown>): string[] {
|
|
361
|
+
const required = tool.parameters.required ?? [];
|
|
362
|
+
const props = tool.parameters.properties ?? {};
|
|
363
|
+
return required
|
|
364
|
+
.filter((k) => isBlank(args[k]))
|
|
365
|
+
.map((k) => {
|
|
366
|
+
const p = props[k] as { description?: unknown } | undefined;
|
|
367
|
+
const d = typeof p?.description === 'string' ? p.description.trim() : '';
|
|
368
|
+
return d ? d.split(/[((,,。;;::]/)[0] || k : k;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* ============================ 2. 数据访问 ============================ */
|
|
373
|
+
|
|
374
|
+
interface Delegate {
|
|
375
|
+
findMany?: (a?: unknown) => Promise<Record<string, unknown>[]>;
|
|
376
|
+
count?: (a?: unknown) => Promise<number>;
|
|
377
|
+
create?: (a?: unknown) => Promise<Record<string, unknown>>;
|
|
378
|
+
groupBy?: (a?: unknown) => Promise<Record<string, unknown>[]>;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function delegateOf(model: string): Delegate | null {
|
|
382
|
+
const d = (prisma as unknown as Record<string, unknown>)[model] as Delegate | undefined;
|
|
383
|
+
return d && typeof d.findMany === 'function' ? d : null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 软删表默认只看未删除的 */
|
|
387
|
+
function aliveWhere(model: string): Record<string, unknown> {
|
|
388
|
+
return hasSoftDelete(model) ? { deletedAt: null } : {};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 把模型给的 filters 变成 Prisma where。
|
|
393
|
+
* ★ 只认 schema 里真实存在的字段 —— 模型编一个不存在的字段时静默忽略,
|
|
394
|
+
* 而不是抛错(抛错会让它反复重试同一个错误参数)。
|
|
395
|
+
*/
|
|
396
|
+
function buildWhere(model: string, filters: unknown): Record<string, unknown> {
|
|
397
|
+
const byName = new Map(fieldsOfModel(model).map((f) => [f.name, f]));
|
|
398
|
+
const where: Record<string, unknown> = {};
|
|
399
|
+
|
|
400
|
+
if (!filters || typeof filters !== 'object' || Array.isArray(filters)) return where;
|
|
401
|
+
|
|
402
|
+
for (const [k, v] of Object.entries(filters as Record<string, unknown>)) {
|
|
403
|
+
const f = byName.get(k);
|
|
404
|
+
if (!f || v === undefined || v === null || v === '') continue;
|
|
405
|
+
|
|
406
|
+
// 范围 / 包含
|
|
407
|
+
if (typeof v === 'object' && !Array.isArray(v)) {
|
|
408
|
+
const src = v as Record<string, unknown>;
|
|
409
|
+
const range: Record<string, unknown> = {};
|
|
410
|
+
for (const op of ['gte', 'lte', 'gt', 'lt'] as const) {
|
|
411
|
+
if (src[op] === undefined) continue;
|
|
412
|
+
const cv = coerceValue(f, src[op]);
|
|
413
|
+
if (cv !== undefined) range[op] = cv;
|
|
414
|
+
}
|
|
415
|
+
if (typeof src.contains === 'string' && f.kind !== 'number') {
|
|
416
|
+
range.contains = src.contains;
|
|
417
|
+
}
|
|
418
|
+
if (Object.keys(range).length) where[k] = range;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const cv = coerceValue(f, v);
|
|
423
|
+
if (cv !== undefined) where[k] = cv;
|
|
424
|
+
}
|
|
425
|
+
return where;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** 一行压成紧凑 JSON(长文本截断,避免把上下文撑爆) */
|
|
429
|
+
function packRow(row: Record<string, unknown>, titleField: string): string {
|
|
430
|
+
const keep: Record<string, unknown> = {};
|
|
431
|
+
const keys = Object.keys(row).filter((k) => k !== 'deletedAt');
|
|
432
|
+
// 标题字段 + id 必须带上,其余按 schema 顺序
|
|
433
|
+
const ordered = [titleField, 'id', ...keys.filter((k) => k !== titleField && k !== 'id')];
|
|
434
|
+
for (const k of ordered) {
|
|
435
|
+
if (!(k in row)) continue;
|
|
436
|
+
const v = row[k];
|
|
437
|
+
if (v === undefined || v === null) continue;
|
|
438
|
+
if (v instanceof Date) keep[k] = v.toISOString().slice(0, 16).replace('T', ' ');
|
|
439
|
+
else if (typeof v === 'string') keep[k] = v.length > 40 ? `${v.slice(0, 40)}…` : v;
|
|
440
|
+
else if (typeof v === 'number' || typeof v === 'boolean') keep[k] = v;
|
|
441
|
+
}
|
|
442
|
+
return JSON.stringify(keep);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export interface ToolResult {
|
|
446
|
+
ok: boolean;
|
|
447
|
+
/** 给用户看的一句话(也会显示在工具轨迹里) */
|
|
448
|
+
summary: string;
|
|
449
|
+
/** 回灌给模型的文本 */
|
|
450
|
+
content: string;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
interface QueryArgs {
|
|
454
|
+
filters?: unknown;
|
|
455
|
+
keyword?: unknown;
|
|
456
|
+
groupBy?: unknown;
|
|
457
|
+
orderBy?: unknown;
|
|
458
|
+
take?: unknown;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** 模型上有没有 deptId —— 没有的表(日志、字典、关联表)套不了部门范围 */
|
|
462
|
+
function hasDeptField(model: string): boolean {
|
|
463
|
+
return fieldsOfModel(model).some((f) => f.name === 'deptId');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* 读工具的 where 也要过数据范围。
|
|
468
|
+
*
|
|
469
|
+
* ★ 为什么必须:助手是「问一句就出全表」的最短路径。
|
|
470
|
+
* 列表页过滤了、导出发不了,用户转头问 AI "所有员工的姓名和电话" 就全出来了 ——
|
|
471
|
+
* 等于给行级权限开了个后门。
|
|
472
|
+
*/
|
|
473
|
+
function scopeWhereOf(model: string, user?: ScopeUser): Record<string, unknown> {
|
|
474
|
+
if (!user || !hasDeptField(model)) return {};
|
|
475
|
+
return deptScope(user);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** 只读查询 */
|
|
479
|
+
async function runQuery(
|
|
480
|
+
entity: BusinessEntity,
|
|
481
|
+
args: QueryArgs,
|
|
482
|
+
user?: ScopeUser
|
|
483
|
+
): Promise<ToolResult> {
|
|
484
|
+
const d = delegateOf(entity.model);
|
|
485
|
+
if (!d?.findMany) return { ok: false, summary: `「${entity.label}」暂不支持查询`, content: '该表没有查询能力' };
|
|
486
|
+
|
|
487
|
+
const fs = fieldsOfModel(entity.model);
|
|
488
|
+
const take = Math.min(30, Math.max(1, Number(args.take) || 10));
|
|
489
|
+
const where = {
|
|
490
|
+
...aliveWhere(entity.model),
|
|
491
|
+
...buildWhere(entity.model, args.filters),
|
|
492
|
+
...scopeWhereOf(entity.model, user),
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const keyword = String(args.keyword ?? '').trim();
|
|
496
|
+
if (keyword) {
|
|
497
|
+
const textFields = fs
|
|
498
|
+
.filter((f) => f.kind === 'text' || f.kind === 'longtext')
|
|
499
|
+
.slice(0, 4)
|
|
500
|
+
.map((f) => f.name);
|
|
501
|
+
if (textFields.length) {
|
|
502
|
+
where.OR = textFields.map((name) => ({ [name]: { contains: keyword } }));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const groupBy = String(args.groupBy ?? '').trim();
|
|
507
|
+
const hasField = (n: string) => fs.some((f) => f.name === n);
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
// 分组统计:问"哪个部门最多""各状态几条"走这条
|
|
511
|
+
if (groupBy && hasField(groupBy)) {
|
|
512
|
+
// ★ groupBy 的 orderBy 只能按 by 里出现过的字段排,
|
|
513
|
+
// 而 take 必须配 orderBy —— 所以这里按分组字段排,再在内存里按条数排。
|
|
514
|
+
// 直接写 take:30 而不给 orderBy,Prisma 会报
|
|
515
|
+
// "Every field used for orderBy must be included in the by-arguments"。
|
|
516
|
+
const rows = d.groupBy
|
|
517
|
+
? await d
|
|
518
|
+
.groupBy({
|
|
519
|
+
by: [groupBy],
|
|
520
|
+
where,
|
|
521
|
+
_count: { _all: true },
|
|
522
|
+
orderBy: { [groupBy]: 'asc' },
|
|
523
|
+
})
|
|
524
|
+
.catch((e: unknown) => {
|
|
525
|
+
throw new Error(`分组统计失败:${(e as Error).message}`);
|
|
526
|
+
})
|
|
527
|
+
: null;
|
|
528
|
+
|
|
529
|
+
if (rows) {
|
|
530
|
+
const counted = rows
|
|
531
|
+
.map((r) => ({
|
|
532
|
+
key: String(r[groupBy] ?? '(空)'),
|
|
533
|
+
count: (r._count as { _all?: number } | undefined)?._all ?? 0,
|
|
534
|
+
}))
|
|
535
|
+
.sort((a, b) => b.count - a.count)
|
|
536
|
+
.slice(0, 30);
|
|
537
|
+
|
|
538
|
+
// ★ 分组字段是外键时,必须把 id 换成名称。
|
|
539
|
+
// 否则"哪个部门最多"会答出一串 cuid —— 模型看不懂,用户更看不懂。
|
|
540
|
+
const gf = fs.find((x) => x.name === groupBy);
|
|
541
|
+
let labelOf = (v: string) => v;
|
|
542
|
+
if (gf?.kind === 'fk' && gf.refModel) {
|
|
543
|
+
const refEnt = businessEntities().find((e) => e.model === gf.refModel);
|
|
544
|
+
const titleField = refEnt?.titleField ?? 'name';
|
|
545
|
+
const refDelegate = delegateOf(gf.refModel);
|
|
546
|
+
const refs = refDelegate?.findMany
|
|
547
|
+
? await refDelegate
|
|
548
|
+
.findMany({ select: { id: true, [titleField]: true }, take: 500 })
|
|
549
|
+
.catch(() => [])
|
|
550
|
+
: [];
|
|
551
|
+
const map = new Map(refs.map((r) => [String(r.id), String(r[titleField] ?? r.id)]));
|
|
552
|
+
labelOf = (v) => map.get(v) ?? v;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const total = typeof d.count === 'function' ? await d.count({ where }).catch(() => undefined) : undefined;
|
|
556
|
+
return {
|
|
557
|
+
ok: true,
|
|
558
|
+
summary: `按「${gf?.label ?? groupBy}」分组:${entity.label}共 ${counted.length} 组`,
|
|
559
|
+
content:
|
|
560
|
+
`「${entity.label}」共 ${total ?? '若干'} 条,按「${gf?.label ?? groupBy}」分组(条数从多到少):\n` +
|
|
561
|
+
counted.map((c) => `${labelOf(c.key)}:${c.count} 条`).join('\n'),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const orderField = hasField(String(args.orderBy ?? ''))
|
|
567
|
+
? String(args.orderBy)
|
|
568
|
+
: dateFieldOf(entity.model) ?? undefined;
|
|
569
|
+
|
|
570
|
+
const rows = await d.findMany({
|
|
571
|
+
where,
|
|
572
|
+
take,
|
|
573
|
+
...(orderField ? { orderBy: { [orderField]: 'desc' } } : {}),
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
const total = typeof d.count === 'function' ? await d.count({ where }).catch(() => undefined) : undefined;
|
|
577
|
+
|
|
578
|
+
if (!rows.length) {
|
|
579
|
+
return {
|
|
580
|
+
ok: true,
|
|
581
|
+
summary: `「${entity.label}」没有匹配的数据`,
|
|
582
|
+
content: `按条件查询「${entity.label}」,0 条。`,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
ok: true,
|
|
588
|
+
summary: `查询「${entity.label}」:${total ?? rows.length} 条,返回 ${rows.length} 条`,
|
|
589
|
+
content:
|
|
590
|
+
`「${entity.label}」共 ${total ?? rows.length} 条,下面是 ${rows.length} 条:\n` +
|
|
591
|
+
rows.map((r) => packRow(r, entity.titleField)).join('\n'),
|
|
592
|
+
};
|
|
593
|
+
} catch (e) {
|
|
594
|
+
return {
|
|
595
|
+
ok: false,
|
|
596
|
+
summary: `查询「${entity.label}」失败`,
|
|
597
|
+
content: `查询出错:${(e as Error).message}`,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** 写工具的参数预检:只校验,不写库 */
|
|
603
|
+
export function validateWrite(name: string, args: Record<string, unknown>): {
|
|
604
|
+
ok: boolean;
|
|
605
|
+
missing?: string[];
|
|
606
|
+
message?: string;
|
|
607
|
+
} {
|
|
608
|
+
// 手写工具的必填项来自它自己的 JSON Schema;以前这里直接放行,
|
|
609
|
+
// 结果"缺参数的审批动作"会被原样摆到确认卡上,用户点确认才报错。
|
|
610
|
+
const extra = extraOf(name);
|
|
611
|
+
if (extra) {
|
|
612
|
+
if (extra.kind !== 'write') return { ok: false, message: '不是可执行的新增动作' };
|
|
613
|
+
const missing = missingHandParams(extra, args);
|
|
614
|
+
if (missing.length) {
|
|
615
|
+
return { ok: false, missing, message: `还缺这些必填信息:${missing.join('、')}` };
|
|
616
|
+
}
|
|
617
|
+
return { ok: true };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const p = parseTool(name);
|
|
621
|
+
if (!p || p.action !== 'create') return { ok: false, message: '不是可执行的新增动作' };
|
|
622
|
+
|
|
623
|
+
const data = coercePayload(p.model, args);
|
|
624
|
+
const missing = fieldsOfModel(p.model)
|
|
625
|
+
.filter((f) => f.required && (data[f.name] === undefined || data[f.name] === ''))
|
|
626
|
+
.map((f) => f.label);
|
|
627
|
+
|
|
628
|
+
if (missing.length) {
|
|
629
|
+
return { ok: false, missing, message: `还缺这些必填信息:${missing.join('、')}` };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// 格式校验:和用户手填表单共用同一套底座。
|
|
633
|
+
// 模型/用户绕过前端直接发参数时,这里仍能拦住「手机号 123」「身份证少一位」这类脏数据。
|
|
634
|
+
for (const f of fieldsOfModel(p.model)) {
|
|
635
|
+
// 枚举/外键/布尔是选项或 id,不是用户手输的文本,不能套手机号/邮箱规则
|
|
636
|
+
if (f.kind === 'enum' || f.kind === 'fk' || f.kind === 'boolean') continue;
|
|
637
|
+
const value = data[f.name];
|
|
638
|
+
if (typeof value !== 'string' || !value.trim()) continue;
|
|
639
|
+
const type = inferValidationType(f.name, f.label, f.hint);
|
|
640
|
+
if (!type) continue;
|
|
641
|
+
const result = validateField(type, value, { label: f.label });
|
|
642
|
+
if (!result.ok) {
|
|
643
|
+
return { ok: false, message: result.message };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return { ok: true };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** 真正写库(只能由 /api/ai/act 在用户确认后调用) */
|
|
650
|
+
export async function runWrite(
|
|
651
|
+
name: string,
|
|
652
|
+
args: Record<string, unknown>,
|
|
653
|
+
ctx: { userId: string; user?: ScopeUser } = { userId: '' }
|
|
654
|
+
): Promise<ToolResult & { row?: Record<string, unknown> }> {
|
|
655
|
+
const extra = extraOf(name);
|
|
656
|
+
if (extra) {
|
|
657
|
+
if (extra.kind !== 'write') return { ok: false, summary: '不是写操作', content: '该工具是只读的' };
|
|
658
|
+
try {
|
|
659
|
+
return await extra.run(args, ctx);
|
|
660
|
+
} catch (e) {
|
|
661
|
+
return { ok: false, summary: `执行失败:${(e as Error).message}`, content: (e as Error).message };
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const p = parseTool(name);
|
|
666
|
+
if (!p || p.action !== 'create') return { ok: false, summary: '未知动作', content: '不支持的操作' };
|
|
667
|
+
|
|
668
|
+
const check = validateWrite(name, args);
|
|
669
|
+
if (!check.ok) {
|
|
670
|
+
return { ok: false, summary: check.message ?? '参数不完整', content: check.message ?? '' };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ★ 数据范围:AI 也不能把记录写到别人部门去。
|
|
674
|
+
// 读能过滤只是"看不到",写不校验就是"换个入口照样改"。
|
|
675
|
+
if (ctx.user && hasDeptField(p.model)) {
|
|
676
|
+
const targetDept = args.deptId === undefined || args.deptId === null ? undefined : String(args.deptId);
|
|
677
|
+
if (!deptAllowed(ctx.user, targetDept)) {
|
|
678
|
+
return {
|
|
679
|
+
ok: false,
|
|
680
|
+
summary: '超出数据范围',
|
|
681
|
+
content: '只能在自己有权管辖的部门范围内创建记录',
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const d = delegateOf(p.model);
|
|
687
|
+
if (!d?.create) return { ok: false, summary: '该表不支持新增', content: '该表不支持新增' };
|
|
688
|
+
|
|
689
|
+
try {
|
|
690
|
+
const row = await d.create({ data: coercePayload(p.model, args) });
|
|
691
|
+
const title = p.entity ? String(row[p.entity.titleField] ?? row.id) : String(row.id);
|
|
692
|
+
return {
|
|
693
|
+
ok: true,
|
|
694
|
+
summary: `已新建「${p.entity?.label ?? p.model}」:${title}`,
|
|
695
|
+
content: `创建成功,记录 id = ${String(row.id)}`,
|
|
696
|
+
row,
|
|
697
|
+
};
|
|
698
|
+
} catch (e) {
|
|
699
|
+
return {
|
|
700
|
+
ok: false,
|
|
701
|
+
summary: `新建失败:${(e as Error).message}`,
|
|
702
|
+
content: `新建失败:${(e as Error).message}`,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** 只读工具的统一入口(chat 路由用) */
|
|
708
|
+
export async function runReadTool(
|
|
709
|
+
name: string,
|
|
710
|
+
args: Record<string, unknown>,
|
|
711
|
+
ctx: { userId: string; user?: ScopeUser } = { userId: '' }
|
|
712
|
+
): Promise<ToolResult> {
|
|
713
|
+
const extra = extraOf(name);
|
|
714
|
+
if (extra) {
|
|
715
|
+
if (extra.kind !== 'read') {
|
|
716
|
+
return { ok: false, summary: `${name} 需要用户确认`, content: '写操作必须走确认流程' };
|
|
717
|
+
}
|
|
718
|
+
try {
|
|
719
|
+
return await extra.run(args, ctx);
|
|
720
|
+
} catch (e) {
|
|
721
|
+
return { ok: false, summary: `${name} 执行失败`, content: (e as Error).message };
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const p = parseTool(name);
|
|
726
|
+
if (!p || !p.entity) {
|
|
727
|
+
return { ok: false, summary: `未知工具:${name}`, content: `没有名为 ${name} 的工具` };
|
|
728
|
+
}
|
|
729
|
+
if (p.action !== 'query') {
|
|
730
|
+
return { ok: false, summary: `${name} 不是只读工具`, content: '该工具需要用户确认后才能执行' };
|
|
731
|
+
}
|
|
732
|
+
return runQuery(p.entity, args as QueryArgs, ctx.user);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* 动作摘要:给前端确认卡显示(人话)。
|
|
737
|
+
*
|
|
738
|
+
* ★ 异步是因为外键要翻成名称:确认卡上写「所属部门:cmvei0lpd0003…」
|
|
739
|
+
* 用户根本没法核对,必须显示「技术部」。这是"确认卡真的能被看懂"的前提。
|
|
740
|
+
*/
|
|
741
|
+
export async function describeAction(
|
|
742
|
+
name: string,
|
|
743
|
+
args: Record<string, unknown>
|
|
744
|
+
): Promise<{ title: string; fields: { label: string; value: string }[] }> {
|
|
745
|
+
const extra = extraOf(name);
|
|
746
|
+
if (extra) {
|
|
747
|
+
// 手写工具没有字段元数据,直接按参数键显示(比不显示强,用户能核对)
|
|
748
|
+
return {
|
|
749
|
+
title: extra.description.split(/[。;;\n]/)[0] || name,
|
|
750
|
+
fields: Object.entries(args)
|
|
751
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
752
|
+
.map(([k, v]) => ({
|
|
753
|
+
label: k,
|
|
754
|
+
value: typeof v === 'object' ? JSON.stringify(v) : String(v),
|
|
755
|
+
})),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const p = parseTool(name);
|
|
760
|
+
if (!p) return { title: name, fields: [] };
|
|
761
|
+
|
|
762
|
+
const label = p.entity?.label ?? p.model;
|
|
763
|
+
const byName = new Map(fieldsOfModel(p.model).map((f) => [f.name, f]));
|
|
764
|
+
const fields: { label: string; value: string }[] = [];
|
|
765
|
+
|
|
766
|
+
for (const [k, v] of Object.entries(args)) {
|
|
767
|
+
if (v === undefined || v === null || v === '') continue;
|
|
768
|
+
const f = byName.get(k);
|
|
769
|
+
if (!f) continue;
|
|
770
|
+
|
|
771
|
+
let text = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
772
|
+
|
|
773
|
+
if (f.kind === 'datetime') {
|
|
774
|
+
const d = new Date(text);
|
|
775
|
+
if (!Number.isNaN(d.getTime())) text = d.toISOString().slice(0, 10);
|
|
776
|
+
} else if (f.kind === 'fk' && f.refModel) {
|
|
777
|
+
// 外键翻名称,查不到就退回原值(宁可显示 id,也不要显示空白)
|
|
778
|
+
const refEnt = businessEntities().find((e) => e.model === f.refModel);
|
|
779
|
+
const titleField = refEnt?.titleField ?? 'name';
|
|
780
|
+
const rd = delegateOf(f.refModel);
|
|
781
|
+
const rows = rd?.findMany
|
|
782
|
+
? await rd
|
|
783
|
+
.findMany({ where: { id: text }, take: 1, select: { [titleField]: true } })
|
|
784
|
+
.catch(() => [])
|
|
785
|
+
: [];
|
|
786
|
+
const hit = rows?.[0]?.[titleField];
|
|
787
|
+
if (hit !== undefined && hit !== null) text = `${String(hit)}(${text})`;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
fields.push({ label: f.label, value: text });
|
|
791
|
+
}
|
|
792
|
+
return { title: `新建「${label}」`, fields };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/* ============================ 3. 动作令牌 ============================ */
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* 写动作不是让前端传参数,而是传一个签名令牌。
|
|
799
|
+
*
|
|
800
|
+
* 原因:前端能改的东西都不能信。若把 args 放在请求体里,
|
|
801
|
+
* 用户(或页面上的脚本)可以把"数量 1"改成"数量 30"再提交。
|
|
802
|
+
* 令牌里含 args + 用户 id + 过期时间,改一个字节签名就对不上。
|
|
803
|
+
*
|
|
804
|
+
* ★ 密钥与验签逻辑在 `lib/aiToken.ts` —— 表单令牌用的是同一套,
|
|
805
|
+
* 这样"用户填的表单"和"用户点的确认"是同一种可信度,不必维护两份密钥。
|
|
806
|
+
*/
|
|
807
|
+
export function signAction(userId: string, name: string, args: Record<string, unknown>): string {
|
|
808
|
+
// k 是令牌类型。表单令牌也带 n/a,不加这个标记的话,
|
|
809
|
+
// 一张"只填了一半的表单"可以直接拿去 /api/ai/act 冒充确认令牌。
|
|
810
|
+
return signPayload({ k: 'act', u: userId, n: name, a: args, e: Date.now() + TOKEN_TTL_MS });
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export type ActionCheck =
|
|
814
|
+
| { ok: true; name: string; args: Record<string, unknown> }
|
|
815
|
+
| { ok: false; error: string };
|
|
816
|
+
|
|
817
|
+
export function verifyAction(token: string, userId: string): ActionCheck {
|
|
818
|
+
const r = readPayload<{ k?: string; u?: string; e?: number; n?: string; a?: Record<string, unknown> }>(token, userId);
|
|
819
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
820
|
+
if (r.payload.k !== 'act') return { ok: false, error: '令牌类型不对,请重新发起' };
|
|
821
|
+
if (!r.payload.n) return { ok: false, error: '令牌缺少动作名' };
|
|
822
|
+
return { ok: true, name: r.payload.n, args: r.payload.a ?? {} };
|
|
823
|
+
}
|