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,8 +60,6 @@ public class AgentChatController {
60 60
61 private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); 61 private static final Logger log = LoggerFactory.getLogger(AgentChatController.class);
62 private static final Set<String> WRITE_TOOLS = Set.of("proposeWrite"); 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 /** 反编造护栏:agent 声称「已生成/已完成」写操作的说法(无 proposeWrite 提议时要纠正)。 */ 63 /** 反编造护栏:agent 声称「已生成/已完成」写操作的说法(无 proposeWrite 提议时要纠正)。 */
66 private static final Pattern WRITE_CLAIM = Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核)"); 64 private static final Pattern WRITE_CLAIM = Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核)");
67 65
@@ -210,14 +208,9 @@ public class AgentChatController { @@ -210,14 +208,9 @@ public class AgentChatController {
210 return out; 208 return out;
211 } 209 }
212 210
213 - /** 意图门 + 确定性路由。 */ 211 + /** 意图门 + 确定性路由。表单提交不再走这里——见 {@link #formSubmit}(结构化直达,确定性)。 */
214 private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { 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 String digest = state.digest(convId); 214 String digest = state.digest(convId);
222 Intent it = intentService.classify(userInput, digest); 215 Intent it = intentService.classify(userInput, digest);
223 state.recordIntent(convId, it.intent, it.danju); 216 state.recordIntent(convId, it.intent, it.danju);
src/main/resources/templates/chat.html
1 <!DOCTYPE html> 1 <!DOCTYPE html>
2 <html lang="zh-CN"> 2 <html lang="zh-CN">
3 <head> 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 </head> 294 </head>
415 <body> 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 </div> 318 </div>
428 </div> 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 </div> 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 </div> 334 </div>
463 </div> 335 </div>
464 </div> 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 try { 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 } catch (e) { 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 try { 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 method: "POST", 704 method: "POST",
631 - headers: { "Content-Type": "application/json;charset=UTF-8" }, 705 + headers: { "Content-Type": "application/json" },
632 body: JSON.stringify({ 706 body: JSON.stringify({
633 - text: message, 707 + entity: ev.entity,
  708 + fields: fields,
634 userid: userid, 709 userid: userid,
635 conversationId: conversationId, 710 conversationId: conversationId,
636 - // 透传身份 + ERP 登录 token(后端据此按用户真实权限收紧;token 只用于转发给 ERP,不进 prompt)  
637 authorization: authorization, 711 authorization: authorization,
638 - username: username,  
639 brandsid: brandsid, 712 brandsid: brandsid,
640 subsidiaryid: subsidiaryid, 713 subsidiaryid: subsidiaryid,
641 usertype: usertype 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 } else { 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 } catch (e) { 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 </script> 831 </script>
1081 </body> 832 </body>
1082 </html> 833 </html>