mingdao-harness 0.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/assets/tokenizer-data.json.gz +0 -0
  4. package/docs/ARCHITECTURE.md +108 -0
  5. package/docs/CONFIG.md +257 -0
  6. package/docs/DESKTOP-EVALUATION.md +48 -0
  7. package/docs/PROVIDERS.md +98 -0
  8. package/docs/QA-REPORT.md +333 -0
  9. package/install.bat +8 -0
  10. package/install.ps1 +57 -0
  11. package/install.sh +154 -0
  12. package/package.json +61 -0
  13. package/skills/api-design/SKILL.md +21 -0
  14. package/skills/code-review/SKILL.md +31 -0
  15. package/skills/debugging/SKILL.md +21 -0
  16. package/skills/docker/SKILL.md +27 -0
  17. package/skills/docx/SKILL.md +28 -0
  18. package/skills/frontend-design/SKILL.md +31 -0
  19. package/skills/git-commit/SKILL.md +32 -0
  20. package/skills/pdf/SKILL.md +30 -0
  21. package/skills/pptx/SKILL.md +24 -0
  22. package/skills/refactoring/SKILL.md +24 -0
  23. package/skills/release-checklist/SKILL.md +20 -0
  24. package/skills/testing/SKILL.md +30 -0
  25. package/skills/webapp-testing/SKILL.md +27 -0
  26. package/skills/xlsx/SKILL.md +28 -0
  27. package/src/agent.js +472 -0
  28. package/src/audit.js +68 -0
  29. package/src/autostart.js +74 -0
  30. package/src/batch.js +182 -0
  31. package/src/cachestats.js +215 -0
  32. package/src/cli.js +1099 -0
  33. package/src/commands/key.js +75 -0
  34. package/src/commands/schedule.js +176 -0
  35. package/src/commands/skill.js +168 -0
  36. package/src/commands/sync.js +222 -0
  37. package/src/commands/update.js +157 -0
  38. package/src/commands/workspace.js +109 -0
  39. package/src/compact.js +112 -0
  40. package/src/config.js +134 -0
  41. package/src/context.js +86 -0
  42. package/src/cost-guard.js +58 -0
  43. package/src/credentials.js +69 -0
  44. package/src/hooks.js +123 -0
  45. package/src/index.js +42 -0
  46. package/src/mcp-presets.js +78 -0
  47. package/src/mcp.js +284 -0
  48. package/src/memory.js +242 -0
  49. package/src/model-discovery.js +153 -0
  50. package/src/models.js +186 -0
  51. package/src/notify.js +38 -0
  52. package/src/permissions.js +83 -0
  53. package/src/pricing.js +156 -0
  54. package/src/prompts.js +58 -0
  55. package/src/providers/index.js +135 -0
  56. package/src/providers/openai-compatible.js +171 -0
  57. package/src/routing.js +126 -0
  58. package/src/schedule.js +434 -0
  59. package/src/session-index.js +122 -0
  60. package/src/session.js +131 -0
  61. package/src/skill-lib.js +350 -0
  62. package/src/skill-registry.js +165 -0
  63. package/src/skills.js +135 -0
  64. package/src/sync-server.js +564 -0
  65. package/src/sync.js +479 -0
  66. package/src/tasks.js +126 -0
  67. package/src/titles.js +75 -0
  68. package/src/tokenizer.js +287 -0
  69. package/src/tools/bash.js +165 -0
  70. package/src/tools/fs-tools.js +346 -0
  71. package/src/tools/index.js +287 -0
  72. package/src/ui.js +643 -0
  73. package/src/update.js +222 -0
  74. package/src/web/attachments.js +49 -0
  75. package/src/web/index.html +993 -0
  76. package/src/web/server.js +1173 -0
  77. package/src/web/web-io.js +107 -0
  78. package/src/workspace.js +157 -0
@@ -0,0 +1,993 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>MingDao 明道</title>
7
+ <link rel="manifest" href="/manifest.webmanifest">
8
+ <link rel="icon" href="/icon.svg" type="image/svg+xml">
9
+ <meta name="theme-color" content="#0f1115">
10
+ <style>
11
+ :root{
12
+ --bg:#0f1115; --bg2:#171a21; --bg3:#1f242e; --border:#2a303c;
13
+ --text:#e6e9ef; --dim:#9aa3b2; --faint:#5c6675;
14
+ --accent:#3ddc97; --accent2:#22b8cf; --warn:#e8b93d; --err:#e5534b;
15
+ --user:#1f3d57; --code:#11141a;
16
+ }
17
+ *{box-sizing:border-box}
18
+ html,body{height:100%}
19
+ body{margin:0;background:var(--bg);color:var(--text);font:15px/1.65 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;display:flex;flex-direction:column;overflow-x:hidden}
20
+ header{min-height:52px;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);flex:none;flex-wrap:nowrap;white-space:nowrap;overflow-x:auto}
21
+ header .logo{font-weight:700;font-size:16px;color:var(--accent);letter-spacing:.5px;flex:none}
22
+ header .logo span{color:var(--text)}
23
+ header .badge{font-size:12px;color:var(--dim);background:var(--bg3);border:1px solid var(--border);padding:2px 10px;border-radius:20px;flex:none;overflow:hidden;text-overflow:ellipsis;max-width:180px}
24
+ header button{white-space:nowrap;flex:none}
25
+ header select{flex:none}
26
+ #sessionSearch{flex:1 1 auto;min-width:70px;max-width:220px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:13px}
27
+ #modelSel{max-width:170px;min-width:0}
28
+ #permSel{max-width:150px;min-width:0}
29
+ #sessions{max-width:150px;min-width:0}
30
+ #wsSel{max-width:130px;min-width:64px}
31
+ @media (max-width:860px){main{padding:14px 0 8px}#chat{padding:0 10px}#envBadge{display:none!important}#sessionSearch{max-width:110px}#modelSel{max-width:110px}#permSel{max-width:120px}#sessions{max-width:110px}#wsSel{max-width:96px}header{gap:6px;padding:8px 8px}header .logo span{display:none}}
32
+ #sessionSearch:focus{outline:none;border-color:var(--accent2)}
33
+ #tasksPanel{position:fixed;right:0;top:52px;bottom:0;width:280px;background:var(--bg2);border-left:1px solid var(--border);z-index:40;display:flex;flex-direction:column}
34
+ .tp-head{padding:12px 14px;font-size:14px;font-weight:600;border-bottom:1px solid var(--border);color:var(--accent2)}
35
+ .tp-head span{color:var(--faint);font-weight:400;font-size:12px}
36
+ #tpList{flex:1;overflow-y:auto;padding:8px}
37
+ .tp-item{padding:8px 10px;border:1px solid var(--border);border-radius:8px;margin-bottom:8px;font-size:12.5px;background:var(--bg3)}
38
+ .tp-item .tp-title{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
39
+ .tp-item .tp-meta{color:var(--faint);font-size:11.5px;margin-top:3px;display:flex;gap:6px;align-items:center}
40
+ .tp-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
41
+ .tp-run{background:var(--accent);animation:blink 1.2s infinite}
42
+ .tp-done{background:var(--accent2)}
43
+ .tp-bad{background:var(--err)}
44
+ @keyframes blink{50%{opacity:.35}}
45
+ header .spacer{flex:1}
46
+ select,button{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:13px;cursor:pointer}
47
+ select:hover,button:hover{border-color:var(--accent2)}
48
+ button.primary{background:var(--accent);color:#06231a;border:none;font-weight:600}
49
+ button.danger{background:var(--err);color:#fff;border:none}
50
+ button:disabled{opacity:.45;cursor:not-allowed}
51
+ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
52
+ #chat{max-width:860px;margin:0 auto;padding:0 18px;display:flex;flex-direction:column;gap:18px}
53
+ .msg-user{display:flex;justify-content:flex-end}
54
+ .msg-user .bubble{background:var(--user);border:1px solid #2c4a66;border-radius:14px 14px 4px 14px;padding:10px 14px;max-width:78%;white-space:pre-wrap;word-break:break-word}
55
+ .msg-ai{display:flex;flex-direction:column;gap:10px}
56
+ .msg-ai .content{white-space:normal;word-break:break-word}
57
+ .msg-ai .content h1,.msg-ai .content h2,.msg-ai .content h3{font-size:1.05em;margin:.6em 0 .3em;color:var(--accent2)}
58
+ .msg-ai .content h4{font-size:1em;margin:.5em 0 .2em}
59
+ .msg-ai .content p{margin:.35em 0}
60
+ .msg-ai .content ul,.msg-ai .content ol{margin:.3em 0;padding-left:1.4em}
61
+ .msg-ai .content li{margin:.15em 0}
62
+ .msg-ai .content blockquote{margin:.4em 0;padding-left:10px;border-left:3px solid var(--border);color:var(--dim)}
63
+ .msg-ai .content code{background:var(--code);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font:12.5px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;color:#7ee0b8}
64
+ .msg-ai .content pre{background:var(--code);border:1px solid var(--border);border-radius:10px;padding:12px 14px;overflow-x:auto;margin:.5em 0}
65
+ .msg-ai .content pre code{background:none;border:none;padding:0;color:#c8d3e0}
66
+ .msg-ai .content a{color:var(--accent2)}
67
+ .msg-ai .content .hl-kw{color:#7aa2f7;font-weight:600}.hl-s{color:#9ece6a}.hl-n{color:#e0af68}.hl-c{color:#565f6e;font-style:italic}.hl-f{color:#7dcfff}
68
+ .reasoning{margin:6px 0;border:1px dashed var(--border);border-radius:10px;background:var(--bg2)}
69
+ .reasoning summary{cursor:pointer;color:var(--faint);font-size:12.5px;padding:6px 12px;list-style:none}
70
+ .reasoning summary:after{content:"(点击展开)";margin-left:6px}
71
+ .reasoning[open] summary:after{content:""}
72
+ .reasoning summary.live{color:var(--accent2)}
73
+ .reasoning summary.live:after{content:"(思考中…)";color:var(--accent2);animation:blink 1.2s infinite}
74
+ .think{color:var(--dim);font-size:13px;padding:6px 2px;animation:blink 1.2s infinite}
75
+ #composer{display:flex;gap:8px;align-items:flex-end}
76
+ #attachBtn{padding:10px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:10px;cursor:pointer;font-size:15px}
77
+ #attachList{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px}
78
+ .deliver{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:10px;background:var(--bg2);padding:8px 12px;margin:4px 0;font-size:12.5px}
79
+ .deliver .t{color:var(--accent2);font-weight:600;margin-bottom:2px}
80
+ .deliver .i{color:var(--dim);padding:1px 0;font-family:ui-monospace,Consolas,monospace}
81
+ #hint.working{color:var(--accent2);animation:blink 1.6s infinite}
82
+ .atchip{background:var(--bg3);border:1px solid var(--border);border-radius:14px;padding:3px 10px;font-size:12px;cursor:pointer;color:var(--dim)}
83
+ .reasoning .body{padding:8px 12px;color:var(--dim);font-size:13px;white-space:pre-wrap;max-height:260px;overflow-y:auto;border-top:1px solid var(--border)}
84
+ .tool{border:1px solid var(--border);border-radius:10px;background:var(--bg2);overflow:hidden;margin:8px 0}
85
+ .tool .head{padding:8px 14px;display:flex;gap:10px;align-items:center;font-size:13.5px}
86
+ .tool .head .ico{width:22px;text-align:center}
87
+ .tool .head .name{font-weight:600;color:#8ab4f8}
88
+ .tool .head .args{color:var(--dim);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
89
+ .tool .head .st{font-size:12px;color:var(--dim)}
90
+ .tool .head .st.ok{color:var(--accent)}.tool .head .st.bad{color:var(--err)}
91
+ .tool .body{border-top:1px solid var(--border);padding:10px 14px;font-size:12.8px;overflow-x:auto}
92
+ .tool .body pre{margin:0;font:12.5px/1.5 ui-monospace,Consolas,monospace;white-space:pre-wrap;word-break:break-all}
93
+ .diff .l{display:block;padding:0 6px;border-radius:3px;white-space:pre-wrap}
94
+ .diff .del{background:rgba(229,83,75,.12);color:#ff8f88}
95
+ .diff .add{background:rgba(61,220,151,.12);color:#7ee2b8}
96
+ .diff .ctx{color:var(--faint)}
97
+ .todo{list-style:none;margin:6px 0;padding:0 4px}
98
+ .todo li{padding:4px 0;color:var(--dim);display:flex;gap:8px}
99
+ .todo li.done{color:var(--faint);text-decoration:line-through}
100
+ .todo li.doing{color:var(--accent2)}
101
+ .usage{font-size:12px;color:var(--faint);border-top:1px dashed var(--border);padding-top:6px}
102
+ .banner{text-align:center;color:var(--faint);font-size:12.5px}
103
+ .errline{color:var(--err);background:rgba(229,83,75,.08);border:1px solid rgba(229,83,75,.3);border-radius:10px;padding:10px 14px;font-size:13.5px}
104
+ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);padding:12px 18px}
105
+ #composer{max-width:860px;margin:0 auto;display:flex;gap:10px;align-items:flex-end}
106
+ #input{flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:12px;color:var(--text);padding:10px 14px;font:14px/1.6 inherit;resize:none;min-height:44px;max-height:200px}
107
+ #input:focus{outline:none;border-color:var(--accent2)}
108
+ #hint{font-size:11.5px;color:var(--faint);margin-top:6px;max-width:860px;margin-left:auto;margin-right:auto}
109
+ #statusBar{font-size:11.5px;color:var(--faint);margin-top:2px;max-width:860px;margin-left:auto;margin-right:auto;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
110
+ .modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:50}
111
+ .modal{background:var(--bg2);border:1px solid var(--border);border-radius:14px;width:min(520px,92vw);padding:18px;max-height:88vh;overflow-y:auto}
112
+ .modal h3{margin:0 0 8px;font-size:15px;color:var(--warn)}
113
+ .modal .q{color:var(--text);font-size:14px;white-space:pre-wrap;word-break:break-all;max-height:220px;overflow-y:auto;margin-bottom:14px}
114
+ .modal .opts{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}
115
+ .modal .opts button{text-align:left}
116
+ .cfg-row{margin:12px 0}
117
+ .cfg-row label{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--text)}
118
+ .cfg-row select,.cfg-row input[type=number]{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px}
119
+ .cfg-hint{font-size:12px;color:var(--faint);margin-top:4px}
120
+ .modal .row{display:flex;gap:10px;justify-content:flex-end}
121
+ .spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--accent);border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px}
122
+ @keyframes spin{to{transform:rotate(360deg)}}
123
+ </style>
124
+ </head>
125
+ <body>
126
+ <header>
127
+ <div class="logo">MingDao <span>明道</span></div>
128
+ <select id="modelSel" title="切换模型"></select>
129
+ <select id="permSel" title="权限模式">
130
+ <option value="ask">权限 ask</option>
131
+ <option value="auto">权限 auto</option>
132
+ <option value="readonly">权限 readonly</option>
133
+ </select>
134
+ <span id="cfgMsg" class="badge" style="display:none"></span>
135
+ <button id="cfgBtn" title="设置">⚙</button>
136
+ <select id="wsSel" title="工作空间(切换 / 新建,目录自动创建)"></select>
137
+ <span class="badge" id="envBadge"></span><span class="badge" id="costBadge" title="今日费用 / 缓存命中率(每 15s 刷新)"></span>
138
+ <div class="spacer"></div>
139
+ <input id="sessionSearch" placeholder="搜索会话…" title="关键词检索历史会话">
140
+ <select id="sessions" title="历史会话"><option value="">历史会话</option></select>
141
+ <button id="tasksBtn" title="任务面板">任务</button><button id="sessMgrBtn" title="管理会话(重命名/删除)">✎</button>
142
+ <button id="newChat">新对话</button>
143
+ </header>
144
+ <aside id="tasksPanel" style="display:none">
145
+ <div class="tp-head">任务面板 <span id="tpCount"></span><button id="tpClose" style="float:right">✕</button></div>
146
+ <div id="tpList"></div>
147
+ </aside>
148
+ <main><div id="chat"></div></main>
149
+ <footer>
150
+ <input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp,.txt,.md,.json,.js,.py,.log,.csv,.html,.css" multiple style="display:none">
151
+ <div id="attachList" style="display:none"></div>
152
+ <div id="composer">
153
+ <button id="attachBtn" title="上传图片或文本文件(图片需视觉模型,如 deepseek-v4-flash-vision-exp)">📎</button>
154
+ <textarea id="input" rows="1" placeholder="输入任务…(Enter 发送,Shift+Enter 换行)"></textarea>
155
+ <button id="sendBtn" class="primary">发送</button>
156
+ </div>
157
+ <div id="hint">工具执行与权限确认会实时展示;生成中可点「中断」停止。<label style="margin-left:12px;cursor:pointer;user-select:none"><input type="checkbox" id="journalChk" style="vertical-align:-2px;margin:0 4px 0 0">📌 带上文(最近会话日志,默认关=新会话全新开始)</label></div>
158
+ <div id="statusBar" title="累计使用统计(轮次/步数/LLM 与工具时长/首 token/吞吐/缓存命中/输入输出 tokens)"></div>
159
+ </footer>
160
+ <div id="modalRoot"></div>
161
+ <div id="sessModal" class="modal-mask" style="display:none">
162
+ <div class="modal">
163
+ <h3>会话管理(重命名 / 删除)</h3>
164
+ <div id="sessList" style="max-height:300px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:4px 8px"></div>
165
+ <div class="row" style="margin-top:12px"><button id="sessClose">关闭</button></div>
166
+ </div>
167
+ </div>
168
+ <div id="cfgModal" class="modal-mask" style="display:none">
169
+ <div class="modal">
170
+ <h3>⚙ 设置</h3>
171
+ <hr style="border:none;border-top:1px solid var(--border);margin:0 0 10px">
172
+ <h4 style="margin:4px 0;color:var(--accent2)">模型与 API Key</h4>
173
+ <div id="pkList" style="max-height:150px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
174
+ <div class="cfg-hint">自定义模型(OpenAI 兼容端点):</div>
175
+ <div id="cmList" style="max-height:120px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
176
+ <div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
177
+ <input id="cmName" placeholder="模型名(如 my-gpt4)" style="flex:1;min-width:110px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
178
+ <input id="cmLabel" placeholder="标签(可选)" style="flex:1;min-width:90px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
179
+ </div>
180
+ <div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
181
+ <input id="cmUrl" placeholder="API 地址 https://…/v1" style="flex:1.4;min-width:170px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
182
+ <input id="cmKey" type="password" placeholder="API Key(可选)" style="flex:1;min-width:120px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
183
+ </div>
184
+ <div class="row" style="justify-content:flex-start"><button id="cmAdd">+ 添加自定义模型</button></div>
185
+ <div class="cfg-row" style="display:flex;gap:6px"><input id="baseUrlOverride" placeholder="API 地址覆盖(可选,覆盖当前服务商地址)" style="flex:1;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"><button id="baseUrlSave">保存</button></div>
186
+ <hr style="border:none;border-top:1px solid var(--border);margin:12px 0 8px">
187
+ <h4 style="margin:4px 0;color:var(--accent2)">云同步(跨设备会话同步,服务器端运行 mingdao sync-server)</h4>
188
+ <div id="syncStatus" style="font-size:12.5px;color:var(--dim);padding:2px"></div>
189
+ <div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
190
+ <input id="syncUrl" placeholder="服务器地址 https://…" style="flex:1.4;min-width:150px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
191
+ <input id="syncUser" placeholder="用户名" style="flex:1;min-width:90px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
192
+ </div>
193
+ <div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
194
+ <input id="syncPass" type="password" placeholder="密码(≥8 位)" style="flex:1;min-width:120px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
195
+ <input id="syncDevice" placeholder="设备名(默认主机名)" style="flex:1;min-width:120px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
196
+ </div>
197
+ <div class="row" style="justify-content:flex-start;align-items:center">
198
+ <button id="syncLogin">登录/注册</button><button id="syncPush">立即同步</button><button id="syncLogout" class="danger">退出</button>
199
+ <label style="font-size:12px"><input type="checkbox" id="syncAutoChk"> 会话结束自动同步</label>
200
+ </div>
201
+ <div class="cfg-row" style="display:flex;gap:6px"><input id="oldPass" type="password" placeholder="旧密码" style="flex:1;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"><input id="newPass" type="password" placeholder="新密码(≥8 位)" style="flex:1;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"><button id="passwdBtn">改密码</button></div>
202
+ <div class="cfg-hint">分享与协作(分享码给对方,对方接受后进入自己的会话列表):</div>
203
+ <div class="cfg-row" style="display:flex;gap:6px"><input id="shareName" placeholder="要分享的会话文件名(如 2026-08-21T10-00-00-aaaa.jsonl)" style="flex:1;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"><button id="shareCreate">创建分享</button></div>
204
+ <div id="shareList" style="max-height:110px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
205
+ <div class="cfg-row" style="display:flex;gap:6px"><input id="acceptCode" placeholder="输入分享码,接受对方会话…" style="flex:1;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"><button id="shareAccept">接受</button></div>
206
+ <div id="shareAccepted" style="max-height:90px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
207
+ <div class="cfg-hint">会话冲突(多设备同时修改同一会话时出现):</div>
208
+ <div id="conflictList" style="max-height:120px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
209
+ <div class="cfg-row"><label>沙箱模式(bash 执行隔离)</label>
210
+ <select id="sbxSel">
211
+ <option value="off">off — 直接执行</option>
212
+ <option value="readonly">readonly — 全盘只读(/tmp 可写)</option>
213
+ <option value="safe">safe — 只读 + 断网(最安全)</option>
214
+ </select>
215
+ <div class="cfg-hint" id="sbxHint"></div>
216
+ </div>
217
+ <div class="cfg-row"><label><input type="checkbox" id="routeChk"> 自动模型路由(规划→pro / 执行→flash)</label></div>
218
+ <div class="cfg-row"><label>上下文预算 tokens <input type="number" id="budgetInput" min="1000" step="1000"></label></div>
219
+ <div class="cfg-row"><label><input type="checkbox" id="autoStartChk"> 开机自启(登录后自动启动服务器)</label></div>
220
+ <div class="cfg-row"><label><input type="checkbox" id="notifyChk"> 后台任务完成/失败时桌面通知</label></div>
221
+ <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
222
+ <h4 style="margin:4px 0;color:var(--accent2)">调度队列(定时 / 周期 / 依赖)</h4>
223
+ <div id="schList" style="max-height:170px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
224
+ <div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
225
+ <input id="schQuestion" placeholder="定时任务内容…" style="flex:1;min-width:130px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
226
+ <select id="schWhen" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 8px;font-size:13px">
227
+ <option value="at">定时一次</option><option value="every">周期</option><option value="after">依赖任务ID</option><option value="chain">链式(每行一个任务)</option>
228
+ </select>
229
+ </div>
230
+ <div class="cfg-row" id="schAtRow"><input type="datetime-local" id="schAt" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"></div>
231
+ <div class="cfg-row" style="align-items:center;gap:8px"><label style="font-size:12px;color:var(--dim);display:flex;align-items:center;gap:4px"><input type="checkbox" id="schOffpeak">🌙 避峰执行(高峰时段自动顺延到 14:00 后,输入价省 50%)</label></div>
232
+ <div class="cfg-row" id="schEveryRow" style="display:none">
233
+ <select id="schEvery" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
234
+ <option value="30s">每 30 秒</option><option value="5m">每 5 分钟</option><option value="1h">每 1 小时</option><option value="1d">每 1 天</option>
235
+ </select>
236
+ </div>
237
+ <div class="cfg-row" id="schAfterRow" style="display:none"><input id="schAfter" placeholder="任务 ID(mingdao run 返回)或调度 ID" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"></div>
238
+ <div class="cfg-row" id="schChainRow" style="display:none"><textarea id="schChain" rows="4" placeholder="每行一个任务,按顺序执行(后者依赖前者成功)&#10;构建&#10;测试&#10;部署" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px;resize:vertical"></textarea></div>
239
+ <div class="row"><button id="schAdd">+ 添加调度</button></div>
240
+ <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
241
+ <h4 style="margin:4px 0;color:var(--accent2)">工作空间(项目目录注册表,与 CLI 共享)</h4>
242
+ <div id="wsList" style="max-height:150px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
243
+ <div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
244
+ <input id="wsName" placeholder="空间名称…" style="flex:1;min-width:90px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
245
+ <input id="wsDir" placeholder="目录(留空 = 服务器工作目录)" style="flex:1.4;min-width:140px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
246
+ </div>
247
+ <div class="row" style="justify-content:flex-start"><button id="wsAdd">+ 登记工作空间</button></div>
248
+ <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
249
+ <h4 style="margin:4px 0;color:var(--accent2)">长期记忆(注入每次会话的偏好/约定)</h4>
250
+ <textarea id="memArea" rows="5" placeholder="例如:所有文档用中文;提交信息用约定式提交…" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12.5px;resize:vertical"></textarea>
251
+ <div class="row" style="justify-content:flex-start"><button id="memSave">保存记忆</button><button id="memDedupe">去重合并</button><span id="memMsg" style="font-size:12px;color:var(--faint)"></span></div>
252
+ <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
253
+ <h4 style="margin:4px 0;color:var(--accent2)">缓存命中率仪表盘(最近 10 次)</h4>
254
+ <div id="cacheSummary" style="font-size:12.5px;color:var(--dim);padding:2px"></div>
255
+ <div id="cacheRecent" style="max-height:130px;overflow-y:auto"></div>
256
+ <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
257
+ <h4 style="margin:4px 0;color:var(--accent2)">MCP 生态预设(一键接入,重启生效)</h4>
258
+ <div id="mcpList" style="font-size:12px;color:var(--dim);padding:2px"></div>
259
+ <div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
260
+ <select id="mcpPresetSel" style="flex:1.3;min-width:150px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 8px;font-size:13px"></select>
261
+ <input id="mcpArg" placeholder="参数(按提示,留空用默认)" style="flex:1;min-width:140px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
262
+ </div>
263
+ <div class="row" style="justify-content:flex-start"><button id="mcpAdd">+ 接入 MCP 服务</button></div>
264
+ <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
265
+ <h4 style="margin:4px 0;color:var(--accent2)">技能库(搜索 / 一键安装,装到用户级可改可删)<button id="skillRegRefresh" style="float:right;padding:1px 8px;font-size:11px">刷新线上</button></h4>
266
+ <div class="cfg-row"><input id="skillSearch" placeholder="搜索技能…(留空显示全部)" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"></div>
267
+ <div id="skillLibList" style="max-height:170px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
268
+ <div class="row"><button id="cfgCancel">取消</button><button class="primary" id="cfgSave">保存</button></div>
269
+ </div>
270
+ </div>
271
+ <script>
272
+ // 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
273
+ // 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
274
+ const AUTH_TOKEN = (() => {
275
+ try {
276
+ const q = new URLSearchParams(location.search).get('token');
277
+ if (q) {
278
+ sessionStorage.setItem('mingdao-token', q);
279
+ history.replaceState(null, '', location.pathname);
280
+ return q;
281
+ }
282
+ return sessionStorage.getItem('mingdao-token') || null;
283
+ } catch { return null; }
284
+ })();
285
+ if (AUTH_TOKEN) {
286
+ const rawFetch = window.fetch.bind(window);
287
+ window.fetch = (input, init = {}) => {
288
+ init.headers = Object.assign({}, init.headers, { 'X-MingDao-Token': AUTH_TOKEN });
289
+ return rawFetch(input, init);
290
+ };
291
+ }
292
+ const $ = (s) => document.querySelector(s);
293
+ const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
294
+ let generating = false; // 生成中:发送按钮复用为停止按钮
295
+ let currentSession = null, thinking = null;
296
+
297
+ function esc(s){return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
298
+
299
+ function highlight(code, lang){
300
+ const kws = new Set('const let var function return if else for while import from export class new await async try catch throw null undefined true false def elif raise with as lambda None True False self echo exit if then else fi done local function'.split(' '));
301
+ const re = /(\/\/[^\n]*|#[^\n]*|"(?:\\.|[^"\\])*"|'[^']*'|`[^`]*`|\b\d[\d._]*\b|[A-Za-z_$][\w$]*)/g;
302
+ return esc(code).replace(re, (tok)=>{
303
+ if(tok.startsWith('//')||tok.startsWith('#')) return '<span class="hl-c">'+tok+'</span>';
304
+ if(/^["'`]/.test(tok)) return '<span class="hl-s">'+tok+'</span>';
305
+ if(/^\d/.test(tok)) return '<span class="hl-n">'+tok+'</span>';
306
+ if(kws.has(tok)) return '<span class="hl-kw">'+tok+'</span>';
307
+ return tok;
308
+ });
309
+ }
310
+ function renderMarkdown(text){
311
+ const lines = String(text).split('\n'); let out='', code=null, codeLang='';
312
+ // 审计 P1-3:codeLang 经 esc() 转义防 XSS(信息串闭合 class 注入)
313
+ const flush=()=>{ if(code!==null){ out+='<pre><code class="lang-'+esc(codeLang)+'">'+highlight(code.join('\n'),codeLang)+'</code></pre>'; code=null; } };
314
+ for(const line of lines){
315
+ if(line.trim().startsWith('```')){
316
+ if(code===null){ code=[]; codeLang=(line.trim().slice(3).trim().split(/\s+/)[0]||''); }
317
+ else flush();
318
+ continue;
319
+ }
320
+ if(code!==null){ code.push(line); continue; }
321
+ let l = esc(line);
322
+ l = l.replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\*\*([^*]+)\*\*/g,'<b>$1</b>').replace(/\*([^*\s][^*]*)\*/g,'<i>$1</i>');
323
+ l = l.replace(/\[([^\]]+)\]\((https?:\/\/[^\s'"<>)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
324
+ if(/^#{1,6}\s/.test(line.trim())){ const m=line.trim().match(/^(#+)\s+(.*)/); const h=Math.min(m[1].length,3); l='<h'+h+'>'+esc(m[2])+'</h'+h+'>'; }
325
+ else if(/^\s*[-*+]\s/.test(line)) l='• '+l.replace(/^\s*[-*+]\s/,'');
326
+ else if(/^\s*\d+[.)]\s/.test(line)) l=l.replace(/^\s*(\d+)[.)]\s/,'<b>$1.</b> ');
327
+ else if(/^>\s?/.test(line)) l='<blockquote>'+l.replace(/^>\s?/,'')+'</blockquote>';
328
+ out+= l+'<br>';
329
+ }
330
+ flush();
331
+ return out;
332
+ }
333
+ function scrollBottom(){ chatEl.scrollTop = chatEl.scrollHeight; }
334
+
335
+ function newAiMsg(){ const el=document.createElement('div'); el.className='msg-ai'; chatEl.appendChild(el); scrollBottom(); return el; }
336
+ function aiContent(msg){ const c=msg.querySelector('.content'); return c || (()=>{const d=document.createElement('div');d.className='content';msg.appendChild(d);return d;})(); }
337
+ function addReasoning(msg){ const d=document.createElement('details'); d.className='reasoning'; d.innerHTML='<summary>思考过程</summary><div class="body"></div>'; msg.appendChild(d); return d.querySelector('.body'); }
338
+
339
+ function addUser(text){ const el=document.createElement('div'); el.className='msg-user'; el.innerHTML='<div class="bubble">'+esc(text)+'</div>'; chatEl.appendChild(el); scrollBottom(); }
340
+
341
+ function expandableBody(previewHtml, fullHtml){
342
+ const b=document.createElement('div'); b.className='body';
343
+ const prev=document.createElement('div'); const full=document.createElement('div');
344
+ prev.innerHTML=previewHtml; full.innerHTML=fullHtml; full.style.display='none';
345
+ const btn=document.createElement('button'); btn.textContent='展开全文'; btn.style.cssText='padding:1px 8px;font-size:11px;margin-top:4px';
346
+ btn.onclick=()=>{ const open=full.style.display!=='none'; full.style.display=open?'none':'block'; btn.textContent=open?'展开全文':'收起'; scrollBottom(); };
347
+ b.appendChild(prev); b.appendChild(full); b.appendChild(btn);
348
+ return b;
349
+ }
350
+ const runningTools = new Map();
351
+ function renderToolStartEvent(ev){
352
+ const msg = newAiMsg();
353
+ const card=document.createElement('div'); card.className='tool';
354
+ const ico = {'read':'📄','write':'✏️','edit':'✎','bash':'⚙','grep':'🔎','glob':'🔎','ls':'📁','task':'👥','skill':'🧩','todo':'☑','undo':'↩'}[ev.name]||'🔌';
355
+ const args = ev.name==='bash'? (ev.args&&ev.args.command):(ev.args&&(ev.args.path||ev.args.pattern||ev.args.name||''))||'';
356
+ card.innerHTML='<div class="head"><span class="ico">'+ico+'</span><span class="name">'+esc(ev.name)+'</span><span class="args">'+esc(args)+'</span><span class="st" style="animation:blink 1.2s infinite">执行中…</span></div>';
357
+ msg.appendChild(card);
358
+ runningTools.set(ev.seq, card);
359
+ scrollBottom();
360
+ }
361
+ function renderToolEvent(ev){
362
+ const msg = newAiMsg();
363
+ const card=document.createElement('div'); card.className='tool';
364
+ const ico = {'read':'📄','write':'✏️','edit':'✎','bash':'⚙','grep':'🔎','glob':'🔎','ls':'📁','task':'👥','skill':'🧩','todo':'☑','undo':'↩'}[ev.name]||'🔌';
365
+ const args = ev.name==='bash'? (ev.args&&ev.args.command):(ev.args&&(ev.args.path||ev.args.pattern||ev.args.name||''))||'';
366
+ const r = ev.result||{};
367
+ const ok = ev.result===undefined || r.ok!==false;
368
+ card.innerHTML='<div class="head"><span class="ico">'+ico+'</span><span class="name">'+esc(ev.name)+'</span><span class="args">'+esc(args)+'</span><span class="st '+(ok?'ok':'bad')+'">'+(ev.result===undefined?'…':(ok?'✓':'✖'))+(ev.durationMs!=null?' '+ev.durationMs+'ms':'')+'</span></div>';
369
+ const bodyWrap=(el)=>{ if(el) card.appendChild(el); };
370
+ if(ev.name==='skill'){
371
+ // 技能内容只供模型使用,不整篇倒给用户:一行摘要
372
+ const out=String(r.output||''); const desc=(out.match(/^description:\s*(.+)$/m)||[])[1]||'';
373
+ const b=document.createElement('div'); b.className='body';
374
+ b.innerHTML='<span style="color:var(--dim)">已加载技能 <b>'+esc(ev.args&&ev.args.name||'')+'</b>'+(desc?':'+esc(desc.slice(0,80)):'')+'(全文 '+out.length+' 字,供模型参考)</span>';
375
+ bodyWrap(b);
376
+ }else if(ev.name==='edit'&&r.diff){
377
+ const b=document.createElement('div'); b.className='body diff';
378
+ const a=(r.diff.before||'').split('\n'), bb=(r.diff.after||'').split('\n');
379
+ const n=Math.max(a.length,bb.length); const cap=40;
380
+ const line=(x)=>'<span class="l ctx"> '+esc(x??'')+'</span>';
381
+ const line2=(x)=>'<span class="l del">- '+esc(x)+'</span>';
382
+ const line3=(x)=>'<span class="l add">+ '+esc(x)+'</span>';
383
+ let prev='', full='';
384
+ for(let i=0;i<n;i++){
385
+ const html=(a[i]===bb[i])?line(a[i]??''):((a[i]!==undefined?line2(a[i]):'')+(bb[i]!==undefined?line3(bb[i]):''));
386
+ if(i<cap) prev+=html; full+=html;
387
+ }
388
+ if(n>cap){ b.appendChild(expandableBody(prev+'<div style="color:var(--faint)">…(共 '+n+' 行差异)</div>', full)); }
389
+ else b.innerHTML=prev;
390
+ bodyWrap(b);
391
+ }else if(ev.name==='write'&&r.ok){
392
+ const lines=String(ev.args&&ev.args.content||'').split('\n');
393
+ const b=document.createElement('div'); b.className='body';
394
+ b.innerHTML='<pre>'+esc(lines.slice(0,8).join('\n'))+(lines.length>8?'\n…(共 '+lines.length+' 行)':'')+'</pre>';
395
+ bodyWrap(b);
396
+ }else if(ev.name==='bash'&&r.ok){
397
+ const badge='<b style="color:'+(r.exitCode===0?'var(--accent)':'var(--err)')+'">'+(r.timedOut?'⏱ 超时':'exit '+r.exitCode)+'</b>';
398
+ const out=(r.stdout?'<pre>'+esc(String(r.stdout).slice(-1200))+'</pre>':'')+(r.stderr?'<pre style="color:#ff9d97">'+esc(String(r.stderr).slice(-600))+'</pre>':'');
399
+ const full=badge+out;
400
+ if((String(r.stdout||'').length+String(r.stderr||'').length)>1800) bodyWrap(expandableBody(badge+'<div style="color:var(--faint)">…(输出较长)</div>', full));
401
+ else { const b=document.createElement('div'); b.className='body'; b.innerHTML=full; bodyWrap(b); }
402
+ }else if(ev.name==='ls'&&r.ok){
403
+ const out=String(r.output||''); const lines=out.split('\n').filter((x)=>x.trim());
404
+ const names=lines.slice(0,3).map((x)=>x.trim()).join('、');
405
+ const prev='<span style="color:var(--dim)">列出 <b>'+lines.length+'</b> 项'+(names?':'+esc(names):'')+(lines.length>3?' 等':'')+'</span>';
406
+ if(lines.length>3) bodyWrap(expandableBody(prev, '<pre>'+esc(out)+'</pre>'));
407
+ else { const b=document.createElement('div'); b.className='body'; b.innerHTML=prev; bodyWrap(b); }
408
+ }else if(r.output){
409
+ const out=String(r.output);
410
+ const max=400;
411
+ if(out.length>max){
412
+ bodyWrap(expandableBody('<pre>'+esc(out.slice(0,max))+'…</pre>', '<pre>'+esc(out)+'</pre>'));
413
+ }else{
414
+ const b=document.createElement('div'); b.className='body'; b.innerHTML='<pre>'+esc(out)+'</pre>'; bodyWrap(b);
415
+ }
416
+ }else if(r.error){
417
+ const b=document.createElement('div'); b.className='body'; b.innerHTML='<span style="color:var(--err)">'+esc(r.error)+'</span>'; bodyWrap(b);
418
+ }
419
+ msg.appendChild(card);
420
+ if(ev.name==='todo'&&ev.result&&ev.result.todos) renderTodos(ev.result.todos, msg);
421
+ scrollBottom();
422
+ }
423
+ function renderTodos(todos,msg){
424
+ const ul=document.createElement('ul'); ul.className='todo';
425
+ for(const t of todos){ const li=document.createElement('li'); li.className=t.status==='completed'?'done':t.status==='in_progress'?'doing':''; li.innerHTML=(t.status==='completed'?'✓ ':'○ ')+esc(t.content); ul.appendChild(li); }
426
+ msg.appendChild(ul);
427
+ }
428
+ function renderBanner(ev){ const d=document.createElement('div'); d.className='banner'; d.textContent=ev.text||ev.title||''; chatEl.appendChild(d); scrollBottom(); }
429
+
430
+ function askModal(ev){
431
+ return new Promise(resolve=>{
432
+ const root=$('#modalRoot');
433
+ const mask=document.createElement('div'); mask.className='modal-mask';
434
+ const m=document.createElement('div'); m.className='modal';
435
+ m.innerHTML='<h3>权限确认</h3><div class="q">'+esc(ev.question)+'</div><div class="opts"></div><div class="row"><button class="danger" data-a="n">拒绝</button><button class="primary" data-a="y">允许</button></div>';
436
+ if(ev.options&&ev.options.length){ m.querySelector('.opts').style.display='flex'; ev.options.forEach(o=>{ const b=document.createElement('button'); b.dataset.a=o.value; b.textContent=o.label||o.value; b.onclick=()=>finish(o.value); m.querySelector('.opts').appendChild(b); }); m.querySelector('.row').style.display='none'; }
437
+ m.querySelectorAll('[data-a]').forEach(b=>{ if(!b.onclick) b.onclick=()=>finish(b.dataset.a); });
438
+ function finish(a){ mask.remove(); resolve(a); }
439
+ mask.appendChild(m); root.appendChild(mask);
440
+ });
441
+ }
442
+
443
+ async function handleEvents(stream, onEvent){
444
+ const reader=stream.getReader(); const dec=new TextDecoder(); let buf='';
445
+ for(;;){ const {done,value}=await reader.read(); if(done) break; buf+=dec.decode(value,{stream:true});
446
+ let nl; while((nl=buf.indexOf('\n'))>=0){ const line=buf.slice(0,nl).trim(); buf=buf.slice(nl+1); if(!line.startsWith('data:'))continue; try{ onEvent(JSON.parse(line.slice(5))); }catch{} }
447
+ }
448
+ }
449
+
450
+ // —— 附件(图片 / 文本文件) ——
451
+ let attachments=[];
452
+ $('#attachBtn').onclick=()=>{ $('#fileInput').click(); };
453
+ $('#fileInput').addEventListener('change', async e=>{
454
+ for(const f of e.target.files||[]){
455
+ if(attachments.length>=4){ alert('最多 4 个附件'); break; }
456
+ if(f.type.startsWith('image/')){
457
+ if(f.size>5*1024*1024){ alert(f.name+' 超过 5MB'); continue; }
458
+ const dataUrl=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); });
459
+ attachments.push({type:'image',name:f.name,dataUrl});
460
+ }else if(f.type.startsWith('text/')||/\.(txt|md|json|js|py|log|csv|html|css)$/i.test(f.name)){
461
+ if(f.size>200*1024){ alert(f.name+' 超过 200KB'); continue; }
462
+ const content=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsText(f); });
463
+ attachments.push({type:'text',name:f.name,content});
464
+ }else{
465
+ alert(f.name+':暂只支持图片与文本文件');
466
+ }
467
+ }
468
+ e.target.value='';
469
+ renderAttachments();
470
+ });
471
+ function renderAttachments(){
472
+ const list=$('#attachList'); list.innerHTML='';
473
+ if(!attachments.length){ list.style.display='none'; return; }
474
+ list.style.display='flex';
475
+ for(let i=0;i<attachments.length;i++){
476
+ const a=attachments[i];
477
+ const chip=document.createElement('span'); chip.className='atchip';
478
+ chip.textContent=(a.type==='image'?'🖼 ':'📄 ')+a.name+' ✕';
479
+ chip.title='点击移除';
480
+ chip.onclick=()=>{ attachments.splice(i,1); renderAttachments(); };
481
+ list.appendChild(chip);
482
+ }
483
+ }
484
+
485
+ async function send(){
486
+ if(generating){ abort(); return; } // 生成中:同一按钮承担停止职责
487
+ const text=input.value.trim(); if(!text && !attachments.length) return;
488
+ input.value=''; input.style.height='44px';
489
+ const payload={message:text,file:currentSession};
490
+ // 带上文开关:勾选后系统提示注入最近会话日志(默认不注入——新会话全新开始,避免串到历史会话上下文)
491
+ if($('#journalChk').checked) payload.withJournal=true;
492
+ if(attachments.length) payload.attachments=attachments;
493
+ const sentAttachments=attachments;
494
+ attachments=[]; renderAttachments();
495
+ addUser(text||(sentAttachments.map(a=>(a.type==='image'?'🖼 ':'📄 ')+a.name).join(' ')));
496
+ const msg=newAiMsg(); const content=aiContent(msg); let raw='';
497
+ let reason=null; let taskId=null;
498
+ // 等待输出时的可见状态:脉动「正在思考…」(每轮生成重新显示)
499
+ const think=document.createElement('div'); think.className='think'; think.textContent='💭 正在思考…'; msg.appendChild(think);
500
+ const showThink=()=>{ if(!think.parentNode) msg.appendChild(think); };
501
+ // 工作指示:提示栏实时显示已工作时长与执行步数,让用户时刻感受到智能体在干活
502
+ let stepsCount=0; const workT0=Date.now();
503
+ const hintEl=$('#hint'); const defaultHint=hintEl.textContent;
504
+ hintEl.classList.add('working');
505
+ const hintTimer=setInterval(()=>{ hintEl.textContent='⏳ 正在工作 '+Math.round((Date.now()-workT0)/1000)+'s · 已执行 '+stepsCount+' 步 — 可点「■ 停止」中断'; },1000);
506
+ const nearBottom=()=> chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 90;
507
+ const scroll=()=>{ if(nearBottom()) chatEl.scrollTop = chatEl.scrollHeight; };
508
+ let pending=false;
509
+ const update=()=>{ if(pending) return; pending=true; requestAnimationFrame(()=>{ pending=false; content.innerHTML=renderMarkdown(raw); scroll(); }); };
510
+ const onActivity=()=>{ if(think.parentNode) think.remove(); if(reason&&reason.parentElement){ reason.parentElement.open=false; reason.parentElement.querySelector('summary').classList.remove('live'); } };
511
+ updateTasksPanel();
512
+ try{
513
+ const resp=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
514
+ if(!resp.ok){ const j=await resp.json().catch(()=>({})); onActivity(); msg.innerHTML='<div class="errline">'+esc(j.error||('HTTP '+resp.status))+'</div>'; updateTasksPanel(); return; }
515
+ await handleEvents(resp.body, ev=>{
516
+ if(ev.taskId) taskId=ev.taskId;
517
+ if(ev.type==='text'){ onActivity(); raw+=ev.delta; update(); }
518
+ else if(ev.type==='reasoning'){ if(!reason){ reason=addReasoning(msg); } if(!reason.parentElement.open) reason.parentElement.open=true; reason.parentElement.querySelector('summary').classList.add('live'); reason.textContent+=ev.delta; reason.scrollTop=reason.scrollHeight; scroll(); }
519
+ else if(ev.type==='turnStart'){ stepsCount+=1; showThink(); scroll(); }
520
+ else if(ev.type==='toolStart'){ stepsCount+=1; onActivity(); update(); renderToolStartEvent(ev); }
521
+ else if(ev.type==='code'){ onActivity(); const pre=document.createElement('pre'); pre.innerHTML='<code>'+highlight(ev.code,ev.lang)+'</code>'; content.appendChild(pre); scroll(); }
522
+ else if(ev.type==='tool'){ onActivity(); update(); const pending=runningTools.get(ev.seq); if(pending){ pending.remove(); runningTools.delete(ev.seq); } renderToolEvent(ev); }
523
+ else if(ev.type==='toolDenied'){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent='✖ '+(ev.reason==='未授权'||!ev.reason?'未授权':ev.reason)+':'+ev.name; msg.appendChild(d); scroll(); }
524
+ else if(ev.type==='banner'){ renderBanner(ev); }
525
+ else if(ev.type==='ask'){ update(); askModal(ev).then(a=>fetch('/api/permission',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:ev.id,answer:a,taskId})}).catch(()=>{})); }
526
+ else if(ev.type==='usage'){ onActivity(); const u=document.createElement('div'); u.className='usage'; u.textContent=''+ev.modelName+' · ↑'+ev.usage.prompt_tokens+' ↓'+ev.usage.completion_tokens+' tokens · '+((ev.durationMs||0)/1000).toFixed(1)+'s'+ev.cost; msg.appendChild(u); scroll(); }
527
+ else if(ev.type==='error'){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=ev.message; msg.appendChild(d); scroll(); }
528
+ else if(ev.type==='done'){ onActivity(); if(ev.budget){ const b=ev.budget; hintEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; } refreshStatusBar(); if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); } if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); } currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
529
+ });
530
+ }catch(e){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=e.message||'网络错误'; msg.appendChild(d); scroll(); }
531
+ finally{ clearInterval(hintTimer); hintEl.classList.remove('working'); hintEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); scroll(); updateTasksPanel(); }
532
+ }
533
+
534
+ function abort(){ fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(()=>updateTasksPanel()).catch(()=>{}); }
535
+
536
+ // —— 任务面板(多会话并行) ——
537
+ async function updateTasksPanel(){
538
+ const r=await fetch('/api/tasks').catch(()=>null); if(!r) return;
539
+ const j=await r.json(); const list=$('#tpList'); list.innerHTML='';
540
+ $('#tpCount').textContent='('+j.running+' 运行中 / 上限 '+j.maxConcurrent+')';
541
+ // 发送按钮复用:生成中变「■ 停止」(点击中止全部运行中任务)
542
+ generating = j.running > 0;
543
+ sendBtn.textContent = generating ? '■ 停止' : '发送';
544
+ sendBtn.className = generating ? 'danger' : 'primary';
545
+ for(const t of j.tasks){
546
+ const div=document.createElement('div'); div.className='tp-item';
547
+ const dot = t.status==='running'?'tp-run':(t.status==='done'?'tp-done':'tp-bad');
548
+ const secs = t.status==='running'?'…':' '+(t.durationMs/1000).toFixed(1)+'s';
549
+ div.innerHTML='<div class="tp-title">'+esc(t.message||'任务')+'</div><div class="tp-meta"><span class="tp-dot '+dot+'"></span>'+t.status+secs+'</div>';
550
+ if(t.status==='running'){ const b=document.createElement('button'); b.textContent='中断'; b.className='danger'; b.style.cssText='margin-left:auto;padding:2px 8px;font-size:11px'; b.onclick=()=>fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({taskId:t.id})}).catch(()=>{}); div.querySelector('.tp-meta').appendChild(b); }
551
+ list.appendChild(div);
552
+ }
553
+ }
554
+ setInterval(updateTasksPanel, 2000);
555
+ // 费用徽标:今日费用 / 缓存命中率 / 护栏(15s 刷新)
556
+ async function refreshCostBadge(){
557
+ const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
558
+ const j=await r.json().catch(()=>null); if(!j) return;
559
+ const bd=j.breakdown||{}; const gd=j.guard||null;
560
+ let t='今日 ≈¥'+(bd.today||0).toFixed(4);
561
+ if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
562
+ if(gd&&gd.limit>0) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
563
+ $('#costBadge').textContent=t;
564
+ }
565
+ refreshCostBadge(); setInterval(refreshCostBadge, 15000);
566
+ // 底部状态栏:轮次/步数/LLM 与工具时长/首 token 平均/吞吐/缓存命中/输入输出 tokens
567
+ function fmtDur(ms){ if(!ms) return '0s'; const sec=Math.round(ms/1000); const m=Math.floor(sec/60); return m>0 ? m+'m'+String(sec%60).padStart(2,'0')+'s' : sec+'s'; }
568
+ function fmtTok(n){ if(n>=1e9) return (n/1e9).toFixed(1)+'B'; if(n>=1e6) return (n/1e6).toFixed(0)+'M'; if(n>=1e3) return (n/1e3).toFixed(1)+'K'; return String(n); }
569
+ async function refreshStatusBar(){
570
+ const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
571
+ const j=await r.json().catch(()=>null); if(!j) return;
572
+ const s=j.summary||{};
573
+ const ft = s.firstTokenAvgMs!=null ? (s.firstTokenAvgMs/1000).toFixed(0)+'s' : '—';
574
+ const tps = s.tokensPerSec!=null ? Math.round(s.tokensPerSec)+' tok/s' : '—';
575
+ const rate = s.rate!=null ? (s.rate*100).toFixed(1)+'%' : '—';
576
+ $('#statusBar').textContent = s.turns+' 轮 · '+s.steps+' 步 | LLM '+fmtDur(s.llmMs)+' · 工具调用 '+fmtDur(s.toolMs)+' | 首 token 平均 '+ft+' · '+tps+' | 缓存命中 '+rate+' | 输入 '+fmtTok(s.prompt)+' tok · 输出 '+fmtTok(s.completion)+' tok';
577
+ }
578
+ refreshStatusBar(); setInterval(refreshStatusBar, 15000);
579
+ $('#tasksBtn').onclick=()=>{ const p=$('#tasksPanel'); p.style.display = p.style.display==='none'?'flex':'none'; if(p.style.display==='flex') updateTasksPanel(); };
580
+ $('#tpClose').onclick=()=>{ $('#tasksPanel').style.display='none'; };
581
+
582
+ async function refreshSessions(q){ const u=q?'/api/sessions?q='+encodeURIComponent(q):'/api/sessions'; const r=await fetch(u).catch(()=>null); if(!r)return; const j=await r.json(); const sel=$('#sessions'); sel.innerHTML='<option value="">历史会话</option>'; for(const s of j.sessions){ const o=document.createElement('option'); o.value=s.file; o.textContent=s.label; sel.appendChild(o); } if(currentSession&&!q) sel.value=currentSession; }
583
+ let searchTimer=null;
584
+ $('#sessionSearch').addEventListener('input',e=>{ clearTimeout(searchTimer); searchTimer=setTimeout(()=>refreshSessions(e.target.value.trim()),300); });
585
+ async function init(){
586
+ try{
587
+ const r=await fetch('/api/state',{cache:'no-store'}); const j=await r.json();
588
+ const ms=$('#modelSel'); ms.innerHTML='';
589
+ const groups={}; const order=[];
590
+ for(const m of (j.models||[])){ const g=m.providerLabel||'其他'; if(!groups[g]){groups[g]=[];order.push(g);} groups[g].push(m); }
591
+ for(const g of order){
592
+ const og=document.createElement('optgroup'); og.label=g;
593
+ for(const m of groups[g]){ const o=document.createElement('option'); o.value=m.name; o.textContent=m.label; if(m.name===j.model) o.selected=true; og.appendChild(o); }
594
+ ms.appendChild(og);
595
+ }
596
+ if(!(j.models||[]).length){ const o=document.createElement('option'); o.value=j.model; o.textContent=j.model; ms.appendChild(o); }
597
+ $('#permSel').value=j.permission||'ask';
598
+ $('#sbxSel').value=j.sandbox||'off';
599
+ $('#sbxHint').textContent=j.sandboxSupported?'':'当前环境未检测到 bubblewrap,readonly/safe 将自动降级为 off';
600
+ $('#routeChk').checked=Boolean(j.routing);
601
+ $('#budgetInput').value=j.contextBudget||128000;
602
+ $('#autoStartChk').checked=Boolean(j.autostart);
603
+ $('#notifyChk').checked=j.notify!==false;
604
+ const env = ('路由'+(j.routing?'开':'关')+' · 沙箱'+((j.sandbox&&j.sandbox!=='off')?(j.sandboxSupported?j.sandbox:'降级'):'off'));
605
+ $('#envBadge').textContent=env; $('#envBadge').style.display='';
606
+ }catch(e){
607
+ const ms=$('#modelSel'); if(!ms.options.length){ const o=document.createElement('option'); o.textContent='加载失败,刷新重试'; ms.appendChild(o); }
608
+ }
609
+ refreshSessions();
610
+ refreshWsSel();
611
+ try{
612
+ const dr=await fetch('/api/draft',{cache:'no-store'}); const dj=await dr.json();
613
+ if(dj.text){ input.value=dj.text; input.style.height=Math.min(input.scrollHeight,200)+'px'; input.focus(); }
614
+ }catch(e){}
615
+ }
616
+ $('#cfgBtn').onclick=()=>{ $('#cfgModal').style.display='flex'; };
617
+ $('#cfgCancel').onclick=()=>{ $('#cfgModal').style.display='none'; };
618
+ // —— 工作空间头部下拉(⚙ 右侧:切换 / 新建 / 管理) ——
619
+ async function refreshWsSel(){
620
+ const r=await fetch('/api/workspaces',{cache:'no-store'}).catch(()=>null); if(!r) return;
621
+ const j=await r.json();
622
+ const sel=$('#wsSel'); sel.innerHTML='';
623
+ const cur=document.createElement('option'); cur.value=''; cur.textContent='工作空间'+(j.current?':'+j.current:'');
624
+ sel.appendChild(cur);
625
+ for(const w of j.workspaces||[]){
626
+ const o=document.createElement('option'); o.value=w.name; o.textContent=(w.name===j.current?'● ':'')+w.name;
627
+ if(w.name===j.current) o.selected=true;
628
+ sel.appendChild(o);
629
+ }
630
+ const add=document.createElement('option'); add.value='__add__'; add.textContent='+ 新建工作空间…';
631
+ sel.appendChild(add);
632
+ const mgr=document.createElement('option'); mgr.value='__manage__'; mgr.textContent='管理(重命名/删除/改目录)…';
633
+ sel.appendChild(mgr);
634
+ }
635
+ $('#wsSel').addEventListener('change', async e=>{
636
+ const v=e.target.value;
637
+ if(v==='__add__'){
638
+ const name=prompt('新工作空间名称:'); if(!name){ refreshWsSel(); return; }
639
+ const dir=prompt('目录路径(留空 = 当前目录,目录不存在会自动创建):');
640
+ const r=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'add',name,dir:dir||undefined})});
641
+ const j=await r.json().catch(()=>({error:'请求失败'}));
642
+ if(j.ok) renderBanner({text:'✓ 已登记工作空间 '+j.name+' → '+j.dir+'(目录已自动创建)'}); else alert(j.error||'创建失败');
643
+ refreshWsSel(); reloadModels();
644
+ return;
645
+ }
646
+ if(v==='__manage__'){
647
+ $('#cfgModal').style.display='flex'; refreshWorkspaces();
648
+ refreshWsSel();
649
+ return;
650
+ }
651
+ if(v){
652
+ // 携带当前会话:显式切换时该会话的工作空间一并切过去(P3-4 会话级工作空间)
653
+ const payload={action:'set',name:v};
654
+ if(currentSession) payload.file=currentSession;
655
+ const r=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
656
+ const j=await r.json().catch(()=>({error:'请求失败'}));
657
+ if(j.ok) renderBanner({text:'✓ 已切换到工作空间 '+j.name+'('+j.dir+'),后续工具在此目录执行'}); else { alert(j.error||'切换失败'); }
658
+ refreshWsSel(); reloadModels();
659
+ }
660
+ });
661
+ $('#cfgBtn').addEventListener('click', ()=>{ refreshModelsCfg(); refreshSyncUI(); refreshSyncShares(); refreshSyncConflicts(); refreshSchList(); refreshWorkspaces(); loadMemoryUI(); refreshCache(); refreshMcpPresets(); refreshSkillLib(''); });
662
+ $('#schWhen').onchange=e=>{ const v=e.target.value; $('#schAtRow').style.display=v==='at'?'':'none'; $('#schEveryRow').style.display=v==='every'?'':'none'; $('#schAfterRow').style.display=v==='after'?'':'none'; $('#schChainRow').style.display=v==='chain'?'':'none'; };
663
+ async function refreshSchList(){
664
+ const r=await fetch('/api/schedule',{cache:'no-store'}).catch(()=>null); if(!r) return;
665
+ const j=await r.json(); const list=$('#schList'); list.innerHTML='';
666
+ if(!j.jobs||!j.jobs.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无调度任务</div>'; return; }
667
+ for(const s of j.jobs){
668
+ const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
669
+ const mark=s.status==='running'?'▶':s.status==='paused'?'⏸':s.status==='done'?'✓':s.status==='skipped'?'⊘':'⏳';
670
+ const when=s.kind==='every'?('每 '+(s.interval>=86400000?(s.interval/86400000)+'d':s.interval>=3600000?(s.interval/3600000)+'h':s.interval>=60000?(s.interval/60000)+'m':(s.interval/1000)+'s')):s.kind==='once'?(s.nextRunAt?new Date(s.nextRunAt).toLocaleString():''):('依赖 '+ (s.after||[]).join(','));
671
+ div.innerHTML='<span>'+mark+'</span><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.question)+'</span><span style="color:var(--faint);white-space:nowrap">'+esc(when)+' · '+s.runs+'次</span>';
672
+ if((s.history||[]).length){ const hb=document.createElement('button'); hb.textContent='历史('+s.history.length+')'; hb.style.cssText='padding:1px 8px;font-size:11px'; const hist=document.createElement('div'); hist.style.cssText='display:none;padding:4px 0 4px 18px;font-size:11.5px;color:var(--dim)'; for(const h of s.history.slice(-8).reverse()){ hist.innerHTML+='<div>· '+new Date(h.at).toLocaleTimeString()+' '+(h.status==='done'?'✓':'✖')+' '+(h.durationMs!=null?(h.durationMs/1000).toFixed(1)+'s':'')+' '+esc((h.text||'').slice(0,60))+'</div>'; } hb.onclick=()=>{ hist.style.display=hist.style.display==='none'?'block':'none'; }; div.parentElement.insertBefore(hist, div.nextSibling); div.appendChild(hb); }
673
+ if(s.status!=='done'&&s.status!=='skipped'){ const pa=document.createElement('button'); pa.textContent=s.status==='paused'?'恢复':'暂停'; pa.style.cssText='padding:1px 8px;font-size:11px'; pa.onclick=()=>schAction(s.status==='paused'?'resume':'pause', s.id); div.appendChild(pa); }
674
+ const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=()=>schAction('remove', s.id); div.appendChild(rm);
675
+ list.appendChild(div);
676
+ }
677
+ }
678
+ async function schAction(action,id){ await fetch('/api/schedule',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,id})}); refreshSchList(); }
679
+ $('#schAdd').onclick=async ()=>{
680
+ const q=$('#schQuestion').value.trim(); if(!q) return;
681
+ const kind=$('#schWhen').value;
682
+ const payload={action:'add',question:q};
683
+ if($('#schOffpeak').checked) payload.offpeak=true;
684
+ if(kind==='at'){ const v=$('#schAt').value; if(!v) return; payload.at=v.replace('T',' '); }
685
+ else if(kind==='every'){ payload.every=$('#schEvery').value; }
686
+ else if(kind==='chain'){ const v=$('#schChain').value.trim(); if(!v) return; const qs=v.split('\n').map(x=>x.trim()).filter(Boolean); if(qs.length<2){ alert('链式需要至少两行任务'); return; } payload.action='chain'; payload.questions=qs; }
687
+ else { const v=$('#schAfter').value.trim(); if(!v) return; payload.after=[v]; }
688
+ const r=await fetch('/api/schedule',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
689
+ const j=await r.json().catch(()=>({error:'请求失败'}));
690
+ if(j.ok){ $('#schQuestion').value=''; refreshSchList(); } else { alert(j.error||'添加失败'); }
691
+ };
692
+ // —— 工作空间 ——
693
+ async function refreshWorkspaces(){
694
+ const r=await fetch('/api/workspaces',{cache:'no-store'}).catch(()=>null); if(!r) return;
695
+ const j=await r.json(); const list=$('#wsList'); list.innerHTML='';
696
+ if(!j.workspaces||!j.workspaces.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无登记的工作空间</div>'; return; }
697
+ for(const w of j.workspaces){
698
+ const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
699
+ div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(w.name)+(w.name===j.current?' <span style="color:var(--accent)">●当前</span>':'')+'</span><span style="flex:1.4;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left">'+esc(w.dir||'')+'</span>';
700
+ const ed=document.createElement('button'); ed.textContent='改目录'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=async()=>{ const d=prompt('新目录:', w.dir); if(!d) return; const rr=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'set',name:w.name,dir:d})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshWorkspaces(); refreshWsSel(); reloadModels(); } else alert(jj.error||'修改失败'); };
701
+ const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=prompt('新名称:', w.name); if(!t) return; const rr=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'rename',name:w.name,newName:t})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshWorkspaces(); refreshWsSel(); } else alert(jj.error||'重命名失败'); };
702
+ const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!confirm('删除工作空间 '+w.name+'?')) return; await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'remove',name:w.name})}); refreshWorkspaces(); refreshWsSel(); reloadModels(); };
703
+ div.appendChild(ed); div.appendChild(rn); div.appendChild(rm); list.appendChild(div);
704
+ }
705
+ }
706
+ $('#wsAdd').onclick=async ()=>{
707
+ const name=$('#wsName').value.trim(); if(!name) return;
708
+ const dir=$('#wsDir').value.trim();
709
+ const r=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'add',name,dir})});
710
+ const j=await r.json().catch(()=>({error:'请求失败'}));
711
+ if(j.ok){ $('#wsName').value=''; $('#wsDir').value=''; refreshWorkspaces(); refreshWsSel(); reloadModels(); } else alert(j.error||'添加失败');
712
+ };
713
+ // —— 长期记忆 ——
714
+ function memFlash(msg, good){ const m=$('#memMsg'); m.textContent=msg; m.style.color=good?'var(--accent)':'var(--err)'; setTimeout(()=>{m.textContent='';},4000); }
715
+ async function loadMemoryUI(){
716
+ const r=await fetch('/api/memory',{cache:'no-store'}).catch(()=>null); if(!r) return;
717
+ const j=await r.json(); if(j.ok) $('#memArea').value=j.content||'';
718
+ }
719
+ $('#memSave').onclick=async ()=>{
720
+ const r=await fetch('/api/memory',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:$('#memArea').value})});
721
+ const j=await r.json().catch(()=>({error:'请求失败'}));
722
+ memFlash(j.ok?'✓ 已保存':('✖ '+(j.error||'保存失败')), Boolean(j.ok));
723
+ };
724
+ $('#memDedupe').onclick=async ()=>{
725
+ const r=await fetch('/api/memory',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'dedupe'})});
726
+ const j=await r.json().catch(()=>({error:'请求失败'}));
727
+ if(j.ok){ memFlash('✓ 去重完成,移除 '+j.removed+' 行', true); loadMemoryUI(); } else memFlash('✖ '+(j.error||'去重失败'), false);
728
+ };
729
+ // —— 缓存命中率仪表盘 ——
730
+ function fmtT(n){ n=Number(n||0); return n>=1000?(n/1000).toFixed(1)+'k':String(n); }
731
+ async function refreshCache(){
732
+ const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
733
+ const j=await r.json(); if(!j.ok) return;
734
+ const s=j.summary||{};
735
+ const rate=s.rate!=null?(s.rate*100).toFixed(0)+'%':'暂无缓存数据';
736
+ $('#cacheSummary').innerHTML='轮次 '+s.turns+' · ↑'+fmtT(s.prompt)+' ↓'+fmtT(s.completion)+' tokens · 命中率 <b style="color:var(--accent)">'+rate+'</b> · 累计 ≈¥'+Number(s.cost||0).toFixed(5)+'(比全未命中省 ≈¥'+Number(s.saved||0).toFixed(5)+')';
737
+ const list=$('#cacheRecent'); list.innerHTML='';
738
+ if(!(j.recent||[]).length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无记录(每次对话后自动统计)</div>'; return; }
739
+ for(const e of j.recent){
740
+ const hit=e.hit!=null&&e.miss!=null&&(e.hit+e.miss)>0?e.hit/(e.hit+e.miss):null;
741
+ const pct=hit==null?'—':(hit*100).toFixed(0)+'%';
742
+ const bar=document.createElement('div'); bar.style.cssText='height:4px;border-radius:2px;background:var(--bg3);margin:2px 0;overflow:hidden';
743
+ bar.innerHTML='<div style="height:100%;width:'+(hit==null?0:Math.round(hit*100))+'%;background:var(--accent)"></div>';
744
+ const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--border);font-size:11.5px;color:var(--dim)';
745
+ const left=document.createElement('div'); left.style.cssText='flex:1';
746
+ left.innerHTML='<div style="display:flex;justify-content:space-between"><span>'+esc(e.model||'')+'</span><span>'+pct+' · ¥'+Number(e.cost||0).toFixed(5)+'</span></div>';
747
+ left.appendChild(bar);
748
+ const when=document.createElement('span'); when.style.cssText='white-space:nowrap;color:var(--faint)'; when.textContent=new Date(e.at).toLocaleTimeString();
749
+ div.appendChild(left); div.appendChild(when); list.appendChild(div);
750
+ }
751
+ }
752
+ // —— MCP 生态预设 ——
753
+ let mcpPresetData=[];
754
+ async function refreshMcpPresets(){
755
+ const r=await fetch('/api/mcp-presets',{cache:'no-store'}).catch(()=>null); if(!r) return;
756
+ const j=await r.json(); if(!j.ok) return;
757
+ mcpPresetData=j.presets||[]; const sel=$('#mcpPresetSel'); sel.innerHTML='';
758
+ for(const p of mcpPresetData){ const o=document.createElement('option'); o.value=p.name; o.textContent=p.name+' — '+p.label; o.title=p.command+' '+p.args.join(' '); sel.appendChild(o); }
759
+ sel.onchange=()=>{ const p=mcpPresetData.find(x=>x.name===sel.value); $('#mcpArg').placeholder=p&&p.argLabel?('参数:'+p.argLabel):'本预设无需参数'; };
760
+ sel.onchange();
761
+ }
762
+ $('#mcpAdd').onclick=async ()=>{
763
+ const name=$('#mcpPresetSel').value; if(!name) return;
764
+ const arg=$('#mcpArg').value.trim();
765
+ const r=await fetch('/api/mcp-presets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,arg})});
766
+ const j=await r.json().catch(()=>({error:'请求失败'}));
767
+ if(j.ok) alert('✓ 已接入 '+name+'(重启 mingdao web 后生效,/mcp 查看状态)'); else alert(j.error||'接入失败');
768
+ };
769
+ // —— 技能库 ——
770
+ let skillSearchTimer=null;
771
+ $('#skillSearch').addEventListener('input',e=>{ clearTimeout(skillSearchTimer); skillSearchTimer=setTimeout(()=>refreshSkillLib(e.target.value.trim()),300); });
772
+ async function refreshSkillLib(q, force){
773
+ let u=q?'/api/skill-library?q='+encodeURIComponent(q):'/api/skill-library';
774
+ if(force) u+=(q?'&':'?')+'refresh=1';
775
+ const r=await fetch(u,{cache:'no-store'}).catch(()=>null); if(!r) return;
776
+ const j=await r.json(); const list=$('#skillLibList'); list.innerHTML='';
777
+ if(j.registry&&j.registry.error){
778
+ const err=document.createElement('div'); err.style.cssText='color:var(--warn);font-size:11.5px;padding:4px 2px';
779
+ err.textContent='✗ 线上 registry 不可达(仅显示内置库):'+j.registry.error; list.appendChild(err);
780
+ }else if(j.registry){
781
+ const meta=document.createElement('div'); meta.style.cssText='color:var(--faint);font-size:11.5px;padding:4px 2px';
782
+ meta.textContent='线上索引 '+new Date(j.registry.updatedAt).toLocaleString()+(j.registry.stale?'(缓存,已过期)':'');
783
+ list.appendChild(meta);
784
+ }
785
+ if(!j.library||!j.library.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">没有匹配的技能</div>'; return; }
786
+ for(const s of j.library){
787
+ const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
788
+ const badge=s.source==='registry'?'<span style="color:var(--accent2);font-size:10px">线上</span>':'<span style="color:var(--faint);font-size:10px">内置库</span>';
789
+ div.innerHTML='<span style="flex:1;min-width:0"><b>'+esc(s.name)+'</b> '+badge+' <span style="color:var(--faint)">'+esc(s.description||'')+'</span></span>';
790
+ const b=document.createElement('button');
791
+ if(s.installed){ b.textContent='卸载'; b.className='danger'; } else { b.textContent='安装'; }
792
+ b.style.cssText='padding:1px 8px;font-size:11px';
793
+ b.onclick=async()=>{
794
+ const body=s.installed?{action:'uninstall',name:s.name}:{action:'install',name:s.name};
795
+ const rr=await fetch('/api/skills',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
796
+ const jj=await rr.json().catch(()=>({error:'请求失败'}));
797
+ if(jj.ok) refreshSkillLib($('#skillSearch').value.trim()); else alert(jj.error||'操作失败');
798
+ };
799
+ div.appendChild(b); list.appendChild(div);
800
+ }
801
+ }
802
+ $('#skillRegRefresh').onclick=()=>{ refreshSkillLib($('#skillSearch').value.trim(), true); };
803
+ // —— 模型与 API Key ——
804
+ async function reloadModels(){
805
+ try{
806
+ const r=await fetch('/api/state',{cache:'no-store'}); const j=await r.json();
807
+ const ms=$('#modelSel'); ms.innerHTML='';
808
+ const groups={}; const order=[];
809
+ for(const m of (j.models||[])){ const g=m.providerLabel||'其他'; if(!groups[g]){groups[g]=[];order.push(g);} groups[g].push(m); }
810
+ for(const g of order){
811
+ const og=document.createElement('optgroup'); og.label=g;
812
+ for(const m of groups[g]){ const o=document.createElement('option'); o.value=m.name; o.textContent=m.label; if(m.name===j.model) o.selected=true; og.appendChild(o); }
813
+ ms.appendChild(og);
814
+ }
815
+ if(!(j.models||[]).length){ const o=document.createElement('option'); o.value=j.model; o.textContent=j.model; ms.appendChild(o); }
816
+ const env=('路由'+(j.routing?'开':'关')+' · 沙箱'+((j.sandbox&&j.sandbox!=='off')?(j.sandboxSupported?j.sandbox:'降级'):'off'));
817
+ $('#envBadge').textContent=env; $('#envBadge').style.display='';
818
+ }catch(e){}
819
+ }
820
+ async function refreshModelsCfg(){
821
+ const r=await fetch('/api/models-config',{cache:'no-store'}).catch(()=>null); if(!r) return;
822
+ const j=await r.json(); if(!j.ok) return;
823
+ const pk=$('#pkList'); pk.innerHTML='';
824
+ for(const p of j.providers){
825
+ const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
826
+ const state=p.keyState==='stored'?'<span style="color:var(--accent)">已存 '+esc(p.keyMasked||'')+'</span>':p.keyState==='env'?'<span style="color:var(--dim)">环境变量 '+esc(p.envKey||'')+'</span>':'<span style="color:var(--err)">未设置</span>';
827
+ div.innerHTML='<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><b>'+esc(p.name)+'</b> '+esc(p.label)+'</span><span style="white-space:nowrap">'+state+'</span>';
828
+ const set=document.createElement('button'); set.textContent='设Key'; set.style.cssText='padding:1px 8px;font-size:11px';
829
+ set.onclick=async()=>{ const k=prompt('API Key('+p.name+'):'); if(k===null) return; const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setProviderKey',provider:p.name,key:k})}); const jj=await rr.json().catch(()=>({error:'请求失败'})); if(jj.ok){ refreshModelsCfg(); reloadModels(); if(jj.modelsNote) alert(jj.modelsNote); } else alert(jj.error||'设置失败'); };
830
+ if(p.keyState==='stored'&&p.name!=='custom'){ const rf=document.createElement('button'); rf.textContent='刷新模型'; rf.style.cssText='padding:1px 8px;font-size:11px'; rf.onclick=async()=>{ const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'refreshModels',provider:p.name})}); const jj=await rr.json().catch(()=>({error:'请求失败'})); if(jj.ok){ reloadModels(); alert('✓ 已拉取 '+jj.models.length+' 个线上模型'+(jj.fromCache?'(缓存)':'')); } else alert(jj.error||'刷新失败'); }; div.appendChild(rf); }
831
+ if(p.keyState==='stored'){ const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!confirm('删除 '+p.name+' 的 Key?')) return; await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'removeProviderKey',provider:p.name})}); refreshModelsCfg(); reloadModels(); }; div.appendChild(rm); }
832
+ div.appendChild(set); pk.appendChild(div);
833
+ }
834
+ const cl=$('#cmList'); cl.innerHTML='';
835
+ if(!(j.customModels||[]).length) cl.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无自定义模型</div>';
836
+ for(const c of j.customModels){
837
+ const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
838
+ const kstate=c.keyState==='stored'?'<span style="color:var(--accent)">'+esc(c.keyMasked||'')+'</span>':'<span style="color:var(--err)">无Key</span>';
839
+ div.innerHTML='<span style="flex:1;min-width:0"><b>'+esc(c.name)+'</b> <span style="color:var(--faint)">'+esc(c.label)+' · '+esc(c.baseUrl)+'</span></span><span style="white-space:nowrap">'+kstate+'</span>';
840
+ const sk=document.createElement('button'); sk.textContent='设Key'; sk.style.cssText='padding:1px 8px;font-size:11px'; sk.onclick=()=>{ const k=prompt('API Key('+c.name+'):'); if(k===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setCustomKey',name:c.name,key:k})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok) refreshModelsCfg(); else alert(jj.error||'设置失败'); }); };
841
+ const ed=document.createElement('button'); ed.textContent='修改'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=()=>{ const u=prompt('API 地址:', c.baseUrl); if(u===null) return; const l=prompt('标签:', c.label); if(l===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'updateCustom',name:c.name,baseUrl:u,label:l})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok){ refreshModelsCfg(); reloadModels(); } else alert(jj.error||'修改失败'); }); };
842
+ const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!confirm('删除自定义模型 '+c.name+'?')) return; const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'removeCustom',name:c.name})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshModelsCfg(); reloadModels(); } else alert(jj.error||'删除失败'); };
843
+ div.appendChild(sk); div.appendChild(ed); div.appendChild(rm); cl.appendChild(div);
844
+ }
845
+ $('#baseUrlOverride').value=j.baseUrlOverride||'';
846
+ }
847
+ $('#cmAdd').onclick=async ()=>{
848
+ const name=$('#cmName').value.trim();
849
+ const url=$('#cmUrl').value.trim();
850
+ if(!name||!url){ alert('模型名与 API 地址必填'); return; }
851
+ const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'addCustom',name,label:$('#cmLabel').value.trim(),baseUrl:url,key:$('#cmKey').value})});
852
+ const j=await r.json().catch(()=>({error:'请求失败'}));
853
+ if(j.ok){ $('#cmName').value=''; $('#cmLabel').value=''; $('#cmUrl').value=''; $('#cmKey').value=''; refreshModelsCfg(); reloadModels(); } else alert(j.error||'添加失败');
854
+ };
855
+ $('#baseUrlSave').onclick=async ()=>{
856
+ const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setBaseUrl',baseUrl:$('#baseUrlOverride').value.trim()})});
857
+ const j=await r.json().catch(()=>({error:'请求失败'}));
858
+ if(j.ok) alert('✓ API 地址已保存(立即生效)'); else alert(j.error||'保存失败');
859
+ };
860
+ // —— 云同步 ——
861
+ async function refreshSyncUI(){
862
+ const r=await fetch('/api/sync',{cache:'no-store'}).catch(()=>null); if(!r) return;
863
+ const j=await r.json();
864
+ $('#syncUrl').value=j.url||''; $('#syncUser').value=j.username||''; $('#syncDevice').value=j.deviceName||''; $('#syncAutoChk').checked=Boolean(j.auto);
865
+ const st=$('#syncStatus');
866
+ if(!j.configured) st.innerHTML='<span style="color:var(--faint)">未配置:填服务器地址并登录(服务器端运行 mingdao sync-server)</span>';
867
+ else if(!j.loggedIn) st.innerHTML='<span style="color:var(--warn)">已配置 '+esc(j.url)+' · 未登录(输入密码登录)</span>';
868
+ else {
869
+ let line='<span style="color:var(--accent)">✓ 已登录 '+esc(j.username)+'('+esc(j.deviceName)+')→ '+esc(j.url)+'</span>';
870
+ if(j.remote&&j.remote.sessions) line+=' · 远端 '+j.remote.sessions.length+' 个会话';
871
+ if(j.remote&&j.remote.error) line+=' · <span style="color:var(--err)">'+esc(j.remote.error)+'</span>';
872
+ st.innerHTML=line;
873
+ }
874
+ }
875
+ $('#syncLogin').onclick=async ()=>{
876
+ const url=$('#syncUrl').value.trim(); const username=$('#syncUser').value.trim(); const password=$('#syncPass').value;
877
+ if(!url||!username||!password){ alert('服务器地址、用户名、密码均必填'); return; }
878
+ const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'login',url,username,password,deviceName:$('#syncDevice').value.trim()})});
879
+ const j=await r.json().catch(()=>({error:'请求失败'}));
880
+ if(j.ok){ $('#syncPass').value=''; refreshSyncUI(); } else alert(j.error||'登录失败');
881
+ };
882
+ $('#syncPush').onclick=async ()=>{
883
+ const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'push'})});
884
+ const j=await r.json().catch(()=>({error:'请求失败'}));
885
+ alert(j.ok?('✓ 已推送 '+j.pushed+' 个会话'+(j.conflicts?','+j.conflicts+' 个远端版本已备份 .server-*':'')):j.error);
886
+ refreshSyncUI(); refreshSyncConflicts();
887
+ };
888
+ $('#syncLogout').onclick=async ()=>{ await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'logout'})}); refreshSyncUI(); };
889
+ $('#syncAutoChk').onchange=e=>{ applyConfig({syncAuto:e.target.checked}); };
890
+ // —— 分享与协作 / 冲突 ——
891
+ async function refreshSyncShares(){
892
+ const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'shares'})}).catch(()=>null); if(!r) return;
893
+ const j=await r.json();
894
+ const ml=$('#shareList'); ml.innerHTML='';
895
+ if(j.ok&&j.mine&&j.mine.length){
896
+ for(const s of j.mine){
897
+ const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px';
898
+ div.innerHTML='<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><b>'+esc(s.shareId)+'</b> <span style="color:var(--faint)">'+esc(s.name)+' · 被接受 '+s.pulls+' 次</span></span>';
899
+ const b=document.createElement('button'); b.textContent='撤销'; b.className='danger'; b.style.cssText='padding:1px 8px;font-size:11px';
900
+ b.onclick=async()=>{ const rr=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'unshare',shareId:s.shareId})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok) refreshSyncShares(); else alert(jj.error||'撤销失败'); };
901
+ div.appendChild(b); ml.appendChild(div);
902
+ }
903
+ } else { ml.innerHTML='<div style="color:var(--faint);font-size:12px;padding:4px 2px">暂无分享(分享后此处显示分享码)</div>'; }
904
+ const al=$('#shareAccepted'); al.innerHTML='';
905
+ if(j.ok&&j.accepted&&j.accepted.length){
906
+ for(const s of j.accepted){
907
+ const div=document.createElement('div'); div.style.cssText='padding:4px 0;border-bottom:1px solid var(--border);font-size:12px;color:var(--dim)';
908
+ div.textContent='· '+s.owner+' 的 '+s.name+' → 本地 '+s.savedAs;
909
+ al.appendChild(div);
910
+ }
911
+ } else { al.innerHTML='<div style="color:var(--faint);font-size:12px;padding:4px 2px">未接受任何分享</div>'; }
912
+ }
913
+ $('#shareCreate').onclick=async ()=>{
914
+ const name=$('#shareName').value.trim(); if(!name){ alert('输入会话文件名'); return; }
915
+ const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'share',name})});
916
+ const j=await r.json().catch(()=>({error:'请求失败'}));
917
+ if(j.ok){ $('#shareName').value=''; refreshSyncShares(); } else alert(j.error||'创建失败');
918
+ };
919
+ $('#shareAccept').onclick=async ()=>{
920
+ const code=$('#acceptCode').value.trim(); if(!code){ alert('输入分享码'); return; }
921
+ const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'accept',shareId:code})});
922
+ const j=await r.json().catch(()=>({error:'请求失败'}));
923
+ if(j.ok){ $('#acceptCode').value=''; refreshSyncShares(); refreshSessions(); } else alert(j.error||'接受失败');
924
+ };
925
+ $('#passwdBtn').onclick=async ()=>{
926
+ const oldPass=$('#oldPass').value, newPass=$('#newPass').value;
927
+ if(!oldPass||!newPass){ alert('旧密码与新密码均必填'); return; }
928
+ const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'passwd',oldPassword:oldPass,newPassword:newPass})});
929
+ const j=await r.json().catch(()=>({error:'请求失败'}));
930
+ if(j.ok){ $('#oldPass').value=''; $('#newPass').value=''; alert('✓ 密码已修改'); } else alert(j.error||'修改失败');
931
+ };
932
+ async function refreshSyncConflicts(){
933
+ const r=await fetch('/api/sync-conflicts',{cache:'no-store'}).catch(()=>null); if(!r) return;
934
+ const j=await r.json(); const list=$('#conflictList'); list.innerHTML='';
935
+ if(!j.conflicts||!j.conflicts.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:4px 2px">暂无冲突</div>'; return; }
936
+ for(const c of j.conflicts){
937
+ const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
938
+ div.innerHTML='<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(c.base)+' <span style="color:var(--faint)">'+(c.localExists?'本地有':'本地无')+' · 备份 '+c.entries.length+'</span></span>';
939
+ const mk=(label,choice,cls)=>{ const b=document.createElement('button'); b.textContent=label; if(cls) b.className=cls; b.style.cssText='padding:1px 8px;font-size:11px'; b.onclick=async()=>{ const rr=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'resolveConflict',base:c.base,choice})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok) refreshSyncConflicts(); else alert(jj.error||'解决失败'); }; return b; };
940
+ div.appendChild(mk('保留本地','local')); div.appendChild(mk('采用远端','remote','danger')); div.appendChild(mk('都保留','both'));
941
+ list.appendChild(div);
942
+ }
943
+ }
944
+ $('#cfgSave').onclick=()=>{ applyConfig({sandbox:$('#sbxSel').value, routing:$('#routeChk').checked, contextBudget:Number($('#budgetInput').value), autostart:$('#autoStartChk').checked, notify:$('#notifyChk').checked}); $('#cfgModal').style.display='none'; };
945
+ async function applyConfig(payload, revertTarget){
946
+ const r=await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
947
+ const j=await r.json().catch(()=>({error:'请求失败'}));
948
+ const m=$('#cfgMsg');
949
+ if(j.ok){
950
+ if(payload.model!==undefined) m.textContent='✓ 已切换 '+j.model;
951
+ else if(payload.permission!==undefined) m.textContent='✓ 权限 '+j.permission;
952
+ else m.textContent='✓ 设置已保存';
953
+ m.style.color='var(--accent)';
954
+ reloadModels(); // 路由/沙箱等状态徽章实时刷新
955
+ }else{
956
+ m.textContent='✖ '+j.error; m.style.color='var(--err)';
957
+ if(revertTarget){
958
+ const st=await fetch('/api/state',{cache:'no-store'});
959
+ const sj=await st.json();
960
+ revertTarget.value=revertTarget.id==='modelSel'?sj.model:sj.permission;
961
+ }
962
+ }
963
+ m.style.display=''; setTimeout(()=>{m.style.display='none';},4000);
964
+ }
965
+ $('#modelSel').addEventListener('change', e=>{ applyConfig({model:e.target.value}, e.target); });
966
+ $('#permSel').addEventListener('change', e=>{ applyConfig({permission:e.target.value}, e.target); });
967
+ async function loadSession(file){ currentSession=file; chatEl.innerHTML=''; const r=await fetch('/api/session?file='+encodeURIComponent(file)); const j=await r.json(); for(const m of j.messages||[]){ if(typeof m.content==='string'&&m.content.startsWith('(系统提示)')) continue; if(m.role==='user') addUser(m.content); else { const el=newAiMsg(); aiContent(el).innerHTML=renderMarkdown(m.content); } } if(j.workspace){ const sel=$('#wsSel'); const has=[...sel.options].some(o=>o.value===j.workspace); if(has){ sel.value=j.workspace; } else { refreshWsSel(); } renderBanner({text:'↩ 已回到该会话的工作空间:'+j.workspace}); } scrollBottom(); }
968
+
969
+ sendBtn.onclick=send;
970
+ input.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } });
971
+ input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
972
+ $('#newChat').onclick=()=>{ currentSession=null; chatEl.innerHTML=''; refreshSessions(); };
973
+ $('#sessMgrBtn').onclick=openSessModal;
974
+ $('#sessClose').onclick=()=>{ $('#sessModal').style.display='none'; };
975
+ async function openSessModal(){
976
+ $('#sessModal').style.display='flex';
977
+ const r=await fetch('/api/sessions',{cache:'no-store'}); const j=await r.json();
978
+ const list=$('#sessList'); list.innerHTML='';
979
+ if(!j.sessions.length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px">暂无会话</div>'; return; }
980
+ for(const s of j.sessions){
981
+ const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:12.5px';
982
+ div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.label)+'</span>';
983
+ const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=prompt('新标题:', s.file.replace(/\.jsonl$/,'')); if(!t) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'rename',file:s.file,title:t})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshSessions(); openSessModal(); } else alert(jj.error||'重命名失败'); };
984
+ const dl=document.createElement('button'); dl.textContent='删除'; dl.className='danger'; dl.style.cssText='padding:1px 8px;font-size:11px'; dl.onclick=async()=>{ if(!confirm('删除会话 '+s.file+'?此操作不可恢复。')) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'delete',file:s.file})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ if(currentSession===s.file) currentSession=null; refreshSessions(); openSessModal(); } else alert(jj.error||'删除失败'); };
985
+ div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
986
+ }
987
+ }
988
+ $('#sessions').onchange=e=>{ if(e.target.value){ loadSession(e.target.value); } else { currentSession=null; chatEl.innerHTML=''; } };
989
+ if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); }
990
+ init();
991
+ </script>
992
+ </body>
993
+ </html>