dsh-agentone 0.5.0

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.
package/lib/page.js ADDED
@@ -0,0 +1,857 @@
1
+ // 状态页与授权完成页的 HTML/CSS/JS 模板(纯静态,无运行时依赖;
2
+ // 从 index.js 拆出——那文件此前约 1600 行,其中约 780 行是模板字符串)。
3
+
4
+ const PAGE_CSS = `
5
+ :root {
6
+ --bg: #f6f8fa; --card: #ffffff; --text: #1f2328; --muted: #57606a; --line: #d8dee4;
7
+ --brand: #2f6feb; --brand-weak: #eaf1fe; --ok: #1a7f37; --ok-weak: #e6f4ea;
8
+ --warn: #9a6700; --warn-weak: #fff8c5; --err: #cf222e; --err-weak: #ffebe9;
9
+ --hover: #f0f2f5; --radius: 14px;
10
+ }
11
+ @media (prefers-color-scheme: dark) {
12
+ :root {
13
+ --bg: #0d1117; --card: #161b22; --text: #e6edf3; --muted: #9ea7b3; --line: #30363d;
14
+ --brand: #4a8dff; --brand-weak: #16233a; --ok: #3fb950; --ok-weak: #12261e;
15
+ --warn: #d29922; --warn-weak: #2b2410; --err: #f85149; --err-weak: #3a1518;
16
+ --hover: #21262d;
17
+ }
18
+ }
19
+ * { box-sizing: border-box; }
20
+ body {
21
+ font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
22
+ background: var(--bg); color: var(--text); margin: 0; padding: 20px 16px 32px;
23
+ font-size: 14px; line-height: 1.6;
24
+ }
25
+ .wrap { max-width: 560px; margin: 0 auto; }
26
+ .head { display: flex; align-items: center; gap: 10px; margin: 4px 0 18px; }
27
+ .logo {
28
+ width: 34px; height: 34px; border-radius: 10px; background: var(--brand); color: #fff;
29
+ display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 15px;
30
+ }
31
+ .head h2 { margin: 0; font-size: 17px; flex: 1; }
32
+ .pill {
33
+ font-size: 12px; padding: 3px 10px; border-radius: 999px; font-weight: 500;
34
+ }
35
+ .pill.ok { background: var(--ok-weak); color: var(--ok); }
36
+ .pill.warn { background: var(--warn-weak); color: var(--warn); }
37
+ .card {
38
+ background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
39
+ padding: 16px 18px; margin-bottom: 14px;
40
+ }
41
+ .card h3 { margin: 0 0 10px; font-size: 14px; display: flex; align-items: center; gap: 8px; }
42
+ .card h3 .muted { font-weight: 400; font-size: 12px; }
43
+ .muted { color: var(--muted); }
44
+ .kv { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; }
45
+ .kv .k { color: var(--muted); flex-shrink: 0; }
46
+ .kv .v { text-align: right; word-break: break-all; }
47
+ button {
48
+ font: inherit; cursor: pointer; border-radius: 10px; transition: filter .15s, opacity .15s;
49
+ border: 1px solid transparent;
50
+ }
51
+ button:disabled { opacity: .55; cursor: default; }
52
+ .btn { padding: 9px 20px; font-size: 14px; }
53
+ .btn.primary { background: var(--brand); color: #fff; }
54
+ .btn.primary:hover:not(:disabled) { filter: brightness(1.08); }
55
+ .btn.ghost { background: transparent; color: var(--text); border-color: var(--line); }
56
+ .btn.ghost:hover:not(:disabled) { background: var(--hover); }
57
+ .btn.danger { background: transparent; color: var(--err); border-color: var(--line); }
58
+ .btn.danger:hover:not(:disabled) { background: var(--err-weak); }
59
+ .btn.small { padding: 4px 12px; font-size: 12px; border-radius: 8px; }
60
+ .row { display: flex; align-items: center; gap: 10px; }
61
+ .hero { text-align: center; padding: 34px 20px; }
62
+ .hero .big {
63
+ font-size: 16px; font-weight: 600; margin: 0 0 6px;
64
+ }
65
+ .hero p { margin: 0 0 18px; color: var(--muted); }
66
+ .hint { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
67
+ .banner {
68
+ border-radius: 10px; padding: 9px 14px; margin-bottom: 12px; font-size: 13px; display: none;
69
+ }
70
+ .banner.ok { background: var(--ok-weak); color: var(--ok); display: block; }
71
+ .banner.err { background: var(--err-weak); color: var(--err); display: block; }
72
+ /* toast 常驻可视区顶部:页面滚动到底部时操作反馈仍可见 */
73
+ .toast-fixed { position: sticky; top: 8px; z-index: 10; }
74
+ /* 首屏骨架占位:慢网下 status 未回来前不至于一片空白 */
75
+ .skeleton {
76
+ height: 14px; border-radius: 7px; margin: 10px 0;
77
+ background: linear-gradient(90deg, var(--hover) 25%, var(--line) 50%, var(--hover) 75%);
78
+ background-size: 200% 100%;
79
+ animation: shimmer 1.4s infinite linear;
80
+ }
81
+ .skeleton.w60 { width: 60%; }
82
+ @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
83
+ /* 窄屏(iframe 嵌设置弹窗/手机)下允许换行,避免 6 个 tab 挤爆裁切 */
84
+ .tabs { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 14px; border-bottom: 1px solid var(--line); }
85
+ .tab {
86
+ padding: 8px 14px; font-size: 13.5px; border: none; background: transparent; color: var(--muted);
87
+ border-bottom: 2px solid transparent; margin-bottom: -1px; border-radius: 8px 8px 0 0;
88
+ }
89
+ .tab:hover { background: var(--hover); color: var(--text); }
90
+ .tab.active { color: var(--brand); border-bottom-color: var(--brand); font-weight: 600; }
91
+ .tabpane[hidden] { display: none; }
92
+ .skill-item {
93
+ display: flex; align-items: center; gap: 10px; padding: 9px 0;
94
+ border-top: 1px solid var(--line);
95
+ }
96
+ .skill-item:first-of-type { border-top: none; }
97
+ .skill-info { flex: 1; min-width: 0; }
98
+ .skill-name { font-weight: 500; }
99
+ .skill-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
100
+ .tag {
101
+ font-size: 11px; padding: 1px 8px; border-radius: 999px; margin-left: 6px;
102
+ background: var(--brand-weak); color: var(--brand); vertical-align: 1px;
103
+ }
104
+ /* 分类徽章配色与 SkillHub skill_category.color 对齐 */
105
+ .tag[data-cat="通用"] { background: rgba(107,114,128,.15); color: #6B7280; }
106
+ .tag[data-cat="人力行政"] { background: rgba(139,92,246,.15); color: #8B5CF6; }
107
+ .tag[data-cat="市场经营"] { background: rgba(245,158,11,.15); color: #F59E0B; }
108
+ .tag[data-cat="工程技术"] { background: rgba(59,130,246,.15); color: #3B82F6; }
109
+ .tag[data-cat="秩序安全"] { background: rgba(239,68,68,.15); color: #EF4444; }
110
+ .tag[data-cat="环境品质"] { background: rgba(16,185,129,.15); color: #10B981; }
111
+ .tag[data-cat="数字化"] { background: rgba(99,102,241,.15); color: #6366F1; }
112
+ .tag[data-cat="客户服务"] { background: rgba(236,72,153,.15); color: #EC4899; }
113
+ .search { display: flex; gap: 8px; margin: 4px 0 8px; }
114
+ .search input {
115
+ flex: 1; font: inherit; padding: 8px 12px; border-radius: 10px;
116
+ border: 1px solid var(--line); background: var(--card); color: var(--text); outline: none;
117
+ }
118
+ .search input:focus { border-color: var(--brand); }
119
+ .footer { display: flex; justify-content: space-between; align-items: center; margin-top: 4px; }
120
+ .spin {
121
+ width: 13px; height: 13px; border: 2px solid currentColor; border-top-color: transparent;
122
+ border-radius: 50%; display: inline-block; vertical-align: -2px; margin-right: 7px;
123
+ animation: spin .8s linear infinite;
124
+ }
125
+ @keyframes spin { to { transform: rotate(360deg); } }
126
+ `;
127
+
128
+ const PAGE_JS = `
129
+ const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({
130
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
131
+ })[ch]);
132
+ const $ = (id) => document.getElementById(id);
133
+ const statePill = $('state-pill');
134
+ const loginCard = $('login-card');
135
+ const mainCards = $('main-cards');
136
+ const logoutBtn = $('logout-btn');
137
+ const bootCard = $('boot-card');
138
+ const toast = $('toast');
139
+ let toastTimer = null;
140
+ // 最近一次 /api/status 快照:CLI 页按需探测时读取(登录态/能力开关)
141
+ let lastStatus = null;
142
+ // 上一次轮询到的套餐状态(pending→active 自动同步模型用)
143
+ let lastPlanState = null;
144
+ const notify = (message, kind) => {
145
+ toast.className = 'banner ' + (kind || 'ok');
146
+ toast.textContent = message;
147
+ clearTimeout(toastTimer);
148
+ // 错误信息可能较长(含平台原因),给足阅读时间;成功提示维持 4s
149
+ toastTimer = setTimeout(() => { toast.className = 'banner'; }, kind === 'err' ? 10000 : 4000);
150
+ };
151
+ const withLoading = async (btn, action, okMessage) => {
152
+ const original = btn.innerHTML;
153
+ btn.disabled = true;
154
+ btn.innerHTML = '<span class="spin"></span>' + (btn.dataset.loading || '处理中…');
155
+ try {
156
+ const resp = await action();
157
+ const data = await resp.json().catch(() => ({}));
158
+ if (!resp.ok) throw new Error(data.error || '操作失败');
159
+ if (okMessage) notify(okMessage);
160
+ } catch (error) {
161
+ notify(error.message || '操作失败', 'err');
162
+ } finally {
163
+ btn.disabled = false;
164
+ btn.innerHTML = original;
165
+ await refresh();
166
+ }
167
+ };
168
+ // 卸载类破坏性操作的轻量二次确认:第一次点击把按钮变成「确认卸载?」,
169
+ // 3 秒内再点才真正执行;超时自动还原。不引入弹窗。
170
+ const confirmThen = (btn, armLabel, action, okMessage) => {
171
+ if (btn.dataset.confirming === '1') {
172
+ clearTimeout(Number(btn.dataset.confirmTimer));
173
+ delete btn.dataset.confirming;
174
+ delete btn.dataset.confirmTimer;
175
+ delete btn.dataset.originalLabel;
176
+ withLoading(btn, action, okMessage);
177
+ return;
178
+ }
179
+ btn.dataset.confirming = '1';
180
+ btn.dataset.originalLabel = btn.textContent;
181
+ btn.textContent = armLabel;
182
+ btn.dataset.confirmTimer = String(setTimeout(() => {
183
+ btn.textContent = btn.dataset.originalLabel || armLabel;
184
+ delete btn.dataset.confirming;
185
+ delete btn.dataset.confirmTimer;
186
+ delete btn.dataset.originalLabel;
187
+ }, 3000));
188
+ };
189
+ const activateTab = (name) => {
190
+ document.querySelectorAll('.tab').forEach((tab) => {
191
+ const active = tab.dataset.tab === name;
192
+ tab.classList.toggle('active', active);
193
+ tab.setAttribute('aria-selected', active ? 'true' : 'false');
194
+ });
195
+ document.querySelectorAll('.tabpane').forEach((pane) => {
196
+ pane.hidden = pane.id !== 'pane-' + name;
197
+ });
198
+ // 套餐 tab 每次切入重拉(审批状态随时可能变化)
199
+ if (name === 'plan') {
200
+ renderPlan();
201
+ }
202
+ // 插件列表按需加载:首次切到插件页时拉取(含版本/升级信息,开销大不做轮询)
203
+ if (name === 'plugins' && !renderPlugins._loaded) {
204
+ renderPlugins._loaded = true;
205
+ renderPlugins();
206
+ }
207
+ // CLI 状态按需探测(auth status 有进程开销,不进 5s 轮询;1 分钟内不重复)
208
+ if (name === 'cli') {
209
+ const now = Date.now();
210
+ if (!renderCli._probedAt || now - renderCli._probedAt > 60000) {
211
+ renderCli._probedAt = now;
212
+ renderCli(lastStatus);
213
+ }
214
+ }
215
+ };
216
+ // CLI 页手动重新检测:清掉节流时间戳立即重探
217
+ $('cli-recheck-btn').onclick = () => {
218
+ renderCli._probedAt = 0;
219
+ renderCli(lastStatus);
220
+ };
221
+ document.querySelectorAll('.tab').forEach((tab) => {
222
+ tab.onclick = () => activateTab(tab.dataset.tab);
223
+ });
224
+ const skillAction = (slug, action, btn) => withLoading(
225
+ btn,
226
+ () => fetch('api/skill/' + action, {
227
+ method: 'POST',
228
+ headers: { 'Content-Type': 'application/json' },
229
+ body: JSON.stringify({ slug }),
230
+ }),
231
+ action === 'install' ? '技能已安装' : '技能已卸载',
232
+ );
233
+ const PLAN_CAPABILITY_LABELS = {
234
+ image_understanding_enabled: '图片理解',
235
+ image_generation_enabled: 'AI 作图',
236
+ ppt_enabled: 'PPT 生成',
237
+ web_research_enabled: '联网搜索',
238
+ };
239
+ const formatTokenLimit = (limit) => {
240
+ const value = Number(limit) || 0;
241
+ if (!value) return '不限';
242
+ const units = [
243
+ [1_000_000_000_000, 'T'],
244
+ [1_000_000_000, 'B'],
245
+ [1_000_000, 'M'],
246
+ [1000, 'K'],
247
+ ];
248
+ for (const [size, unit] of units) {
249
+ if (value >= size) {
250
+ const scaled = value / size;
251
+ return (scaled % 1 ? scaled.toFixed(1) : String(scaled)) + unit + ' token/月';
252
+ }
253
+ }
254
+ return value + ' token/月';
255
+ };
256
+ const renderPlan = async () => {
257
+ const body = $('plan-body');
258
+ let data;
259
+ try {
260
+ const resp = await fetch('api/plan');
261
+ data = await resp.json().catch(() => ({}));
262
+ if (!resp.ok) throw new Error(data.error || '获取套餐状态失败');
263
+ } catch (error) {
264
+ body.innerHTML = '<div class="skill-sub" style="color:var(--err)">' + esc(error.message) + '</div>';
265
+ return;
266
+ }
267
+ // 套餐 tab 每次切入都重拉(审批状态可能刚变化)
268
+ if (data.state === 'active' && data.plan) {
269
+ const p = data.plan;
270
+ const caps = Object.entries(PLAN_CAPABILITY_LABELS)
271
+ .filter(([key]) => p[key])
272
+ .map(([, label]) => '<span class="tag">' + esc(label) + '</span>')
273
+ .join('');
274
+ const lines = [
275
+ '<div class="kv"><span class="k">当前套餐</span><span class="v">' + esc(p.name) + '</span></div>',
276
+ '<div class="kv"><span class="k">有效期</span><span class="v">'
277
+ + (p.ends_at ? '至 ' + new Date(p.ends_at).toLocaleDateString() : '长期') + '</span></div>',
278
+ '<div class="kv"><span class="k">Token 限额</span><span class="v">' + formatTokenLimit(p.monthly_token_limit) + '</span></div>',
279
+ ];
280
+ body.innerHTML = lines.join('') + (caps ? '<div class="skill-name" style="margin-top:6px">' + caps + '</div>' : '')
281
+ + '<div class="hint">套餐变更与续期请联系管理员。</div>';
282
+ return;
283
+ }
284
+ if (data.state === 'pending' && data.pending_request) {
285
+ const pending = data.pending_request;
286
+ body.innerHTML = '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
287
+ + esc(pending.plan_name || '套餐申请')
288
+ + '<span class="pill warn" style="font-size:11px;margin-left:6px">审批中</span></div><div class="skill-sub">'
289
+ + (pending.created_at ? '提交于 ' + new Date(pending.created_at).toLocaleString() : '')
290
+ + '</div></div></div>'
291
+ + '<div class="hint">管理员审批通过后,模型会自动同步,无需手动操作。</div>';
292
+ return;
293
+ }
294
+ if (data.state === 'expired') {
295
+ body.innerHTML = '<div class="skill-sub" style="color:var(--warn)">套餐已到期,模型暂停使用。重新申请后自动恢复。</div>'
296
+ + '<div id="plan-apply-list"></div>';
297
+ } else {
298
+ const rejected = data.last_rejected;
299
+ const rejectedHint = rejected
300
+ ? '<div class="skill-sub" style="color:var(--err);margin-bottom:8px">上次申请被拒:' + esc(rejected.admin_note || '未说明原因') + '</div>'
301
+ : '';
302
+ body.innerHTML = '<div class="skill-sub" style="margin-bottom:8px">开通套餐后即可使用公司托管的模型。</div>'
303
+ + rejectedHint
304
+ + '<div id="plan-apply-list"></div>';
305
+ }
306
+ const list = body.querySelector('#plan-apply-list');
307
+ const plans = data.plans || [];
308
+ if (!plans.length) {
309
+ list.innerHTML = '<div class="skill-sub">暂无可申请套餐,请联系管理员。</div>';
310
+ return;
311
+ }
312
+ list.innerHTML = plans.map((plan) => {
313
+ const caps = Object.entries(PLAN_CAPABILITY_LABELS)
314
+ .filter(([key]) => plan[key])
315
+ .map(([, label]) => '<span class="tag">' + esc(label) + '</span>')
316
+ .join('');
317
+ return '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
318
+ + esc(plan.name) + caps + '</div><div class="skill-sub">'
319
+ + esc(plan.description || '') + ' · ' + formatTokenLimit(plan.monthly_token_limit)
320
+ + '</div></div><button class="btn small primary" data-apply-plan="' + esc(plan.id) + '">申请</button></div>';
321
+ }).join('');
322
+ list.querySelectorAll('[data-apply-plan]').forEach((btn) => {
323
+ btn.dataset.loading = '提交中…';
324
+ btn.onclick = () => withLoading(
325
+ btn,
326
+ () => fetch('api/plan/apply', {
327
+ method: 'POST',
328
+ headers: { 'Content-Type': 'application/json' },
329
+ body: JSON.stringify({ plan_id: btn.dataset.applyPlan, message: '' }),
330
+ }),
331
+ '申请已提交,等待管理员审批',
332
+ ).then(() => renderPlan());
333
+ });
334
+ };
335
+ const renderSkills = (s) => {
336
+ const skillhubEnabled = s.capabilities?.channels?.skillhub?.enabled !== false;
337
+ // null = 平台拉取失败(区别于 [] 套餐确实没配技能),见 /api/status 组装
338
+ const list = Array.isArray(s.skills?.skills) ? s.skills.skills : null;
339
+ const visible = s.logged_in && skillhubEnabled;
340
+ // 技能通道被平台关闭时连 tab 一起藏;当前停留在技能页则退回模型页
341
+ const skillsTab = document.querySelector('[data-tab="skills"]');
342
+ skillsTab.hidden = !visible;
343
+ if (!visible && $('pane-skills').hidden === false) activateTab('model');
344
+ if (!visible) return;
345
+ const names = s.skill_display_names || {};
346
+ const displayName = (canonical) => {
347
+ const bare = canonical.includes('--') ? canonical.split('--').slice(1).join('--') : canonical;
348
+ return names[canonical] || names[bare] || bare;
349
+ };
350
+ // 加载失败(list 为 null)不能误显示成「未配置技能」
351
+ if (list === null) {
352
+ $('skills-list').innerHTML =
353
+ '<div class="skill-sub" style="color:var(--err)">技能列表加载失败,稍后自动重试;也可检查网络后刷新页面。</div>';
354
+ $('skills-meta').innerHTML = '';
355
+ return;
356
+ }
357
+ const rows = list.map((skill) => {
358
+ const tag = skill.source === 'plan' ? '<span class="tag">套餐</span>' : '';
359
+ const meta = skill.installed
360
+ ? '已装 ' + esc(skill.installed_version || '')
361
+ : '<span class="muted">未安装</span>';
362
+ const action = skill.installed
363
+ ? '<button class="btn small danger" data-uninstall="' + esc(skill.canonical_slug) + '">卸载</button>'
364
+ : '<button class="btn small primary" data-install="' + esc(skill.canonical_slug) + '">安装</button>';
365
+ return '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
366
+ + esc(displayName(skill.canonical_slug)) + tag + '</div><div class="skill-sub">'
367
+ + esc(skill.canonical_slug) + ' · ' + meta + '</div></div>' + action + '</div>';
368
+ });
369
+ const empty = rows.length ? '' : '<div class="skill-sub">套餐暂未配置技能,可在下方市场搜索安装</div>';
370
+ const metaLine = [];
371
+ if (s.skills_synced_at) metaLine.push('自动同步:' + new Date(s.skills_synced_at).toLocaleString());
372
+ if (s.skills_error) metaLine.push('<span style="color:var(--err)">' + esc(s.skills_error) + '</span>');
373
+ const metaHtml = metaLine.join(' · ');
374
+ // 5s 轮询会反复调用本函数:指纹(slug:装否:版本 + meta 行)没变时跳过
375
+ // innerHTML 重写,避免替换按钮节点吞掉正在进行的点击、打断卸载二次确认
376
+ const fingerprint = list.map((skill) =>
377
+ skill.canonical_slug + ':' + (skill.installed ? 1 : 0) + ':' + (skill.installed_version || ''),
378
+ ).join('|') + '#' + metaHtml;
379
+ if ($('skills-list').dataset.fingerprint !== fingerprint) {
380
+ $('skills-list').dataset.fingerprint = fingerprint;
381
+ $('skills-list').innerHTML = rows.join('') + empty;
382
+ $('skills-meta').innerHTML = metaHtml;
383
+ $('skills-list').querySelectorAll('[data-install]').forEach((btn) => {
384
+ btn.dataset.loading = '安装中…';
385
+ btn.onclick = () => skillAction(btn.dataset.install, 'install', btn);
386
+ });
387
+ $('skills-list').querySelectorAll('[data-uninstall]').forEach((btn) => {
388
+ btn.dataset.loading = '卸载中…';
389
+ btn.onclick = () => confirmThen(btn, '确认卸载?', () =>
390
+ fetch('api/skill/uninstall', {
391
+ method: 'POST',
392
+ headers: { 'Content-Type': 'application/json' },
393
+ body: JSON.stringify({ slug: btn.dataset.uninstall }),
394
+ }), '技能已卸载');
395
+ });
396
+ }
397
+ };
398
+ const CLI_TOOL_META = {
399
+ 'lark-cli': { label: '飞书开放工具', desc: '飞书消息、文档、日历等操作能力' },
400
+ 'ccpg-cli': { label: '公司内部工具', desc: '公司内部业务系统操作能力' },
401
+ };
402
+ const renderCli = async (s) => {
403
+ const cliEnabled = s.capabilities?.cli_deployd_enabled !== false;
404
+ const body = $('cli-body');
405
+ if (!s.logged_in) {
406
+ body.innerHTML = '<div class="skill-sub">登录后可用公司托管的 CLI 工具。</div>';
407
+ return;
408
+ }
409
+ if (!cliEnabled) {
410
+ body.innerHTML = '<div class="skill-sub">平台未开启 CLI 功能。</div>';
411
+ return;
412
+ }
413
+ // 真实探测本机安装与授权态(数秒),期间先展示读取中
414
+ body.innerHTML = '<div class="skill-sub">检测中…</div>';
415
+ let tools;
416
+ try {
417
+ const resp = await fetch('api/cli/status');
418
+ const data = await resp.json().catch(() => ({}));
419
+ if (!resp.ok) throw new Error(data.error || '检测失败');
420
+ tools = data.tools || {};
421
+ } catch {
422
+ body.innerHTML = '<div class="skill-sub" style="color:var(--err)">CLI 状态检测失败,请稍后重试。</div>';
423
+ return;
424
+ }
425
+ const rows = Object.entries(CLI_TOOL_META).map(([name, meta]) => {
426
+ const state = tools[name] || 'missing';
427
+ const status = state === 'authorized'
428
+ ? '<span class="pill ok" style="font-size:11px">已授权</span>'
429
+ : state === 'unauthorized'
430
+ ? '<span class="pill warn" style="font-size:11px">未授权</span>'
431
+ : '<span class="pill warn" style="font-size:11px">未安装</span>';
432
+ return '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
433
+ + esc(name) + status + '</div><div class="skill-sub">'
434
+ + esc(meta.desc) + ' · ' + esc(meta.label) + '</div></div></div>';
435
+ });
436
+ body.innerHTML = rows.join('')
437
+ + '<div class="hint">在对话中直接使用(如 /lark 帮助),由 dsh 自动调用。</div>'
438
+ + '<div class="hint">未安装/未授权时,请联系管理员在镜像或本机完成 lark-cli、ccpg-cli 的安装与授权。</div>';
439
+ };
440
+ const pluginAction = (name, action, btn) => withLoading(
441
+ btn,
442
+ () => fetch('api/plugin/' + action, {
443
+ method: 'POST',
444
+ headers: { 'Content-Type': 'application/json' },
445
+ body: JSON.stringify({ name }),
446
+ }),
447
+ action === 'install' ? '插件已安装' : action === 'remove' ? '插件已卸载' : '插件已升级',
448
+ ).then(() => renderPlugins());
449
+ const renderPlugins = async () => {
450
+ const list = $('plugins-list');
451
+ list.innerHTML = '<div class="skill-sub">读取中…</div>';
452
+ let data;
453
+ try {
454
+ const resp = await fetch('api/plugin/list');
455
+ data = await resp.json().catch(() => ({}));
456
+ if (!resp.ok) throw new Error(data.error || '读取插件列表失败');
457
+ } catch (error) {
458
+ list.innerHTML = '<div class="skill-sub" style="color:var(--err)">' + esc(error.message) + '</div>';
459
+ return;
460
+ }
461
+ // 渲染成功即标记已加载:避免切 tab 重复拉取(outdated 查询有网络开销)
462
+ renderPlugins._loaded = true;
463
+ if (!data.plugins?.length) {
464
+ list.innerHTML = '<div class="skill-sub">暂无已安装插件;内置插件缺失时会在 dsh 启动时自动恢复。</div>';
465
+ return;
466
+ }
467
+ const rows = data.plugins.map((plugin) => {
468
+ // 三态:未安装 → 安装按钮;已装 → 升级(有新版本时)+ 卸载(非内置非自身)
469
+ const statePill = !plugin.version
470
+ ? '<span class="pill warn" style="font-size:11px">未安装</span>'
471
+ : plugin.active
472
+ ? '<span class="pill ok" style="font-size:11px">已激活</span>'
473
+ : '<span class="pill warn" style="font-size:11px">未激活</span>';
474
+ const builtinTag = plugin.builtin
475
+ ? '<span class="tag">内置</span>'
476
+ : '';
477
+ const version = plugin.latest
478
+ ? esc(plugin.version) + ' → <b>' + esc(plugin.latest) + '</b>'
479
+ : esc(plugin.version || '—');
480
+ const desc = plugin.description
481
+ ? esc(plugin.description) + '<br>'
482
+ : '';
483
+ const actions = [];
484
+ if (!plugin.version) {
485
+ actions.push('<button class="btn small primary" data-install-pkg="' + esc(plugin.name) + '">安装</button>');
486
+ } else {
487
+ // 自身随平台镜像升级(self_managed 由后端下发):不提供升级/卸载;
488
+ // 内置插件可升级但不提供卸载(后端同样拒绝)
489
+ if (plugin.latest && plugin.latest !== plugin.version && !plugin.self_managed) {
490
+ actions.push('<button class="btn small primary" data-upgrade="' + esc(plugin.name) + '">升级</button>');
491
+ }
492
+ if (!plugin.self_managed && !plugin.builtin) {
493
+ actions.push('<button class="btn small danger" data-remove="' + esc(plugin.name) + '">卸载</button>');
494
+ }
495
+ }
496
+ const actionHtml = actions.length
497
+ ? '<div style="display:flex;gap:6px">' + actions.join('') + '</div>'
498
+ : '';
499
+ return '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
500
+ + esc(plugin.name) + builtinTag + statePill
501
+ + '</div><div class="skill-sub">' + desc + esc(plugin.source) + ' · ' + version
502
+ + (plugin.version && !plugin.active ? ' · 未在 bundle 层,重启 dsh 后生效' : '')
503
+ + '</div></div>' + actionHtml + '</div>';
504
+ });
505
+ list.innerHTML = rows.join('');
506
+ list.querySelectorAll('[data-install-pkg]').forEach((btn) => {
507
+ btn.dataset.loading = '安装中…';
508
+ btn.onclick = () => pluginAction(btn.dataset.installPkg, 'install', btn);
509
+ });
510
+ list.querySelectorAll('[data-upgrade]').forEach((btn) => {
511
+ btn.dataset.loading = '升级中…';
512
+ btn.onclick = () => pluginAction(btn.dataset.upgrade, 'upgrade', btn);
513
+ });
514
+ list.querySelectorAll('[data-remove]').forEach((btn) => {
515
+ btn.dataset.loading = '卸载中…';
516
+ btn.onclick = () => confirmThen(btn, '确认卸载?', () =>
517
+ fetch('api/plugin/remove', {
518
+ method: 'POST',
519
+ headers: { 'Content-Type': 'application/json' },
520
+ body: JSON.stringify({ name: btn.dataset.remove }),
521
+ }).then(() => renderPlugins()), '插件已卸载');
522
+ });
523
+ };
524
+ const renderAbout = (s) => {
525
+ const lines = [
526
+ '<div class="kv"><span class="k">插件版本</span><span class="v">dsh-agentone v' + esc(s.plugin_version || '?') + '</span></div>',
527
+ '<div class="kv"><span class="k">登录账号</span><span class="v">' + esc(s.user_email || '未登录') + '</span></div>',
528
+ '<div class="kv"><span class="k">所属套餐</span><span class="v">' + esc(s.capabilities?.plan_name || '—') + '</span></div>',
529
+ '<div class="kv"><span class="k">平台地址</span><span class="v muted">' + esc(s.platform_url || '—') + '</span></div>',
530
+ ];
531
+ $('about-body').innerHTML = lines.join('');
532
+ };
533
+ const searchCatalog = async () => {
534
+ const btn = $('search-btn');
535
+ const q = $('search-input').value.trim();
536
+ btn.disabled = true;
537
+ btn.innerHTML = '<span class="spin"></span>搜索中';
538
+ try {
539
+ const resp = await fetch('api/skill/catalog?q=' + encodeURIComponent(q));
540
+ const data = await resp.json().catch(() => ({}));
541
+ if (!resp.ok) throw new Error(data.error || '搜索失败');
542
+ const installed = new Set((data.installed || []).map((skill) => skill.canonical_slug));
543
+ const rows = (data.items || []).map((item) => {
544
+ const known = installed.has(item.slug);
545
+ const action = known
546
+ ? '<span class="pill ok" style="font-size:11px">已安装</span>'
547
+ : '<button class="btn small primary" data-install="' + esc(item.slug) + '">安装</button>';
548
+ return '<div class="skill-item"><div class="skill-info"><div class="skill-name">'
549
+ + esc(item.display_name || item.slug)
550
+ + '<span class="tag" data-cat="' + esc(item.category || '') + '">' + esc(item.category || '技能') + '</span></div><div class="skill-sub">'
551
+ + esc(item.summary || item.slug) + ' · ' + (item.downloads || 0) + ' 次安装</div></div>' + action + '</div>';
552
+ });
553
+ $('search-results').innerHTML = rows.length
554
+ ? rows.join('')
555
+ : '<div class="skill-sub">没有匹配的技能</div>';
556
+ $('search-results').querySelectorAll('[data-install]').forEach((btn) => {
557
+ btn.dataset.loading = '安装中…';
558
+ btn.onclick = () => skillAction(btn.dataset.install, 'install', btn).then(() => searchCatalog());
559
+ });
560
+ } catch (error) {
561
+ notify(error.message, 'err');
562
+ } finally {
563
+ btn.disabled = false;
564
+ btn.innerHTML = '搜索';
565
+ }
566
+ };
567
+ const refresh = async () => {
568
+ let s;
569
+ try {
570
+ s = await (await fetch('api/status')).json();
571
+ } catch {
572
+ statePill.className = 'pill warn';
573
+ statePill.textContent = '连接中…';
574
+ return;
575
+ }
576
+ lastStatus = s;
577
+ const modelEnabled = s.capabilities?.channels?.model?.enabled !== false;
578
+ // 套餐审批通过检测:plan_state 由非 active 变为 active 时自动同步模型
579
+ const planState = s.capabilities?.plan_state;
580
+ if (s.logged_in && modelEnabled) {
581
+ if (planState === 'active' && lastPlanState && lastPlanState !== 'active') {
582
+ notify('套餐已生效,正在同步模型…');
583
+ fetch('api/model/sync', { method: 'POST' }).catch(() => {});
584
+ }
585
+ lastPlanState = planState || null;
586
+ }
587
+ statePill.className = 'pill ' + (s.logged_in ? 'ok' : 'warn');
588
+ statePill.textContent = s.logged_in ? '已登录' : '未登录';
589
+ // 首个状态到达:骨架占位让位给登录卡/主内容
590
+ bootCard.hidden = true;
591
+ loginCard.hidden = s.logged_in;
592
+ mainCards.hidden = !s.logged_in;
593
+ logoutBtn.hidden = !s.logged_in;
594
+ if (s.logged_in && s.model) {
595
+ const m = s.model;
596
+ const lines = [];
597
+ // 阻断判定优先看平台错误码(plan_required,见 plugin_gateway
598
+ // _plan_required_response);中文正则仅作旧平台/无码时的兜底。
599
+ const planRequiredError = m.error
600
+ && (m.error_code === 'plan_required' || /申请套餐|暂无可用模型/.test(m.error));
601
+ const noModels = !m.default_model && !(m.models_count > 0);
602
+ const planBlocked = modelEnabled && Boolean(planRequiredError || (noModels && m.error));
603
+ if (!modelEnabled) {
604
+ lines.push('<div class="kv"><span class="k">模型同步</span><span class="v muted">平台未开启模型托管</span></div>');
605
+ } else if (planBlocked) {
606
+ const planText = s.capabilities?.plan_state === 'pending'
607
+ ? '套餐申请审批中,通过后模型自动可用。'
608
+ : '当前账号暂无可用模型,申请套餐通过后自动可用。';
609
+ lines.push('<div class="kv"><span class="k">模型使用</span><span class="v" style="color:var(--warn)">' + planText + '</span></div>');
610
+ } else {
611
+ lines.push('<div class="kv"><span class="k">默认模型</span><span class="v">' + esc(m.default_model || '未同步') + '</span></div>');
612
+ lines.push('<div class="kv"><span class="k">可用模型</span><span class="v">' + esc(m.models_count) + ' 个</span></div>');
613
+ if (m.synced_at) lines.push('<div class="kv"><span class="k">上次同步</span><span class="v">' + new Date(m.synced_at).toLocaleString() + '</span></div>');
614
+ }
615
+ if (!planBlocked && m.error) lines.push('<div class="kv"><span class="k">同步状态</span><span class="v" style="color:var(--err)">' + esc(m.error) + '</span></div>');
616
+ if (s.token_expires_at) {
617
+ lines.push('<div class="kv"><span class="k">登录有效期</span><span class="v muted">至 ' + new Date(s.token_expires_at).toLocaleDateString() + '(自动续期)</span></div>');
618
+ }
619
+ $('model-body').innerHTML = lines.join('')
620
+ + (planBlocked
621
+ ? '<div class="row" style="margin-top:10px"><button id="goto-plan-btn" class="btn primary small">去申请套餐</button></div>'
622
+ : '');
623
+ $('sync-btn').hidden = !modelEnabled || planBlocked;
624
+ const gotoPlan = $('goto-plan-btn');
625
+ if (gotoPlan) gotoPlan.onclick = () => activateTab('plan');
626
+ }
627
+ renderSkills(s);
628
+ renderAbout(s);
629
+ };
630
+ $('login-btn').onclick = async () => {
631
+ const btn = $('login-btn');
632
+ const base = location.pathname.replace(/\\/agentone\\/?$/, '');
633
+ const callbackUrl = location.pathname.startsWith('/portal/')
634
+ ? base + '/agentone/callback'
635
+ : location.origin + base + '/agentone/callback';
636
+ const resp = await fetch('api/login', {
637
+ method: 'POST',
638
+ headers: { 'Content-Type': 'application/json' },
639
+ body: JSON.stringify({ callback_url: callbackUrl }),
640
+ });
641
+ const data = await resp.json();
642
+ if (!resp.ok) { notify(data.error || '发起登录失败', 'err'); return; }
643
+ // 新标签页(顶层上下文)打开:iframe 内跨源 localStorage 被浏览器分区隔离
644
+ window.open(data.login_url, '_blank');
645
+ // 按钮保持可点:授权窗被用户关掉后可立即重新发起,无需等 2 分钟
646
+ btn.textContent = '已打开授权窗口,完成后自动继续(可重开)';
647
+ };
648
+ $('sync-btn').onclick = () => withLoading(
649
+ $('sync-btn'),
650
+ () => fetch('api/model/sync', { method: 'POST' }),
651
+ '平台模型已同步',
652
+ );
653
+ logoutBtn.onclick = () => withLoading(
654
+ logoutBtn,
655
+ () => fetch('api/logout', { method: 'POST' }),
656
+ '已退出登录',
657
+ );
658
+ $('search-btn').onclick = searchCatalog;
659
+ $('search-input').addEventListener('keydown', (event) => {
660
+ if (event.key === 'Enter') searchCatalog();
661
+ });
662
+ // 页面隐藏时暂停轮询(后台标签页每 5s 打一次 status 是纯浪费),
663
+ // 重新可见时立即刷新一次拿最新状态
664
+ const pollTimer = setInterval(() => { if (!document.hidden) refresh(); }, 5000);
665
+ document.addEventListener('visibilitychange', () => {
666
+ if (!document.hidden) refresh();
667
+ });
668
+ refresh();
669
+ `;
670
+
671
+ export function statusPageHtml() {
672
+ return `<!doctype html>
673
+ <html lang="zh-CN">
674
+ <head>
675
+ <meta charset="utf-8">
676
+ <title>AgentOne</title>
677
+ <meta name="viewport" content="width=device-width, initial-scale=1">
678
+ <style>${PAGE_CSS}</style>
679
+ </head>
680
+ <body>
681
+ <div class="wrap">
682
+ <div class="head">
683
+ <div class="logo">A1</div>
684
+ <h2>AgentOne 平台</h2>
685
+ <span id="state-pill" class="pill warn">读取中…</span>
686
+ <button id="logout-btn" class="btn danger small" data-loading="退出中…" hidden>退出登录</button>
687
+ </div>
688
+ <div id="toast" class="banner toast-fixed"></div>
689
+
690
+ <div id="boot-card" class="card">
691
+ <div class="skeleton w60"></div>
692
+ <div class="skeleton"></div>
693
+ <div class="skeleton"></div>
694
+ </div>
695
+
696
+ <div id="login-card" class="card hero" hidden>
697
+ <p class="big">登录后申请套餐,即可使用公司托管的模型与技能</p>
698
+ <p>飞书账号一键授权,无需任何 API Key;套餐审批通过后模型自动配置。</p>
699
+ <button id="login-btn" class="btn primary">飞书登录</button>
700
+ <p class="hint">将在新窗口完成授权,此处自动继续。</p>
701
+ </div>
702
+
703
+ <div id="main-cards" hidden>
704
+ <nav class="tabs" id="tabs" role="tablist">
705
+ <button class="tab active" role="tab" aria-selected="true" data-tab="model">模型</button>
706
+ <button class="tab" role="tab" aria-selected="false" data-tab="plan">套餐</button>
707
+ <button class="tab" role="tab" aria-selected="false" data-tab="skills">技能</button>
708
+ <button class="tab" role="tab" aria-selected="false" data-tab="plugins">插件</button>
709
+ <button class="tab" role="tab" aria-selected="false" data-tab="cli">CLI</button>
710
+ <button class="tab" role="tab" aria-selected="false" data-tab="about">关于</button>
711
+ </nav>
712
+
713
+ <div class="tabpane" id="pane-model">
714
+ <div class="card">
715
+ <h3>平台模型</h3>
716
+ <div id="model-body"></div>
717
+ <div class="row" style="margin-top:10px">
718
+ <button id="sync-btn" class="btn ghost small" data-loading="同步中…">重新同步模型</button>
719
+ </div>
720
+ </div>
721
+ </div>
722
+
723
+ <div class="tabpane" id="pane-plan" hidden>
724
+ <div class="card">
725
+ <h3>平台套餐 <span class="muted">决定能否使用平台模型</span></h3>
726
+ <div id="plan-body"><div class="skill-sub">读取中…</div></div>
727
+ </div>
728
+ </div>
729
+
730
+ <div class="tabpane" id="pane-skills" hidden>
731
+ <div class="card" id="skills-card">
732
+ <h3>平台技能 <span class="muted">套餐自动同步</span></h3>
733
+ <div id="skills-list"></div>
734
+ <div class="hint" id="skills-meta"></div>
735
+ <div class="search" style="margin-top:12px">
736
+ <input id="search-input" placeholder="搜索技能市场,如:数据 / 报告 / lark" />
737
+ <button id="search-btn" class="btn ghost small">搜索</button>
738
+ </div>
739
+ <div id="search-results"></div>
740
+ </div>
741
+ </div>
742
+
743
+ <div class="tabpane" id="pane-plugins" hidden>
744
+ <div class="card">
745
+ <h3>dsh 插件 <span class="muted">镜像内置与自行安装的插件</span></h3>
746
+ <div id="plugins-list"></div>
747
+ <div class="hint" id="plugins-hint">标注「内置」的插件由镜像预装,不可卸载,可升级到最新版。更多插件可在 dsh 设置的插件市场里安装。</div>
748
+ </div>
749
+ </div>
750
+
751
+ <div class="tabpane" id="pane-cli" hidden>
752
+ <div class="card">
753
+ <h3>CLI 工具
754
+ <button id="cli-recheck-btn" class="btn ghost small" style="margin-left:auto" data-loading="检测中…">重新检测</button>
755
+ </h3>
756
+ <div id="cli-body"></div>
757
+ </div>
758
+ </div>
759
+
760
+ <div class="tabpane" id="pane-about" hidden>
761
+ <div class="card">
762
+ <h3>关于</h3>
763
+ <div id="about-body"></div>
764
+ <div class="footer" style="margin-top:12px">
765
+ <span class="muted" style="font-size:12px">登录与配置自动续期,日常无需维护</span>
766
+ </div>
767
+ </div>
768
+ </div>
769
+ </div>
770
+ </div>
771
+ <script>${PAGE_JS}</script>
772
+ </body>
773
+ </html>`;
774
+ }
775
+
776
+ /** 授权回调后的完成页(顶层标签页):引导用户返回 dsh,并尝试自动关闭。 */
777
+ export function donePageHtml(ok) {
778
+ const body = ok
779
+ ? '<p class="big">✓ 已连接 AgentOne 平台</p><p>模型与技能正在自动配置,稍候即可关闭本页返回 dsh。</p>'
780
+ : '<p class="big">授权未完成</p><p>可以关闭本页,回到 dsh 设置里的「AgentOne」重新发起登录。</p>';
781
+ return `<!doctype html>
782
+ <html lang="zh-CN">
783
+ <head>
784
+ <meta charset="utf-8">
785
+ <title>AgentOne</title>
786
+ <meta name="viewport" content="width=device-width, initial-scale=1">
787
+ <style>
788
+ body { font-family: system-ui, -apple-system, "PingFang SC", sans-serif; background:#f6f8fa;
789
+ color:#1f2328; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
790
+ @media (prefers-color-scheme: dark) { body { background:#0d1117; color:#e6edf3; } .card { background:#161b22; border-color:#30363d; } }
791
+ .card { background:#fff; border:1px solid #d8dee4; border-radius:16px; padding:32px 36px;
792
+ text-align:center; max-width:380px; margin:16px; }
793
+ .big { font-size:17px; font-weight:600; margin:0 0 8px; }
794
+ .card p { color:#57606a; margin:0 0 18px; font-size:14px; }
795
+ button { font:inherit; padding:9px 22px; border-radius:10px; border:none; cursor:pointer;
796
+ background:#2f6feb; color:#fff; }
797
+ .spin { width:14px;height:14px;border:2px solid #57606a;border-top-color:transparent;
798
+ border-radius:50%;display:inline-block;vertical-align:-2px;margin-right:8px;
799
+ animation:spin .8s linear infinite; }
800
+ @keyframes spin { to { transform: rotate(360deg); } }
801
+ </style>
802
+ </head>
803
+ <body>
804
+ <div class="card">
805
+ <div id="body">${body}</div>
806
+ <button onclick="window.close()">关闭本页</button>
807
+ <button id="retry-btn" style="display:none;margin-left:10px" onclick="retryModelSync()">重试同步</button>
808
+ </div>
809
+ <script>
810
+ if (${ok ? 'true' : 'false'}) {
811
+ // 配置在宿主进程异步进行;就绪后给出可关闭提示并尝试自动关页。
812
+ // 模型同步失败时不再无限等待:展示原因并提供重试。
813
+ const timer = setInterval(async () => {
814
+ try {
815
+ const s = await (await fetch('api/status')).json();
816
+ if (s.logged_in && s.model?.default_model) {
817
+ clearInterval(timer);
818
+ document.getElementById('body').innerHTML =
819
+ '<p class="big">✓ 一切就绪</p><p>默认模型 ' + s.model.default_model +
820
+ ',技能已同步。请返回 dsh 继续使用。</p>';
821
+ setTimeout(() => window.close(), 1200);
822
+ return;
823
+ }
824
+ if (s.logged_in && s.model?.error) {
825
+ clearInterval(timer);
826
+ document.getElementById('body').innerHTML =
827
+ '<p class="big" style="color:#c0392b">模型配置未完成</p><p>' + s.model.error + '</p>';
828
+ document.getElementById('retry-btn').style.display = 'inline-block';
829
+ }
830
+ } catch { /* 继续等待 */ }
831
+ }, 1500);
832
+ setTimeout(() => clearInterval(timer), 120000);
833
+ }
834
+ const retryModelSync = async () => {
835
+ const btn = document.getElementById('retry-btn');
836
+ btn.disabled = true;
837
+ btn.textContent = '同步中…';
838
+ try {
839
+ const resp = await fetch('api/model/sync', { method: 'POST' });
840
+ const data = await resp.json().catch(() => ({}));
841
+ if (!resp.ok) throw new Error(data.error || '同步失败');
842
+ document.getElementById('body').innerHTML =
843
+ '<p class="big">✓ 一切就绪</p><p>默认模型 ' + data.default_model +
844
+ '。请返回 dsh 继续使用。</p>';
845
+ setTimeout(() => window.close(), 1200);
846
+ } catch (error) {
847
+ document.getElementById('body').innerHTML =
848
+ '<p class="big" style="color:#c0392b">模型配置未完成</p><p>' + error.message + '</p>';
849
+ btn.disabled = false;
850
+ btn.textContent = '重试同步';
851
+ btn.style.display = 'inline-block';
852
+ }
853
+ };
854
+ </script>
855
+ </body>
856
+ </html>`;
857
+ }