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,970 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { App, Avatar, Button, Input, Tag, Typography } from 'antd';
5
+ import {
6
+ RobotOutlined,
7
+ SendOutlined,
8
+ ClearOutlined,
9
+ CheckCircleOutlined,
10
+ CloseCircleOutlined,
11
+ EditOutlined,
12
+ SearchOutlined,
13
+ } from '@ant-design/icons';
14
+ import Markdown from '@/components/Markdown';
15
+ import AiInputForm from '@/components/ai/AiInputForm';
16
+ import { formAnswerText, type FormCard, type FormSpec } from '@/lib/aiFormTypes';
17
+ import { fs, MILITARY } from '@/lib/theme';
18
+ import { formatElapsed, useElapsed } from '@/components/feedback/usePending';
19
+
20
+ /**
21
+ * AI 助手面板 —— 系统内置的大模型对话。
22
+ *
23
+ * ★ 一个面板同时做三件事(不再分成多个对话框):
24
+ * - 系统怎么用("怎么新增一条申请")
25
+ * - 数据问答("这个月有多少条申请")
26
+ * - **直接办事**("帮我新增一条记录")—— 走确认卡,不是静默执行
27
+ * 服务端 /api/ai/chat 每次都会注入时间 + 用户 + 数据快照,见 lib/aiContext.ts;
28
+ * 工具由 lib/aiTools.ts 按 ontology + schema 自动生成。
29
+ *
30
+ * ★ 界面上的坑(都踩过):
31
+ * 1. 回答是 Markdown,直接当纯文本显示会看到一堆 ** 和 | 表格 → 用 Markdown 组件渲染
32
+ * 2. 面板高度会随回答越来越长 → 父容器给固定高度 + flex 子项 minHeight: 0 + 内部滚动
33
+ * 3. 每次新回答都滚到底,会把上一条用户气泡裁成一条"蓝色横条"露在顶部 →
34
+ * 发送后滚到**问题那一行**,流式输出期间不强行贴底
35
+ * 4. 工具轨迹和确认卡都挂在"最后一条 assistant 消息"上,
36
+ * 而不是独立的 state —— 否则用户往上翻历史时,卡片会跑到当前这轮去
37
+ */
38
+
39
+ const { TextArea } = Input;
40
+
41
+ /** 一次工具调用的痕迹(服务端 SSE 的 step 事件) */
42
+ interface StepTrace {
43
+ name: string;
44
+ kind: 'read' | 'write';
45
+ ok: boolean;
46
+ summary: string;
47
+ }
48
+
49
+ /** 待确认的写操作(服务端 SSE 的 action 事件) */
50
+ interface ActionCard {
51
+ token: string;
52
+ title: string;
53
+ fields: { label: string; value: string }[];
54
+ status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
55
+ result?: string;
56
+ }
57
+
58
+ interface Msg {
59
+ role: 'user' | 'assistant';
60
+ content: string;
61
+ /** 这轮回答参考了哪些表(服务端 SSE meta 下发) */
62
+ tables?: { model: string; total: number; monthTotal?: number }[];
63
+ /** 这轮调用了哪些工具 */
64
+ steps?: StepTrace[];
65
+ /** 这轮提出的待确认操作 */
66
+ actions?: ActionCard[];
67
+ /**
68
+ * 这轮弹出的输入表单。
69
+ *
70
+ * 挂在消息上而不是独立 state:用户往上翻历史时,表单应该留在它出现的那一轮,
71
+ * 而不是跑到最新一条回答下面(确认卡踩过同一个坑)。
72
+ */
73
+ forms?: FormCard[];
74
+ }
75
+
76
+ interface Meta {
77
+ now?: { date: string; time: string; weekday: string; month: string };
78
+ tables?: { model: string; total: number; monthTotal?: number }[];
79
+ toolCount?: number;
80
+ }
81
+
82
+ /** 更新最后一条 assistant 消息(轨迹/动作都挂在它上面) */
83
+ function patchLastAssistant(prev: Msg[], fn: (m: Msg) => Msg): Msg[] {
84
+ const cp = [...prev];
85
+ for (let i = cp.length - 1; i >= 0; i--) {
86
+ if (cp[i].role === 'assistant') {
87
+ cp[i] = fn(cp[i]);
88
+ break;
89
+ }
90
+ }
91
+ return cp;
92
+ }
93
+
94
+ export interface AiPanelProps {
95
+ /** 预置的引导问题,显示在空白态,点了就发 */
96
+ suggestions?: string[];
97
+ /** 追加的角色说明(不会覆盖内置的时间/数据/规则) */
98
+ system?: string;
99
+ /** 面板标题(showHeader=false 时不显示) */
100
+ title?: string;
101
+ /** 对话区高度:数字或 '100%'(嵌在等高卡片里时用 '100%') */
102
+ height?: number | string;
103
+ /** 是否显示面板自带标题栏。外层 Card 已有标题时传 false,避免标题重复 */
104
+ showHeader?: boolean;
105
+ }
106
+
107
+ export default function AiPanel({
108
+ suggestions = [
109
+ '这个系统怎么用?给我一份快速上手指南',
110
+ '这个月新增了哪些数据?分别是什么类型',
111
+ '当前数据里有哪些明显的问题或风险?',
112
+ ],
113
+ system,
114
+ title = 'AI 助手',
115
+ height = 540,
116
+ showHeader = true,
117
+ }: AiPanelProps) {
118
+ const { message } = App.useApp();
119
+ const [msgs, setMsgs] = useState<Msg[]>([]);
120
+ const [input, setInput] = useState('');
121
+ const [busy, setBusy] = useState(false);
122
+ const [err, setErr] = useState<string | null>(null);
123
+ const [meta, setMeta] = useState<Meta | null>(null);
124
+ const scrollRef = useRef<HTMLDivElement | null>(null);
125
+ /** 取消当前这轮对话用;请求发出后才有值 */
126
+ const abortRef = useRef<AbortController | null>(null);
127
+ /**
128
+ * 已等待时长。
129
+ *
130
+ * ★ 这是这个面板最需要的一个数字:本地模型是流式的,首字出来之前可能等十几秒。
131
+ * 以前只有一个三点跳动的气泡,看不出来"它到底是在想,还是卡住了";
132
+ * 有了秒数,用户看到数字在涨就愿意继续等。
133
+ */
134
+ const elapsed = useElapsed(busy);
135
+ /** 用户是否贴着底部(自己往上翻历史时不要强行拉回去) */
136
+ const stickRef = useRef(true);
137
+ /** 刚发出问题时:把这条问题滚到顶部,而不是滚到底部 */
138
+ const anchorRef = useRef(false);
139
+
140
+ // 页面加载时先问一次"现在几点 + 能查哪些表"(不调模型,只查库)
141
+ useEffect(() => {
142
+ fetch('/api/ai/chat')
143
+ .then((r) => r.json())
144
+ .then((j: Meta) => {
145
+ if (j?.now) setMeta({ now: j.now, tables: j.tables ?? [] });
146
+ })
147
+ .catch(() => {
148
+ /* 拿不到就算了,不影响对话 */
149
+ });
150
+ }, []);
151
+
152
+ const onScroll = () => {
153
+ const el = scrollRef.current;
154
+ if (!el) return;
155
+ stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
156
+ };
157
+
158
+ useEffect(() => {
159
+ const el = scrollRef.current;
160
+ if (!el) return;
161
+
162
+ if (anchorRef.current) {
163
+ // 把最后一条用户消息滚到可视区顶部,避免它被裁成一条横条
164
+ const nodes = el.querySelectorAll('[data-msg]');
165
+ const userNode = nodes[nodes.length - 2] as HTMLElement | undefined;
166
+ if (userNode) {
167
+ el.scrollTop += userNode.getBoundingClientRect().top - el.getBoundingClientRect().top - 4;
168
+ }
169
+ anchorRef.current = false;
170
+ stickRef.current = false;
171
+ return;
172
+ }
173
+
174
+ if (stickRef.current) el.scrollTop = el.scrollHeight;
175
+ }, [msgs]);
176
+
177
+ const clear = () => {
178
+ setMsgs([]);
179
+ setErr(null);
180
+ };
181
+
182
+ /** 停止这一轮生成(长任务等待里,"能停"比"等得快"更让人安心) */
183
+ const stop = useCallback(() => {
184
+ abortRef.current?.abort();
185
+ }, []);
186
+
187
+ /**
188
+ * 把"提交中"的表单收口。
189
+ *
190
+ * ★ 必须全局扫,不能只改最后一条 assistant 消息:
191
+ * 用户提交表单后会追加一条新的 assistant 消息承载确认卡,
192
+ * 而表单卡还挂在**上一条**消息里。只 patch 最后一条的话,
193
+ * 表单会永远停在 submitting(按钮一直转圈、摘要也不出现)。
194
+ */
195
+ const settleForms = useCallback(
196
+ (status: 'done' | 'pending' | 'cancelled' | 'failed', error?: string) => {
197
+ setMsgs((prev) =>
198
+ prev.map((m) => {
199
+ if (!m.forms?.some((c) => c.status === 'submitting')) return m;
200
+ return {
201
+ ...m,
202
+ forms: m.forms.map((c) =>
203
+ c.status === 'submitting' ? { ...c, status, error } : c
204
+ ),
205
+ };
206
+ })
207
+ );
208
+ },
209
+ []
210
+ );
211
+
212
+ /**
213
+ * 跑一轮 SSE。
214
+ *
215
+ * ★ 抽出来是因为有两个入口:用户打字提问(send)和用户提交表单(submitForm)。
216
+ * 两者除了"请求体多一个 form 字段",其余(打字机、工具轨迹、确认卡)完全一样。
217
+ * 复制两份的话,迟早出现"表单提交后不出确认卡"这种只修了一条路径的 bug。
218
+ */
219
+ const runStream = useCallback(
220
+ async (
221
+ history: { role: 'user' | 'assistant'; content: string }[],
222
+ form?: { token: string; values: Record<string, unknown> }
223
+ ) => {
224
+ setErr(null);
225
+ setBusy(true);
226
+ // 每轮一个控制器:点"停止生成"只掐掉这一轮,不影响后续提问
227
+ const ctrl = new AbortController();
228
+ abortRef.current = ctrl;
229
+
230
+ try {
231
+ const res = await fetch('/api/ai/chat', {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({
235
+ system,
236
+ messages: history,
237
+ maxTokens: 1200,
238
+ ...(form ? { form } : {}),
239
+ }),
240
+ signal: ctrl.signal,
241
+ });
242
+ if (!res.ok || !res.body) {
243
+ // 表单过期 / 令牌被改这类错误是 JSON 响应,把中文原因读出来再抛
244
+ const j = (await res.json().catch(() => null)) as { error?: string } | null;
245
+ throw new Error(j?.error ?? `请求失败(${res.status})`);
246
+ }
247
+
248
+ const reader = res.body.getReader();
249
+ const dec = new TextDecoder();
250
+ let buf = '';
251
+ for (;;) {
252
+ const { done, value } = await reader.read();
253
+ if (done) break;
254
+ buf += dec.decode(value, { stream: true });
255
+ let i: number;
256
+ while ((i = buf.indexOf('\n')) >= 0) {
257
+ const line = buf.slice(0, i).trim();
258
+ buf = buf.slice(i + 1);
259
+ if (!line.startsWith('data:')) continue;
260
+ const p = line.slice(5).trim();
261
+ if (p === '[DONE]') continue;
262
+ try {
263
+ const j = JSON.parse(p) as {
264
+ content?: string;
265
+ error?: string;
266
+ hint?: string;
267
+ meta?: Meta;
268
+ step?: StepTrace;
269
+ form?: FormSpec;
270
+ action?: { token: string; title: string; fields: { label: string; value: string }[] };
271
+ };
272
+ if (j.meta) {
273
+ setMeta(j.meta);
274
+ // 把"参考了哪些表"挂到本轮回答上
275
+ setMsgs((prev) =>
276
+ patchLastAssistant(prev, (m) => ({ ...m, tables: j.meta?.tables ?? [] }))
277
+ );
278
+ } else if (j.step) {
279
+ // 模型调了一次工具:留痕,让"它真的在查系统"这件事看得见
280
+ const step = j.step;
281
+ setMsgs((prev) =>
282
+ patchLastAssistant(prev, (m) => ({ ...m, steps: [...(m.steps ?? []), step] }))
283
+ );
284
+ } else if (j.form) {
285
+ /**
286
+ * 服务端要用户填信息。
287
+ *
288
+ * 同一 token 的表单是"同一张表重新下发"(提交后仍有必填没填),就地替换;
289
+ * 新 token 说明这是另一张表,保留旧的并标成已提交 —— 否则用户填过的内容
290
+ * 会随着卡片被替换而消失,看起来像"白填了"。
291
+ */
292
+ const spec = j.form;
293
+ // 上一张表已提交完成(新 token 说明这是另一张),先把它的 submitting 收口
294
+ settleForms('done');
295
+ setMsgs((prev) =>
296
+ patchLastAssistant(prev, (m) => {
297
+ const list = m.forms ?? [];
298
+ const hit = list.findIndex((c) => c.spec.token === spec.token);
299
+ if (hit >= 0) {
300
+ // 同一张表重新下发:保留用户已填的值,只更新字段定义
301
+ const next = [...list];
302
+ next[hit] = { ...next[hit], spec, status: 'pending' as const, error: undefined };
303
+ return { ...m, forms: next };
304
+ }
305
+ return { ...m, forms: [...list, { spec, status: 'pending' as const }] };
306
+ })
307
+ );
308
+ } else if (j.action) {
309
+ // 待用户确认的写操作:不执行,先摆到卡片上
310
+ const a = j.action;
311
+ settleForms('done');
312
+ setMsgs((prev) =>
313
+ patchLastAssistant(prev, (m) => ({
314
+ ...m,
315
+ actions: [
316
+ ...(m.actions ?? []),
317
+ { token: a.token, title: a.title, fields: a.fields, status: 'pending' as const },
318
+ ],
319
+ }))
320
+ );
321
+ } else if (j.error) {
322
+ setErr(j.hint ? `${j.error}(${j.hint})` : j.error);
323
+ } else if (j.content) {
324
+ setMsgs((prev) =>
325
+ patchLastAssistant(prev, (m) => ({ ...m, content: m.content + j.content }))
326
+ );
327
+ }
328
+ } catch {
329
+ /* 忽略半包 */
330
+ }
331
+ }
332
+ }
333
+ } catch (e) {
334
+ const aborted = ctrl.signal.aborted || (e as Error)?.name === 'AbortError';
335
+ if (aborted) {
336
+ // 用户主动停止不是错误:没吐字的空气泡直接摘掉,吐了一半的留着
337
+ setMsgs((prev) => {
338
+ const last = prev[prev.length - 1];
339
+ return last?.role === 'assistant' && !last.content.trim() ? prev.slice(0, -1) : prev;
340
+ });
341
+ } else {
342
+ const errMsg = e instanceof Error ? e.message : String(e);
343
+ setErr(errMsg);
344
+ message.error(`AI 助手不可用:${errMsg}`);
345
+ // 丢掉没吐字的空气泡,再把表单的"提交中"解开(否则按钮永远转圈)
346
+ setMsgs((prev) => prev.slice(0, -1));
347
+ settleForms('failed', errMsg);
348
+ }
349
+ // 用户主动中断:令牌没过期,恢复成可再次提交
350
+ if (aborted) settleForms('pending');
351
+ } finally {
352
+ abortRef.current = null;
353
+ setBusy(false);
354
+ }
355
+ },
356
+ [message, settleForms, system]
357
+ );
358
+
359
+ const send = useCallback(
360
+ async (text: string) => {
361
+ const q = text.trim();
362
+ if (!q || busy) return;
363
+ setInput('');
364
+ anchorRef.current = true;
365
+
366
+ // ★ 带上完整历史(含上一轮 assistant 的回答),追问才有上下文
367
+ const history = [
368
+ ...msgs
369
+ .filter((m) => m.content.trim())
370
+ .slice(-18)
371
+ .map((m) => ({ role: m.role, content: m.content })),
372
+ { role: 'user' as const, content: q },
373
+ ];
374
+
375
+ setMsgs([...msgs, { role: 'user', content: q }, { role: 'assistant', content: '' }]);
376
+ await runStream(history);
377
+ },
378
+ [busy, msgs, runStream]
379
+ );
380
+
381
+ /**
382
+ * 提交表单。
383
+ *
384
+ * ★ 摘要会进对话历史,但不额外插一条用户气泡:
385
+ * 卡片自己就是"我填了什么"的记录,再加一条文字只是噪音。
386
+ * 历史里有摘要,是为了之后追问"再请一天"时模型知道上次的日期。
387
+ */
388
+ const submitForm = useCallback(
389
+ async (card: FormCard, values: Record<string, unknown>) => {
390
+ /**
391
+ * ★ 用 abortRef 而不是 busy 判断"有没有在跑"。
392
+ * busy 是渲染状态,表单卡片渲染时可能捕获到上一轮的 true,
393
+ * 用户填完点提交会被静默丢掉(按钮没反应、请求也没发)。
394
+ * abortRef 是即时值:流结束的 finally 里就会清空。
395
+ */
396
+ if (abortRef.current) return;
397
+ const token = card.spec.token;
398
+ setMsgs((prev) =>
399
+ prev.map((m) =>
400
+ m.forms?.some((c) => c.spec.token === token)
401
+ ? {
402
+ ...m,
403
+ forms: m.forms.map((c) =>
404
+ c.spec.token === token ? { ...c, status: 'submitting' as const, error: undefined } : c
405
+ ),
406
+ }
407
+ : m
408
+ )
409
+ );
410
+
411
+ anchorRef.current = false;
412
+ const history = [
413
+ ...msgs
414
+ .filter((m) => m.content.trim())
415
+ .slice(-18)
416
+ .map((m) => ({ role: m.role, content: m.content })),
417
+ { role: 'user' as const, content: formAnswerText(card.spec, values) },
418
+ ];
419
+ setMsgs((prev) => [...prev, { role: 'assistant', content: '' }]);
420
+
421
+ await runStream(history, { token, values });
422
+ },
423
+ [msgs, runStream]
424
+ );
425
+
426
+ const cancelForm = useCallback((token: string) => {
427
+ setMsgs((prev) =>
428
+ prev.map((m) =>
429
+ m.forms?.some((c) => c.spec.token === token)
430
+ ? {
431
+ ...m,
432
+ forms: m.forms.map((c) =>
433
+ c.spec.token === token ? { ...c, status: 'cancelled' as const } : c
434
+ ),
435
+ }
436
+ : m
437
+ )
438
+ );
439
+ }, []);
440
+
441
+ /** 按令牌更新确认卡状态(同一轮可能有多张卡) */
442
+ const patchAction = useCallback((token: string, patch: Partial<ActionCard>) => {
443
+ setMsgs((prev) =>
444
+ prev.map((m) =>
445
+ m.actions?.length
446
+ ? { ...m, actions: m.actions.map((a) => (a.token === token ? { ...a, ...patch } : a)) }
447
+ : m
448
+ )
449
+ );
450
+ }, []);
451
+
452
+ /**
453
+ * 确认执行。
454
+ *
455
+ * ★ 只传 token,不传参数:参数在令牌里签过名,前端改不动。
456
+ * 这也是"确认卡不是装饰"的技术保证 —— 卡片上看到的和真正执行的是同一条数据。
457
+ */
458
+ const confirmAction = useCallback(
459
+ async (token: string) => {
460
+ patchAction(token, { status: 'running' });
461
+ try {
462
+ const res = await fetch('/api/ai/act', {
463
+ method: 'POST',
464
+ headers: { 'Content-Type': 'application/json' },
465
+ body: JSON.stringify({ token }),
466
+ });
467
+ const j = (await res.json()) as { ok?: boolean; summary?: string; error?: string };
468
+ if (j.ok) {
469
+ patchAction(token, { status: 'done', result: j.summary ?? '已执行' });
470
+ message.success(j.summary ?? '已执行');
471
+ } else {
472
+ patchAction(token, { status: 'failed', result: j.summary ?? j.error ?? '执行失败' });
473
+ message.error(j.summary ?? j.error ?? '执行失败');
474
+ }
475
+ } catch (e) {
476
+ const m = e instanceof Error ? e.message : String(e);
477
+ patchAction(token, { status: 'failed', result: m });
478
+ message.error(`执行失败:${m}`);
479
+ }
480
+ },
481
+ [message, patchAction]
482
+ );
483
+
484
+ const timeText = meta?.now
485
+ ? `当前时间 ${meta.now.date} ${meta.now.time} ${meta.now.weekday} · 接入 ${
486
+ meta.tables?.length ?? 0
487
+ } 张业务表${meta.toolCount ? ` · ${Math.floor(meta.toolCount / 2)} 类对象可操作` : ''}`
488
+ : '正在接入业务数据…';
489
+
490
+ return (
491
+ <div style={{ display: 'flex', flexDirection: 'column', height, minHeight: 0 }}>
492
+ {showHeader ? (
493
+ <>
494
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
495
+ <RobotOutlined style={{ fontSize: fs(18), color: MILITARY.primary }} />
496
+ <Typography.Text strong style={{ color: MILITARY.text }}>
497
+ {title}
498
+ </Typography.Text>
499
+ <Tag
500
+ style={{
501
+ marginInlineStart: 4,
502
+ background: MILITARY.soft,
503
+ color: MILITARY.primary,
504
+ border: `1px solid ${MILITARY.softStrong}`,
505
+ }}
506
+ >
507
+ 本地模型
508
+ </Tag>
509
+ <span style={{ flex: 1 }} />
510
+ <Button size="small" type="text" icon={<ClearOutlined />} onClick={clear}>
511
+ 清空
512
+ </Button>
513
+ </div>
514
+ <div style={{ fontSize: fs(12), color: MILITARY.textMuted, marginBottom: 8 }}>{timeText}</div>
515
+ </>
516
+ ) : (
517
+ // 嵌在已有标题的卡片里:只保留"时间/数据状态 + 清空"一行,避免标题重复
518
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
519
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flex: 1, minWidth: 0 }}>
520
+ <span
521
+ style={{
522
+ width: 6,
523
+ height: 6,
524
+ borderRadius: '50%',
525
+ background: '#7C9A5E',
526
+ boxShadow: '0 0 0 3px rgba(124,154,94,.16)',
527
+ flex: '0 0 6px',
528
+ }}
529
+ />
530
+ <span style={{ fontSize: fs(12), color: MILITARY.textMuted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
531
+ {timeText}
532
+ </span>
533
+ </span>
534
+ <Button size="small" type="text" icon={<ClearOutlined />} onClick={clear} style={{ color: MILITARY.textSecondary }}>
535
+ 清空
536
+ </Button>
537
+ </div>
538
+ )}
539
+
540
+ <div
541
+ ref={scrollRef}
542
+ onScroll={onScroll}
543
+ style={{
544
+ flex: 1,
545
+ minHeight: 0,
546
+ overflowY: 'auto',
547
+ border: `1px solid ${MILITARY.borderSoft}`,
548
+ borderRadius: 10,
549
+ padding: 12,
550
+ background: MILITARY.paper,
551
+ }}
552
+ >
553
+ {msgs.length === 0 ? (
554
+ <div style={{ margin: 'auto', width: '100%', maxWidth: 360, textAlign: 'center', padding: '8px 0' }}>
555
+ <div
556
+ style={{
557
+ width: 52,
558
+ height: 52,
559
+ borderRadius: '50%',
560
+ margin: '0 auto 12px',
561
+ display: 'grid',
562
+ placeItems: 'center',
563
+ background: MILITARY.soft,
564
+ border: `1px solid ${MILITARY.softStrong}`,
565
+ color: MILITARY.primary,
566
+ fontSize: fs(22),
567
+ }}
568
+ >
569
+ <RobotOutlined />
570
+ </div>
571
+ <div style={{ fontSize: fs(13.5), fontWeight: 600, color: MILITARY.text }}>
572
+ 问我数据、问我怎么用,或直接让我办事
573
+ </div>
574
+ <div style={{ fontSize: fs(12), color: MILITARY.textMuted, margin: '4px 0 12px' }}>
575
+ 查询会留下痕迹,写操作先给你一张确认卡
576
+ </div>
577
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
578
+ {suggestions.map((s) => (
579
+ <Button
580
+ key={s}
581
+ block
582
+ size="small"
583
+ style={{
584
+ textAlign: 'left',
585
+ whiteSpace: 'normal',
586
+ height: 'auto',
587
+ padding: '9px 12px',
588
+ background: MILITARY.card,
589
+ borderColor: MILITARY.border,
590
+ color: MILITARY.textSecondary,
591
+ borderRadius: 8,
592
+ }}
593
+ onClick={() => void send(s)}
594
+ >
595
+ <span style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
596
+ <span>{s}</span>
597
+ <span style={{ color: '#A9AF9C', flex: '0 0 auto' }}>→</span>
598
+ </span>
599
+ </Button>
600
+ ))}
601
+ </div>
602
+ </div>
603
+ ) : (
604
+ msgs.map((m, i) => {
605
+ const mine = m.role === 'user';
606
+ // 思考文案优先级:正在调工具(最具体)> 耗时阶段推测
607
+ const lastStep = m.steps?.[m.steps.length - 1];
608
+ const stepText = lastStep?.summary
609
+ ? `${lastStep.summary.slice(0, 30)}${lastStep.summary.length > 30 ? '…' : ''}`
610
+ : '';
611
+ const thinkingText = stepText
612
+ ? `正在执行:${stepText}`
613
+ : elapsed >= 8000
614
+ ? '本地模型响应较慢,仍在生成…'
615
+ : elapsed >= 3000
616
+ ? '正在检索业务数据…'
617
+ : '正在理解你的问题…';
618
+ return (
619
+ <div
620
+ key={i}
621
+ data-msg={i}
622
+ style={{
623
+ display: 'flex',
624
+ alignItems: 'flex-start',
625
+ justifyContent: mine ? 'flex-end' : 'flex-start',
626
+ gap: 8,
627
+ marginBottom: 14,
628
+ }}
629
+ >
630
+ {!mine && (
631
+ <Avatar
632
+ size={26}
633
+ icon={<RobotOutlined />}
634
+ style={{
635
+ flex: '0 0 26px',
636
+ background: MILITARY.soft,
637
+ color: MILITARY.primary,
638
+ border: `1px solid ${MILITARY.softStrong}`,
639
+ marginTop: 2,
640
+ }}
641
+ />
642
+ )}
643
+
644
+ <div style={{ maxWidth: mine ? '82%' : 'calc(100% - 34px)', minWidth: 0 }}>
645
+ <div
646
+ style={{
647
+ display: mine ? 'inline-block' : 'block',
648
+ whiteSpace: mine ? 'pre-wrap' : 'normal',
649
+ wordBreak: 'break-word',
650
+ padding: mine ? '8px 12px' : '10px 12px',
651
+ borderRadius: mine ? '10px 10px 2px 10px' : '10px 10px 10px 2px',
652
+ background: mine ? MILITARY.primary : MILITARY.card,
653
+ color: mine ? MILITARY.onPrimary : MILITARY.text,
654
+ border: mine ? 'none' : `1px solid ${MILITARY.borderSoft}`,
655
+ boxShadow: mine ? MILITARY.shadowSm : 'none',
656
+ textAlign: 'left',
657
+ }}
658
+ >
659
+ {mine ? (
660
+ m.content
661
+ ) : m.content ? (
662
+ <Markdown text={m.content} />
663
+ ) : (
664
+ /**
665
+ * 思考态。
666
+ *
667
+ * 原来只有三个跳动的小点 —— 本地模型首字要等十几秒时,
668
+ * 用户没法判断"它在想"还是"它卡了"。现在按耗时给三句话:
669
+ * 0~3s 正在理解你的问题…
670
+ * 3~8s 正在检索业务数据…
671
+ * 8s 以上 本地模型响应较慢,仍在生成…
672
+ * 有工具调用时直接说正在做什么(比任何推测都准),
673
+ * 并且始终显示已等待秒数 + 一个"停止生成"。
674
+ */
675
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
676
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
677
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, height: 14 }}>
678
+ <span className="ai-typing-dot" />
679
+ <span className="ai-typing-dot" />
680
+ <span className="ai-typing-dot" />
681
+ </span>
682
+ <span style={{ fontSize: fs(12.5), color: MILITARY.textSecondary }}>
683
+ {thinkingText}
684
+ </span>
685
+ </div>
686
+
687
+ <div
688
+ style={{
689
+ display: 'flex',
690
+ alignItems: 'center',
691
+ gap: 10,
692
+ fontSize: fs(11.5),
693
+ color: MILITARY.textMuted,
694
+ }}
695
+ >
696
+ {busy ? (
697
+ <>
698
+ <span>已等待 {formatElapsed(elapsed)}</span>
699
+ <button
700
+ type="button"
701
+ onClick={stop}
702
+ style={{
703
+ padding: '1px 9px',
704
+ fontSize: fs(11.5),
705
+ color: MILITARY.textSecondary,
706
+ background: 'transparent',
707
+ border: `1px solid ${MILITARY.border}`,
708
+ borderRadius: 999,
709
+ cursor: 'pointer',
710
+ }}
711
+ >
712
+ 停止生成
713
+ </button>
714
+ </>
715
+ ) : (
716
+ <span>没有收到回答,可以重试一次</span>
717
+ )}
718
+ </div>
719
+ </div>
720
+ )}
721
+ </div>
722
+
723
+ {/* 工具轨迹:让"它真的查了数据库 / 真的准备写库"看得见 */}
724
+ {!mine && !!m.steps?.length && (
725
+ <div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
726
+ {m.steps.map((s, k) => (
727
+ <div
728
+ key={`${s.name}-${k}`}
729
+ style={{
730
+ display: 'flex',
731
+ alignItems: 'center',
732
+ gap: 5,
733
+ fontSize: fs(11.5),
734
+ color: s.ok ? MILITARY.textMuted : MILITARY.error,
735
+ }}
736
+ >
737
+ {s.kind === 'write' ? (
738
+ <EditOutlined style={{ fontSize: fs(11) }} />
739
+ ) : (
740
+ <SearchOutlined style={{ fontSize: fs(11) }} />
741
+ )}
742
+ <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
743
+ {s.summary}
744
+ </span>
745
+ {s.ok ? (
746
+ <CheckCircleOutlined style={{ fontSize: fs(10), color: MILITARY.success }} />
747
+ ) : (
748
+ <CloseCircleOutlined style={{ fontSize: fs(10) }} />
749
+ )}
750
+ </div>
751
+ ))}
752
+ </div>
753
+ )}
754
+
755
+ {/*
756
+ 输入表单:模型缺信息时弹出。
757
+ 和确认卡一样挂在消息上 —— 表单是"这一轮缺什么"的证据,
758
+ 不该因为用户继续提问就跑到最新一条回答下面。
759
+ */}
760
+ {!mine &&
761
+ m.forms?.map((c) => (
762
+ <AiInputForm
763
+ key={c.spec.token}
764
+ card={c}
765
+ onSubmit={(values) => void submitForm(c, values)}
766
+ onCancel={() => cancelForm(c.spec.token)}
767
+ />
768
+ ))}
769
+
770
+ {/* 确认卡:写操作唯一入口。卡片内容与令牌里签名的参数是同一份数据 */}
771
+ {!mine &&
772
+ m.actions?.map((a) => (
773
+ <div
774
+ key={a.token}
775
+ data-testid="ai-action-card"
776
+ data-action-state={a.status}
777
+ style={{
778
+ marginTop: 8,
779
+ border: `1px solid ${MILITARY.softStrong}`,
780
+ borderLeft: `3px solid ${MILITARY.accent}`,
781
+ borderRadius: 8,
782
+ background: MILITARY.paper,
783
+ padding: '10px 12px',
784
+ }}
785
+ >
786
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
787
+ <EditOutlined style={{ color: MILITARY.accent, fontSize: fs(13) }} />
788
+ <span style={{ fontSize: fs(12.5), fontWeight: 500, color: MILITARY.text }}>
789
+ {a.status === 'done'
790
+ ? `已执行:${a.title}`
791
+ : a.status === 'cancelled'
792
+ ? `已取消:${a.title}`
793
+ : a.status === 'failed'
794
+ ? `执行失败:${a.title}`
795
+ : `待确认:${a.title}`}
796
+ </span>
797
+ </div>
798
+
799
+ {!!a.fields.length && (
800
+ <div
801
+ style={{
802
+ borderTop: `1px dashed ${MILITARY.border}`,
803
+ paddingTop: 8,
804
+ marginBottom: 8,
805
+ }}
806
+ >
807
+ {a.fields.map((f) => (
808
+ <div
809
+ key={f.label}
810
+ style={{ display: 'flex', gap: 8, fontSize: fs(12), lineHeight: '20px' }}
811
+ >
812
+ <span style={{ flex: '0 0 76px', color: MILITARY.textMuted }}>
813
+ {f.label}
814
+ </span>
815
+ <span style={{ color: MILITARY.text, wordBreak: 'break-all' }}>
816
+ {f.value}
817
+ </span>
818
+ </div>
819
+ ))}
820
+ </div>
821
+ )}
822
+
823
+ {a.status === 'pending' && (
824
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
825
+ <Button
826
+ size="small"
827
+ type="primary"
828
+ onClick={() => void confirmAction(a.token)}
829
+ data-testid="ai-action-confirm"
830
+ >
831
+ 确认执行
832
+ </Button>
833
+ <Button
834
+ size="small"
835
+ onClick={() => patchAction(a.token, { status: 'cancelled' })}
836
+ >
837
+ 取消
838
+ </Button>
839
+ <span style={{ fontSize: fs(11), color: MILITARY.textMuted }}>
840
+ 确认后写入数据库并记审计日志
841
+ </span>
842
+ </div>
843
+ )}
844
+ {a.status === 'running' && (
845
+ <span style={{ fontSize: fs(12), color: MILITARY.textMuted }}>正在执行…</span>
846
+ )}
847
+ {a.status === 'done' && (
848
+ <div
849
+ style={{
850
+ fontSize: fs(12),
851
+ color: MILITARY.success,
852
+ display: 'flex',
853
+ alignItems: 'center',
854
+ gap: 5,
855
+ }}
856
+ >
857
+ <CheckCircleOutlined />
858
+ {a.result}
859
+ </div>
860
+ )}
861
+ {a.status === 'failed' && (
862
+ <div style={{ fontSize: fs(12), color: MILITARY.error }}>{a.result}</div>
863
+ )}
864
+ {a.status === 'cancelled' && (
865
+ <div style={{ fontSize: fs(12), color: MILITARY.textMuted }}>
866
+ 已取消,未写入数据库
867
+ </div>
868
+ )}
869
+ </div>
870
+ ))}
871
+
872
+ {!mine && !!m.tables?.length && (
873
+ <div
874
+ style={{
875
+ marginTop: 6,
876
+ display: 'flex',
877
+ alignItems: 'center',
878
+ gap: 4,
879
+ flexWrap: 'wrap',
880
+ fontSize: fs(11.5),
881
+ color: MILITARY.textMuted,
882
+ }}
883
+ >
884
+ <span>参考数据</span>
885
+ {m.tables.map((t) => (
886
+ <span
887
+ key={t.model}
888
+ style={{
889
+ background: MILITARY.soft,
890
+ color: MILITARY.textSecondary,
891
+ borderRadius: 4,
892
+ padding: '0 6px',
893
+ lineHeight: '18px',
894
+ }}
895
+ >
896
+ {t.model} · {t.total}
897
+ </span>
898
+ ))}
899
+ </div>
900
+ )}
901
+ </div>
902
+
903
+ {mine && (
904
+ <Avatar
905
+ size={26}
906
+ style={{
907
+ flex: '0 0 26px',
908
+ background: MILITARY.primary,
909
+ color: MILITARY.onPrimary,
910
+ fontSize: fs(11),
911
+ marginTop: 2,
912
+ }}
913
+ >
914
+ 我
915
+ </Avatar>
916
+ )}
917
+ </div>
918
+ );
919
+ })
920
+ )}
921
+ </div>
922
+
923
+ {err && <div style={{ marginTop: 8, fontSize: fs(12), color: '#cf1322' }}>{err}</div>}
924
+
925
+ {/* 输入区做成一个整体面板:输入框无边框,提示与发送按钮同在一层 */}
926
+ <div
927
+ style={{
928
+ marginTop: 10,
929
+ border: `1px solid ${MILITARY.border}`,
930
+ borderRadius: 10,
931
+ background: MILITARY.card,
932
+ padding: '4px 8px 8px',
933
+ }}
934
+ >
935
+ <TextArea
936
+ data-testid="ai-chat-input"
937
+ value={input}
938
+ variant="borderless"
939
+ style={{ padding: '4px 0 0', background: 'transparent' }}
940
+ onChange={(e) => setInput(e.target.value)}
941
+ placeholder="输入问题,Enter 发送,Shift+Enter 换行"
942
+ autoSize={{ minRows: 1, maxRows: 4 }}
943
+ onKeyDown={(e) => {
944
+ // Enter 发送 / Shift+Enter 换行(Ctrl+Enter 也发送,兼容旧习惯)。
945
+ // 以前只有 Ctrl+Enter 能发,反直觉,用户和自动化测试都踩过。
946
+ if (e.key === 'Enter' && (e.ctrlKey || !e.shiftKey)) {
947
+ e.preventDefault();
948
+ void send(input);
949
+ }
950
+ }}
951
+ disabled={busy}
952
+ />
953
+ <div style={{ marginTop: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
954
+ <span style={{ fontSize: fs(11.5), color: MILITARY.textMuted }}>回答基于系统真实数据与当前时间生成</span>
955
+ <Button
956
+ size="small"
957
+ type="primary"
958
+ icon={<SendOutlined />}
959
+ loading={busy}
960
+ onClick={() => void send(input)}
961
+ disabled={!input.trim()}
962
+ data-testid="ai-chat-send"
963
+ >
964
+ 发送
965
+ </Button>
966
+ </div>
967
+ </div>
968
+ </div>
969
+ );
970
+ }