Commit 52d7bade56c560448deeb928ef0fe2a1cdda66e9

Authored by zichun
1 parent 9dba4b16

feat(ui): rewrite chat.html — vanilla JS, full-viewport, deterministic form submit, FK picker modal

- drop jQuery + page-in-page card; collapsible sidebar, single scroll area,
  light minimal style, markdown tables (markdown-it)
- collectForm: grid layout, required flags, typed controls, enum dropdown,
  FK secondary picker modal (multi-column list / search / paging / select)
- form submit posts structured fields to /api/agent/form/submit (no NL re-encoding);
  legacy FORM_SUBMIT_MARK route removed
- askUser card: option chips + always-available free-text input
- history replays from ledger endpoint; localStorage history dropped
src/main/java/com/xly/web/AgentChatController.java
... ... @@ -60,8 +60,6 @@ public class AgentChatController {
60 60  
61 61 private static final Logger log = LoggerFactory.getLogger(AgentChatController.class);
62 62 private static final Set<String> WRITE_TOOLS = Set.of("proposeWrite");
63   - /** 前端 collectForm 表单提交后拼出的消息带此标记 → 本轮直接走「写」执行 proposeWrite(action=create)。 */
64   - private static final String FORM_SUBMIT_MARK = "proposeWrite(action=create)";
65 63 /** 反编造护栏:agent 声称「已生成/已完成」写操作的说法(无 proposeWrite 提议时要纠正)。 */
66 64 private static final Pattern WRITE_CLAIM = Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核)");
67 65  
... ... @@ -210,14 +208,9 @@ public class AgentChatController {
210 208 return out;
211 209 }
212 210  
213   - /** 意图门 + 确定性路由。 */
  211 + /** 意图门 + 确定性路由。表单提交不再走这里——见 {@link #formSubmit}(结构化直达,确定性)。 */
214 212 private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) {
215   - // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。
216   - if (userInput.contains(FORM_SUBMIT_MARK)) {
217   - runAgent(emitter, convId, identity, userInput, false);
218   - return;
219   - }
220   - // 1) 意图门(带上一轮留下的状态槽;失败时返回 其他,走兜底)。
  213 + // 意图门(带上一轮留下的状态槽;失败时返回 其他,走兜底)。
221 214 String digest = state.digest(convId);
222 215 Intent it = intentService.classify(userInput, digest);
223 216 state.recordIntent(convId, it.intent, it.danju);
... ...
src/main/resources/templates/chat.html
1 1 <!DOCTYPE html>
2 2 <html lang="zh-CN">
3 3 <head>
4   - <meta charset="UTF-8">
5   - <meta name="viewport" content="width=device-width, initial-scale=1.0">
6   - <title>AI 印刷助手</title>
7   - <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
8   - <script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/13.0.1/markdown-it.min.js"></script>
9   - <style>
10   - * {
11   - margin: 0;
12   - padding: 0;
13   - box-sizing: border-box;
14   - }
15   -
16   - body {
17   - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18   - background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
19   - min-height: 100vh;
20   - display: flex;
21   - justify-content: center;
22   - align-items: center;
23   - padding: 20px;
24   - }
25   -
26   - .chat-container {
27   - width: 100%;
28   - max-width: 900px;
29   - height: 85vh;
30   - background: white;
31   - border-radius: 20px;
32   - box-shadow: 0 20px 60px rgba(0,0,0,0.15);
33   - display: flex;
34   - flex-direction: column;
35   - overflow: hidden;
36   - }
37   -
38   - .chat-header {
39   - background: linear-gradient(90deg, #2c3e50, #4a6491);
40   - color: white;
41   - padding: 20px;
42   - display: flex;
43   - justify-content: space-between;
44   - align-items: center;
45   - flex-shrink: 0;
46   - }
47   -
48   - .header-left h1 {
49   - font-size: 24px;
50   - font-weight: 600;
51   - margin-bottom: 5px;
52   - }
53   -
54   - .header-left p {
55   - opacity: 0.8;
56   - font-size: 14px;
57   - }
58   -
59   - .header-right {
60   - display: flex;
61   - gap: 15px;
62   - }
63   -
64   - .model-selector {
65   - background: rgba(255,255,255,0.1);
66   - border: 1px solid rgba(255,255,255,0.2);
67   - color: #180a4b;
68   - padding: 8px 15px;
69   - border-radius: 20px;
70   - font-size: 14px;
71   - outline: none;
72   - cursor: pointer;
73   - }
74   -
75   - .model-selector:hover {
76   - background: rgba(255,255,255,0.2);
77   - }
78   -
79   - .chat-body {
80   - display: flex;
81   - flex: 1;
82   - overflow: hidden;
83   - min-height: 0;
84   - }
85   -
86   - .sidebar {
87   - width: 250px;
88   - background: #f8f9fa;
89   - border-right: 1px solid #e9ecef;
90   - padding: 20px;
91   - overflow-y: auto;
92   - flex-shrink: 0;
93   - }
94   -
95   - .sidebar-title {
96   - font-size: 16px;
97   - font-weight: 600;
98   - margin-bottom: 15px;
99   - color: #2c3e50;
100   - }
101   -
102   - .preset-question {
103   - background: white;
104   - border: 1px solid #e9ecef;
105   - border-radius: 10px;
106   - padding: 12px 15px;
107   - margin-bottom: 10px;
108   - cursor: pointer;
109   - transition: all 0.3s;
110   - font-size: 14px;
111   - }
112   -
113   - .preset-question:hover {
114   - background: #667eea;
115   - color: white;
116   - border-color: #667eea;
117   - transform: translateX(5px);
118   - }
119   -
120   - .chat-main {
121   - flex: 1;
122   - display: flex;
123   - flex-direction: column;
124   - min-height: 0;
125   - }
126   -
127   - .messages-container {
128   - flex: 1;
129   - display: flex;
130   - flex-direction: column;
131   - min-height: 0;
132   - position: relative;
133   - }
134   -
135   - .chat-messages {
136   - flex: 1;
137   - overflow-y: auto;
138   - padding: 20px;
139   - background: white;
140   - }
141   -
142   - .message {
143   - margin-bottom: 20px;
144   - max-width: 80%;
145   - animation: fadeIn 0.3s ease;
146   - }
147   -
148   - .user-message {
149   - margin-left: auto;
150   - }
151   -
152   - .ai-message {
153   - margin-right: auto;
154   - }
155   -
156   - .message-bubble {
157   - padding: 15px 20px;
158   - border-radius: 20px;
159   - position: relative;
160   - word-wrap: break-word;
161   - line-height: 1.6;
162   - }
163   -
164   - .user-message .message-bubble {
165   - background: linear-gradient(90deg, #667eea, #764ba2);
166   - color: white;
167   - border-bottom-right-radius: 5px;
168   - }
169   -
170   - .ai-message .message-bubble {
171   - background: #f8f9fa;
172   - color: #333;
173   - border: 1px solid #e9ecef;
174   - border-bottom-left-radius: 5px;
175   - }
176   -
177   - .message-content {
178   - font-size: 15px;
179   - }
180   -
181   - .ai-message .message-content code {
182   - background: #e9ecef;
183   - padding: 2px 6px;
184   - border-radius: 4px;
185   - font-family: 'Courier New', monospace;
186   - font-size: 14px;
187   - }
188   -
189   - .ai-message .message-content pre {
190   - background: #f1f3f5;
191   - padding: 10px;
192   - border-radius: 8px;
193   - overflow-x: auto;
194   - margin: 10px 0;
195   - border: 1px solid #dee2e6;
196   - }
197   -
198   - .message-meta {
199   - display: flex;
200   - justify-content: space-between;
201   - align-items: center;
202   - margin-top: 8px;
203   - font-size: 12px;
204   - }
205   -
206   - .message-time {
207   - color: #6c757d;
208   - }
209   -
210   - .message-actions {
211   - display: flex;
212   - gap: 8px;
213   - }
214   -
215   - .action-btn {
216   - background: none;
217   - border: none;
218   - color: #6c757d;
219   - cursor: pointer;
220   - font-size: 12px;
221   - padding: 2px 5px;
222   - border-radius: 3px;
223   - transition: all 0.2s;
224   - }
225   -
226   - .action-btn:hover {
227   - background: #e9ecef;
228   - color: #495057;
229   - }
230   -
231   - .typing-indicator {
232   - display: flex;
233   - align-items: center;
234   - padding: 10px 20px;
235   - background: #f8f9fa;
236   - border-radius: 20px;
237   - width: fit-content;
238   - border: 1px solid #e9ecef;
239   - margin-bottom: 20px;
240   - }
241   -
242   - .typing-dot {
243   - width: 8px;
244   - height: 8px;
245   - background: #667eea;
246   - border-radius: 50%;
247   - margin: 0 2px;
248   - animation: typing 1.4s infinite;
249   - }
250   -
251   - .typing-dot:nth-child(2) { animation-delay: 0.2s; }
252   - .typing-dot:nth-child(3) { animation-delay: 0.4s; }
253   -
254   - .input-section {
255   - border-top: 1px solid #e9ecef;
256   - background: white;
257   - flex-shrink: 0;
258   - }
259   -
260   - .chat-input-container {
261   - padding: 20px;
262   - }
263   -
264   - .input-wrapper {
265   - display: flex;
266   - gap: 10px;
267   - }
268   -
269   - #messageInput {
270   - flex: 1;
271   - padding: 15px 20px;
272   - border: 2px solid #e9ecef;
273   - border-radius: 25px;
274   - font-size: 16px;
275   - outline: none;
276   - transition: border-color 0.3s;
277   - }
278   -
279   - #messageInput:focus {
280   - border-color: #667eea;
281   - }
282   -
283   - #sendButton {
284   - padding: 15px 30px;
285   - background: linear-gradient(90deg, #667eea, #764ba2);
286   - color: white;
287   - border: none;
288   - border-radius: 25px;
289   - font-size: 16px;
290   - font-weight: 600;
291   - cursor: pointer;
292   - transition: all 0.2s;
293   - white-space: nowrap;
294   - }
295   -
296   - #sendButton:hover {
297   - transform: translateY(-2px);
298   - box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
299   - }
300   -
301   - #sendButton:disabled {
302   - opacity: 0.5;
303   - cursor: not-allowed;
304   - transform: none;
305   - box-shadow: none;
306   - }
307   -
308   - .status-bar {
309   - display: flex;
310   - justify-content: space-between;
311   - align-items: center;
312   - padding: 10px 20px;
313   - font-size: 14px;
314   - color: #666;
315   - background: #f8f9fa;
316   - border-top: 1px solid #e9ecef;
317   - }
318   -
319   - .api-status {
320   - display: flex;
321   - align-items: center;
322   - gap: 8px;
323   - }
324   -
325   - .status-indicator {
326   - width: 8px;
327   - height: 8px;
328   - border-radius: 50%;
329   - }
330   -
331   - .status-connected {
332   - background: #28a745;
333   - animation: pulse 2s infinite;
334   - }
335   -
336   - .status-disconnected {
337   - background: #dc3545;
338   - }
339   -
340   - .status-connecting {
341   - background: #ffc107;
342   - }
343   -
344   - @keyframes fadeIn {
345   - from { opacity: 0; transform: translateY(10px); }
346   - to { opacity: 1; transform: translateY(0); }
347   - }
348   -
349   - @keyframes typing {
350   - 0%, 60%, 100% { transform: translateY(0); }
351   - 30% { transform: translateY(-10px); }
352   - }
353   -
354   - @keyframes pulse {
355   - 0% { opacity: 1; }
356   - 50% { opacity: 0.5; }
357   - 100% { opacity: 1; }
358   - }
359   -
360   - /* 滚动条样式 */
361   - .chat-messages::-webkit-scrollbar,
362   - .sidebar::-webkit-scrollbar {
363   - width: 6px;
364   - }
365   -
366   - .chat-messages::-webkit-scrollbar-track,
367   - .sidebar::-webkit-scrollbar-track {
368   - background: #f1f1f1;
369   - border-radius: 3px;
370   - }
371   -
372   - .chat-messages::-webkit-scrollbar-thumb,
373   - .sidebar::-webkit-scrollbar-thumb {
374   - background: #c1c1c1;
375   - border-radius: 3px;
376   - }
377   -
378   - .chat-messages::-webkit-scrollbar-thumb:hover,
379   - .sidebar::-webkit-scrollbar-thumb:hover {
380   - background: #a1a1a1;
381   - }
382   -
383   - /* 底部间隔 */
384   - .bottom-spacer {
385   - height: 20px;
386   - flex-shrink: 0;
387   - }
388   -
389   - /* 响应式设计 */
390   - @media (max-width: 768px) {
391   - .chat-container {
392   - height: 95vh;
393   - border-radius: 10px;
394   - }
395   -
396   - .sidebar {
397   - display: none;
398   - }
399   -
400   - .message {
401   - max-width: 90%;
402   - }
403   -
404   - #sendButton {
405   - padding: 15px 20px;
406   - }
407   -
408   - .header-right {
409   - flex-direction: column;
410   - gap: 8px;
411   - }
412   - }
413   - </style>
  4 +<meta charset="UTF-8">
  5 +<meta name="viewport" content="width=device-width, initial-scale=1.0">
  6 +<title>小羚羊 AI 助手</title>
  7 +<script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/13.0.1/markdown-it.min.js"></script>
  8 +<style>
  9 +:root {
  10 + --bg: #f7f7f8;
  11 + --panel: #ffffff;
  12 + --border: #e5e5e7;
  13 + --text: #1d1d1f;
  14 + --text2: #6e6e73;
  15 + --accent: #0a7d50;
  16 + --accent-weak: #e6f4ee;
  17 + --danger: #c0392b;
  18 + --radius: 10px;
  19 +}
  20 +* { box-sizing: border-box; margin: 0; padding: 0; }
  21 +html, body { height: 100%; }
  22 +body {
  23 + font: 14px/1.6 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
  24 + color: var(--text);
  25 + background: var(--bg);
  26 + display: flex;
  27 + overflow: hidden;
  28 +}
  29 +
  30 +/* ---------- 侧栏 ---------- */
  31 +#sidebar {
  32 + width: 250px;
  33 + min-width: 250px;
  34 + background: var(--panel);
  35 + border-right: 1px solid var(--border);
  36 + display: flex;
  37 + flex-direction: column;
  38 + transition: margin-left .2s ease;
  39 +}
  40 +#sidebar.hidden { margin-left: -250px; }
  41 +#sidebar .side-head {
  42 + padding: 14px 14px 10px;
  43 + display: flex;
  44 + align-items: center;
  45 + justify-content: space-between;
  46 +}
  47 +#sidebar .side-head .brand { font-size: 15px; font-weight: 600; }
  48 +#newConvBtn {
  49 + border: 1px solid var(--border);
  50 + background: var(--panel);
  51 + border-radius: 8px;
  52 + padding: 4px 10px;
  53 + cursor: pointer;
  54 + color: var(--accent);
  55 +}
  56 +#newConvBtn:hover { background: var(--accent-weak); }
  57 +#convList { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
  58 +.conv-item {
  59 + padding: 8px 10px;
  60 + border-radius: 8px;
  61 + cursor: pointer;
  62 + display: flex;
  63 + align-items: center;
  64 + gap: 6px;
  65 + color: var(--text2);
  66 +}
  67 +.conv-item:hover { background: var(--bg); }
  68 +.conv-item.active { background: var(--accent-weak); color: var(--text); }
  69 +.conv-item .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  70 +.conv-item .del { visibility: hidden; border: 0; background: none; cursor: pointer; color: var(--text2); }
  71 +.conv-item:hover .del { visibility: visible; }
  72 +
  73 +/* ---------- 主区 ---------- */
  74 +#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
  75 +#topbar {
  76 + height: 52px;
  77 + background: var(--panel);
  78 + border-bottom: 1px solid var(--border);
  79 + display: flex;
  80 + align-items: center;
  81 + gap: 10px;
  82 + padding: 0 16px;
  83 +}
  84 +#toggleSide {
  85 + border: 1px solid var(--border);
  86 + background: var(--panel);
  87 + border-radius: 8px;
  88 + width: 32px;
  89 + height: 32px;
  90 + cursor: pointer;
  91 + color: var(--text2);
  92 +}
  93 +#topbar .title { font-weight: 600; }
  94 +#topbar .sub { color: var(--text2); font-size: 12px; }
  95 +
  96 +#scroll { flex: 1; overflow-y: auto; }
  97 +#messages { max-width: 860px; margin: 0 auto; padding: 24px 20px 12px; }
  98 +
  99 +.msg { display: flex; margin-bottom: 18px; }
  100 +.msg.user { justify-content: flex-end; }
  101 +.bubble {
  102 + max-width: 82%;
  103 + padding: 10px 14px;
  104 + border-radius: var(--radius);
  105 + word-break: break-word;
  106 +}
  107 +.msg.user .bubble { background: var(--accent); color: #fff; white-space: pre-wrap; }
  108 +.msg.ai .bubble { background: var(--panel); border: 1px solid var(--border); }
  109 +.msg.ai .bubble.thinking { color: var(--text2); }
  110 +
  111 +/* markdown 内容 */
  112 +.bubble p { margin: 4px 0; }
  113 +.bubble h1, .bubble h2, .bubble h3 { margin: 8px 0 4px; font-size: 15px; }
  114 +.bubble ul, .bubble ol { padding-left: 20px; margin: 4px 0; }
  115 +.bubble code { background: var(--bg); padding: 1px 5px; border-radius: 4px; font-size: 13px; }
  116 +.bubble pre { background: var(--bg); padding: 10px; border-radius: 8px; overflow-x: auto; margin: 6px 0; }
  117 +.bubble table { border-collapse: collapse; margin: 8px 0; max-width: 100%; display: block; overflow-x: auto; }
  118 +.bubble th, .bubble td { border: 1px solid var(--border); padding: 5px 10px; white-space: nowrap; }
  119 +.bubble th { background: var(--bg); }
  120 +
  121 +/* 卡片(提议 / 表单 / 追问)*/
  122 +.card {
  123 + background: var(--panel);
  124 + border: 1px solid var(--border);
  125 + border-radius: var(--radius);
  126 + padding: 14px 16px;
  127 + margin: 0 0 18px;
  128 + max-width: 82%;
  129 +}
  130 +.card .card-title { font-weight: 600; margin-bottom: 8px; }
  131 +.card .muted { color: var(--text2); font-size: 13px; }
  132 +.btn {
  133 + border: 1px solid var(--border);
  134 + background: var(--panel);
  135 + border-radius: 8px;
  136 + padding: 6px 16px;
  137 + cursor: pointer;
  138 + font-size: 14px;
  139 +}
  140 +.btn:hover { background: var(--bg); }
  141 +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
  142 +.btn.primary:hover { filter: brightness(1.08); }
  143 +.btn:disabled { opacity: .5; cursor: default; }
  144 +.card .actions { display: flex; gap: 10px; margin-top: 10px; }
  145 +.card .result { margin-top: 10px; font-size: 13px; }
  146 +.card .result.ok { color: var(--accent); }
  147 +.card .result.bad { color: var(--danger); }
  148 +
  149 +/* 追问选项片 */
  150 +.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
  151 +.chip {
  152 + border: 1px solid var(--border);
  153 + background: var(--panel);
  154 + border-radius: 16px;
  155 + padding: 5px 14px;
  156 + cursor: pointer;
  157 +}
  158 +.chip:hover { border-color: var(--accent); color: var(--accent); }
  159 +.chip-free { display: flex; gap: 6px; margin-top: 10px; }
  160 +.chip-free input {
  161 + flex: 1;
  162 + border: 1px solid var(--border);
  163 + border-radius: 8px;
  164 + padding: 6px 10px;
  165 + font-size: 14px;
  166 +}
  167 +
  168 +/* collectForm 表单 */
  169 +.form-grid {
  170 + display: grid;
  171 + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  172 + gap: 10px 16px;
  173 + margin-top: 10px;
  174 +}
  175 +.ffield label { display: block; font-size: 12px; color: var(--text2); margin-bottom: 3px; }
  176 +.ffield label .req { color: var(--danger); margin-left: 2px; }
  177 +.ffield input, .ffield select {
  178 + width: 100%;
  179 + border: 1px solid var(--border);
  180 + border-radius: 8px;
  181 + padding: 7px 10px;
  182 + font-size: 14px;
  183 + background: var(--panel);
  184 +}
  185 +.ffield input:focus, .ffield select:focus { outline: none; border-color: var(--accent); }
  186 +.ffield .fkrow { display: flex; gap: 6px; }
  187 +.ffield .fkrow input { flex: 1; background: var(--bg); }
  188 +.ffield .hint { font-size: 12px; color: var(--text2); margin-top: 2px; }
  189 +
  190 +/* 输入区 */
  191 +#inputbar { background: var(--bg); padding: 8px 20px 16px; }
  192 +#inputbox {
  193 + max-width: 860px;
  194 + margin: 0 auto;
  195 + background: var(--panel);
  196 + border: 1px solid var(--border);
  197 + border-radius: 14px;
  198 + display: flex;
  199 + align-items: flex-end;
  200 + padding: 8px 10px;
  201 + gap: 8px;
  202 +}
  203 +#inputbox:focus-within { border-color: var(--accent); }
  204 +#messageInput {
  205 + flex: 1;
  206 + border: 0;
  207 + resize: none;
  208 + font: inherit;
  209 + max-height: 140px;
  210 + outline: none;
  211 + background: transparent;
  212 +}
  213 +#sendBtn {
  214 + border: 0;
  215 + background: var(--accent);
  216 + color: #fff;
  217 + border-radius: 10px;
  218 + padding: 7px 18px;
  219 + cursor: pointer;
  220 + font-size: 14px;
  221 +}
  222 +#sendBtn:disabled { opacity: .5; cursor: default; }
  223 +
  224 +/* FK 选择器弹层 */
  225 +#modalMask {
  226 + position: fixed;
  227 + inset: 0;
  228 + background: rgba(0,0,0,.35);
  229 + display: none;
  230 + align-items: center;
  231 + justify-content: center;
  232 + z-index: 50;
  233 +}
  234 +#modalMask.show { display: flex; }
  235 +#fkModal {
  236 + background: var(--panel);
  237 + border-radius: 12px;
  238 + width: min(720px, 92vw);
  239 + max-height: 80vh;
  240 + display: flex;
  241 + flex-direction: column;
  242 + box-shadow: 0 12px 40px rgba(0,0,0,.18);
  243 +}
  244 +#fkModal .m-head {
  245 + padding: 14px 16px;
  246 + border-bottom: 1px solid var(--border);
  247 + display: flex;
  248 + align-items: center;
  249 + gap: 10px;
  250 +}
  251 +#fkModal .m-head .m-title { font-weight: 600; flex: 1; }
  252 +#fkSearch {
  253 + border: 1px solid var(--border);
  254 + border-radius: 8px;
  255 + padding: 6px 10px;
  256 + width: 220px;
  257 + font-size: 14px;
  258 +}
  259 +#fkClose { border: 0; background: none; font-size: 18px; cursor: pointer; color: var(--text2); }
  260 +#fkBody { flex: 1; overflow: auto; padding: 0 16px; }
  261 +#fkBody table { width: 100%; border-collapse: collapse; }
  262 +#fkBody th, #fkBody td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
  263 +#fkBody th { position: sticky; top: 0; background: var(--panel); color: var(--text2); font-weight: 500; }
  264 +#fkBody tbody tr { cursor: pointer; }
  265 +#fkBody tbody tr:hover { background: var(--accent-weak); }
  266 +#fkModal .m-foot {
  267 + padding: 10px 16px;
  268 + border-top: 1px solid var(--border);
  269 + display: flex;
  270 + align-items: center;
  271 + justify-content: flex-end;
  272 + gap: 10px;
  273 + color: var(--text2);
  274 + font-size: 13px;
  275 +}
  276 +#fkModal .m-foot button { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel); cursor: pointer; }
  277 +#fkModal .m-foot button:disabled { opacity: .4; cursor: default; }
  278 +
  279 +/* 提示条 */
  280 +#toast {
  281 + position: fixed;
  282 + top: 14px;
  283 + left: 50%;
  284 + transform: translateX(-50%);
  285 + background: #333;
  286 + color: #fff;
  287 + padding: 8px 18px;
  288 + border-radius: 8px;
  289 + font-size: 13px;
  290 + display: none;
  291 + z-index: 99;
  292 +}
  293 +</style>
414 294 </head>
415 295 <body>
416   -<div class="chat-container">
417   - <div class="chat-header">
418   - <div class="header-left">
419   - <h1>小羚羊Ai-agent智能体</h1>
420   - </div>
421   - <div class="header-right">
422   - <select class="model-selector" id="modelSelector">
423   - <option value="process">小羚羊印刷行业大模型</option>
424   - <option value="general">qwen2.5:14b</option>
425   - </select>
426   - <button class="model-selector" onclick="newConversation()">新建会话</button>
  296 +
  297 +<aside id="sidebar">
  298 + <div class="side-head">
  299 + <span class="brand">小羚羊 AI</span>
  300 + <button id="newConvBtn" title="新会话">+ 新会话</button>
  301 + </div>
  302 + <div id="convList"></div>
  303 +</aside>
  304 +
  305 +<div id="main">
  306 + <div id="topbar">
  307 + <button id="toggleSide" title="收起/展开会话栏">☰</button>
  308 + <span class="title">小羚羊印刷 ERP 智能助手</span>
  309 + <span class="sub" id="convTitle"></span>
  310 + </div>
  311 + <div id="scroll">
  312 + <div id="messages"></div>
  313 + </div>
  314 + <div id="inputbar">
  315 + <div id="inputbox">
  316 + <textarea id="messageInput" rows="1" placeholder="输入消息,如:查一下这个月的报价单 / 给必胜客报价5000个纸盒…"></textarea>
  317 + <button id="sendBtn">发送</button>
427 318 </div>
428 319 </div>
  320 +</div>
429 321  
430   - <div class="chat-body">
431   - <div class="sidebar">
432   - <div class="sidebar-title">会话</div>
433   - <button class="preset-question" style="text-align:center;font-weight:600;background:#eef;" onclick="newConversation()">+ 新建会话</button>
434   - <div id="convList"></div>
  322 +<div id="modalMask">
  323 + <div id="fkModal">
  324 + <div class="m-head">
  325 + <span class="m-title" id="fkTitle">选择</span>
  326 + <input id="fkSearch" placeholder="输入名称搜索…">
  327 + <button id="fkClose" title="关闭">✕</button>
435 328 </div>
436   - <div class="chat-main">
437   - <div class="messages-container">
438   - <div class="chat-messages" id="chatMessages">
439   - <!-- 初始欢迎消息 -->
440   - <div class="message ai-message">
441   - <div class="message-bubble">
442   - <div class="message-content" id="ts">
443   - <strong></strong><br><br>
444   - </div>
445   - <div class="message-meta">
446   - <span class="message-time" id="welcomeTime"></span>
447   - </div>
448   - </div>
449   - </div>
450   - </div>
451   - </div>
452   -
453   - <div class="input-section">
454   - <div class="chat-input-container">
455   - <div class="input-wrapper">
456   - <input type="text" id="messageInput" placeholder="输入您的问题..." autocomplete="off">
457   - <button id="sendButton" onclick="sendMessage()">发送</button>
458   - <button id="reset" onclick="reset('重置')">重置</button>
459   - </div>
460   - </div>
461   - </div>
  329 + <div id="fkBody"></div>
  330 + <div class="m-foot">
  331 + <span id="fkPageInfo"></span>
  332 + <button id="fkPrev">‹</button>
  333 + <button id="fkNext">›</button>
462 334 </div>
463 335 </div>
464 336 </div>
465 337  
466   -<script>
467   - let sessionId ="";
468   - let conversationId = "c-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8);
469   - let userid= "17522967560005776104370282597000";
470   - let username= "qianb";
471   - let brandsid= "1111111111";
472   - let subsidiaryid= "1111111111";
473   - let usertype= "sysadmin";
474   - // ERP 登录 token(透传给后端 → 转发给 ERP,用于按真实用户身份读写;绝不进 prompt)。
475   - // 本地独立聊天页无浏览器登录流程,留空 → 后端回退 dev-login(admin)读写;
476   - // 生产环境由 ERP 外壳把用户的实时有效 token 注入这里,即启用逐用户 token 透传 + 表单权限收紧。
477   - let authorization="";
478   - let hrefLock = window.location.origin+"/xlyAi";
479   -
480   - const CONFIG = {
481   - backendUrl: hrefLock,
482   - headers: {
483   - 'Content-Type': 'application/json',
484   - 'Accept': 'application/json'
485   - },
486   - maxHistory: 20,
487   - };
488   -
489   - let chatHistory = [];
490   - let currentModel = 'general';
491   - const md = window.markdownit({
492   - html: true,
493   - linkify: true,
494   - typographer: true
495   - });
496   -
497   - $(document).ready(function() {
498   - document.getElementById('welcomeTime').textContent = getCurrentTime();
499   - $('#messageInput').focus();
500   - bindKeyboardEvents();
501   - ensureInputAtBottom();
502   - initConversations();
503   - });
504   -
505   - // ====================== 多命名会话(侧栏) ======================
506   - function convApi(path, opts) {
507   - return fetch(CONFIG.backendUrl + '/api/agent/conversations' + path, opts || {});
508   - }
509   -
510   - async function initConversations() {
511   - try {
512   - const res = await convApi('?userid=' + encodeURIComponent(userid));
513   - const list = await res.json();
514   - renderConvList(list);
515   - if (list && list.length > 0) {
516   - await switchConversation(list[0].id);
517   - }
518   - } catch (e) { console.log('会话初始化失败', e); }
519   - }
520   -
521   - async function loadConversations() {
522   - try {
523   - const res = await convApi('?userid=' + encodeURIComponent(userid));
524   - renderConvList(await res.json());
525   - } catch (e) { /* ignore */ }
526   - }
  338 +<div id="toast"></div>
527 339  
528   - function renderConvList(list) {
529   - const box = $('#convList');
530   - box.empty();
531   - (list || []).forEach(c => {
532   - const isActive = c.id === conversationId;
533   - const item = $('<div class="preset-question conv-item"></div>')
534   - .css({display:'flex', justifyContent:'space-between', alignItems:'center', gap:'6px'});
535   - if (isActive) item.css({background:'#667eea', color:'#fff', borderColor:'#667eea'});
536   - const title = $('<span></span>').text(c.title || '会话')
537   - .css({flex:'1', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'});
538   - const del = $('<span title="删除">×</span>').css({cursor:'pointer', fontWeight:'bold', opacity:'0.6'});
539   - title.on('click', () => switchConversation(c.id));
540   - del.on('click', (ev) => { ev.stopPropagation(); deleteConversation(c.id); });
541   - item.append(title).append(del);
542   - box.append(item);
  340 +<script>
  341 +/* ================= 身份(生产环境由 ERP 外壳注入真实值 + 用户 token;token 绝不进 prompt) ================= */
  342 +let userid = "17522967560005776104370282597000";
  343 +let brandsid = "1111111111";
  344 +let subsidiaryid = "1111111111";
  345 +let usertype = "sysadmin";
  346 +let authorization = ""; // 本地留空 → 后端回退 dev-login;生产注入用户实时 ERP token
  347 +const BASE = window.location.origin + "/xlyAi";
  348 +
  349 +const md = window.markdownit({ html: false, linkify: true, breaks: true });
  350 +
  351 +let conversationId = null;
  352 +let sending = false;
  353 +
  354 +const $ = (sel) => document.querySelector(sel);
  355 +const messagesEl = $("#messages");
  356 +const scrollEl = $("#scroll");
  357 +
  358 +function toast(msg) {
  359 + const t = $("#toast");
  360 + t.textContent = msg;
  361 + t.style.display = "block";
  362 + clearTimeout(t._h);
  363 + t._h = setTimeout(() => { t.style.display = "none"; }, 2600);
  364 +}
  365 +
  366 +function scrollBottom() {
  367 + scrollEl.scrollTop = scrollEl.scrollHeight;
  368 +}
  369 +
  370 +function el(tag, cls, text) {
  371 + const e = document.createElement(tag);
  372 + if (cls) e.className = cls;
  373 + if (text !== undefined) e.textContent = text;
  374 + return e;
  375 +}
  376 +
  377 +/* ================= 消息渲染 ================= */
  378 +function addUserMsg(text) {
  379 + const row = el("div", "msg user");
  380 + row.appendChild(el("div", "bubble", text));
  381 + messagesEl.appendChild(row);
  382 + scrollBottom();
  383 +}
  384 +
  385 +function addAiBubble() {
  386 + const row = el("div", "msg ai");
  387 + const b = el("div", "bubble thinking", "…");
  388 + row.appendChild(b);
  389 + messagesEl.appendChild(row);
  390 + scrollBottom();
  391 + return b;
  392 +}
  393 +
  394 +function renderMd(bubble, text) {
  395 + bubble.classList.remove("thinking");
  396 + bubble.innerHTML = md.render(text || "");
  397 +}
  398 +
  399 +function addAiMd(text) {
  400 + const b = addAiBubble();
  401 + renderMd(b, text);
  402 + return b;
  403 +}
  404 +
  405 +/* ================= 会话管理 ================= */
  406 +async function loadConvs() {
  407 + try {
  408 + const r = await fetch(BASE + "/api/agent/conversations?userid=" + encodeURIComponent(userid));
  409 + const list = await r.json();
  410 + const box = $("#convList");
  411 + box.innerHTML = "";
  412 + list.forEach(c => {
  413 + const item = el("div", "conv-item" + (c.id === conversationId ? " active" : ""));
  414 + const t = el("span", "t", c.title || "新会话");
  415 + const del = el("button", "del", "✕");
  416 + del.title = "删除会话";
  417 + del.onclick = async (e) => {
  418 + e.stopPropagation();
  419 + await fetch(BASE + "/api/agent/conversations/" + encodeURIComponent(c.id) + "?userid=" + encodeURIComponent(userid), { method: "DELETE" });
  420 + if (c.id === conversationId) newConv();
  421 + loadConvs();
  422 + };
  423 + item.appendChild(t);
  424 + item.appendChild(del);
  425 + item.onclick = () => switchConv(c.id, c.title);
  426 + box.appendChild(item);
543 427 });
544   - }
545   -
546   - async function switchConversation(convId) {
547   - if (convId === conversationId) return;
548   - conversationId = convId;
549   - $('#chatMessages').empty();
550   - try {
551   - const res = await convApi('/' + convId + '/messages');
552   - const msgs = await res.json();
553   - (msgs || []).forEach(m => addMessage(m.content, m.role === 'user' ? 'user' : 'ai'));
554   - if (!msgs || msgs.length === 0) addMessage('(空会话)请提问。', 'ai');
555   - } catch (e) { console.log('加载历史失败', e); }
556   - renderConvList(await (await convApi('?userid=' + encodeURIComponent(userid))).json());
557   - ensureInputAtBottom();
558   - }
559   -
560   - async function newConversation() {
  428 + } catch (e) { /* 静默:侧栏失败不影响聊天 */ }
  429 +}
  430 +
  431 +async function newConv() {
  432 + try {
  433 + const r = await fetch(BASE + "/api/agent/conversations", {
  434 + method: "POST",
  435 + headers: { "Content-Type": "application/json" },
  436 + body: JSON.stringify({ userid: userid })
  437 + });
  438 + conversationId = (await r.json()).conversationId;
  439 + } catch (e) {
  440 + conversationId = "c-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8);
  441 + }
  442 + $("#convTitle").textContent = "";
  443 + messagesEl.innerHTML = "";
  444 + addAiMd("你好,我是小羚羊 AI 助手。可以帮你查询业务数据、新建报价、修改/作废/审核单据。");
  445 + loadConvs();
  446 +}
  447 +
  448 +async function switchConv(id, title) {
  449 + conversationId = id;
  450 + $("#convTitle").textContent = title || "";
  451 + messagesEl.innerHTML = "";
  452 + try {
  453 + const r = await fetch(BASE + "/api/agent/conversations/" + encodeURIComponent(id) + "/messages");
  454 + const hist = await r.json();
  455 + hist.forEach(m => {
  456 + if (m.role === "user") addUserMsg(m.content);
  457 + else addAiMd(m.content);
  458 + });
  459 + } catch (e) { }
  460 + loadConvs();
  461 + scrollBottom();
  462 +}
  463 +
  464 +/* ================= 发送 & SSE ================= */
  465 +async function sendMessage(text) {
  466 + text = (text || "").trim();
  467 + if (!text || sending) return;
  468 + sending = true;
  469 + $("#sendBtn").disabled = true;
  470 + addUserMsg(text);
  471 + const bubble = addAiBubble();
  472 + let buf = "";
  473 + try {
  474 + const resp = await fetch(BASE + "/api/agent/chat", {
  475 + method: "POST",
  476 + headers: { "Content-Type": "application/json" },
  477 + body: JSON.stringify({
  478 + text: text,
  479 + userid: userid,
  480 + conversationId: conversationId,
  481 + authorization: authorization,
  482 + brandsid: brandsid,
  483 + subsidiaryid: subsidiaryid,
  484 + usertype: usertype
  485 + })
  486 + });
  487 + const reader = resp.body.getReader();
  488 + const dec = new TextDecoder();
  489 + let pending = "";
  490 + for (;;) {
  491 + const { done, value } = await reader.read();
  492 + if (done) break;
  493 + pending += dec.decode(value, { stream: true });
  494 + let idx;
  495 + while ((idx = pending.indexOf("\n\n")) >= 0) {
  496 + const frame = pending.slice(0, idx);
  497 + pending = pending.slice(idx + 2);
  498 + const line = frame.split("\n").find(l => l.startsWith("data:"));
  499 + if (!line) continue;
  500 + let ev;
  501 + try { ev = JSON.parse(line.slice(5)); } catch (e) { continue; }
  502 + buf = handleEvent(ev, bubble, buf);
  503 + }
  504 + }
  505 + } catch (e) {
  506 + renderMd(bubble, "⚠️ 连接失败:" + e.message);
  507 + }
  508 + if (bubble.classList.contains("thinking")) {
  509 + bubble.closest(".msg").remove(); // 本轮只有卡片/表单,无正文
  510 + }
  511 + sending = false;
  512 + $("#sendBtn").disabled = false;
  513 + loadConvs(); // 刷新标题
  514 +}
  515 +
  516 +function handleEvent(ev, bubble, buf) {
  517 + switch (ev.type) {
  518 + case "token":
  519 + buf += ev.content || "";
  520 + renderMd(bubble, buf);
  521 + scrollBottom();
  522 + return buf;
  523 + case "reset":
  524 + renderMd(bubble, "");
  525 + bubble.classList.add("thinking");
  526 + bubble.textContent = "…";
  527 + return "";
  528 + case "write_proposal":
  529 + addProposalCard(ev.opId, ev.summary);
  530 + return buf;
  531 + case "question":
  532 + addQuestionCard(ev.question, ev.options || [], ev.allowFree !== false);
  533 + return buf;
  534 + case "form_collect":
  535 + addFormCard(ev);
  536 + return buf;
  537 + case "error":
  538 + renderMd(bubble, (buf ? buf + "\n\n" : "") + "⚠️ " + (ev.content || "服务异常"));
  539 + return buf;
  540 + case "done":
  541 + default:
  542 + return buf;
  543 + }
  544 +}
  545 +
  546 +/* ================= 写提议卡 ================= */
  547 +function addProposalCard(opId, summary) {
  548 + const card = el("div", "card");
  549 + card.appendChild(el("div", "card-title", "待确认操作"));
  550 + card.appendChild(el("div", "muted", summary || ""));
  551 + const actions = el("div", "actions");
  552 + const ok = el("button", "btn primary", "确认执行");
  553 + const no = el("button", "btn", "取消");
  554 + actions.appendChild(ok);
  555 + actions.appendChild(no);
  556 + card.appendChild(actions);
  557 + const result = el("div", "result");
  558 + card.appendChild(result);
  559 + messagesEl.appendChild(card);
  560 + scrollBottom();
  561 +
  562 + const finish = (cls, text) => {
  563 + ok.disabled = no.disabled = true;
  564 + result.className = "result " + cls;
  565 + result.textContent = text;
  566 + scrollBottom();
  567 + };
  568 + ok.onclick = async () => {
  569 + ok.disabled = no.disabled = true;
561 570 try {
562   - const res = await convApi('', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({userid: userid}) });
563   - conversationId = (await res.json()).conversationId;
  571 + const r = await fetch(BASE + "/api/agent/op/" + encodeURIComponent(opId) + "/confirm",
  572 + { method: "POST", headers: { "Authorization": authorization } });
  573 + const d = await r.json();
  574 + finish(d.status === "executed" ? "ok" : "bad", d.msg || d.status);
564 575 } catch (e) {
565   - conversationId = 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2,8);
  576 + finish("bad", "执行请求失败:" + e.message);
566 577 }
567   - $('#chatMessages').empty();
568   - addMessage('新会话已开始,请提问。', 'ai');
569   - loadConversations();
570   - $('#messageInput').focus();
571   - }
572   -
573   - async function deleteConversation(convId) {
574   - if (!confirm('删除这个会话?')) return;
575   - try { await convApi('/' + convId + '?userid=' + encodeURIComponent(userid), { method:'DELETE' }); } catch (e) {}
576   - if (convId === conversationId) {
577   - conversationId = 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2,8);
578   - $('#chatMessages').empty();
579   - addMessage('会话已删除,可新建或选择其它会话。', 'ai');
  578 + };
  579 + no.onclick = async () => {
  580 + ok.disabled = no.disabled = true;
  581 + try {
  582 + await fetch(BASE + "/api/agent/op/" + encodeURIComponent(opId) + "/cancel", { method: "POST" });
  583 + } catch (e) { }
  584 + finish("bad", "已取消");
  585 + };
  586 +}
  587 +
  588 +/* ================= askUser 追问卡(选项片 + 永远保留自由输入) ================= */
  589 +function addQuestionCard(question, options, allowFree) {
  590 + const card = el("div", "card");
  591 + card.appendChild(el("div", "card-title", question || "请补充信息"));
  592 + const chips = el("div", "chips");
  593 + options.forEach(o => {
  594 + const c = el("button", "chip", o);
  595 + c.onclick = () => { card.remove(); sendMessage(o); };
  596 + chips.appendChild(c);
  597 + });
  598 + if (options.length) card.appendChild(chips);
  599 + if (allowFree) {
  600 + const free = el("div", "chip-free");
  601 + const inp = el("input");
  602 + inp.placeholder = options.length ? "或输入其他答案…" : "输入你的回答…";
  603 + const go = el("button", "btn", "回复");
  604 + const submit = () => {
  605 + const v = inp.value.trim();
  606 + if (!v) return;
  607 + card.remove();
  608 + sendMessage(v);
  609 + };
  610 + go.onclick = submit;
  611 + inp.onkeydown = (e) => { if (e.key === "Enter") submit(); };
  612 + free.appendChild(inp);
  613 + free.appendChild(go);
  614 + card.appendChild(free);
  615 + }
  616 + messagesEl.appendChild(card);
  617 + scrollBottom();
  618 +}
  619 +
  620 +/* ================= collectForm 表单卡 ================= */
  621 +function addFormCard(ev) {
  622 + const card = el("div", "card");
  623 + card.appendChild(el("div", "card-title", ev.title || ("新建" + (ev.entity || ""))));
  624 + if (ev.message) card.appendChild(el("div", "muted", ev.message));
  625 + const grid = el("div", "form-grid");
  626 + const controls = []; // {label, required, get()}
  627 +
  628 + (ev.fields || []).forEach(f => {
  629 + const wrap = el("div", "ffield");
  630 + const lab = el("label");
  631 + lab.textContent = f.label || f.name;
  632 + if (f.required) lab.appendChild(el("span", "req", "*"));
  633 + wrap.appendChild(lab);
  634 +
  635 + let getter;
  636 + if (f.type === "fkselect") {
  637 + const row = el("div", "fkrow");
  638 + const inp = el("input");
  639 + inp.readOnly = true;
  640 + inp.placeholder = "点击选择…";
  641 + if (f.default) inp.value = f.default;
  642 + const pick = el("button", "btn", "选择");
  643 + pick.onclick = () => openFkPicker(f.fkTable, f.label || f.name, (name) => { inp.value = name; });
  644 + row.appendChild(inp);
  645 + row.appendChild(pick);
  646 + wrap.appendChild(row);
  647 + getter = () => inp.value.trim();
  648 + } else if (f.type === "select" && Array.isArray(f.options) && f.options.length) {
  649 + const sel = el("select");
  650 + sel.appendChild(el("option", null, ""));
  651 + f.options.forEach(o => {
  652 + const op = el("option", null, o);
  653 + op.value = o;
  654 + sel.appendChild(op);
  655 + });
  656 + if (f.default) sel.value = f.default;
  657 + wrap.appendChild(sel);
  658 + getter = () => sel.value;
  659 + } else {
  660 + const inp = el("input");
  661 + if (f.type === "number") { inp.type = "text"; inp.inputMode = "decimal"; }
  662 + else if (f.type === "date") inp.type = "date";
  663 + if (f.default) inp.value = f.default;
  664 + wrap.appendChild(inp);
  665 + getter = () => inp.value.trim();
  666 + }
  667 + if (f.hint) wrap.appendChild(el("div", "hint", f.hint));
  668 + grid.appendChild(wrap);
  669 + controls.push({ label: f.label || f.name, required: !!f.required, get: getter });
  670 + });
  671 + card.appendChild(grid);
  672 +
  673 + const actions = el("div", "actions");
  674 + const submit = el("button", "btn primary", "提交");
  675 + const result = el("div", "result");
  676 + actions.appendChild(submit);
  677 + card.appendChild(actions);
  678 + card.appendChild(result);
  679 + messagesEl.appendChild(card);
  680 + scrollBottom();
  681 +
  682 + submit.onclick = async () => {
  683 + const fields = {};
  684 + for (const c of controls) {
  685 + const v = c.get();
  686 + if (c.required && !v) {
  687 + result.className = "result bad";
  688 + result.textContent = "「" + c.label + "」为必填项。";
  689 + return;
  690 + }
  691 + if (v) fields[c.label] = v;
580 692 }
581   - loadConversations();
582   - }
583   -
584   - function generateRandomString(length) {
585   - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
586   - let result = '';
587   - for (let i = 0; i < length; i++) {
588   - result += chars.charAt(Math.floor(Math.random() * chars.length));
  693 + if (!Object.keys(fields).length) {
  694 + result.className = "result bad";
  695 + result.textContent = "请至少填写一个字段。";
  696 + return;
589 697 }
590   - return result;
591   - }
592   -
593   - window.onload = function(){
594   - $("#ts").html("<strong>你好,我是小羚羊 🦌</strong><br><br>我可以帮你查 ERP 里的业务单据。试试问我:<br>· “有哪些和报价相关的表单?”<br>· “客户资料在哪张表单里?”");
595   - }
596   -
597   - function reset(message){
598   - const input = $('#messageInput');
599   - const button = $('#sendButton');
600   - input.val('');
601   - input.prop('disabled', true);
602   - button.prop('disabled', true);
603   - doMessage(input,message,button);
604   - }
605   -
606   - async function sendMessage() {
607   - const input = $('#messageInput');
608   - const button = $('#sendButton');
609   - const message = input.val();
610   - if (!message) return;
611   - input.val('');
612   - input.prop('disabled', true);
613   - button.prop('disabled', true);
614   - doMessage(input, message, button);
615   - }
616   -
617   - // ======================
618   - // 单 agent 流式对话:POST /api/agent/chat -> SSE(text/event-stream)
619   - // 每帧一条 JSON:{type:"token|done|error", content:"..."}
620   - // ======================
621   - async function doMessage(input, message, button) {
622   - addMessage(message, 'user');
623   - showTypingIndicator();
624   -
625   - let aiText = '';
626   - let aiMsgId = null;
627   -
  698 + submit.disabled = true;
  699 + result.className = "result";
  700 + result.textContent = "提交中…";
628 701 try {
629   - const response = await fetch(`${CONFIG.backendUrl}/api/agent/chat`, {
  702 + // 确定性提交:结构化字段直达 proposeWrite(action=create),不经 LLM 再编码
  703 + const r = await fetch(BASE + "/api/agent/form/submit", {
630 704 method: "POST",
631   - headers: { "Content-Type": "application/json;charset=UTF-8" },
  705 + headers: { "Content-Type": "application/json" },
632 706 body: JSON.stringify({
633   - text: message,
  707 + entity: ev.entity,
  708 + fields: fields,
634 709 userid: userid,
635 710 conversationId: conversationId,
636   - // 透传身份 + ERP 登录 token(后端据此按用户真实权限收紧;token 只用于转发给 ERP,不进 prompt)
637 711 authorization: authorization,
638   - username: username,
639 712 brandsid: brandsid,
640 713 subsidiaryid: subsidiaryid,
641 714 usertype: usertype
642 715 })
643 716 });
644   - if (!response.ok) throw new Error("HTTP " + response.status);
645   -
646   - const reader = response.body.getReader();
647   - const decoder = new TextDecoder("utf-8");
648   - let buffer = '';
649   -
650   - while (true) {
651   - const { value, done } = await reader.read();
652   - if (done) break;
653   - buffer += decoder.decode(value, { stream: true });
654   -
655   - let sep;
656   - while ((sep = buffer.indexOf("\n\n")) >= 0) {
657   - const frame = buffer.slice(0, sep);
658   - buffer = buffer.slice(sep + 2);
659   - const dataLine = frame.split("\n").find(l => l.startsWith("data:"));
660   - if (!dataLine) continue;
661   - const payload = dataLine.slice(5).trim();
662   - if (!payload) continue;
663   - let evt;
664   - try { evt = JSON.parse(payload); } catch (e) { continue; }
665   -
666   - if (evt.type === "token") {
667   - if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); }
668   - aiText += evt.content;
669   - updateMessage(aiMsgId, aiText);
670   - } else if (evt.type === "reset") {
671   - // 工具执行前的旁白作废,清空气泡,等最终答复流入
672   - aiText = '';
673   - if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); }
674   - $(`#${aiMsgId} .message-content`).html('🔎 正在处理…');
675   - } else if (evt.type === "write_proposal") {
676   - renderProposalCard(evt.opId, evt.summary);
677   - } else if (evt.type === "form_collect") {
678   - renderFormCollect(evt.entity, evt.fields || []);
679   - } else if (evt.type === "question") {
680   - renderQuestion(evt.question, evt.options || []);
681   - } else if (evt.type === "error") {
682   - if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); }
683   - aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content;
684   - updateMessage(aiMsgId, aiText);
685   - }
686   - // evt.type === "done" -> 结束,无需处理
687   - }
688   - }
689   -
690   - hideTypingIndicator();
691   - if (aiMsgId === null) addMessage("(无响应,请重试)", 'ai');
692   -
693   - } catch (error) {
694   - console.error('错误:', error);
695   - hideTypingIndicator();
696   - if (aiMsgId === null) addMessage("服务异常,请重试:" + error.message, 'ai');
697   - } finally {
698   - input.prop('disabled', false);
699   - button.prop('disabled', false);
700   - input.focus();
701   - scrollToBottom();
702   - loadConversations();
703   - }
704   - }
705   -
706   - function updateMessage(messageId, content) {
707   - $(`#${messageId} .message-content`).html(md.render(content));
708   - scrollToBottom();
709   - }
710   -
711   - function escapeHtml(s){ return (s==null?'':String(s)).replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
712   -
713   - // ====================== 写操作确认卡片(人在环) ======================
714   - function renderProposalCard(opId, summary) {
715   - hideTypingIndicator();
716   - const cardId = 'op-' + opId;
717   - if (document.getElementById(cardId)) return;
718   - const html = `
719   - <div class="message ai-message" id="${cardId}">
720   - <div class="message-bubble" style="border:1px solid #ffd27f;background:#fff8ec;">
721   - <div class="message-content">
722   - <div style="font-weight:600;margin-bottom:8px;">⚠️ 待确认的修改</div>
723   - <div style="margin-bottom:12px;">${escapeHtml(summary)}</div>
724   - <div class="op-actions">
725   - <button onclick="confirmOp('${opId}')" style="padding:8px 18px;border:none;border-radius:16px;background:linear-gradient(90deg,#28a745,#20913c);color:#fff;cursor:pointer;font-weight:600;margin-right:8px;">确认执行</button>
726   - <button onclick="cancelOp('${opId}')" style="padding:8px 18px;border:1px solid #ccc;border-radius:16px;background:#fff;cursor:pointer;">取消</button>
727   - </div>
728   - <div class="op-result" style="margin-top:10px;color:#555;"></div>
729   - </div>
730   - </div>
731   - </div>`;
732   - $('#chatMessages').append(html);
733   - scrollToBottom();
734   - }
735   -
736   - async function confirmOp(opId) {
737   - const card = $('#op-' + opId);
738   - card.find('.op-actions button').prop('disabled', true);
739   - card.find('.op-result').text('处理中…');
740   - try {
741   - const res = await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/confirm', { method:'POST', headers:{ 'Authorization': authorization } });
742   - const data = await res.json();
743   - if (data.status === 'executed') {
744   - card.find('.op-actions').remove();
745   - card.find('.op-result').html('✅ ' + escapeHtml(data.msg || '已修改'));
  717 + const d = await r.json();
  718 + if (d.opId) {
  719 + result.className = "result ok";
  720 + result.textContent = "已生成待确认提议。";
  721 + addProposalCard(d.opId, d.summary);
746 722 } else {
747   - card.find('.op-result').html('❌ ' + escapeHtml(data.msg || '执行失败') + '(可重试)');
748   - card.find('.op-actions button').prop('disabled', false);
  723 + submit.disabled = false;
  724 + result.className = "result bad";
  725 + result.textContent = d.error || "提交失败。";
749 726 }
750 727 } catch (e) {
751   - card.find('.op-result').text('❌ 请求失败:' + e.message);
752   - card.find('.op-actions button').prop('disabled', false);
753   - }
754   - }
755   -
756   - async function cancelOp(opId) {
757   - const card = $('#op-' + opId);
758   - try { await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/cancel', { method:'POST', headers:{ 'Authorization': authorization } }); } catch (e) {}
759   - card.find('.op-actions').remove();
760   - card.find('.op-result').text('已取消');
761   - }
762   -
763   - // ====================== FormCollect:type-aware 动态表单 ======================
764   - // fields = [{name,label,type,required,default,options?,fkTable?}](新版)或 ["中文名",...](旧版兼容)
765   - // type: text | number | date | select(固定选项) | fkselect(选项从对应表实时取)
766   - function renderFormCollect(entity, fields) {
767   - hideTypingIndicator();
768   - const fid = 'fc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
769   - const norm = (fields || []).map(f => (typeof f === 'string') ? { name: f, label: f, type: 'text' } : f);
770   - const st = 'padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;';
771   - const inputs = norm.map((f, i) => {
772   - const label = escapeHtml(f.label || f.name);
773   - const req = f.required ? ' <span style="color:#e00;">*</span>' : '';
774   - const defv = f.default != null ? escapeHtml(String(f.default)) : '';
775   - const type = f.type || 'text';
776   - const ph = f.hint ? ` placeholder="${escapeHtml(String(f.hint))}"` : '';
777   - let control;
778   - if (type === 'select' && Array.isArray(f.options)) {
779   - const opts = ['<option value=""></option>']
780   - .concat(f.options.map(o => `<option${String(o)===defv?' selected':''}>${escapeHtml(String(o))}</option>`)).join('');
781   - control = `<select data-label="${label}" class="fc-input" style="${st}">${opts}</select>`;
782   - } else if (type === 'fkselect') {
783   - const dlId = `${fid}-dl-${i}`;
784   - control = `<input data-label="${label}" class="fc-input fc-fk" list="${dlId}" data-fk="${escapeHtml(f.fkTable||'')}" placeholder="输入以搜索…" autocomplete="off" style="${st}"><datalist id="${dlId}"></datalist>`;
785   - } else if (type === 'number') {
786   - control = `<input type="number" step="any" data-label="${label}" value="${defv}" class="fc-input" style="${st}"${ph}>`;
787   - } else if (type === 'date') {
788   - control = `<input type="date" data-label="${label}" value="${defv}" class="fc-input" style="${st}">`;
789   - } else {
790   - control = `<input type="text" data-label="${label}" value="${defv}" class="fc-input" style="${st}"${ph}>`;
791   - }
792   - return `<div style="margin-bottom:8px;"><label style="display:inline-block;width:130px;color:#555;vertical-align:middle;">${label}${req}</label>${control}</div>`;
793   - }).join('');
794   - const html = `
795   - <div class="message ai-message" id="${fid}">
796   - <div class="message-bubble" style="border:1px solid #b9d6ff;background:#f2f8ff;">
797   - <div class="message-content">
798   - <div style="font-weight:600;margin-bottom:10px;">📝 填写「${escapeHtml(entity)}」信息</div>
799   - ${inputs}
800   - <div style="margin-top:8px;">
801   - <button class="fc-submit" style="padding:8px 18px;border:none;border-radius:16px;background:linear-gradient(90deg,#667eea,#764ba2);color:#fff;cursor:pointer;font-weight:600;">提交</button>
802   - </div>
803   - </div>
804   - </div>
805   - </div>`;
806   - $('#chatMessages').append(html);
807   - // 外键下拉:聚焦拉一批候选,输入按关键词刷新(选项来自对应表)
808   - $(`#${fid} .fc-fk`).each(function () {
809   - const el = this, dlId = $(el).attr('list'), fkTable = $(el).data('fk');
810   - const load = (q) => fetch(`${CONFIG.backendUrl}/api/agent/form/options?table=${encodeURIComponent(fkTable)}&brandsid=${encodeURIComponent(brandsid)}&q=${encodeURIComponent(q||'')}`)
811   - .then(r => r.json()).then(list => {
812   - const dl = document.getElementById(dlId);
813   - if (dl) dl.innerHTML = (list||[]).map(o => `<option value="${escapeHtml(String(o))}"></option>`).join('');
814   - }).catch(() => {});
815   - let t = null;
816   - $(el).on('focus', () => load(''));
817   - $(el).on('input', function () { clearTimeout(t); const v = this.value; t = setTimeout(() => load(v), 250); });
818   - });
819   - $(`#${fid} .fc-submit`).on('click', function () {
820   - const parts = [];
821   - $(`#${fid} .fc-input`).each(function () {
822   - const v = ($(this).val() || '').trim();
823   - if (v) parts.push($(this).data('label') + '=' + v);
824   - });
825   - if (parts.length === 0) { alert('请至少填写一个字段'); return; }
826   - $(this).prop('disabled', true).text('已提交');
827   - $('#messageInput').val('为「' + entity + '」新增,字段如下:' + parts.join(',') + '。请调用 proposeWrite(action=create) 生成待确认的新增。');
828   - sendMessage();
829   - });
830   - scrollToBottom();
831   - }
832   -
833   - // ====================== AskUser:带可点选项的澄清问题 ======================
834   - function renderQuestion(question, options) {
835   - hideTypingIndicator();
836   - const qid = 'q-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
837   - const chips = (options || []).map(o =>
838   - `<button class="q-opt" data-val="${escapeHtml(String(o))}" style="margin:4px 6px 0 0;padding:6px 14px;border:1px solid #667eea;border-radius:16px;background:#fff;color:#667eea;cursor:pointer;">${escapeHtml(String(o))}</button>`
839   - ).join('');
840   - const html = `
841   - <div class="message ai-message" id="${qid}">
842   - <div class="message-bubble" style="border:1px solid #d9c6ff;background:#f7f2ff;">
843   - <div class="message-content">
844   - <div style="font-weight:600;margin-bottom:8px;">❓ ${escapeHtml(question)}</div>
845   - <div class="q-opts">${chips}</div>
846   - </div>
847   - </div>
848   - </div>`;
849   - $('#chatMessages').append(html);
850   - $(`#${qid} .q-opt`).on('click', function () {
851   - const val = $(this).data('val');
852   - $(`#${qid} .q-opt`).prop('disabled', true).css('opacity', '0.6');
853   - $(this).css({background:'#667eea', color:'#fff'});
854   - $('#messageInput').val(String(val));
855   - sendMessage();
856   - });
857   - scrollToBottom();
858   - }
859   -
860   - function getCurrentTime() {
861   - const now = new Date();
862   - return now.getHours().toString().padStart(2, '0') + ':' +
863   - now.getMinutes().toString().padStart(2, '0');
864   - }
865   -
866   - function addMessage(content, type = 'ai') {
867   - const messagesDiv = $('#chatMessages');
868   - const messageId = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
869   -
870   - const messageHtml = `
871   - <div class="message ${type}-message" id="${messageId}">
872   - <div class="message-bubble">
873   - <div class="message-content">${type === 'ai' ? md.render(content) : content}</div>
874   - <div class="message-meta">
875   - <span class="message-time">${getCurrentTime()}</span>
876   - <div class="message-actions">
877   - <button class="action-btn" onclick="copyMessage('${messageId}')">复制</button>
878   - </div>
879   - <div class="message-actions">
880   - <button class="action-btn" onclick="regenerateMessage('${messageId}')">重新生成</button>
881   - </div>
882   - </div>
883   - </div>
884   - </div>
885   - `;
886   -
887   - messagesDiv.append(messageHtml);
888   - scrollToBottom();
889   - return messageId;
890   - }
891   -
892   - function showTypingIndicator() {
893   - const messagesDiv = $('#chatMessages');
894   - const typingHtml = `
895   - <div class="message ai-message" id="typingIndicator">
896   - <div class="typing-indicator">
897   - <div class="typing-dot"></div>
898   - <div class="typing-dot"></div>
899   - <div class="typing-dot"></div>
900   - <span style="margin-left: 10px; color: #666; font-size: 14px;">正在思考...</span>
901   - </div>
902   - </div>
903   - `;
904   - messagesDiv.append(typingHtml);
905   - scrollToBottom();
906   - }
907   -
908   - function hideTypingIndicator() {
909   - $('#typingIndicator').remove();
910   - }
911   -
912   - function updateStatus(text, type = 'connected') {
913   - const indicator = $('#statusIndicator');
914   - const statusText = $('#statusText');
915   - statusText.text(text);
916   - indicator.removeClass('status-connected status-disconnected status-connecting');
917   - switch(type) {
918   - case 'connected':
919   - indicator.addClass('status-connected');
920   - break;
921   - case 'error':
922   - indicator.addClass('status-disconnected');
923   - break;
924   - case 'connecting':
925   - indicator.addClass('status-connecting');
926   - break;
927   - }
928   - }
929   -
930   - function scrollToBottom() {
931   - const messagesDiv = $('#chatMessages');
932   - setTimeout(() => {
933   - messagesDiv.scrollTop(messagesDiv[0].scrollHeight);
934   - }, 10);
935   - }
936   -
937   - function ensureInputAtBottom() {
938   - setTimeout(() => {
939   - scrollToBottom();
940   - const messagesDiv = $('#chatMessages');
941   - let bottomSpacer = messagesDiv.find('.bottom-spacer');
942   - if (bottomSpacer.length === 0) {
943   - messagesDiv.append('<div class="bottom-spacer"></div>');
944   - }
945   - }, 100);
946   - }
947   -
948   - function saveToHistory(role, content) {
949   - chatHistory.push({
950   - role: role,
951   - content: content,
952   - timestamp: Date.now()
953   - });
954   -
955   - if (chatHistory.length > CONFIG.maxHistory) {
956   - chatHistory = chatHistory.slice(-CONFIG.maxHistory);
957   - }
958   - localStorage.setItem('chatHistory', JSON.stringify(chatHistory));
959   - }
960   -
961   - function loadChatHistory() {
962   - const saved = localStorage.getItem('chatHistory');
963   - if (saved) {
964   - try {
965   - chatHistory = JSON.parse(saved);
966   - if (chatHistory.length > 0) {
967   - chatHistory.forEach(item => {
968   - if (item.role === 'user' || item.role === 'assistant') {
969   - addMessage(item.content, item.role === 'user' ? 'user' : 'ai');
970   - }
971   - });
972   - ensureInputAtBottom();
973   - }
974   - } catch (e) {
975   - console.error('加载聊天历史失败:', e);
976   - chatHistory = [];
977   - }
  728 + submit.disabled = false;
  729 + result.className = "result bad";
  730 + result.textContent = "提交失败:" + e.message;
978 731 }
979   - }
980   -
981   - function clearChat() {
982   - if (confirm('确定要清空当前对话吗?')) {
983   - $('#chatMessages').html(`
984   - <div class="message ai-message">
985   - <div class="message-bubble">
986   - <div class="message-content">
987   - 对话已清空,请开始新的对话。
988   - </div>
989   - <div class="message-meta">
990   - <span class="message-time">${getCurrentTime()}</span>
991   - </div>
992   - </div>
993   - </div>
994   - `);
995   - chatHistory = [];
996   - localStorage.removeItem('chatHistory');
997   - updateStatus('对话已清空', 'connected');
998   - sessionId ="";
999   - conversationId = "c-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8);
1000   - ensureInputAtBottom();
1001   - }
1002   - }
1003   -
1004   - function copyMessage(messageId) {
1005   - const messageContent = $(`#${messageId}`).find('.message-content').text();
1006   - navigator.clipboard.writeText(messageContent).then(() => {
1007   - const button = $(`#${messageId} .action-btn:first-child`);
1008   - const originalText = button.text();
1009   - button.text('已复制');
1010   - setTimeout(() => {
1011   - button.text(originalText);
1012   - }, 2000);
1013   - });
1014   - }
1015   -
1016   - function regenerateMessage(messageId) {
1017   - const messageDiv = $(`#${messageId}`);
1018   - const content = messageDiv.find('.message-content').text();
1019   - chatHistory = chatHistory.filter(item =>
1020   - item.role !== 'assistant' || item.content !== content
1021   - );
1022   - $('#messageInput').val(content);
1023   - sendMessage();
1024   - messageDiv.remove();
1025   - }
1026   -
1027   - function handleError(error) {
1028   - hideTypingIndicator();
1029   - const errorMessage = `
1030   - 抱歉,请求出现错误:${error.message}<br><br>
1031   - <strong>可能的原因:</strong><br>
1032   - 1. Spring Boot 后端服务未启动<br>
1033   - 2. API 接口路径不正确<br>
1034   - 3. 网络连接问题<br><br>
1035   - <strong>检查步骤:</strong><br>
1036   - 1. 确保后端服务在端口 8099 运行<br>
1037   - 2. 检查浏览器控制台查看详细错误<br>
1038   - 3. 刷新页面重试
1039   - `;
1040   - addMessage(errorMessage, 'ai');
1041   - updateStatus('请求失败', 'error');
1042   - ensureInputAtBottom();
1043   - }
1044   -
1045   - function bindKeyboardEvents() {
1046   - $('#messageInput').on('keypress', function(e) {
1047   - if (e.which === 13 && !e.shiftKey) {
1048   - e.preventDefault();
1049   - sendMessage();
1050   - }
1051   - });
1052   -
1053   - $(document).on('keydown', function(e) {
1054   - if (e.ctrlKey && e.key === 'Enter') {
1055   - sendMessage();
1056   - }
1057   - if (e.key === 'Escape') {
1058   - $('#messageInput').val('');
1059   - }
1060   - if (e.key === 'ArrowUp' && $('#messageInput').val() === '') {
1061   - const lastUserMessage = chatHistory
1062   - .filter(item => item.role === 'user')
1063   - .pop();
1064   - if (lastUserMessage) {
1065   - $('#messageInput').val(lastUserMessage.content);
1066   - e.preventDefault();
1067   - }
1068   - }
  732 + loadConvs();
  733 + };
  734 +}
  735 +
  736 +/* ================= FK 二级选择器(列表/搜索/分页/选择) ================= */
  737 +const fk = { table: null, q: "", page: 1, pageSize: 20, total: 0, onPick: null };
  738 +
  739 +function openFkPicker(table, title, onPick) {
  740 + fk.table = table;
  741 + fk.q = "";
  742 + fk.page = 1;
  743 + fk.onPick = onPick;
  744 + $("#fkTitle").textContent = "选择" + (title || "");
  745 + $("#fkSearch").value = "";
  746 + $("#modalMask").classList.add("show");
  747 + loadFkPage();
  748 + $("#fkSearch").focus();
  749 +}
  750 +
  751 +async function loadFkPage() {
  752 + const body = $("#fkBody");
  753 + body.innerHTML = '<div style="padding:20px;color:#888">加载中…</div>';
  754 + try {
  755 + const url = BASE + "/api/agent/form/options?table=" + encodeURIComponent(fk.table)
  756 + + "&brandsid=" + encodeURIComponent(brandsid)
  757 + + "&q=" + encodeURIComponent(fk.q)
  758 + + "&page=" + fk.page + "&pageSize=" + fk.pageSize;
  759 + const d = await (await fetch(url)).json();
  760 + fk.total = d.total || 0;
  761 + const cols = d.columns || [];
  762 + const rows = d.rows || [];
  763 + const table = el("table");
  764 + const thead = el("thead");
  765 + const trh = el("tr");
  766 + cols.forEach(c => trh.appendChild(el("th", null, c.label)));
  767 + thead.appendChild(trh);
  768 + table.appendChild(thead);
  769 + const tbody = el("tbody");
  770 + const nameCol = cols.length ? cols[0].col : null;
  771 + rows.forEach(r => {
  772 + const tr = el("tr");
  773 + cols.forEach(c => tr.appendChild(el("td", null, r[c.col] == null ? "" : String(r[c.col]))));
  774 + tr.onclick = () => {
  775 + $("#modalMask").classList.remove("show");
  776 + if (fk.onPick && nameCol) fk.onPick(String(r[nameCol] || ""));
  777 + };
  778 + tbody.appendChild(tr);
1069 779 });
1070   - }
1071   -
1072   - $('#modelSelector').on('change', function() {
1073   - currentModel = $(this).val();
1074   - updateStatus(`切换到${$(this).find('option:selected').text()}模式`, 'connected');
1075   - });
1076   -
1077   - $(window).on('resize', function() {
1078   - ensureInputAtBottom();
1079   - });
  780 + table.appendChild(tbody);
  781 + body.innerHTML = "";
  782 + if (!rows.length) {
  783 + body.innerHTML = '<div style="padding:20px;color:#888">没有匹配的记录</div>';
  784 + } else {
  785 + body.appendChild(table);
  786 + }
  787 + const pages = Math.max(1, Math.ceil(fk.total / fk.pageSize));
  788 + $("#fkPageInfo").textContent = "共 " + fk.total + " 条 · 第 " + fk.page + "/" + pages + " 页";
  789 + $("#fkPrev").disabled = fk.page <= 1;
  790 + $("#fkNext").disabled = fk.page >= pages;
  791 + } catch (e) {
  792 + body.innerHTML = '<div style="padding:20px;color:#c0392b">加载失败:' + e.message + "</div>";
  793 + }
  794 +}
  795 +
  796 +$("#fkClose").onclick = () => $("#modalMask").classList.remove("show");
  797 +$("#modalMask").onclick = (e) => { if (e.target === $("#modalMask")) $("#modalMask").classList.remove("show"); };
  798 +$("#fkPrev").onclick = () => { if (fk.page > 1) { fk.page--; loadFkPage(); } };
  799 +$("#fkNext").onclick = () => { fk.page++; loadFkPage(); };
  800 +let fkSearchTimer;
  801 +$("#fkSearch").oninput = (e) => {
  802 + clearTimeout(fkSearchTimer);
  803 + fkSearchTimer = setTimeout(() => { fk.q = e.target.value.trim(); fk.page = 1; loadFkPage(); }, 300);
  804 +};
  805 +
  806 +/* ================= 输入框 & 初始化 ================= */
  807 +const input = $("#messageInput");
  808 +input.addEventListener("keydown", (e) => {
  809 + if (e.key === "Enter" && !e.shiftKey) {
  810 + e.preventDefault();
  811 + const v = input.value;
  812 + input.value = "";
  813 + input.style.height = "auto";
  814 + sendMessage(v);
  815 + }
  816 +});
  817 +input.addEventListener("input", () => {
  818 + input.style.height = "auto";
  819 + input.style.height = Math.min(input.scrollHeight, 140) + "px";
  820 +});
  821 +$("#sendBtn").onclick = () => {
  822 + const v = input.value;
  823 + input.value = "";
  824 + input.style.height = "auto";
  825 + sendMessage(v);
  826 +};
  827 +$("#toggleSide").onclick = () => $("#sidebar").classList.toggle("hidden");
  828 +$("#newConvBtn").onclick = newConv;
  829 +
  830 +newConv();
1080 831 </script>
1081 832 </body>
1082 833 </html>
... ...