node-resource-utils 0.0.0-stage → 1.1.7
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,532 @@
|
|
|
1
|
+
import ONTOLOGY from '@/lib/ontology';
|
|
2
|
+
import { coercePayload, optionsOf, type FieldMeta, type FieldOption } from '@/lib/graph-schema';
|
|
3
|
+
import { isBlank, writableFields, writeToolInfo, type HandTool } from '@/lib/aiTools';
|
|
4
|
+
import { inferValidationType } from '@/lib/validation';
|
|
5
|
+
import { readPayload, signPayload, TOKEN_TTL_MS, type PayloadCheck } from '@/lib/aiToken';
|
|
6
|
+
import type {
|
|
7
|
+
FormFieldKind,
|
|
8
|
+
FormFieldOption,
|
|
9
|
+
FormFieldSpec,
|
|
10
|
+
FormMode,
|
|
11
|
+
FormSpec,
|
|
12
|
+
} from '@/lib/aiFormTypes';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* AI 输入表单生成器 —— 把"模型要问的信息"翻译成"用户能填的表单"。
|
|
16
|
+
*
|
|
17
|
+
* ★ 一句话原则:**字段定义只能来自 schema,不能来自模型的想象。**
|
|
18
|
+
* 模型负责说"我还缺哪些字段"(甚至只给字段名),
|
|
19
|
+
* 至于这个字段该用日期选择器还是下拉框、下拉里有哪些选项、
|
|
20
|
+
* 能不能为空、中文名叫什么 —— 全部由 `prisma/schema.prisma` +
|
|
21
|
+
* `ontology.ts` 反推。于是换业务(请销假 / 派车 / 报销)不用改这里一行。
|
|
22
|
+
*
|
|
23
|
+
* ★ 两个入口:
|
|
24
|
+
* - `buildWriteForm(工具名, 已填参数)` —— 写工具参数不全时自动弹,
|
|
25
|
+
* 提交后由服务端直接签动作令牌,**不再让模型重解析一遍**(少一次出错机会)。
|
|
26
|
+
* - `buildAskForm(模型给的参数)` —— 模型主动调 ask_user 时弹,
|
|
27
|
+
* 可以绑到某个写工具(走 schema 定义),也可以是天马行空的自由追问。
|
|
28
|
+
*
|
|
29
|
+
* ★ 表单令牌里存了什么:工具名、已填参数、允许提交的字段白名单、标题、过期时间。
|
|
30
|
+
* 白名单是关键 —— 前端多塞一个 `roleId` 进来会被直接丢掉,
|
|
31
|
+
* 和确认令牌一样,前端改不动真正执行的内容。
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** 一张表单最多问几项。超过就不是"补两个字段",是在让人填一张长表 */
|
|
35
|
+
const MAX_FIELDS = 6;
|
|
36
|
+
|
|
37
|
+
/** 表单令牌载荷(短键是为了让令牌别太长) */
|
|
38
|
+
export interface FormTokenPayload {
|
|
39
|
+
k: 'form';
|
|
40
|
+
u: string;
|
|
41
|
+
/** 写工具名;自由追问时为空 */
|
|
42
|
+
n: string;
|
|
43
|
+
m: FormMode;
|
|
44
|
+
/** 已填参数(写工具) */
|
|
45
|
+
a: Record<string, unknown>;
|
|
46
|
+
/** 允许提交的字段白名单(name + label,label 用于回灌文案) */
|
|
47
|
+
f: { n: string; l: string }[];
|
|
48
|
+
t: string;
|
|
49
|
+
d?: string;
|
|
50
|
+
e: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readFormToken(token: unknown, userId: string): PayloadCheck<FormTokenPayload> {
|
|
54
|
+
const r = readPayload<FormTokenPayload>(token, userId);
|
|
55
|
+
if (!r.ok) return r;
|
|
56
|
+
// 和确认令牌互斥:两种令牌能做的事完全不同,不能互相冒充
|
|
57
|
+
if (r.payload.k !== 'form') return { ok: false, error: '令牌类型不对,请重新发起' };
|
|
58
|
+
return r;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ---------------------------- 值的整形 ---------------------------- */
|
|
62
|
+
|
|
63
|
+
function pad(n: number): string {
|
|
64
|
+
return String(n).padStart(2, '0');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 本地日期。不能用 toISOString:东八区凌晨 0~8 点会算成前一天 */
|
|
68
|
+
function localDate(d: Date): string {
|
|
69
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function today(): string {
|
|
73
|
+
return localDate(new Date());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 任意日期值 → `YYYY-MM-DD`。纯日期字符串直接截取,避免时区偏移 */
|
|
77
|
+
function dateText(v: unknown): string | undefined {
|
|
78
|
+
if (isBlank(v)) return undefined;
|
|
79
|
+
if (v instanceof Date) return Number.isNaN(v.getTime()) ? undefined : localDate(v);
|
|
80
|
+
const s = String(v).trim();
|
|
81
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
|
|
82
|
+
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
|
83
|
+
const d = new Date(s);
|
|
84
|
+
return Number.isNaN(d.getTime()) ? undefined : localDate(d);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 把模型给的值对齐到选项上。
|
|
89
|
+
*
|
|
90
|
+
* ★ 这是很常见的一类错误:模型填了「技术部」而不是部门 id,
|
|
91
|
+
* 或者填了「年假」而不是枚举值 `annual`。表单里如果能对上,
|
|
92
|
+
* 就在打开时换成正解 —— 比让用户在确认卡上看到"没选"好得多。
|
|
93
|
+
*/
|
|
94
|
+
function matchOption(v: unknown, options?: FormFieldOption[]): unknown {
|
|
95
|
+
if (!options?.length || isBlank(v)) return v;
|
|
96
|
+
const s = String(v).trim();
|
|
97
|
+
const byValue = options.find((o) => String(o.value) === s);
|
|
98
|
+
if (byValue) return byValue.value;
|
|
99
|
+
const byLabel =
|
|
100
|
+
options.find((o) => o.label === s) ??
|
|
101
|
+
options.find((o) => o.label.includes(s) || s.includes(o.label));
|
|
102
|
+
return byLabel ? byLabel.value : v;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function controlValue(
|
|
106
|
+
kind: FormFieldKind,
|
|
107
|
+
v: unknown,
|
|
108
|
+
options?: FormFieldOption[]
|
|
109
|
+
): FormFieldSpec['value'] {
|
|
110
|
+
if (v === false || v === 0) return v;
|
|
111
|
+
if (isBlank(v)) return undefined;
|
|
112
|
+
switch (kind) {
|
|
113
|
+
case 'date':
|
|
114
|
+
return dateText(v);
|
|
115
|
+
case 'number': {
|
|
116
|
+
const n = Number(v);
|
|
117
|
+
return Number.isFinite(n) ? n : undefined;
|
|
118
|
+
}
|
|
119
|
+
case 'boolean':
|
|
120
|
+
return v === true || v === 'true' || v === 1 || v === '1';
|
|
121
|
+
case 'enum':
|
|
122
|
+
case 'fk':
|
|
123
|
+
return matchOption(v, options) as string | number;
|
|
124
|
+
default:
|
|
125
|
+
return String(v);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isUnset(v: FormFieldSpec['value']): boolean {
|
|
130
|
+
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formKind(f: FieldMeta): FormFieldKind {
|
|
134
|
+
if (f.kind === 'datetime') return 'date';
|
|
135
|
+
if (f.kind === 'longtext') return 'longtext';
|
|
136
|
+
if (f.kind === 'number' || f.kind === 'boolean' || f.kind === 'enum' || f.kind === 'fk') return f.kind;
|
|
137
|
+
return 'text';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 字段的候选选项:枚举直接给,外键查关联表(和知识图谱详情面板同一套) */
|
|
141
|
+
async function optionsFor(f: FieldMeta): Promise<FormFieldOption[] | undefined> {
|
|
142
|
+
if (f.kind === 'enum') return f.options?.length ? f.options : undefined;
|
|
143
|
+
if (f.kind === 'fk' && f.refModel) {
|
|
144
|
+
const opts: FieldOption[] = await optionsOf(f.refModel);
|
|
145
|
+
return opts.length ? opts : undefined;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* schema 注释能不能直接展示给用户。
|
|
152
|
+
*
|
|
153
|
+
* ★ 不能:`gender String @default("male") // male | female` 这行注释的读者是开发者,
|
|
154
|
+
* 原样放到表单里就是一句「male | female」挂在性别控件下面,用户看不懂。
|
|
155
|
+
* 只放行含中文的注释(那才是真的给使用者看的说明)。
|
|
156
|
+
*/
|
|
157
|
+
function usableHint(s?: string): string | undefined {
|
|
158
|
+
if (!s) return undefined;
|
|
159
|
+
const t = s.trim();
|
|
160
|
+
if (!/[\u4e00-\u9fa5]/.test(t)) return undefined;
|
|
161
|
+
return t.length > 40 ? `${t.slice(0, 40)}…` : t;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function placeholderOf(label: string, kind: FormFieldKind): string {
|
|
165
|
+
if (kind === 'enum' || kind === 'fk') return `请选择${label}`;
|
|
166
|
+
if (kind === 'date') return `请选择${label}`;
|
|
167
|
+
if (kind === 'number') return `请输入${label}`;
|
|
168
|
+
if (kind === 'longtext') return `请输入${label}`;
|
|
169
|
+
return `请输入${label}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 预填默认值。
|
|
174
|
+
*
|
|
175
|
+
* ★ 只做两件"几乎不会错"的推断,且都能改:
|
|
176
|
+
* - 指向 User 且名字像"申请人/提交人/负责人" → 当前登录用户
|
|
177
|
+
* - 名字像"申请日期/提交日期" → 今天
|
|
178
|
+
* 其余一律不猜。猜错的默认值比空白更糟:用户会直接提交。
|
|
179
|
+
*/
|
|
180
|
+
function defaultOf(
|
|
181
|
+
f: FieldMeta,
|
|
182
|
+
userId: string
|
|
183
|
+
): { value: FormFieldSpec['value']; hint: string } | null {
|
|
184
|
+
if (f.kind === 'fk' && f.refModel === 'User' && /(user|applicant|owner|creator|submitter|handler|operator)/i.test(f.name)) {
|
|
185
|
+
return { value: userId, hint: '已按当前操作人预填,可修改' };
|
|
186
|
+
}
|
|
187
|
+
if (f.kind === 'datetime' && /^(apply|request|submit|report|leave|start)/i.test(f.name)) {
|
|
188
|
+
return { value: today(), hint: '已按今天预填,可修改' };
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ---------------------------- 自动工具 → 表单 ---------------------------- */
|
|
194
|
+
|
|
195
|
+
interface BuildOptions {
|
|
196
|
+
/** 只问这些字段(模型点名要问的),其余不显示 */
|
|
197
|
+
only?: string[];
|
|
198
|
+
/** 即使参数已经齐了也弹表单(模型主动"请用户核对/补充"时用) */
|
|
199
|
+
force?: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 写工具 → 表单。
|
|
204
|
+
*
|
|
205
|
+
* 返回 null 有两种情况:不是写工具,或者参数已经齐了(不需要打扰用户)。
|
|
206
|
+
* 前者是调用方用错了,后者是正常路径 —— 齐了就直接出确认卡。
|
|
207
|
+
*/
|
|
208
|
+
export async function buildWriteForm(
|
|
209
|
+
name: string,
|
|
210
|
+
args: Record<string, unknown>,
|
|
211
|
+
userId: string,
|
|
212
|
+
opts: BuildOptions = {}
|
|
213
|
+
): Promise<FormSpec | null> {
|
|
214
|
+
const info = writeToolInfo(name);
|
|
215
|
+
if (!info) return null;
|
|
216
|
+
|
|
217
|
+
const cleanArgs =
|
|
218
|
+
info.kind === 'model'
|
|
219
|
+
? (coercePayload(info.model, args ?? {}) as Record<string, unknown>)
|
|
220
|
+
: { ...(args ?? {}) };
|
|
221
|
+
|
|
222
|
+
if (info.kind === 'model') {
|
|
223
|
+
return buildModelForm(name, info.model, info.entity?.label ?? info.model, cleanArgs, userId, opts);
|
|
224
|
+
}
|
|
225
|
+
return buildHandForm(userId, name, info.tool, cleanArgs, opts);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function buildModelForm(
|
|
229
|
+
toolName: string,
|
|
230
|
+
model: string,
|
|
231
|
+
entityLabel: string,
|
|
232
|
+
args: Record<string, unknown>,
|
|
233
|
+
userId: string,
|
|
234
|
+
opts: BuildOptions
|
|
235
|
+
): Promise<FormSpec | null> {
|
|
236
|
+
// 标签/选项允许被 ontology 覆盖,和详情面板保持一致
|
|
237
|
+
const labels = ONTOLOGY.fieldLabels?.[model] ?? {};
|
|
238
|
+
const optOverride = ONTOLOGY.fieldOptions?.[model] ?? {};
|
|
239
|
+
|
|
240
|
+
const decorated: FieldMeta[] = writableFields(model).map((f) => ({
|
|
241
|
+
...f,
|
|
242
|
+
label: labels[f.name] ?? f.label,
|
|
243
|
+
options: optOverride[f.name]?.length ? optOverride[f.name] : f.options,
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
const only = opts.only?.length ? new Set(opts.only) : null;
|
|
247
|
+
const pool = only ? decorated.filter((f) => only.has(f.name)) : decorated;
|
|
248
|
+
|
|
249
|
+
const missing = pool.filter((f) => f.required && isBlank(args[f.name]));
|
|
250
|
+
if (!missing.length && !opts.force) return null;
|
|
251
|
+
if (!pool.length) return null;
|
|
252
|
+
|
|
253
|
+
const picked = pickFields(pool, missing, args);
|
|
254
|
+
|
|
255
|
+
const fields = await Promise.all(
|
|
256
|
+
picked.map(async (f) => {
|
|
257
|
+
const options = await optionsFor(f);
|
|
258
|
+
let value = controlValue(formKind(f), args[f.name], options);
|
|
259
|
+
let hint: string | undefined = usableHint(f.hint);
|
|
260
|
+
|
|
261
|
+
if (isUnset(value) && f.required) {
|
|
262
|
+
const d = defaultOf(f, userId);
|
|
263
|
+
if (d) {
|
|
264
|
+
value = d.value;
|
|
265
|
+
hint = d.hint;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 注释常写成 `name String // 名称`,和标签一模一样。
|
|
270
|
+
// 原样展示就是「名称 / 名称」两行,纯噪音。
|
|
271
|
+
if (hint === f.label) hint = undefined;
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
name: f.name,
|
|
275
|
+
label: f.label,
|
|
276
|
+
kind: formKind(f),
|
|
277
|
+
required: f.required,
|
|
278
|
+
value,
|
|
279
|
+
options,
|
|
280
|
+
placeholder: placeholderOf(f.label, formKind(f)),
|
|
281
|
+
hint,
|
|
282
|
+
validation: inferValidationType(f.name, f.label, f.hint, model),
|
|
283
|
+
} satisfies FormFieldSpec;
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const title = `补全「${entityLabel}」`;
|
|
288
|
+
const stillMissing = fields.filter((f) => f.required && isUnset(f.value));
|
|
289
|
+
const description = stillMissing.length
|
|
290
|
+
? `还差 ${stillMissing.map((f) => f.label).join('、')}。填好后会生成确认卡,确认后才写库。`
|
|
291
|
+
: '信息已基本齐全,请核对后提交。提交后会生成确认卡。';
|
|
292
|
+
|
|
293
|
+
return issue(userId, toolName, 'fill', args, title, description, fields);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 选字段:必填缺失 → 已填 → 可选空(补到上限)。
|
|
298
|
+
* 顺序最终按 schema 原序,保证每次打开表单的位置一致(不会因为模型填了几个字段就跳位)。
|
|
299
|
+
*/
|
|
300
|
+
function pickFields(
|
|
301
|
+
pool: FieldMeta[],
|
|
302
|
+
missing: FieldMeta[],
|
|
303
|
+
args: Record<string, unknown>
|
|
304
|
+
): FieldMeta[] {
|
|
305
|
+
const chosen = new Set<string>(missing.map((f) => f.name));
|
|
306
|
+
|
|
307
|
+
// 已填的也要露出来:用户得能核对模型填的值,也能改掉填错的
|
|
308
|
+
for (const f of pool) {
|
|
309
|
+
if (chosen.size >= MAX_FIELDS) break;
|
|
310
|
+
if (!isBlank(args[f.name])) chosen.add(f.name);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 补位时优先补「还没出现过的控件类型」。
|
|
315
|
+
*
|
|
316
|
+
* ★ 为什么不按 schema 顺序硬取前 N 个:
|
|
317
|
+
* 入职日期排在 email 后面,一截断表单里就没有日期控件了 ——
|
|
318
|
+
* 而"日期用文本框填"正是这套组件要消灭的东西。
|
|
319
|
+
* 先保证 文本/数字/日期/枚举/外键 各有一个,再按顺序补剩下的。
|
|
320
|
+
*/
|
|
321
|
+
const kinds = new Set([...chosen].map((name) => formKind(pool.find((f) => f.name === name)!)));
|
|
322
|
+
const rest = pool.filter((f) => !chosen.has(f.name));
|
|
323
|
+
for (const f of rest) {
|
|
324
|
+
if (chosen.size >= MAX_FIELDS) break;
|
|
325
|
+
const k = formKind(f);
|
|
326
|
+
if (kinds.has(k)) continue;
|
|
327
|
+
chosen.add(f.name);
|
|
328
|
+
kinds.add(k);
|
|
329
|
+
}
|
|
330
|
+
for (const f of rest) {
|
|
331
|
+
if (chosen.size >= MAX_FIELDS) break;
|
|
332
|
+
chosen.add(f.name);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 最终按 schema 原序展示:同一张表每次打开字段位置都一样
|
|
336
|
+
return pool.filter((f) => chosen.has(f.name));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* ---------------------------- 手写工具 → 表单 ---------------------------- */
|
|
340
|
+
|
|
341
|
+
/** 手写工具的参数只有 JSON Schema,按 type / enum / description 粗略映射控件 */
|
|
342
|
+
function handKind(p: { type?: unknown; description?: unknown }): FormFieldKind {
|
|
343
|
+
const t = String(p.type ?? '');
|
|
344
|
+
if (t === 'number' || t === 'integer') return 'number';
|
|
345
|
+
if (t === 'boolean') return 'boolean';
|
|
346
|
+
const d = String(p.description ?? '');
|
|
347
|
+
if (/(日期|时间|date|time)/i.test(d)) return 'date';
|
|
348
|
+
if (/(备注|说明|理由|原因|description|remark|reason|note)/i.test(d)) return 'longtext';
|
|
349
|
+
return 'text';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function handLabel(name: string, p: { description?: unknown }): string {
|
|
353
|
+
const d = String(p.description ?? '').trim();
|
|
354
|
+
if (!d) return name;
|
|
355
|
+
return d.split(/[((,,。;;::]/)[0] || name;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function buildHandForm(
|
|
359
|
+
userId: string,
|
|
360
|
+
toolName: string,
|
|
361
|
+
tool: HandTool,
|
|
362
|
+
args: Record<string, unknown>,
|
|
363
|
+
opts: BuildOptions
|
|
364
|
+
): Promise<FormSpec | null> {
|
|
365
|
+
const required = new Set(tool.parameters.required ?? []);
|
|
366
|
+
const props = tool.parameters.properties ?? {};
|
|
367
|
+
const only = opts.only?.length ? new Set(opts.only) : null;
|
|
368
|
+
|
|
369
|
+
const all: FormFieldSpec[] = [];
|
|
370
|
+
for (const [name, raw] of Object.entries(props)) {
|
|
371
|
+
if (only && !only.has(name)) continue;
|
|
372
|
+
const p = (raw ?? {}) as { type?: unknown; description?: unknown; enum?: unknown[] };
|
|
373
|
+
const kind = handKind(p);
|
|
374
|
+
const options = Array.isArray(p.enum) && p.enum.length
|
|
375
|
+
? p.enum.map((v) => ({
|
|
376
|
+
label: String(v),
|
|
377
|
+
value: typeof v === 'number' ? v : String(v),
|
|
378
|
+
}))
|
|
379
|
+
: undefined;
|
|
380
|
+
const label = handLabel(name, p);
|
|
381
|
+
all.push({
|
|
382
|
+
name,
|
|
383
|
+
label,
|
|
384
|
+
kind,
|
|
385
|
+
required: required.has(name),
|
|
386
|
+
value: controlValue(kind, args[name], options),
|
|
387
|
+
options,
|
|
388
|
+
placeholder: placeholderOf(label, kind),
|
|
389
|
+
validation: inferValidationType(name, label, String(p.description ?? '')),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const missing = all.filter((f) => f.required && isUnset(f.value));
|
|
394
|
+
if (!missing.length && !opts.force) return null;
|
|
395
|
+
if (!all.length) return null;
|
|
396
|
+
|
|
397
|
+
// 必填缺失 + 已填 + 其余(同样受上限约束)
|
|
398
|
+
const chosen = new Set(missing.map((f) => f.name));
|
|
399
|
+
for (const f of all) {
|
|
400
|
+
if (chosen.size >= MAX_FIELDS) break;
|
|
401
|
+
if (!isUnset(f.value)) chosen.add(f.name);
|
|
402
|
+
}
|
|
403
|
+
for (const f of all) {
|
|
404
|
+
if (chosen.size >= MAX_FIELDS) break;
|
|
405
|
+
chosen.add(f.name);
|
|
406
|
+
}
|
|
407
|
+
const fields = all.filter((f) => chosen.has(f.name));
|
|
408
|
+
|
|
409
|
+
const title = `补全操作信息`;
|
|
410
|
+
const stillMissing = fields.filter((f) => f.required && isUnset(f.value));
|
|
411
|
+
const description = stillMissing.length
|
|
412
|
+
? `还差 ${stillMissing.map((f) => f.label).join('、')}。填好后会生成确认卡,确认后才执行。`
|
|
413
|
+
: '请核对后提交,提交后会生成确认卡。';
|
|
414
|
+
|
|
415
|
+
return issue(userId, toolName, 'fill', args, title, description, fields);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/* ---------------------------- 模型主动追问 ---------------------------- */
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* `ask_user` 工具 → 表单。
|
|
422
|
+
*
|
|
423
|
+
* 模型只给字段名(数组),不给控件类型 —— 这是刻意的:
|
|
424
|
+
* 小模型描述不准"这个字段该是日期还是枚举",让它写只会写出错误控件。
|
|
425
|
+
* 字段名在 schema 里查得到就用 schema 的定义,查不到就退化成普通文本框。
|
|
426
|
+
*/
|
|
427
|
+
export async function buildAskForm(
|
|
428
|
+
raw: { title?: unknown; question?: unknown; tool?: unknown; fields?: unknown },
|
|
429
|
+
userId: string
|
|
430
|
+
): Promise<FormSpec | null> {
|
|
431
|
+
const wanted = Array.isArray(raw.fields)
|
|
432
|
+
? raw.fields
|
|
433
|
+
.map((v) => {
|
|
434
|
+
if (typeof v === 'string') return v.trim();
|
|
435
|
+
if (v && typeof v === 'object') return String((v as { name?: unknown }).name ?? '').trim();
|
|
436
|
+
return '';
|
|
437
|
+
})
|
|
438
|
+
.filter(Boolean)
|
|
439
|
+
.slice(0, MAX_FIELDS)
|
|
440
|
+
: [];
|
|
441
|
+
|
|
442
|
+
const title = String(raw.title ?? '').trim().slice(0, 40) || '请补充信息';
|
|
443
|
+
const question = String(raw.question ?? '').trim().slice(0, 200);
|
|
444
|
+
const toolName = String(raw.tool ?? '').trim();
|
|
445
|
+
|
|
446
|
+
// 绑定了写工具:直接复用该工具的字段定义(有下拉、有日期、有必填标记)
|
|
447
|
+
if (toolName) {
|
|
448
|
+
const spec = await buildWriteForm(toolName, {}, userId, {
|
|
449
|
+
only: wanted.length ? wanted : undefined,
|
|
450
|
+
force: true,
|
|
451
|
+
});
|
|
452
|
+
if (spec) {
|
|
453
|
+
return { ...spec, title, description: question || spec.description };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!wanted.length) return null;
|
|
458
|
+
|
|
459
|
+
const fields: FormFieldSpec[] = wanted.map((name) => ({
|
|
460
|
+
name,
|
|
461
|
+
label: name,
|
|
462
|
+
kind: 'text',
|
|
463
|
+
required: true,
|
|
464
|
+
placeholder: `请输入${name}`,
|
|
465
|
+
validation: inferValidationType(name, name),
|
|
466
|
+
}));
|
|
467
|
+
|
|
468
|
+
return issue(userId, '', 'ask', {}, title, question || '填写后我会继续处理。', fields);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/* ---------------------------- 签发 ---------------------------- */
|
|
472
|
+
|
|
473
|
+
function issue(
|
|
474
|
+
userId: string,
|
|
475
|
+
tool: string,
|
|
476
|
+
mode: FormMode,
|
|
477
|
+
args: Record<string, unknown>,
|
|
478
|
+
title: string,
|
|
479
|
+
description: string,
|
|
480
|
+
fields: FormFieldSpec[]
|
|
481
|
+
): FormSpec {
|
|
482
|
+
const payload: FormTokenPayload = {
|
|
483
|
+
k: 'form',
|
|
484
|
+
u: userId,
|
|
485
|
+
n: tool,
|
|
486
|
+
m: mode,
|
|
487
|
+
a: args,
|
|
488
|
+
f: fields.map((x) => ({ n: x.name, l: x.label })),
|
|
489
|
+
t: title,
|
|
490
|
+
d: description,
|
|
491
|
+
e: Date.now() + TOKEN_TTL_MS,
|
|
492
|
+
};
|
|
493
|
+
return {
|
|
494
|
+
token: signPayload({ ...payload }),
|
|
495
|
+
title,
|
|
496
|
+
description,
|
|
497
|
+
mode,
|
|
498
|
+
tool: tool || undefined,
|
|
499
|
+
fields,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* 把用户提交的值合并进已填参数。
|
|
505
|
+
*
|
|
506
|
+
* ★ 只认令牌里的字段白名单。前端多塞的键一律丢弃 ——
|
|
507
|
+
* 否则表单就成了"绕过确认卡直接改任意字段"的后门。
|
|
508
|
+
*/
|
|
509
|
+
export function mergeFormValues(
|
|
510
|
+
payload: FormTokenPayload,
|
|
511
|
+
values: Record<string, unknown>
|
|
512
|
+
): Record<string, unknown> {
|
|
513
|
+
const allowed = new Set(payload.f.map((x) => x.n));
|
|
514
|
+
const out: Record<string, unknown> = { ...(payload.a ?? {}) };
|
|
515
|
+
for (const [k, v] of Object.entries(values ?? {})) {
|
|
516
|
+
if (!allowed.has(k)) continue;
|
|
517
|
+
out[k] = typeof v === 'string' ? v.trim() : v;
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** ask 模式下回灌给模型的文本:字段名用中文标签,模型不用猜哪个键对应什么 */
|
|
523
|
+
export function formValuesText(
|
|
524
|
+
payload: FormTokenPayload,
|
|
525
|
+
values: Record<string, unknown>
|
|
526
|
+
): string {
|
|
527
|
+
const labelOf = new Map(payload.f.map((x) => [x.n, x.l]));
|
|
528
|
+
const lines = Object.entries(values ?? {})
|
|
529
|
+
.filter(([k, v]) => payload.f.some((x) => x.n === k) && !isBlank(v))
|
|
530
|
+
.map(([k, v]) => `${labelOf.get(k) ?? k}:${typeof v === 'boolean' ? (v ? '是' : '否') : String(v)}`);
|
|
531
|
+
return `【用户填写了表单:${payload.t}】\n${lines.join('\n') || '(未填写)'}`;
|
|
532
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI 输入表单的数据契约 —— 前端组件与服务端生成器共用的唯一结构。
|
|
3
|
+
*
|
|
4
|
+
* ★ 为什么单独一个文件:
|
|
5
|
+
* `aiForm.ts` 要读 prisma schema、查外键选项,是纯服务端代码;
|
|
6
|
+
* 前端组件只需要这几个 type。混在一起会把 node:fs 打进客户端包,
|
|
7
|
+
* 构建时会直接报错(这个坑在知识图谱那边踩过一次)。
|
|
8
|
+
*
|
|
9
|
+
* ★ 字段类型只保留渲染控件真正需要的几种,不做通用 JSON Schema 复刻:
|
|
10
|
+
* text/longtext → Input/TextArea,number → InputNumber,
|
|
11
|
+
* boolean → Switch,date → DatePicker,enum/fk → Select。
|
|
12
|
+
* 少一种类型,前端就少一个可能渲染错的控件。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type FormFieldKind = 'text' | 'longtext' | 'number' | 'boolean' | 'date' | 'enum' | 'fk';
|
|
16
|
+
|
|
17
|
+
export interface FormFieldOption {
|
|
18
|
+
label: string;
|
|
19
|
+
value: string | number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FormFieldSpec {
|
|
23
|
+
/** 提交时用的键(= prisma 字段名 / 工具参数名) */
|
|
24
|
+
name: string;
|
|
25
|
+
label: string;
|
|
26
|
+
kind: FormFieldKind;
|
|
27
|
+
required: boolean;
|
|
28
|
+
/** 已填值 / 预填值。日期统一 YYYY-MM-DD 字符串 */
|
|
29
|
+
value?: string | number | boolean | null;
|
|
30
|
+
options?: FormFieldOption[];
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
/** 字段下方的浅色说明("已按当前操作人预填"这类) */
|
|
33
|
+
hint?: string;
|
|
34
|
+
/** 服务端按字段名/标签推断出的校验类型(见 lib/validation.ts) */
|
|
35
|
+
validation?: string;
|
|
36
|
+
/** 为什么问你:模型填错过、或者必填缺失 */
|
|
37
|
+
note?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 表单提交模式,决定服务端拿到答案后走哪条路 */
|
|
41
|
+
export type FormMode =
|
|
42
|
+
/** 补齐写工具参数 → 直接生成确认卡,不再过一遍模型 */
|
|
43
|
+
| 'fill'
|
|
44
|
+
/** 模型主动追问 → 答案回灌给模型,继续这一轮 */
|
|
45
|
+
| 'ask';
|
|
46
|
+
|
|
47
|
+
export interface FormSpec {
|
|
48
|
+
/** 签名令牌:绑定工具名 + 已填参数 + 用户 + 有效期 */
|
|
49
|
+
token: string;
|
|
50
|
+
title: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
mode: FormMode;
|
|
53
|
+
/** fill 模式下绑定的写工具名(仅用于展示与排查) */
|
|
54
|
+
tool?: string;
|
|
55
|
+
fields: FormFieldSpec[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 前端卡片状态(服务端不知道,也不需要知道) */
|
|
59
|
+
export type FormCardStatus = 'pending' | 'submitting' | 'done' | 'cancelled' | 'failed';
|
|
60
|
+
|
|
61
|
+
export interface FormCard {
|
|
62
|
+
spec: FormSpec;
|
|
63
|
+
status: FormCardStatus;
|
|
64
|
+
error?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 一眼能看懂的提交摘要(进对话历史,让后续追问有上下文) */
|
|
68
|
+
export function formAnswerText(spec: FormSpec, values: Record<string, unknown>): string {
|
|
69
|
+
const parts = spec.fields
|
|
70
|
+
.filter((f) => values[f.name] !== undefined && values[f.name] !== null && values[f.name] !== '')
|
|
71
|
+
.map((f) => `${f.label} ${displayValue(f, values[f.name])}`);
|
|
72
|
+
const body = parts.length ? parts.join(',') : '(未填写内容)';
|
|
73
|
+
return `已填写《${spec.title}》:${body}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function displayValue(field: FormFieldSpec, v: unknown): string {
|
|
77
|
+
if (field.kind === 'boolean') return v ? '是' : '否';
|
|
78
|
+
if (field.options?.length) {
|
|
79
|
+
const hit = field.options.find((o) => String(o.value) === String(v));
|
|
80
|
+
if (hit) return hit.label;
|
|
81
|
+
}
|
|
82
|
+
return String(v ?? '');
|
|
83
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 一次性令牌 —— AI 面板里两种"用户亲手做过的事"的技术底座。
|
|
5
|
+
*
|
|
6
|
+
* 1. 确认令牌(action):模型提出的写操作,用户点「确认」才执行
|
|
7
|
+
* 2. 表单令牌(form):模型要的信息,用户填完表单才继续
|
|
8
|
+
*
|
|
9
|
+
* ★ 为什么都要签名,而不是前端直接传参数:
|
|
10
|
+
* 前端能改的东西都不能信。若把 args 放在请求体里,用户(或页面上的脚本)
|
|
11
|
+
* 可以把"请 1 天假"改成"请 30 天假"再提交。令牌里含参数 + 用户 id + 过期时间,
|
|
12
|
+
* 改一个字节签名就对不上。
|
|
13
|
+
*
|
|
14
|
+
* ★ 密钥来源:AI_ACTION_SECRET → AUTH_SECRET(NextAuth v5)→ NEXTAUTH_SECRET → 启动时随机。
|
|
15
|
+
* 绝不用写死的字符串兜底 —— 那等于把签名密钥印在源码里。
|
|
16
|
+
* 随机密钥的代价是"服务重启后,重启前弹出、尚未确认的卡片会失效"
|
|
17
|
+
* (确认时只提示超时/校验失败,重新问一次即可),这个代价可以接受。
|
|
18
|
+
*
|
|
19
|
+
* ★ 统一字段名(payload 里的短键是刻意的:令牌会进 URL/日志,越短越好):
|
|
20
|
+
* u = 用户 id,e = 过期时间戳,其余由调用方定义。
|
|
21
|
+
*/
|
|
22
|
+
const SECRET =
|
|
23
|
+
process.env.AI_ACTION_SECRET ??
|
|
24
|
+
process.env.AUTH_SECRET ??
|
|
25
|
+
process.env.NEXTAUTH_SECRET ??
|
|
26
|
+
randomBytes(32).toString('hex');
|
|
27
|
+
|
|
28
|
+
function sign(body: string): string {
|
|
29
|
+
return createHmac('sha256', SECRET).update(body).digest('base64url');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 把一个 JSON 载荷签成 `body.signature` */
|
|
33
|
+
export function signPayload(payload: Record<string, unknown>): string {
|
|
34
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
35
|
+
return `${body}.${sign(body)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type PayloadCheck<T> = { ok: true; payload: T } | { ok: false; error: string };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 验签 + 校验归属与有效期。
|
|
42
|
+
*
|
|
43
|
+
* 校验顺序是刻意的:先签名、再归属、最后过期。
|
|
44
|
+
* 签名不对时不应该告诉对方"这个令牌属于别人"——那是在泄露信息。
|
|
45
|
+
*/
|
|
46
|
+
export function readPayload<T extends { u?: string; e?: number }>(
|
|
47
|
+
token: unknown,
|
|
48
|
+
userId: string
|
|
49
|
+
): PayloadCheck<T> {
|
|
50
|
+
if (typeof token !== 'string' || !token.includes('.')) return { ok: false, error: '令牌格式不对' };
|
|
51
|
+
const [body, sig] = token.split('.');
|
|
52
|
+
if (!body || !sig) return { ok: false, error: '令牌格式不对' };
|
|
53
|
+
|
|
54
|
+
const expect = sign(body);
|
|
55
|
+
const a = Buffer.from(sig);
|
|
56
|
+
const b = Buffer.from(expect);
|
|
57
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
58
|
+
return { ok: false, error: '令牌校验失败(参数被改动过)' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as T;
|
|
63
|
+
if (payload.u !== userId) return { ok: false, error: '令牌不属于当前登录用户' };
|
|
64
|
+
if (!payload.e || payload.e < Date.now()) return { ok: false, error: '已超时,请重新发起' };
|
|
65
|
+
return { ok: true, payload };
|
|
66
|
+
} catch {
|
|
67
|
+
return { ok: false, error: '令牌解析失败' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 令牌有效期:10 分钟。放久了容易"确认的是十分钟前看到的内容" */
|
|
72
|
+
export const TOKEN_TTL_MS = 10 * 60_000;
|