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.
Files changed (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -2
  3. package/lib/sync.js +121 -0
  4. package/package.json +14 -4
  5. package/resources/.cache/ref.b64 +1 -0
  6. package/resources/README.md +97 -0
  7. package/resources/_.env +6 -0
  8. package/resources/_.env.example +25 -0
  9. package/resources/_.gitignore +18 -0
  10. package/resources/_.lintstagedrc.json +9 -0
  11. package/resources/_.npmrc +5 -0
  12. package/resources/_.prettierrc +7 -0
  13. package/resources/commitlint.config.mjs +1 -0
  14. package/resources/content/docs/README.md +63 -0
  15. package/resources/content/docs/faq.md +107 -0
  16. package/resources/content/docs/features/_meta.json +5 -0
  17. package/resources/content/docs/features/ai.md +71 -0
  18. package/resources/content/docs/features/appearance.md +47 -0
  19. package/resources/content/docs/features/feedback.md +79 -0
  20. package/resources/content/docs/features/files.md +74 -0
  21. package/resources/content/docs/features/graph.md +66 -0
  22. package/resources/content/docs/features/inbox.md +61 -0
  23. package/resources/content/docs/guide/_meta.json +5 -0
  24. package/resources/content/docs/guide/account.md +76 -0
  25. package/resources/content/docs/guide/data.md +87 -0
  26. package/resources/content/docs/guide/navigation.md +81 -0
  27. package/resources/content/docs/guide/permissions.md +67 -0
  28. package/resources/content/docs/guide/system.md +92 -0
  29. package/resources/content/docs/quick-start.md +70 -0
  30. package/resources/content/docs/shortcuts.md +69 -0
  31. package/resources/eslint.config.mjs +17 -0
  32. package/resources/next-env.d.ts +6 -0
  33. package/resources/next.config.ts +13 -0
  34. package/resources/postcss.config.mjs +7 -0
  35. package/resources/prisma/org.config.ts +98 -0
  36. package/resources/prisma/schema.prisma +336 -0
  37. package/resources/prisma/seed.ts +721 -0
  38. package/resources/prisma/seedSafe.ts +347 -0
  39. package/resources/prisma.config.ts +14 -0
  40. package/resources/scripts/check-config.mjs +773 -0
  41. package/resources/scripts/lib/schema-graph.mjs +410 -0
  42. package/resources/scripts/patch-next-trace.mjs +86 -0
  43. package/resources/scripts/suggest-relations.mjs +105 -0
  44. package/resources/src/app/(admin)/ai/page.tsx +469 -0
  45. package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
  46. package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
  47. package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
  48. package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
  49. package/resources/src/app/(admin)/error.tsx +96 -0
  50. package/resources/src/app/(admin)/feedback/page.tsx +573 -0
  51. package/resources/src/app/(admin)/graph/page.tsx +1224 -0
  52. package/resources/src/app/(admin)/inbox/page.tsx +31 -0
  53. package/resources/src/app/(admin)/layout.tsx +58 -0
  54. package/resources/src/app/(admin)/loading.tsx +31 -0
  55. package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
  56. package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
  57. package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
  58. package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
  59. package/resources/src/app/(admin)/system/users/page.tsx +620 -0
  60. package/resources/src/app/api/account-recovery/route.ts +132 -0
  61. package/resources/src/app/api/ai/act/route.ts +70 -0
  62. package/resources/src/app/api/ai/ask/route.ts +82 -0
  63. package/resources/src/app/api/ai/chat/route.ts +404 -0
  64. package/resources/src/app/api/ai/report/route.ts +164 -0
  65. package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
  66. package/resources/src/app/api/auth/change-password/route.ts +57 -0
  67. package/resources/src/app/api/auth/forgot/route.ts +98 -0
  68. package/resources/src/app/api/auth/register/route.ts +95 -0
  69. package/resources/src/app/api/auth/register-config/route.ts +51 -0
  70. package/resources/src/app/api/badges/route.ts +39 -0
  71. package/resources/src/app/api/depts/[id]/route.ts +145 -0
  72. package/resources/src/app/api/depts/route.ts +126 -0
  73. package/resources/src/app/api/docs/export/route.ts +87 -0
  74. package/resources/src/app/api/docs/parse/route.ts +57 -0
  75. package/resources/src/app/api/docs/search/route.ts +22 -0
  76. package/resources/src/app/api/employees/[id]/route.ts +147 -0
  77. package/resources/src/app/api/employees/export/route.ts +115 -0
  78. package/resources/src/app/api/employees/import/route.ts +205 -0
  79. package/resources/src/app/api/employees/route.ts +149 -0
  80. package/resources/src/app/api/export/route.ts +254 -0
  81. package/resources/src/app/api/favorites/route.ts +56 -0
  82. package/resources/src/app/api/feedback/ai/route.ts +31 -0
  83. package/resources/src/app/api/feedback/approve/route.ts +67 -0
  84. package/resources/src/app/api/feedback/report/route.ts +213 -0
  85. package/resources/src/app/api/feedback/route.ts +246 -0
  86. package/resources/src/app/api/files/[name]/route.ts +82 -0
  87. package/resources/src/app/api/graph/focus/route.ts +75 -0
  88. package/resources/src/app/api/graph/node/move/route.ts +48 -0
  89. package/resources/src/app/api/graph/node/route.ts +274 -0
  90. package/resources/src/app/api/graph/route.ts +272 -0
  91. package/resources/src/app/api/health/route.ts +11 -0
  92. package/resources/src/app/api/inbox/read/route.ts +36 -0
  93. package/resources/src/app/api/inbox/summary/route.ts +15 -0
  94. package/resources/src/app/api/logs/route.ts +82 -0
  95. package/resources/src/app/api/menus/[id]/route.ts +132 -0
  96. package/resources/src/app/api/menus/mine/route.ts +23 -0
  97. package/resources/src/app/api/menus/route.ts +89 -0
  98. package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
  99. package/resources/src/app/api/roles/[id]/route.ts +96 -0
  100. package/resources/src/app/api/roles/route.ts +114 -0
  101. package/resources/src/app/api/search/route.ts +22 -0
  102. package/resources/src/app/api/stats/route.ts +92 -0
  103. package/resources/src/app/api/upload/route.ts +38 -0
  104. package/resources/src/app/api/users/[id]/password/route.ts +55 -0
  105. package/resources/src/app/api/users/[id]/route.ts +145 -0
  106. package/resources/src/app/api/users/route.ts +153 -0
  107. package/resources/src/app/api/visits/route.ts +48 -0
  108. package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
  109. package/resources/src/app/change-password/page.tsx +19 -0
  110. package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
  111. package/resources/src/app/docs/layout.tsx +34 -0
  112. package/resources/src/app/docs/loading.tsx +19 -0
  113. package/resources/src/app/docs/not-found.tsx +25 -0
  114. package/resources/src/app/forgot/page.tsx +392 -0
  115. package/resources/src/app/global-error.tsx +75 -0
  116. package/resources/src/app/globals.css +235 -0
  117. package/resources/src/app/layout.tsx +44 -0
  118. package/resources/src/app/login/page.tsx +267 -0
  119. package/resources/src/app/page.tsx +14 -0
  120. package/resources/src/app/register/page.tsx +237 -0
  121. package/resources/src/auth.ts +215 -0
  122. package/resources/src/components/AiPanel.tsx +970 -0
  123. package/resources/src/components/AuthContext.tsx +82 -0
  124. package/resources/src/components/DataTable.tsx +459 -0
  125. package/resources/src/components/EChart.tsx +140 -0
  126. package/resources/src/components/FeedbackAiModal.tsx +476 -0
  127. package/resources/src/components/FeedbackReportModal.tsx +479 -0
  128. package/resources/src/components/Markdown.tsx +532 -0
  129. package/resources/src/components/OntologyGraph.tsx +1196 -0
  130. package/resources/src/components/Providers.tsx +99 -0
  131. package/resources/src/components/VisitTracker.tsx +89 -0
  132. package/resources/src/components/admin/AdminShell.tsx +749 -0
  133. package/resources/src/components/admin/CountBadge.tsx +67 -0
  134. package/resources/src/components/admin/GlobalSearch.tsx +509 -0
  135. package/resources/src/components/admin/InboxBell.tsx +78 -0
  136. package/resources/src/components/admin/VisitStats.tsx +145 -0
  137. package/resources/src/components/admin/menu-icons.tsx +56 -0
  138. package/resources/src/components/ai/AiInputForm.tsx +351 -0
  139. package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
  140. package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
  141. package/resources/src/components/docs/DocArticle.tsx +106 -0
  142. package/resources/src/components/docs/DocToc.tsx +100 -0
  143. package/resources/src/components/docs/DocsChrome.tsx +259 -0
  144. package/resources/src/components/docs/DocsHome.tsx +209 -0
  145. package/resources/src/components/docs/DocsSearch.tsx +365 -0
  146. package/resources/src/components/docs/docs.css +1458 -0
  147. package/resources/src/components/feedback/AsyncButton.tsx +108 -0
  148. package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
  149. package/resources/src/components/feedback/PressableCard.tsx +92 -0
  150. package/resources/src/components/feedback/README.md +97 -0
  151. package/resources/src/components/feedback/RouteProgress.tsx +108 -0
  152. package/resources/src/components/feedback/Skeleton.tsx +249 -0
  153. package/resources/src/components/feedback/StatCard.tsx +70 -0
  154. package/resources/src/components/feedback/TaskHint.tsx +153 -0
  155. package/resources/src/components/feedback/index.ts +93 -0
  156. package/resources/src/components/feedback/motion.css +204 -0
  157. package/resources/src/components/feedback/motion.ts +63 -0
  158. package/resources/src/components/feedback/useCountUp.ts +110 -0
  159. package/resources/src/components/feedback/usePending.ts +209 -0
  160. package/resources/src/components/feedback/useRowFlash.ts +40 -0
  161. package/resources/src/components/inbox/InboxView.tsx +409 -0
  162. package/resources/src/components/ui/button.tsx +35 -0
  163. package/resources/src/components/ui/card.tsx +37 -0
  164. package/resources/src/components/ui/input.tsx +19 -0
  165. package/resources/src/lib/ai.ts +310 -0
  166. package/resources/src/lib/aiContext.ts +357 -0
  167. package/resources/src/lib/aiForm.test.ts +191 -0
  168. package/resources/src/lib/aiForm.ts +532 -0
  169. package/resources/src/lib/aiFormTypes.ts +83 -0
  170. package/resources/src/lib/aiToken.ts +72 -0
  171. package/resources/src/lib/aiTools.ts +823 -0
  172. package/resources/src/lib/api.ts +160 -0
  173. package/resources/src/lib/appInfo.ts +128 -0
  174. package/resources/src/lib/appearance.ts +643 -0
  175. package/resources/src/lib/badge-count.ts +146 -0
  176. package/resources/src/lib/badges.test.ts +58 -0
  177. package/resources/src/lib/badges.ts +79 -0
  178. package/resources/src/lib/brand-mark.test.ts +87 -0
  179. package/resources/src/lib/brand-mark.ts +45 -0
  180. package/resources/src/lib/docs-shared.test.ts +148 -0
  181. package/resources/src/lib/docs-shared.ts +422 -0
  182. package/resources/src/lib/docs.ts +381 -0
  183. package/resources/src/lib/err-text.ts +21 -0
  184. package/resources/src/lib/excel.ts +152 -0
  185. package/resources/src/lib/favorite-path.ts +60 -0
  186. package/resources/src/lib/favorites.ts +59 -0
  187. package/resources/src/lib/features.ts +69 -0
  188. package/resources/src/lib/feedback-access.ts +17 -0
  189. package/resources/src/lib/feedbackAi.ts +1621 -0
  190. package/resources/src/lib/formRules.ts +76 -0
  191. package/resources/src/lib/format.ts +52 -0
  192. package/resources/src/lib/graph-cascade.ts +304 -0
  193. package/resources/src/lib/graph-focus.ts +301 -0
  194. package/resources/src/lib/graph-link.ts +17 -0
  195. package/resources/src/lib/graph-node-write.ts +150 -0
  196. package/resources/src/lib/graph-schema.ts +475 -0
  197. package/resources/src/lib/graph-scope.test.ts +46 -0
  198. package/resources/src/lib/graph-scope.ts +28 -0
  199. package/resources/src/lib/http.ts +157 -0
  200. package/resources/src/lib/inbox-types.ts +57 -0
  201. package/resources/src/lib/inbox.ts +55 -0
  202. package/resources/src/lib/menu-service.ts +204 -0
  203. package/resources/src/lib/notifications.ts +113 -0
  204. package/resources/src/lib/office.ts +531 -0
  205. package/resources/src/lib/ontology.ts +277 -0
  206. package/resources/src/lib/openaiCompat.ts +257 -0
  207. package/resources/src/lib/pending.ts +98 -0
  208. package/resources/src/lib/prisma.ts +30 -0
  209. package/resources/src/lib/rbac.test.ts +106 -0
  210. package/resources/src/lib/rbac.ts +185 -0
  211. package/resources/src/lib/search-data.ts +158 -0
  212. package/resources/src/lib/security-questions.ts +20 -0
  213. package/resources/src/lib/skin-theme.ts +155 -0
  214. package/resources/src/lib/system-config.ts +55 -0
  215. package/resources/src/lib/theme.ts +96 -0
  216. package/resources/src/lib/todos/feedback.ts +86 -0
  217. package/resources/src/lib/todos/index.ts +73 -0
  218. package/resources/src/lib/todos/types.ts +33 -0
  219. package/resources/src/lib/upload.ts +106 -0
  220. package/resources/src/lib/utils.test.ts +9 -0
  221. package/resources/src/lib/utils.ts +6 -0
  222. package/resources/src/lib/validation.test.ts +297 -0
  223. package/resources/src/lib/validation.ts +658 -0
  224. package/resources/src/lib/visit-stats.ts +109 -0
  225. package/resources/src/lib/visits.ts +111 -0
  226. package/resources/src/lib/zodField.ts +44 -0
  227. package/resources/tsconfig.json +41 -0
  228. package/resources/vitest.config.ts +16 -0
  229. package/resources/vitest.setup.ts +1 -0
@@ -0,0 +1,132 @@
1
+ import path from 'path';
2
+ import { BizError, CODE, audit, clientInfo, ok, withRoute } from '@/lib/api';
3
+ import { prisma } from '@/lib/prisma';
4
+ import { saveUpload } from '@/lib/upload';
5
+ import { validateField } from '@/lib/validation';
6
+
7
+ export const dynamic = 'force-dynamic';
8
+
9
+ /**
10
+ * 账号找回人工申诉(无需登录)。
11
+ *
12
+ * 用户忘记密码、且密保问题也答不上来时,从登录页的「忘记密码 → 人工申诉」
13
+ * 提交账号信息 + 证明材料。落一条 Feedback(type=account),
14
+ * 超级管理员在反馈页核实后可通过申诉,密码会被重置为 123456。
15
+ *
16
+ * 为什么单独开接口而不是复用 /api/feedback:
17
+ * /api/feedback 要求登录(反馈人就是当前用户),而找回密码的人此刻正好登不进来。
18
+ * 上传也在这里一并处理,不经过需要登录的 /api/upload。
19
+ */
20
+
21
+ const MAX_FILES = 3;
22
+ const MAX_SIZE = 10 * 1024 * 1024;
23
+ const ALLOWED_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.pdf', '.doc', '.docx', '.txt']);
24
+
25
+ function isFile(v: unknown): v is File {
26
+ return typeof v === 'object' && v !== null && typeof (v as File).arrayBuffer === 'function';
27
+ }
28
+
29
+ export const POST = withRoute(async (req) => {
30
+ const form = await req.formData();
31
+
32
+ const usernameCheck = validateField('username', form.get('username'), {
33
+ label: '要找回的用户名',
34
+ required: true,
35
+ });
36
+ if (!usernameCheck.ok) throw new BizError(usernameCheck.message, CODE.BAD_REQUEST);
37
+ const username = usernameCheck.value;
38
+
39
+ const realNameCheck = validateField('name', form.get('realName'), {
40
+ label: '真实姓名',
41
+ required: true,
42
+ });
43
+ if (!realNameCheck.ok) throw new BizError(realNameCheck.message, CODE.BAD_REQUEST);
44
+ const realName = realNameCheck.value;
45
+
46
+ const contactCheck = validateField('contact', form.get('contact'), {
47
+ label: '联系方式',
48
+ required: true,
49
+ });
50
+ if (!contactCheck.ok) throw new BizError(contactCheck.message, CODE.BAD_REQUEST);
51
+ const contact = contactCheck.value;
52
+
53
+ const detailCheck = validateField('text', form.get('detail'), {
54
+ label: '情况说明',
55
+ required: true,
56
+ min: 10,
57
+ });
58
+ if (!detailCheck.ok) throw new BizError(detailCheck.message, CODE.BAD_REQUEST);
59
+ const detail = detailCheck.value;
60
+
61
+ const target = await prisma.user.findFirst({
62
+ where: { username, deletedAt: null },
63
+ select: { id: true, username: true, status: true },
64
+ });
65
+ if (!target) throw new BizError('账号不存在,请核对用户名', CODE.NOT_FOUND);
66
+ if (target.status !== 1) throw new BizError('该账号已被停用,请联系管理员', CODE.FORBIDDEN, 403);
67
+
68
+ // 同一账号只保留一条待处理申诉,避免反复提交把管理员淹了
69
+ const pending = await prisma.feedback.findFirst({
70
+ where: {
71
+ userId: target.id,
72
+ type: 'account',
73
+ status: { in: ['pending', 'doing'] },
74
+ deletedAt: null,
75
+ },
76
+ });
77
+ if (pending) {
78
+ throw new BizError('该账号已有待处理的申诉,请耐心等待超级管理员处理', CODE.CONFLICT);
79
+ }
80
+
81
+ const files = form.getAll('files').filter(isFile);
82
+ if (files.length > MAX_FILES) throw new BizError(`最多上传 ${MAX_FILES} 个证明文件`, CODE.BAD_REQUEST);
83
+
84
+ const attachments: { name: string; url: string; size: number }[] = [];
85
+ for (const file of files) {
86
+ if (!file.size) continue;
87
+ if (file.size > MAX_SIZE) throw new BizError(`「${file.name}」超过 10MB,请压缩后再传`, CODE.BAD_REQUEST);
88
+ const ext = path.extname(file.name || '').toLowerCase();
89
+ if (!ALLOWED_EXT.has(ext)) {
90
+ throw new BizError(`「${file.name}」格式不支持,仅限图片 / PDF / Word / TXT`, CODE.BAD_REQUEST);
91
+ }
92
+ const saved = await saveUpload(file, 'recovery');
93
+ attachments.push({ name: file.name, url: saved.url, size: saved.size });
94
+ }
95
+
96
+ const detailText = [
97
+ `账号:${target.username}`,
98
+ `姓名:${realName}`,
99
+ `联系方式:${contact}`,
100
+ '',
101
+ `申诉说明:${detail}`,
102
+ ].join('\n');
103
+
104
+ const row = await prisma.feedback.create({
105
+ data: {
106
+ type: 'account',
107
+ title: `账号找回申诉:${target.username}`.slice(0, 80),
108
+ detail: detailText.slice(0, 2000),
109
+ pagePath: '/forgot',
110
+ contact: contact.slice(0, 254),
111
+ attachments: attachments.length ? JSON.stringify(attachments) : null,
112
+ userId: target.id,
113
+ username: target.username,
114
+ },
115
+ });
116
+
117
+ const { ip, userAgent } = clientInfo(req);
118
+ await audit({
119
+ userId: target.id,
120
+ username: target.username,
121
+ module: '账号安全',
122
+ action: '提交找回申诉',
123
+ method: 'POST',
124
+ path: req.nextUrl.pathname,
125
+ targetId: row.id,
126
+ detail: { username: target.username, attachments: attachments.length },
127
+ ip,
128
+ userAgent,
129
+ });
130
+
131
+ return ok({ id: row.id }, '申诉已提交,请等待超级管理员核实处理');
132
+ });
@@ -0,0 +1,70 @@
1
+ import { currentUser } from '@/auth';
2
+ import { audit } from '@/lib/api';
3
+ import { isWriteTool, runWrite, verifyAction } from '@/lib/aiTools';
4
+
5
+ /**
6
+ * 执行 AI 提出的写操作 —— 只认令牌,不认参数。
7
+ *
8
+ * 请求体只有一个字段:`{ token }`。
9
+ * 动作名、参数、用户、有效期全在令牌里,签名对不上就拒绝。
10
+ *
11
+ * ★ 为什么不做成 `{ name, args }`:
12
+ * 那样前端(以及任何能发请求的脚本)就能把"请 1 天假"改成"请 30 天假"。
13
+ * 确认卡上的内容必须和真正执行的内容**是同一条数据**,
14
+ * 否则"二次确认"只是个装饰。
15
+ *
16
+ * ★ 权限边界:
17
+ * 能走到这里说明用户已经登录,且亲手点了确认卡上的按钮。
18
+ * 这和他在表单里点"提交"是同一个授权动作,所以不再额外要权限码。
19
+ * 如果某个业务需要更严的约束(比如只有主管能派车),
20
+ * 在 `lib/aiTools.ts` 的 `runWrite` 里按模型加 `requirePerm` 即可。
21
+ */
22
+ export async function POST(req: Request) {
23
+ const user = await currentUser();
24
+ if (!user) {
25
+ return Response.json({ error: '请先登录' }, { status: 401 });
26
+ }
27
+
28
+ const body = (await req.json().catch(() => null)) as { token?: string } | null;
29
+ if (!body?.token) {
30
+ return Response.json({ error: '缺少确认令牌' }, { status: 400 });
31
+ }
32
+
33
+ const check = verifyAction(body.token, user.id);
34
+ if (!check.ok) {
35
+ return Response.json({ error: check.error }, { status: 400 });
36
+ }
37
+
38
+ // 令牌只能承载写动作:只读工具不该走这条路径,避免被当成"任意查询"后门
39
+ if (!isWriteTool(check.name)) {
40
+ return Response.json({ error: '该动作不需要确认,也不能通过此接口执行' }, { status: 400 });
41
+ }
42
+
43
+ const t0 = Date.now();
44
+ const result = await runWrite(check.name, check.args, { userId: user.id, user });
45
+
46
+ // 审计:module 记为「AI 助手」,和人工操作区分开,事后能查清是谁让 AI 干的
47
+ await audit({
48
+ userId: user.id,
49
+ username: user.username,
50
+ module: 'AI 助手',
51
+ action: result.ok ? '执行动作' : '执行失败',
52
+ method: 'POST',
53
+ path: '/api/ai/act',
54
+ targetId: result.row?.id ? String(result.row.id) : null,
55
+ detail: {
56
+ tool: check.name,
57
+ args: check.args,
58
+ ok: result.ok,
59
+ summary: result.summary,
60
+ },
61
+ status: result.ok ? 1 : 0,
62
+ durationMs: Date.now() - t0,
63
+ });
64
+
65
+ return Response.json({
66
+ ok: result.ok,
67
+ summary: result.summary,
68
+ id: result.row?.id ?? null,
69
+ });
70
+ }
@@ -0,0 +1,82 @@
1
+ import { ok, withRoute, audit, BizError, CODE } from '@/lib/api';
2
+ import { currentUser } from '@/auth';
3
+ import { compatInvoke, COMPAT_MODEL } from '@/lib/openaiCompat';
4
+ import { buildAssistantContext } from '@/lib/aiContext';
5
+
6
+ /**
7
+ * AI 问答(非流式版本)。
8
+ *
9
+ * ★ 页面上的对话框已经统一走 `/api/ai/chat`(流式,内置当前时间 + 数据快照)。
10
+ * 这个接口保留下来,给"需要一次性拿到完整答案"的场景用:
11
+ * 脚本批量提问、其它模块内嵌问答、或者不支持 SSE 的客户端。
12
+ *
13
+ * 上下文与统一助手是**同一套**(lib/aiContext.ts):
14
+ * 当前时间(本月/上月区间)+ ontology 声明的业务表快照 + 每表本月/今日计数。
15
+ * 所以"这个月有多少条"这类问题这里也能答对,不再依赖模型猜日期。
16
+ *
17
+ * POST /api/ai/ask { question: string, scope?: string[] }
18
+ */
19
+ export const POST = withRoute(async (req) => {
20
+ const user = await currentUser();
21
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
22
+
23
+ const body = (await req.json().catch(() => ({}))) as {
24
+ question?: string;
25
+ scope?: string[];
26
+ };
27
+ const question = String(body.question ?? '').trim();
28
+ if (question.length < 2) throw new BizError('请输入你的问题', CODE.BAD_REQUEST);
29
+ if (question.length > 300) throw new BizError('问题太长了,请精简到 300 字以内', CODE.BAD_REQUEST);
30
+
31
+ // 这个接口没有工具(返回 JSON、没有确认卡,不适合触发写操作),
32
+ // 所以数据多给一些:每表 20 行。chat 那边有工具,只带 6 行样例。
33
+ const ctx = await buildAssistantContext(undefined, {
34
+ withData: true,
35
+ rows: 20,
36
+ scope: Array.isArray(body.scope) ? body.scope.map(String) : [],
37
+ });
38
+ if (!ctx.snapshot.length) {
39
+ throw new BizError(
40
+ '库里没有可查询的业务数据。请先补充演示数据(npm run db:seed),或在 ontology.ts 里声明业务表。',
41
+ CODE.BAD_REQUEST
42
+ );
43
+ }
44
+
45
+ const answer = await compatInvoke({
46
+ system: ctx.system,
47
+ messages: [
48
+ {
49
+ role: 'user',
50
+ content:
51
+ `请回答这个问题:${question}\n\n` +
52
+ `要求:先给结论(具体的数字和名称),再给依据(来自哪张表);` +
53
+ `数据不够就说明缺什么,不要猜。`,
54
+ },
55
+ ],
56
+ maxTokens: 1200,
57
+ temperature: 0.2,
58
+ });
59
+
60
+ void audit({
61
+ module: 'AI 问答',
62
+ action: '提问',
63
+ method: 'POST',
64
+ path: req.url,
65
+ detail: `问题=${question.slice(0, 60)} 表数=${ctx.snapshot.length}`,
66
+ userId: user.id,
67
+ username: user.username,
68
+ });
69
+
70
+ return ok({
71
+ answer,
72
+ model: COMPAT_MODEL,
73
+ tables: ctx.snapshot.map((t) => ({
74
+ model: t.model,
75
+ total: t.total,
76
+ monthTotal: t.monthTotal,
77
+ sampled: t.rows.length,
78
+ })),
79
+ now: { date: ctx.now.date, month: ctx.now.month },
80
+ generatedAt: new Date().toISOString(),
81
+ });
82
+ });
@@ -0,0 +1,404 @@
1
+ import { currentUser } from '@/auth';
2
+ import { aiChatWithTools, streamAiChat, type AiMessage } from '@/lib/ai';
3
+ import { buildAssistantContext, nowContext } from '@/lib/aiContext';
4
+ import {
5
+ buildAiTools,
6
+ describeAction,
7
+ isFormTool,
8
+ isWriteTool,
9
+ runReadTool,
10
+ signAction,
11
+ validateWrite,
12
+ } from '@/lib/aiTools';
13
+ import {
14
+ buildAskForm,
15
+ buildWriteForm,
16
+ formValuesText,
17
+ mergeFormValues,
18
+ readFormToken,
19
+ type FormTokenPayload,
20
+ } from '@/lib/aiForm';
21
+
22
+ /**
23
+ * 统一 AI 助手(流式 + 可调用系统功能)。
24
+ *
25
+ * ★ 一个对话框同时支持三类诉求:
26
+ * - 系统怎么用("怎么新增一条申请""权限怎么配")
27
+ * - 数据问答("这个月有多少条记录""哪个部门最多")
28
+ * - **直接办事**("帮我新增一条记录""把这条记录的负责人改成我")
29
+ *
30
+ * ## 工具从哪来
31
+ * 不是在这里写死的。`lib/aiTools.ts` 按 `ontology.ts` 的实体 + prisma schema 的字段
32
+ * 自动生成 query_ 与 create_ 两类工具,所以**换业务不用改这个文件**。
33
+ *
34
+ * ## 为什么写操作要分两步
35
+ * 模型提出 create_* 后,这里**不写库**,只做三件事:
36
+ * 1. 校验必填(缺就让模型去问用户,而不是建一条空记录)
37
+ * 2. 把"动作名 + 参数 + 用户 id + 过期时间"做成 HMAC 令牌
38
+ * 3. 推一条 `action` 事件给前端,由用户点确认
39
+ * 真正的写在 `/api/ai/act`,它只认令牌、不认参数 —— 前端改不动参数。
40
+ *
41
+ * ## SSE 协议
42
+ * data: {"meta": {...}} 参考表 / 当前时间 / 可用工具数
43
+ * data: {"step": {...}} 一次工具调用(前端显示"查了 XX,返回 N 条")
44
+ * data: {"form": {...}} 需要用户填写的表单(前端渲染输入卡)
45
+ * data: {"action": {...}} 待确认的写操作(前端渲染确认卡)
46
+ * data: {"content": "..."} 正文增量
47
+ * data: {"error": "...", "hint": "..."}
48
+ * data: [DONE]
49
+ *
50
+ * ## 表单这条路(为什么写工具缺参不直接让模型追问)
51
+ * 模型只说"还缺请假类型和起止日期"时,用户得在聊天框里按格式手打一遍,
52
+ * 模型再解析一遍 —— 字段一多就漏、格式一乱就错,还要多等两轮本地模型。
53
+ * 所以缺参时这里推一张 `form`:字段(含下拉选项/日期控件/必填标记)来自 schema,
54
+ * 用户填完提交回来的 `values` 合并进已签名的令牌参数里,**服务端直接生成确认卡**,
55
+ * 不再经过模型。少一次生成,就少一次误解析。
56
+ *
57
+ * 模型也可以主动调 `ask_user` 要信息(比如查询前先问时间范围),
58
+ * 那条路走 'ask' 模式:答案回灌给模型继续这一轮。
59
+ *
60
+ * 注意:这里不用 withRoute —— 它要求返回 NextResponse,
61
+ * 而 SSE 需要返回原生 Response,所以直接导出 POST。
62
+ */
63
+
64
+ /** 最多几轮工具调用。2 轮够用:一轮查数据,一轮补查或发起动作 */
65
+ const MAX_TOOL_TURNS = 2;
66
+
67
+ /** 一次性文本按小段推给前端,保留打字机观感(模型已生成完,只是分帧发) */
68
+ const CHUNK_SIZE = 26;
69
+
70
+ export async function POST(req: Request) {
71
+ const user = await currentUser();
72
+ if (!user) {
73
+ return Response.json({ error: '请先登录' }, { status: 401 });
74
+ }
75
+
76
+ const body = (await req.json().catch(() => null)) as {
77
+ system?: string;
78
+ messages?: { role: 'system' | 'user' | 'assistant'; content: string }[];
79
+ maxTokens?: number;
80
+ withData?: boolean;
81
+ /** 关掉工具(只想要纯问答时用,比如报告生成的内部调用) */
82
+ withTools?: boolean;
83
+ /** 用户提交了表单:令牌 + 填写内容 */
84
+ form?: { token?: string; values?: Record<string, unknown> };
85
+ } | null;
86
+
87
+ if (!body?.messages?.length) {
88
+ return Response.json({ error: 'messages 不能为空' }, { status: 400 });
89
+ }
90
+
91
+ /**
92
+ * 表单提交先验签,再决定后续。
93
+ * 验签失败在流开始前就返回 400 —— 这种情况下前端应该提示"表单已过期,请重新发起",
94
+ * 而不是收到一个半截的 SSE 流。
95
+ */
96
+ let formSubmit: { payload: FormTokenPayload; values: Record<string, unknown> } | null = null;
97
+ if (body.form?.token) {
98
+ const r = readFormToken(body.form.token, user.id);
99
+ if (!r.ok) return Response.json({ error: r.error }, { status: 400 });
100
+ formSubmit = { payload: r.payload, values: body.form.values ?? {} };
101
+ }
102
+
103
+ // 带完整对话历史(含 assistant 的上一轮回答),否则追问会丢上下文
104
+ const history: AiMessage[] = body.messages
105
+ .filter((m) => m?.content && (m.role === 'user' || m.role === 'assistant'))
106
+ .slice(-20)
107
+ .map((m) => ({ role: m.role, content: String(m.content).slice(0, 4000) }));
108
+
109
+ if (!history.length) {
110
+ return Response.json({ error: '没有有效的对话内容' }, { status: 400 });
111
+ }
112
+
113
+ const withTools = body.withTools !== false;
114
+ const tools = withTools ? buildAiTools() : [];
115
+
116
+ // 时间 + 用户 + 数据快照都在服务端组装,前端无法绕过,也不会忘记带
117
+ const ctx = await buildAssistantContext(body.system, {
118
+ withData: body.withData !== false,
119
+ user: { id: user.id, name: user.name, username: user.username },
120
+ tools: tools.length > 0,
121
+ });
122
+
123
+ const encoder = new TextEncoder();
124
+ const stream = new ReadableStream({
125
+ async start(controller) {
126
+ const send = (obj: unknown) =>
127
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
128
+
129
+ /** 已生成好的整段文字,分帧推出去 */
130
+ const sendText = async (text: string) => {
131
+ for (let i = 0; i < text.length; i += CHUNK_SIZE) {
132
+ send({ content: text.slice(i, i + CHUNK_SIZE) });
133
+ // 不 sleep 的话浏览器会一次性收到全部帧,等于没有打字机效果
134
+ await new Promise((r) => setTimeout(r, 8));
135
+ }
136
+ };
137
+
138
+ try {
139
+ // 先把"参考了哪几张表 / 现在几点 / 挂了几个工具"发回去,页面据此显示证据
140
+ send({
141
+ meta: {
142
+ tables: ctx.snapshot.map((t) => ({
143
+ model: t.model,
144
+ total: t.total,
145
+ monthTotal: t.monthTotal,
146
+ })),
147
+ now: {
148
+ date: ctx.now.date,
149
+ time: ctx.now.time,
150
+ weekday: ctx.now.weekday,
151
+ month: ctx.now.month,
152
+ },
153
+ toolCount: tools.length,
154
+ },
155
+ });
156
+
157
+ const messages: AiMessage[] = [...history];
158
+ let done = false;
159
+ /** 本轮弹出的表单说明:循环结束后统一说一句,不再额外调一次模型 */
160
+ const formNotes: string[] = [];
161
+
162
+ // ---------- 用户刚提交了表单 ----------
163
+ if (formSubmit) {
164
+ const p = formSubmit.payload;
165
+ // 只接受令牌里的字段白名单,前端多塞的键会被丢掉
166
+ const merged = mergeFormValues(p, formSubmit.values);
167
+
168
+ if (p.m === 'fill' && p.n) {
169
+ const check = validateWrite(p.n, merged);
170
+ if (check.ok) {
171
+ // 参数齐了 → 直接签动作令牌。不再让模型重说一遍,避免它改字段
172
+ const token = signAction(user.id, p.n, merged);
173
+ const preview = await describeAction(p.n, merged);
174
+ send({ step: { name: p.n, kind: 'write', ok: true, summary: preview.title } });
175
+ send({ action: { token, name: p.n, title: preview.title, fields: preview.fields } });
176
+ await sendText(
177
+ `信息已补齐,请在下方确认卡片上核对「${preview.title}」,确认后才会写库。`
178
+ );
179
+ } else {
180
+ // 还有必填没填:带着已填内容重新弹一张,别让用户从头再填一遍
181
+ const again = await buildWriteForm(p.n, merged, user.id);
182
+ if (again) {
183
+ send({ form: again });
184
+ await sendText(check.message ?? '还有必填信息没填,请继续补充。');
185
+ } else {
186
+ await sendText(check.message ?? '信息不完整,请补充后重试。');
187
+ }
188
+ }
189
+ done = true;
190
+ } else {
191
+ // ask 模式:答案作为用户消息回灌,继续正常流程(可能还要查库/算数)。
192
+ // 前端已经带了一条「已填写《表单标题》:…」的摘要,就别再塞一遍 ——
193
+ // 重复的 user 消息会让模型以为用户回答了两遍。
194
+ const last = history[history.length - 1];
195
+ const already = last?.role === 'user' && last.content.startsWith('已填写《');
196
+ if (!already) messages.push({ role: 'user', content: formValuesText(p, formSubmit.values) });
197
+ }
198
+ }
199
+
200
+ for (let turn = 0; turn < MAX_TOOL_TURNS && !done; turn++) {
201
+ const r = await aiChatWithTools({
202
+ system: ctx.system,
203
+ messages,
204
+ tools,
205
+ maxTokens: 900,
206
+ temperature: 0.2,
207
+ });
208
+
209
+ // 没有要调的工具 → 这一轮的文字就是最终答案
210
+ if (!r.toolCalls.length) {
211
+ if (r.content) await sendText(r.content);
212
+ done = true;
213
+ break;
214
+ }
215
+
216
+ // 把 assistant 的 tool_calls 原样放回历史,否则下一轮模型会重复调用
217
+ messages.push({
218
+ role: 'assistant',
219
+ content: r.content,
220
+ tool_calls: r.toolCalls.map((c) => ({
221
+ id: c.id,
222
+ type: 'function',
223
+ function: { name: c.name, arguments: c.raw },
224
+ })),
225
+ });
226
+
227
+ // 模型这轮可能顺口说了点什么("我先查一下"),先推出去
228
+ if (r.content) send({ content: `${r.content}\n\n` });
229
+
230
+ for (const call of r.toolCalls) {
231
+ // ---- 模型主动要信息:直接弹表单,不执行、不写库 ----
232
+ if (isFormTool(call.name)) {
233
+ const spec = await buildAskForm(
234
+ call.args as { title?: unknown; question?: unknown; tool?: unknown; fields?: unknown },
235
+ user.id
236
+ );
237
+ if (spec) {
238
+ send({ form: spec });
239
+ if (spec.description) formNotes.push(spec.description);
240
+ messages.push({
241
+ role: 'tool',
242
+ tool_call_id: call.id,
243
+ content: JSON.stringify({
244
+ status: 'form_shown',
245
+ message: '已弹出结构化表单,用户填写提交后会继续。',
246
+ hint: '不要再逐条用文字追问同样的问题。',
247
+ }),
248
+ });
249
+ } else {
250
+ send({ step: { name: call.name, kind: 'read', ok: false, summary: '表单参数不合法' } });
251
+ messages.push({
252
+ role: 'tool',
253
+ tool_call_id: call.id,
254
+ content: JSON.stringify({
255
+ status: 'error',
256
+ message: 'fields 不能为空,且每项必须是字段名。',
257
+ }),
258
+ });
259
+ }
260
+ continue;
261
+ }
262
+
263
+ if (isWriteTool(call.name)) {
264
+ const check = validateWrite(call.name, call.args);
265
+
266
+ if (!check.ok) {
267
+ // ★ 参数不全 → 弹一张按 schema 生成的表单。
268
+ // 以前这里只回灌"缺什么",让模型用中文再问一遍:用户得自己按格式
269
+ // 打字、模型再解析一遍,字段一多就漏。现在缺的项直接变成控件。
270
+ send({
271
+ step: { name: call.name, kind: 'write', ok: false, summary: check.message ?? '参数不完整' },
272
+ });
273
+
274
+ const spec = await buildWriteForm(call.name, call.args, user.id);
275
+ if (spec) {
276
+ send({ form: spec });
277
+ formNotes.push(spec.description ?? '请补齐下面的信息。');
278
+ } else if (check.message) {
279
+ formNotes.push(check.message);
280
+ }
281
+
282
+ messages.push({
283
+ role: 'tool',
284
+ tool_call_id: call.id,
285
+ content: JSON.stringify({
286
+ status: 'form_shown',
287
+ message: '已把缺少的信息做成表单弹给用户,用户提交后系统会直接生成确认卡。',
288
+ hint: '不要再用文字逐条追问同样的字段。',
289
+ }),
290
+ });
291
+ continue;
292
+ }
293
+
294
+ const token = signAction(user.id, call.name, call.args);
295
+ const preview = await describeAction(call.name, call.args);
296
+
297
+ send({
298
+ step: { name: call.name, kind: 'write', ok: true, summary: preview.title },
299
+ });
300
+ send({
301
+ action: {
302
+ token,
303
+ name: call.name,
304
+ title: preview.title,
305
+ fields: preview.fields,
306
+ },
307
+ });
308
+
309
+ messages.push({
310
+ role: 'tool',
311
+ tool_call_id: call.id,
312
+ content: JSON.stringify({
313
+ status: 'pending_user_confirmation',
314
+ message: '已生成确认卡,等用户点击确认后才会写库。',
315
+ }),
316
+ });
317
+ continue;
318
+ }
319
+
320
+ // 只读工具:立即执行,结果回灌
321
+ const result = await runReadTool(call.name, call.args, { userId: user.id, user });
322
+ send({
323
+ step: { name: call.name, kind: 'read', ok: result.ok, summary: result.summary },
324
+ });
325
+ messages.push({
326
+ role: 'tool',
327
+ tool_call_id: call.id,
328
+ content: result.content.slice(0, 6000),
329
+ });
330
+ }
331
+
332
+ /**
333
+ * ★ 本轮弹了表单就到此为止,不要再进下一轮。
334
+ *
335
+ * 踩过的坑:不 break 的话,循环会继续调一次模型 ——
336
+ * 用户已经看到表单了,后台还在跑一轮本地推理(几十秒),
337
+ * 期间整个面板处于 busy,用户填完点提交会被当成"正在生成"直接丢掉。
338
+ * 表单是"等用户"的终点,不是"继续想"的中间态。
339
+ */
340
+ if (formNotes.length) {
341
+ await sendText([...new Set(formNotes)].join('\n\n'));
342
+ done = true;
343
+ break;
344
+ }
345
+ }
346
+
347
+ // 弹了表单 → 说一句就停,等用户填。
348
+ // 这里刻意不再调模型:再问一遍只会重复表单上已经列出的字段,
349
+ // 用户还要多等十几秒本地推理,收益是负的。
350
+ if (!done && formNotes.length) {
351
+ await sendText([...new Set(formNotes)].join('\n\n'));
352
+ done = true;
353
+ }
354
+
355
+ // 工具轮用完还没得到最终回答 → 再跑一轮纯流式总结(不带 tools,逼它开口)
356
+ if (!done) {
357
+ await streamAiChat(
358
+ {
359
+ system: ctx.system,
360
+ messages,
361
+ maxTokens: body.maxTokens ?? 1200,
362
+ temperature: 0.3,
363
+ },
364
+ (chunk) => send({ content: chunk })
365
+ );
366
+ }
367
+
368
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
369
+ } catch (e) {
370
+ // 把错误也用 SSE 推回去,前端能直接显示中文原因
371
+ const err = e as Error & { hint?: string };
372
+ send({ error: err.message, hint: err.hint });
373
+ } finally {
374
+ controller.close();
375
+ }
376
+ },
377
+ });
378
+
379
+ return new Response(stream, {
380
+ headers: {
381
+ 'Content-Type': 'text/event-stream; charset=utf-8',
382
+ 'Cache-Control': 'no-cache, no-transform',
383
+ Connection: 'keep-alive',
384
+ 'X-Accel-Buffering': 'no',
385
+ },
386
+ });
387
+ }
388
+
389
+ /** 供调试:返回助手此刻看到的系统提示词(不含敏感数据以外的内容) */
390
+ export async function GET() {
391
+ const user = await currentUser();
392
+ if (!user) return Response.json({ error: '请先登录' }, { status: 401 });
393
+ const ctx = await buildAssistantContext();
394
+ const tools = buildAiTools();
395
+ return Response.json({
396
+ now: nowContext(),
397
+ tables: ctx.snapshot.map((t) => ({ model: t.model, total: t.total })),
398
+ systemChars: ctx.system.length,
399
+ tools: tools.map((t) => ({
400
+ name: t.function.name,
401
+ description: t.function.description,
402
+ })),
403
+ });
404
+ }