Commit 52d7bade56c560448deeb928ef0fe2a1cdda66e9
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
Showing
2 changed files
with
796 additions
and
1052 deletions
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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> | ... | ... |