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.
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,310 @@
1
+ /**
2
+ * 本地大模型客户端(服务端专用)。
3
+ *
4
+ * 为什么要有这个:比赛本身就是"用 AI 造系统",
5
+ * 如果造出来的系统里**没有真正的 AI 功能**,就和其他队伍站在同一起跑线上了。
6
+ * 把"调本地模型"这件事在骨架里封好,模型只需要把业务数据拼进 prompt。
7
+ *
8
+ * 约定:
9
+ * - 地址/模型名走环境变量,换机器不用改代码
10
+ * - 只在服务端调用(API Key 不能进浏览器)
11
+ * - 统一超时 + 统一错误 → 面向用户的中文提示
12
+ */
13
+
14
+ /**
15
+ * 配置来源:`AI_*` 优先,`OPENAI_*` 兜底。
16
+ *
17
+ * ★ 为什么两套都认:提示词里让 AI 去 `.env` 找 `OPENAI_BASE_URL / OPENAI_API_KEY / OPENAI_MODEL`
18
+ * (这是业界最常见的叫法),而骨架一开始只读 `AI_*`。两边对不上时,用户按提示词
19
+ * 填了 `OPENAI_*`,`/api/ai/chat` 却仍走默认地址 —— 表现为"配了也没用",且没有任何报错。
20
+ * `openaiCompat.ts` 早就是两套都认的写法,这里补一致,避免同一个模型有两个入口
21
+ * 却有两条不同的配置路径。
22
+ */
23
+ const pickEnv = (...names: string[]): string | undefined => {
24
+ for (const n of names) {
25
+ const v = process.env[n];
26
+ if (v && v.trim()) return v.trim();
27
+ }
28
+ return undefined;
29
+ };
30
+
31
+ /** 本地引擎地址,默认就是赛前测好的 8092 */
32
+ export const AI_BASE_URL =
33
+ pickEnv('AI_BASE_URL', 'OPENAI_BASE_URL') ?? 'http://127.0.0.1:8092/v1';
34
+ /** 模型 id,必须与引擎启动时的 --model-id 一致 */
35
+ export const AI_MODEL = pickEnv('AI_MODEL', 'OPENAI_MODEL') ?? 'qwen3.8-27b-gsq';
36
+ /** 本地引擎一般不校验 key,但 OpenAI 兼容接口要求这个头存在 */
37
+ export const AI_API_KEY = pickEnv('AI_API_KEY', 'OPENAI_API_KEY') ?? 'local';
38
+
39
+ export interface AiMessage {
40
+ role: 'system' | 'user' | 'assistant' | 'tool';
41
+ content: string;
42
+ /** 模型要调工具时,这一轮返回里带着它(原样回传,不要自己拼) */
43
+ tool_calls?: {
44
+ id: string;
45
+ type: 'function';
46
+ function: { name: string; arguments: string };
47
+ }[];
48
+ /** role='tool' 时,对应哪一次调用 */
49
+ tool_call_id?: string;
50
+ }
51
+
52
+ export interface AiOptions {
53
+ system?: string;
54
+ messages: AiMessage[];
55
+ maxTokens?: number;
56
+ temperature?: number;
57
+ timeoutMs?: number;
58
+ }
59
+
60
+ export class AiError extends Error {
61
+ constructor(message: string, readonly hint?: string) {
62
+ super(message);
63
+ this.name = 'AiError';
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 调本地模型,返回完整文本。
69
+ *
70
+ * 用它做"一次性生成":周报、数据解读、摘要、建议。
71
+ * 需要打字机效果时用 `streamAiChat`。
72
+ */
73
+ export async function aiChat(opts: AiOptions): Promise<string> {
74
+ const { system, messages, maxTokens = 1024, temperature = 0.5, timeoutMs = 180_000 } = opts;
75
+
76
+ const body = {
77
+ model: AI_MODEL,
78
+ messages: system ? [{ role: 'system', content: system }, ...messages] : messages,
79
+ max_completion_tokens: maxTokens,
80
+ temperature,
81
+ enable_thinking: false,
82
+ thinking: { type: 'disabled' },
83
+ reasoning_effort: 'none',
84
+ stream: false,
85
+ };
86
+
87
+ const ctrl = new AbortController();
88
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
89
+ try {
90
+ const res = await fetch(`${AI_BASE_URL}/chat/completions`, {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AI_API_KEY}` },
93
+ body: JSON.stringify(body),
94
+ signal: ctrl.signal,
95
+ // Next.js 里不要缓存推理结果
96
+ cache: 'no-store',
97
+ });
98
+ if (!res.ok) {
99
+ const t = await res.text().catch(() => '');
100
+ throw new AiError(`本地模型返回 ${res.status}`, t.slice(0, 300));
101
+ }
102
+ const data = (await res.json()) as {
103
+ choices?: { message?: { content?: string } }[];
104
+ };
105
+ const text = data.choices?.[0]?.message?.content ?? '';
106
+ if (!text.trim()) throw new AiError('本地模型返回了空内容');
107
+ return text.trim();
108
+ } catch (e) {
109
+ if (e instanceof AiError) throw e;
110
+ if ((e as Error).name === 'AbortError') {
111
+ throw new AiError('本地模型响应超时', '引擎可能没启动,或并发太高(--max-concurrency 1)');
112
+ }
113
+ throw new AiError(
114
+ `连不上本地模型:${(e as Error).message}`,
115
+ `确认引擎已启动,且 ${AI_BASE_URL} 可访问(AI_BASE_URL 环境变量可覆盖)`
116
+ );
117
+ } finally {
118
+ clearTimeout(timer);
119
+ }
120
+ }
121
+
122
+ /** 流式版本,逐段回调,用于打字机效果 */
123
+ export async function streamAiChat(
124
+ opts: AiOptions,
125
+ onDelta: (chunk: string) => void
126
+ ): Promise<void> {
127
+ const { system, messages, maxTokens = 1024, temperature = 0.5, timeoutMs = 300_000 } = opts;
128
+
129
+ const body = {
130
+ model: AI_MODEL,
131
+ messages: system ? [{ role: 'system', content: system }, ...messages] : messages,
132
+ max_completion_tokens: maxTokens,
133
+ temperature,
134
+ enable_thinking: false,
135
+ thinking: { type: 'disabled' },
136
+ reasoning_effort: 'none',
137
+ stream: true,
138
+ };
139
+
140
+ const ctrl = new AbortController();
141
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
142
+ try {
143
+ const res = await fetch(`${AI_BASE_URL}/chat/completions`, {
144
+ method: 'POST',
145
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AI_API_KEY}` },
146
+ body: JSON.stringify(body),
147
+ signal: ctrl.signal,
148
+ cache: 'no-store',
149
+ });
150
+ if (!res.ok || !res.body) throw new AiError(`本地模型返回 ${res.status}`);
151
+
152
+ const reader = res.body.getReader();
153
+ const dec = new TextDecoder();
154
+ let buf = '';
155
+ for (;;) {
156
+ const { done, value } = await reader.read();
157
+ if (done) break;
158
+ buf += dec.decode(value, { stream: true });
159
+ // SSE:逐行取 data: 后面的 JSON
160
+ let idx: number;
161
+ while ((idx = buf.indexOf('\n')) >= 0) {
162
+ const line = buf.slice(0, idx).trim();
163
+ buf = buf.slice(idx + 1);
164
+ if (!line.startsWith('data:')) continue;
165
+ const payload = line.slice(5).trim();
166
+ if (payload === '[DONE]') return;
167
+ try {
168
+ const j = JSON.parse(payload) as {
169
+ choices?: { delta?: { content?: string } }[];
170
+ };
171
+ const c = j.choices?.[0]?.delta?.content;
172
+ if (c) onDelta(c);
173
+ } catch {
174
+ /* 忽略半包 */
175
+ }
176
+ }
177
+ }
178
+ } catch (e) {
179
+ if (e instanceof AiError) throw e;
180
+ if ((e as Error).name === 'AbortError') throw new AiError('本地模型响应超时');
181
+ throw new AiError(`连不上本地模型:${(e as Error).message}`);
182
+ } finally {
183
+ clearTimeout(timer);
184
+ }
185
+ }
186
+
187
+ /* ============================ 工具调用(function calling) ============================ */
188
+
189
+ /** 模型提出的一次工具调用 */
190
+ export interface ToolCallRequest {
191
+ id: string;
192
+ name: string;
193
+ args: Record<string, unknown>;
194
+ /** 原始 JSON 字符串。回传给模型时必须原样,否则有的引擎会报 arguments 格式错 */
195
+ raw: string;
196
+ }
197
+
198
+ export interface ToolTurn {
199
+ /** 模型这一轮的文字说明(可能为空,尤其当它只想调工具时) */
200
+ content: string;
201
+ toolCalls: ToolCallRequest[];
202
+ }
203
+
204
+ /**
205
+ * 带工具的一轮调用(非流式)。
206
+ *
207
+ * ★ 为什么工具阶段不用流式:
208
+ * `tool_calls` 在 SSE 里是**分片**下发的,同一个调用的 name 和 arguments
209
+ * 会跨多个 chunk 拼接,稍不留神就拼出半个 JSON。
210
+ * 工具轮只关心"要不要调、调什么",等这一轮完整返回更稳;
211
+ * 拿到结果之后的**最终回答**照旧走 `streamAiChat`,打字机效果不受影响。
212
+ *
213
+ * ★ 调用方要保证 messages 里保留了完整的 tool 往返:
214
+ * assistant(tool_calls) → tool(tool_call_id)。
215
+ * 只把工具结果塞进 user 消息里,模型会当成"用户说的话"而重复调用工具。
216
+ */
217
+ export async function aiChatWithTools(
218
+ opts: AiOptions & { tools: unknown[]; toolChoice?: 'auto' | 'none' | 'required' }
219
+ ): Promise<ToolTurn> {
220
+ const { system, messages, tools, toolChoice = 'auto', maxTokens = 800, temperature = 0.2 } = opts;
221
+
222
+ const body = {
223
+ model: AI_MODEL,
224
+ messages: system ? [{ role: 'system', content: system }, ...messages] : messages,
225
+ tools,
226
+ tool_choice: toolChoice,
227
+ max_completion_tokens: maxTokens,
228
+ temperature,
229
+ enable_thinking: false,
230
+ thinking: { type: 'disabled' },
231
+ reasoning_effort: 'none',
232
+ stream: false,
233
+ };
234
+
235
+ const ctrl = new AbortController();
236
+ const timer = setTimeout(() => ctrl.abort(), 180_000);
237
+ try {
238
+ const res = await fetch(`${AI_BASE_URL}/chat/completions`, {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AI_API_KEY}` },
241
+ body: JSON.stringify(body),
242
+ signal: ctrl.signal,
243
+ cache: 'no-store',
244
+ });
245
+ if (!res.ok) {
246
+ const t = await res.text().catch(() => '');
247
+ throw new AiError(`本地模型返回 ${res.status}`, t.slice(0, 300));
248
+ }
249
+
250
+ const data = (await res.json()) as {
251
+ choices?: {
252
+ message?: {
253
+ content?: string | null;
254
+ tool_calls?: {
255
+ id?: string;
256
+ type?: string;
257
+ function?: { name?: string; arguments?: unknown };
258
+ }[];
259
+ };
260
+ }[];
261
+ };
262
+ const msg = data.choices?.[0]?.message;
263
+ const calls: ToolCallRequest[] = [];
264
+
265
+ for (const [i, c] of (msg?.tool_calls ?? []).entries()) {
266
+ const name = c?.function?.name ?? '';
267
+ if (!name) continue;
268
+ // arguments 正常是 JSON 字符串,个别网关直接给对象
269
+ const raw =
270
+ typeof c.function?.arguments === 'string'
271
+ ? c.function.arguments
272
+ : JSON.stringify(c.function?.arguments ?? {});
273
+ let args: Record<string, unknown> = {};
274
+ try {
275
+ const parsed = JSON.parse(raw || '{}');
276
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
277
+ args = parsed as Record<string, unknown>;
278
+ }
279
+ } catch {
280
+ // 模型给了半截 JSON:当成空参数,让工具层报"缺必填",它会自己重来
281
+ }
282
+ calls.push({ id: c.id ?? `call_${i}`, name, args, raw: raw || '{}' });
283
+ }
284
+
285
+ return { content: (msg?.content ?? '').trim(), toolCalls: calls };
286
+ } catch (e) {
287
+ if (e instanceof AiError) throw e;
288
+ if ((e as Error).name === 'AbortError') {
289
+ throw new AiError('本地模型响应超时', '引擎可能没启动,或并发太高(--max-concurrency 1)');
290
+ }
291
+ throw new AiError(
292
+ `连不上本地模型:${(e as Error).message}`,
293
+ `确认引擎已启动,且 ${AI_BASE_URL} 可访问(AI_BASE_URL 环境变量可覆盖)`
294
+ );
295
+ } finally {
296
+ clearTimeout(timer);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * 把模型输出里的 Markdown 表格/列表做轻量清洗,
302
+ * 免得直接塞进页面时带着 ``` 围栏或多余空行。
303
+ */
304
+ export function cleanAiText(s: string): string {
305
+ return s
306
+ .replace(/^```[a-z]*\n?/i, '')
307
+ .replace(/\n?```$/i, '')
308
+ .replace(/\n{3,}/g, '\n\n')
309
+ .trim();
310
+ }
@@ -0,0 +1,357 @@
1
+ import { prisma } from '@/lib/prisma';
2
+ import ONTOLOGY from '@/lib/ontology';
3
+ import { APP_NAME } from '@/lib/appInfo';
4
+
5
+ /**
6
+ * AI 助手的统一上下文:当前时间 + 数据库快照。
7
+ *
8
+ * 为什么要有这个文件:
9
+ * 1. 模型不知道"现在"是几号 —— 问"这个月有几条申请"它只能瞎猜。
10
+ * 这里把北京时间、"本月/上月"的具体区间一起塞进 system prompt。
11
+ * 2. 一个对话框要同时回答"系统怎么用"和"库里有多少条",
12
+ * 就必须每次都把业务数据快照带上,而不是分两个接口。
13
+ * 3. /api/ai/chat(流式)和 /api/ai/ask(非流式)共用同一套上下文,避免两边漂移。
14
+ *
15
+ * 注意:快照只读、只读 ontology 声明的业务表,且 User/Role 这类敏感表永远排除。
16
+ */
17
+
18
+ /** 绝对不允许进快照的表 */
19
+ const BLOCKED = new Set(['User', 'Role', 'Menu', 'UserRole', 'RoleMenu']);
20
+
21
+ /**
22
+ * 每张表最多带多少行。
23
+ *
24
+ * ★ 从 30 降到 6:不是因为截断省事,而是架构变了 ——
25
+ * 助手现在能调 query_* 工具做精确查询,快照的作用从"数据来源"
26
+ * 退化成"让模型知道库里长什么样、字段怎么命名"。
27
+ * 继续塞 30 行只会挤占上下文、拖慢首字,且永远算不出全量。
28
+ */
29
+ export const ROWS_PER_TABLE = 6;
30
+ /** 单个字段值最长多少字符 */
31
+ const MAX_FIELD_LEN = 40;
32
+ /** 快照总字符上限(防止 prompt 爆掉) */
33
+ const MAX_SNAPSHOT_CHARS = 7000;
34
+
35
+ /** 北京时间固定 UTC+8(比赛机器在国内,不依赖服务器时区设置) */
36
+ const TZ_OFFSET_MS = 8 * 3600_000;
37
+ const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
38
+
39
+ interface RtField {
40
+ name: string;
41
+ kind?: string;
42
+ type?: string;
43
+ }
44
+
45
+ function rtModels(): Record<string, { fields?: RtField[] }> {
46
+ const rt = (prisma as unknown as {
47
+ _runtimeDataModel?: { models?: Record<string, { fields?: RtField[] }> };
48
+ })._runtimeDataModel;
49
+ return rt?.models ?? {};
50
+ }
51
+
52
+ interface Delegate {
53
+ findMany: (a?: unknown) => Promise<Record<string, unknown>[]>;
54
+ count: (a?: unknown) => Promise<number>;
55
+ }
56
+
57
+ function delegateOf(model: string): Delegate | null {
58
+ const d = (prisma as unknown as Record<string, unknown>)[model] as Partial<Delegate> | undefined;
59
+ return d && typeof d.findMany === 'function' && typeof d.count === 'function'
60
+ ? (d as Delegate)
61
+ : null;
62
+ }
63
+
64
+ /** 标量字段(排除关系字段与敏感字段) */
65
+ function scalarFieldsOf(model: string): string[] {
66
+ const fields = rtModels()[model]?.fields ?? [];
67
+ return fields
68
+ .filter((f) => !f.kind || f.kind === 'scalar')
69
+ .map((f) => f.name)
70
+ .filter((n) => n !== 'password' && n !== 'deletedAt');
71
+ }
72
+
73
+ /** 时间字段:优先 createdAt,否则第一个 DateTime 字段 */
74
+ function dateFieldOf(model: string): string | null {
75
+ const fields = rtModels()[model]?.fields ?? [];
76
+ if (fields.some((f) => f.name === 'createdAt' && (!f.kind || f.kind === 'scalar'))) {
77
+ return 'createdAt';
78
+ }
79
+ const dt = fields.find(
80
+ (f) => (!f.kind || f.kind === 'scalar') && (f.type === 'DateTime' || f.type === 'Date')
81
+ );
82
+ return dt?.name ?? null;
83
+ }
84
+
85
+ function hasField(model: string, name: string): boolean {
86
+ return (rtModels()[model]?.fields ?? []).some((f) => f.name === name);
87
+ }
88
+
89
+ export interface NowContext {
90
+ date: string;
91
+ time: string;
92
+ weekday: string;
93
+ /** 本月,形如 2026-10 */
94
+ month: string;
95
+ monthStart: string;
96
+ monthEnd: string;
97
+ /** 上月,形如 2026-09 */
98
+ prevMonth: string;
99
+ prevMonthStart: string;
100
+ prevMonthEnd: string;
101
+ /** 供数据库查询用的 UTC 边界 */
102
+ monthStartUtc: Date;
103
+ nextMonthUtc: Date;
104
+ todayStartUtc: Date;
105
+ tomorrowStartUtc: Date;
106
+ }
107
+
108
+ const pad = (n: number) => String(n).padStart(2, '0');
109
+
110
+ /** 当前时间(北京时间)。所有"本月/上月/今天"的推算都以它为准 */
111
+ export function nowContext(now = new Date()): NowContext {
112
+ const b = new Date(now.getTime() + TZ_OFFSET_MS);
113
+ const y = b.getUTCFullYear();
114
+ const m = b.getUTCMonth() + 1;
115
+ const d = b.getUTCDate();
116
+
117
+ const daysInMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
118
+ const prevY = m === 1 ? y - 1 : y;
119
+ const prevM = m === 1 ? 12 : m - 1;
120
+ const prevDays = new Date(Date.UTC(prevY, prevM, 0)).getUTCDate();
121
+
122
+ const todayStartUtc = new Date(Date.UTC(y, m - 1, d) - TZ_OFFSET_MS);
123
+ return {
124
+ date: `${y}-${pad(m)}-${pad(d)}`,
125
+ time: `${pad(b.getUTCHours())}:${pad(b.getUTCMinutes())}`,
126
+ weekday: WEEKDAYS[b.getUTCDay()],
127
+ month: `${y}-${pad(m)}`,
128
+ monthStart: `${y}-${pad(m)}-01`,
129
+ monthEnd: `${y}-${pad(m)}-${pad(daysInMonth)}`,
130
+ prevMonth: `${prevY}-${pad(prevM)}`,
131
+ prevMonthStart: `${prevY}-${pad(prevM)}-01`,
132
+ prevMonthEnd: `${prevY}-${pad(prevM)}-${pad(prevDays)}`,
133
+ monthStartUtc: new Date(Date.UTC(y, m - 1, 1) - TZ_OFFSET_MS),
134
+ nextMonthUtc: new Date(Date.UTC(y, m, 1) - TZ_OFFSET_MS),
135
+ todayStartUtc,
136
+ tomorrowStartUtc: new Date(todayStartUtc.getTime() + 86400_000),
137
+ };
138
+ }
139
+
140
+ /** 给模型看的时间说明 */
141
+ export function nowPrompt(now = nowContext()): string {
142
+ return [
143
+ `【当前时间】${now.date} ${now.time} ${now.weekday}(北京时间)`,
144
+ `- 今天 = ${now.date};本月 = ${now.month}(${now.monthStart} 至 ${now.monthEnd});上月 = ${now.prevMonth}(${now.prevMonthStart} 至 ${now.prevMonthEnd})`,
145
+ '- 用户说"这个月/本月"就指上面本月区间;说"今天/昨天/最近N天"都以今天为基准往前推。',
146
+ ].join('\n');
147
+ }
148
+
149
+ /** 把一行压成紧凑文本 */
150
+ function compactRow(row: Record<string, unknown>, fields: string[]): string {
151
+ const keep: Record<string, unknown> = {};
152
+ for (const f of fields) {
153
+ const v = row[f];
154
+ if (v === undefined || v === null) continue;
155
+ if (v instanceof Date) keep[f] = v.toISOString().slice(0, 10);
156
+ else if (typeof v === 'string') {
157
+ keep[f] = v.length > MAX_FIELD_LEN ? `${v.slice(0, MAX_FIELD_LEN)}…` : v;
158
+ } else if (typeof v === 'number' || typeof v === 'boolean') {
159
+ keep[f] = v;
160
+ }
161
+ // 关系对象/数组一律跳过
162
+ }
163
+ return JSON.stringify(keep);
164
+ }
165
+
166
+ export interface TableSnapshot {
167
+ model: string;
168
+ total: number;
169
+ /** 本月新增条数(有时间字段时才有) */
170
+ monthTotal?: number;
171
+ /** 今日新增条数 */
172
+ todayTotal?: number;
173
+ dateField: string | null;
174
+ fields: string[];
175
+ rows: string[];
176
+ }
177
+
178
+ /**
179
+ * 采集数据库快照。
180
+ *
181
+ * 除了样例行,还给每张表预算好「共几条 / 本月几条 / 今日几条」——
182
+ * 这是"这个月有多少条"这种问题能被答对的关键:样例只有 30 行,
183
+ * 模型自己数样例永远算不出全量。
184
+ */
185
+ export async function buildSnapshot(scope: string[] = [], rowsPerTable = ROWS_PER_TABLE): Promise<TableSnapshot[]> {
186
+ const allModels = Object.keys(rtModels());
187
+ const declared = ONTOLOGY.entities.map((e) => e.model);
188
+ const candidates = (scope.length ? scope : declared.length ? declared : allModels).filter(
189
+ (m) => allModels.includes(m) && !BLOCKED.has(m)
190
+ );
191
+
192
+ const now = nowContext();
193
+ const out: TableSnapshot[] = [];
194
+ let budget = MAX_SNAPSHOT_CHARS;
195
+
196
+ for (const model of candidates) {
197
+ if (budget <= 0) break;
198
+ const d = delegateOf(model);
199
+ if (!d) continue;
200
+
201
+ const fields = scalarFieldsOf(model);
202
+ const dateField = dateFieldOf(model);
203
+ const baseWhere = hasField(model, 'deletedAt') ? { deletedAt: null } : {};
204
+ const count = (extra: Record<string, unknown> = {}) =>
205
+ d.count({ where: { ...baseWhere, ...extra } }).catch(() => d.count());
206
+
207
+ try {
208
+ const rows = await d
209
+ .findMany({
210
+ where: baseWhere,
211
+ take: rowsPerTable,
212
+ ...(dateField ? { orderBy: { [dateField]: 'desc' } } : {}),
213
+ })
214
+ .catch(() => d.findMany({ take: rowsPerTable }));
215
+
216
+ const total = await count();
217
+ const monthTotal = dateField
218
+ ? await count({ [dateField]: { gte: now.monthStartUtc, lt: now.nextMonthUtc } })
219
+ : undefined;
220
+ const todayTotal = dateField
221
+ ? await count({ [dateField]: { gte: now.todayStartUtc, lt: now.tomorrowStartUtc } })
222
+ : undefined;
223
+
224
+ const lines = rows.map((r) => compactRow(r, fields)).filter(Boolean);
225
+ budget -= lines.join('\n').length + 120;
226
+ out.push({ model, total, monthTotal, todayTotal, dateField, fields, rows: lines });
227
+ } catch {
228
+ // 表结构与预期不符就跳过,不影响其他表
229
+ continue;
230
+ }
231
+ }
232
+ return out;
233
+ }
234
+
235
+ /** 快照转成给模型看的文本 */
236
+ export function snapshotToText(snapshot: TableSnapshot[], now = nowContext()): string {
237
+ return snapshot
238
+ .map((t) => {
239
+ const stats = [`共 ${t.total} 条`];
240
+ if (t.monthTotal !== undefined) stats.push(`本月(${now.month}) ${t.monthTotal} 条`);
241
+ if (t.todayTotal !== undefined) stats.push(`今日 ${t.todayTotal} 条`);
242
+ const head = `### 表 ${t.model}(${stats.join(';')}${t.dateField ? `;时间字段 ${t.dateField}` : ''})`;
243
+ const fieldLine = t.fields.length ? `字段:${t.fields.join(', ')}` : '';
244
+ const rowLine = t.rows.length
245
+ ? `最近 ${t.rows.length} 条(按时间倒序):\n${t.rows.join('\n')}`
246
+ : '(无数据)';
247
+ return `${head}\n${fieldLine}\n${rowLine}`;
248
+ })
249
+ .join('\n\n');
250
+ }
251
+
252
+ export interface AssistantContext {
253
+ system: string;
254
+ snapshot: TableSnapshot[];
255
+ now: NowContext;
256
+ }
257
+
258
+ export interface AssistantUser {
259
+ id: string;
260
+ name: string;
261
+ username: string;
262
+ }
263
+
264
+ export interface AssistantOptions {
265
+ withData?: boolean;
266
+ scope?: string[];
267
+ /** 当前登录用户。有它模型才能把"我""本人"落到具体的人身上 */
268
+ user?: AssistantUser;
269
+ /** 本次请求是否挂载了工具。false 时不写工具规则,避免模型凭空调不存在的工具 */
270
+ tools?: boolean;
271
+ /**
272
+ * 每张表带几行样例。
273
+ *
274
+ * 默认 6(chat 有工具,数据靠工具查)。
275
+ * 没有工具的调用方(/api/ai/ask)要传大一点,否则它只能看到几行,答不准。
276
+ */
277
+ rows?: number;
278
+ }
279
+
280
+ /**
281
+ * 组装统一助手的 system prompt。
282
+ *
283
+ * extra 是调用方追加的角色说明(会被截断,防止把上下文挤爆)。
284
+ * withData=false 时只要时间不要数据(比如纯使用答疑)。
285
+ */
286
+ export async function buildAssistantContext(
287
+ extra?: string,
288
+ opts: AssistantOptions = {}
289
+ ): Promise<AssistantContext> {
290
+ const now = nowContext();
291
+ const snapshot =
292
+ opts.withData === false
293
+ ? []
294
+ : await buildSnapshot(opts.scope ?? [], opts.rows ?? ROWS_PER_TABLE);
295
+
296
+ const parts: string[] = [
297
+ `你是「${APP_NAME}」的内置助手。你既能回答这个系统怎么用,也能基于数据库里的真实数据回答业务问题。`,
298
+ nowPrompt(now),
299
+ ];
300
+
301
+ if (opts.user) {
302
+ parts.push(
303
+ `【当前操作人】${opts.user.name}(账号 ${opts.user.username},用户 id = ${opts.user.id})\n` +
304
+ '- 用户说"我 / 我的"时指这个人;涉及"操作人 / 申请人 / 提交人"这类字段,默认就是此人的 id。\n' +
305
+ '- 不要向用户索要自己的账号或 id。'
306
+ );
307
+ }
308
+
309
+ if (snapshot.length) {
310
+ parts.push(
311
+ `【数据库快照】下面是数据库里的真实内容(不是示例)。用户问数据时只能依据它回答。\n\n` +
312
+ snapshotToText(snapshot, now)
313
+ );
314
+ }
315
+
316
+ if (opts.tools) {
317
+ parts.push(
318
+ `【你能操作数据,不只是聊天】
319
+ 本次挂了工具,用法:
320
+ 1. query_<表>:只读查询,可按字段筛选、关键字搜索、分组统计。
321
+ 问"有多少条 / 哪个部门最多 / 各状态各几条 / 有哪些"这类问题时**必须调用它**,
322
+ 不要拿快照里的几行样例去估算 —— 快照只是让你知道表长什么样、字段怎么命名。
323
+ 2. create_<表>:新建一条记录。**调用后不会立即写库**,
324
+ 界面上会出现一张确认卡片,等用户点「确认」才真正生效。
325
+ 所以调用之后要对用户说"请在下方确认卡片上核对并确认",
326
+ **绝对不要说"已经为你创建好了"**。
327
+ 3. 信息不全时,**直接把工具调出去**(哪怕只填得出一半参数)。
328
+ 系统会自动弹出一张结构化表单:日期是日期选择器、状态是下拉框、必填项有标记,
329
+ 用户填完提交后系统会直接生成确认卡。
330
+ 所以不要因为"不知道从哪天开始"就停下来先问,
331
+ 也不要用文字把缺的字段一条条列出来让用户自己打字 —— 表单比你问得准。
332
+ 4. 只有一种情况用 ask_user:要问什么取决于用户的选择,
333
+ 或者必须先问清楚才能决定查哪张表。fields 只写字段名(如 ["startDate","endDate"]),
334
+ 不要写中文标签,也不要自己编字段名。
335
+ 5. 不要编造:日期、姓名、事由、外键 id 都不能猜。
336
+ 外键字段(如 deptId / vehicleId / employeeId)如果用户只说了名称,
337
+ 表单会给出下拉选项让用户自己选 —— 你不要填 id,也不要填名称。
338
+ 6. 用户说"我 / 我的"时,申请人 / 提交人 / 负责人就是当前操作人,
339
+ 直接用上面的用户 id,**不要问用户"你的 id 是多少"**。
340
+ 7. 一次只做一件事:用户说了三件事就分三轮做,别在一个调用里塞多个动作。`
341
+ );
342
+ }
343
+
344
+ parts.push(
345
+ `【回答规则】
346
+ 1. 问数据:能用工具就用工具,数字和名称要具体,并说明结论来自哪张表;确实查不到就直说缺什么,不要猜。
347
+ 2. 问用法:按模块给出可执行的步骤。
348
+ 3. 涉及"本月/这个月/今天/上个月/最近N天"时,用上面的当前时间推算,不要自己假设日期。
349
+ 4. 输出用 Markdown(小标题、列表、必要时表格),不要把整段答案包在 \`\`\` 里。
350
+ 5. 中文回答,简洁具体,不堆术语。`
351
+ );
352
+
353
+ const extraText = String(extra ?? '').trim();
354
+ if (extraText) parts.push(`【补充角色说明】${extraText.slice(0, 800)}`);
355
+
356
+ return { system: parts.join('\n\n'), snapshot, now };
357
+ }