@jesonliu/lark-claudecode-bridge 1.4.3 → 2.0.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.
Files changed (42) hide show
  1. package/assets/web/css/main.css +766 -574
  2. package/assets/web/index.html +37 -17
  3. package/assets/web/js/auth.js +217 -0
  4. package/assets/web/js/bootstrap.js +36 -34
  5. package/assets/web/js/core.js +4 -3
  6. package/assets/web/js/main.js +79 -20
  7. package/assets/web/js/markdown.js +64 -0
  8. package/assets/web/js/pages/apps.js +338 -251
  9. package/assets/web/js/pages/chats.js +446 -0
  10. package/assets/web/js/pages/claude.js +243 -180
  11. package/assets/web/js/pages/mcp.js +59 -43
  12. package/assets/web/js/pages/memory.js +39 -43
  13. package/assets/web/js/pages/overview.js +344 -76
  14. package/assets/web/js/pages/permissions.js +40 -32
  15. package/assets/web/js/pages/plugins.js +51 -37
  16. package/assets/web/js/pages/skills.js +68 -92
  17. package/assets/web/js/pages/slash.js +31 -23
  18. package/assets/web/js/pages/workspaces.js +114 -73
  19. package/assets/web/js/theme.js +21 -18
  20. package/assets/web/js/ui.js +75 -32
  21. package/dist/bin/lcb.js +15 -0
  22. package/dist/bin/lcb.js.map +1 -1
  23. package/dist/gateway/feishu-gateway.d.ts +11 -0
  24. package/dist/gateway/feishu-gateway.js +5 -0
  25. package/dist/gateway/feishu-gateway.js.map +1 -1
  26. package/dist/gateway/gateway-registry.d.ts +16 -0
  27. package/dist/gateway/gateway-registry.js +10 -0
  28. package/dist/gateway/gateway-registry.js.map +1 -0
  29. package/dist/index.js +3 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/web/chats-api.d.ts +77 -0
  32. package/dist/web/chats-api.js +475 -0
  33. package/dist/web/chats-api.js.map +1 -0
  34. package/dist/web/logs-api.d.ts +37 -0
  35. package/dist/web/logs-api.js +141 -0
  36. package/dist/web/logs-api.js.map +1 -0
  37. package/dist/web/server.js +125 -6
  38. package/dist/web/server.js.map +1 -1
  39. package/dist/web/webauth.d.ts +44 -0
  40. package/dist/web/webauth.js +177 -0
  41. package/dist/web/webauth.js.map +1 -0
  42. package/package.json +1 -1
@@ -1,6 +1,17 @@
1
- // ============ Claude 认证 ============
1
+ // ============ Claude 认证(生效配置 / 厂商档案 双 Tab) ============
2
+ // 常规路径站在用户角度:bridge 托管 = 从厂商档案选(档案卡单选 → 下拉选模型),凭证不再手填;
3
+ // 手动凭证 / BASE_URL / 模型收进「高级」折叠(兼容老配置直填场景)。档案卡加宽 420px,
4
+ // 卡上候选模型点击即切换当前模型(走 /api/claude/use-profile,凭证明文不出进程)。
2
5
  import { S, $, esc, toast, api, snapDoc, saveDoc, cardDirty, applyDirty, refresh } from '../core.js';
3
- import { openDrawer, closeDrawer, cancelDrawer, saveBarHtml, dirtyDotHtml, bindSaveBar, confirmDialog } from '../ui.js';
6
+ import { openDrawer, closeDrawer, cancelDrawer, isDrawerOpen, saveBarHtml, dirtyDotHtml, bindSaveBar, confirmDialog } from '../ui.js';
7
+
8
+ const svg = (inner, s = 12) => `<svg viewBox="0 0 24 24" width="${s}" height="${s}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
9
+ const IC = {
10
+ key: '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
11
+ globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18"/>',
12
+ plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
13
+ info: '<circle cx="12" cy="12" r="9"/><path d="M12 8h.01"/><path d="M12 12v4"/>',
14
+ };
4
15
 
5
16
  /** 模型名 1M 上下文标记解析(纯函数,可测):'glm-5.3[1m]' → { name:'glm-5.3', tag:'1M' };后缀大小写不敏感(后端归一为小写 [1m]) */
6
17
  export function splitModelTag(m) {
@@ -30,96 +41,161 @@ async function pullModels(getBody, btn, datalistId) {
30
41
  btn.textContent = old;
31
42
  }
32
43
 
44
+ let claudeTab = 'cur'; // 页内 Tab 态(切页保留)
45
+
33
46
  function renderClaude(el) {
34
47
  const c = S.doc.claude || (S.doc.claude = {});
35
48
  c.profiles = c.profiles || [];
36
49
  const mode = c.mode || 'inherit';
37
- const hasToken = c.auth_token?.secretSet, hasKey = c.api_key?.secretSet;
38
- const credType = hasToken ? 'auth_token' : 'api_key';
39
- const cur = S.status?.claude?.current || {};
40
- const hint = (h) => h?.secretSet ? `已设置(${esc(h.secretHint)})` : '未设置';
41
50
  el.innerHTML = `
42
- <div class="card">
43
- <h3>当前生效配置</h3>
44
- <div class="desc">认证模式与实际生效的值(凭证脱敏显示)。managed 模式改动保存后对后续任务即生效;模式切换需重启。</div>
45
- <table>
46
- <tr><td style="width:150px">认证模式</td><td>${mode === 'managed' ? 'bridge 托管(managed)' : '继承本机 ~/.claude(inherit)'}</td></tr>
51
+ <div class="tabs">
52
+ <button data-t="cur" class="${claudeTab === 'cur' ? 'active' : ''}">生效配置</button>
53
+ <button data-t="pf" class="${claudeTab === 'pf' ? 'active' : ''}">厂商档案 <span class="tag off" style="margin-left:4px">${c.profiles.length}</span></button>
54
+ </div><div id="claudePane"></div>`;
55
+ el.querySelectorAll('.tabs button').forEach((b) => b.onclick = () => { claudeTab = b.dataset.t; renderClaude(el); });
56
+ void (claudeTab === 'cur' ? renderCurPane : renderPfPane)($('#claudePane'), c, mode, el);
57
+ applyDirty();
58
+ }
59
+
60
+ /** 档案「使用中」近似判定:前端无凭证明文,用尾 4 位提示 + baseUrl 比对 */
61
+ function inUseProfile(p, cur) {
62
+ const credSame = (p.auth_token?.secretHint && cur.authToken?.secretHint === p.auth_token.secretHint)
63
+ || (p.api_key?.secretHint && cur.apiKey?.secretHint === p.api_key.secretHint);
64
+ return credSame && (p.base_url || '') === (cur.baseUrl || '');
65
+ }
66
+
67
+ /** 生效配置 Tab:当前生效只读卡 + 认证模式卡(managed = 档案卡 + 模型下拉;高级折叠保留手填) */
68
+ function renderCurPane(pane, c, mode, el) {
69
+ const cur = S.status?.claude?.current || {};
70
+ const hint = (h) => (h?.secretSet ? `已设置(${esc(h.secretHint)})` : '未设置');
71
+ const profiles = c.profiles || [];
72
+ const activePf = profiles.find((p) => inUseProfile(p, cur));
73
+ const modelChipsOf = (p) => [...(p.models || []), ...(p.model && !(p.models || []).includes(p.model) ? [p.model] : [])];
74
+ pane.innerHTML = `
75
+ <div class="panel">
76
+ <header class="panel-head"><h3>当前生效</h3>
77
+ <div class="panel-actions"><span class="tag ${mode === 'inherit' ? (cur.authToken?.secretSet || cur.apiKey?.secretSet || cur.model ? 'ok' : 'warn') : (cur.authToken?.secretSet || cur.apiKey?.secretSet ? 'ok' : 'err')}">${cur.authToken?.secretSet || cur.apiKey?.secretSet ? '已就绪' : mode === 'inherit' ? '沿用本机登录态' : '未认证'}</span></div></header>
78
+ <div class="panel-body"><table class="kv">
79
+ <tr><td>认证模式</td><td>${mode === 'managed' ? `bridge 托管(managed)${activePf ? ` · 档案「${esc(activePf.name)}」` : ''}` : '继承本机 ~/.claude(inherit)'}</td></tr>
47
80
  <tr><td>BASE_URL</td><td>${cur.baseUrl ? `<code>${esc(cur.baseUrl)}</code>` : '<span class="hint">官方默认(api.anthropic.com)</span>'}</td></tr>
48
81
  <tr><td>模型</td><td>${(() => { const { name, tag } = splitModelTag(cur.model || ''); return cur.model ? `<code>${esc(name)}</code>${tag ? ' <span class="tag ok">1M 上下文</span>' : ''}` : '<span class="hint">未设置(CLI 默认)</span>'; })()}</td></tr>
49
- <tr><td>Auth Token</td><td>${hint(cur.authToken)}</td></tr>
50
- <tr><td>API Key</td><td>${hint(cur.apiKey)}</td></tr>
51
- <tr><td>自定义环境变量</td><td>${cur.envCount ? `${cur.envCount} 个(写入托管 settings.json env 块,MCP 工具可用)` : '<span class="hint">无</span>'}</td></tr>
82
+ <tr><td>凭证</td><td>${mode === 'managed' ? `AUTH_TOKEN ${hint(cur.authToken)} · API_KEY ${hint(cur.apiKey)}` : '本机登录态(本页不改)'}</td></tr>
83
+ <tr><td>自定义环境变量</td><td>${cur.envCount ? `${cur.envCount} 个(写入托管 settings.json env 块)` : '<span class="hint">无</span>'}</td></tr>
52
84
  <tr><td>settings.json</td><td><code>${esc(S.status?.claude?.settingsPath || '—')}</code></td></tr>
53
- </table>
54
- ${mode !== 'managed' ? '<div class="desc" style="margin-top:8px">inherit 模式上表为本机 ~/.claude 实际状态,本页不修改本机登录。要管理多厂商档案并一键切换,请切到 bridge 托管。</div>' : ''}
85
+ </table></div>
55
86
  </div>
56
- <div class="card">
57
- <h3>认证模式${dirtyDotHtml('claude')}</h3>
58
- <div class="radio-row">
59
- <label><input type="radio" name="mode" value="inherit" ${mode === 'inherit' ? 'checked' : ''}> 继承本机 ~/.claude(已在本机 claude login)</label>
60
- <label><input type="radio" name="mode" value="managed" ${mode === 'managed' ? 'checked' : ''}> bridge 托管(填 API Key / Token,无需本机登录)</label>
61
- </div>
62
- <div id="managedBox" style="${mode === 'managed' ? '' : 'display:none'}">
63
- <div class="desc" style="margin:6px 0 2px">常规用法:在下方「厂商档案」保存凭证后点「设为当前」即可,无须手动填写。</div>
64
- <details style="margin-top:4px">
65
- <summary style="cursor:pointer;font-size:13px;color:var(--muted)">高级:手动编辑当前生效凭证</summary>
66
- <div style="margin-top:10px">
87
+ <div class="panel">
88
+ <header class="panel-head"><h3>认证模式${dirtyDotHtml('claude')}</h3></header>
89
+ <div class="panel-body">
90
+ <div class="rcards">
91
+ <label class="rcard ${mode !== 'managed' ? 'on' : ''}"><input type="radio" name="clMode" value="inherit" ${mode !== 'managed' ? 'checked' : ''}><span><b>继承本机</b><span class="hint">沿用本机 claude login 的登录态</span></span></label>
92
+ <label class="rcard ${mode === 'managed' ? 'on' : ''}"><input type="radio" name="clMode" value="managed" ${mode === 'managed' ? 'checked' : ''}><span><b>bridge 托管</b><span class="hint">凭证来自厂商档案,无需本机登录</span></span></label>
93
+ </div>
94
+ ${mode === 'managed' ? `
95
+ <hr>
96
+ <label class="fld" style="font-size:12.5px;color:var(--ink);font-weight:600">选择厂商档案 <span class="tip" data-tip="凭证类型、BASE_URL 与密钥都在档案中维护;此处只做选择,改档案请到「厂商档案」页签">${svg(IC.info, 13)}</span></label>
97
+ <div class="pfcards" id="clPfs">
98
+ ${profiles.map((p, i) => `
99
+ <div class="pfcard ${activePf && activePf.name === p.name ? 'on' : ''}" data-clpf="${i}" role="button" tabindex="0" aria-label="使用档案 ${esc(p.name)}">
100
+ <b>${esc(p.name || '(未命名)')} ${activePf && activePf.name === p.name ? '<span class="tag ok">使用中</span>' : ''}</b>
101
+ <div class="hint mut">${p.base_url ? `<span class="mono">${esc(p.base_url)}</span>` : '官方默认端点'} · ${p.auth_token?.secretSet ? 'AUTH_TOKEN' : p.api_key?.secretSet ? 'API_KEY' : '未设凭证'}</div>
102
+ </div>`).join('') || '<div class="hint">尚无档案——先到「厂商档案」页签创建,或展开下方高级手动填写。</div>'}
103
+ </div>
104
+ <div style="display:flex;gap:14px;align-items:flex-end;margin-top:14px;flex-wrap:wrap">
105
+ <div style="flex:1;min-width:240px"><label class="fld">模型 <span class="dim" style="font-weight:400">${activePf ? `${esc(activePf.name)} 候选` : '(先选择档案)'}</span></label>
106
+ <select id="clModel" ${activePf ? '' : 'disabled'}>
107
+ <option value="${esc(cur.model || '')}">${cur.model ? `${esc(splitModelTag(cur.model).name)}(当前)` : '(先选择档案)'}</option>
108
+ ${activePf ? modelChipsOf(activePf).filter((m) => m !== cur.model).map((m) => `<option value="${esc(m)}">${esc(splitModelTag(m).name)}${splitModelTag(m).tag ? '(1M)' : ''}</option>`).join('') : ''}
109
+ </select></div>
110
+ <button class="btn" id="clGotoPf">去管理厂商档案 →</button>
111
+ </div>
112
+ <details class="adv" style="margin-top:14px"><summary>高级:手动凭证 / BASE_URL / 模型 / 环境变量(老配置直填与 MCP 变量在这里)</summary>
113
+ <div class="adv-body">
67
114
  <div class="radio-row">
68
- <label><input type="radio" name="cred" value="auth_token" ${credType === 'auth_token' ? 'checked' : ''}> AUTH_TOKEN(中转站常用,Bearer)</label>
69
- <label><input type="radio" name="cred" value="api_key" ${credType === 'api_key' ? 'checked' : ''}> API_KEY(官方,x-api-key)</label>
115
+ <label><input type="radio" name="cred" value="auth_token" ${c.api_key?.secretSet && !c.auth_token?.secretSet ? '' : 'checked'}> AUTH_TOKEN(中转站常用,Bearer)</label>
116
+ <label><input type="radio" name="cred" value="api_key" ${c.api_key?.secretSet && !c.auth_token?.secretSet ? 'checked' : ''}> API_KEY(官方,x-api-key)</label>
70
117
  </div>
71
118
  <div class="row">
72
- <div><label id="credLabel">Auth Token ${hasToken ? `(已设置 ${esc(c.auth_token.secretHint)},留空不改)` : ''}</label>
73
- <input type="password" id="credVal" placeholder="${hasToken || hasKey ? '留空保持不变' : 'sk-…'}"></div>
74
- <div><label>BASE_URL(中转站端点,官方留空)</label><input type="text" id="baseUrl" value="${esc(c.base_url || '')}" placeholder="https://relay.example.com"></div>
119
+ <div><label class="fld" id="credLabel">凭证(留空不改)</label>
120
+ <input type="password" id="credVal" placeholder="${c.auth_token?.secretSet || c.api_key?.secretSet ? '留空保持不变' : 'sk-…'}"></div>
121
+ <div><label class="fld">BASE_URL(官方留空)</label><input type="text" id="baseUrl" value="${esc(c.base_url || '')}" placeholder="https://relay.example.com"></div>
75
122
  </div>
76
123
  <div class="row" style="align-items:end">
77
- <div style="flex:1"><label>模型(写入 settings.json,可留空)</label>
124
+ <div style="flex:1"><label class="fld">模型(写入 settings.json,可留空)</label>
78
125
  <input type="text" id="model" list="modelList" value="${esc(c.model || '')}" placeholder="claude-sonnet-5 或任意厂商模型名">
79
126
  <datalist id="modelList"></datalist></div>
80
127
  <button class="btn" id="btnModels">拉取模型</button>
81
128
  </div>
82
129
  <div class="hint" style="margin-top:6px">「拉取模型」用当前表单的 BASE_URL 与凭证(未填则用已保存值)请求模型列表:自动兼容带 / 不带 /v1 的地址与 Bearer / x-api-key 认证;失败可手动填写。</div>
83
- </div>
84
- </details>
85
- <h4 style="margin:14px 0 2px;font-size:13.5px">环境变量(写入托管 settings.json,MCP 工具依赖的自定义变量在此配置)</h4>
86
- <div class="hint" style="margin-bottom:6px">本机 ~/.claude/settings.json 已有的非认证键会自动继承;此处配置的键<b>优先级更高</b>(覆盖继承值)。认证相关(ANTHROPIC_AUTH_TOKEN / API_KEY / BASE_URL / MODEL)不在此生效,请用上方认证表单。</div>
87
- <table><thead><tr><th style="width:38%">KEY</th><th>VALUE</th><th style="width:44px"></th></tr></thead><tbody id="claudeEnvBody"></tbody></table>
88
- <button class="btn sm" id="claudeEnvAdd" style="margin-top:6px">+ 添加变量</button>
89
- ${saveBarHtml('claude')}
130
+ <hr>
131
+ <div class="hint" style="margin-bottom:6px">环境变量写入托管 settings.json(MCP 工具依赖的自定义变量在此配置);本机 ~/.claude/settings.json 已有的非认证键自动继承,此处配置<b>优先级更高</b>。认证相关键(ANTHROPIC_*)不在此生效,请用上方表单。</div>
132
+ <table><thead><tr><th style="width:38%">KEY</th><th>VALUE</th><th style="width:70px"></th></tr></thead><tbody id="claudeEnvBody"></tbody></table>
133
+ <button class="btn sm" id="claudeEnvAdd" style="margin-top:6px">+ 添加变量</button>
134
+ </div></details>
135
+ ` : `
136
+ <div class="banner info" style="margin-top:12px"><span>继承本机登录态:LCB 不写托管凭证,Claude CLI 直接使用本机 <code>~/.claude</code> 的登录与配置。如需固定凭证 / 模型 / 多厂商档案,请切到「bridge 托管」。</span></div>`}
90
137
  </div>
91
- <div class="footer-note">managed 模式把认证写入 <code>~/.lark-claudecode-bridge/claude/settings.json</code>,与本机 ~/.claude 完全隔离;认证/模型改动保存后对后续任务即生效,模式切换需重启。</div>
92
- </div>
93
- <div class="card" id="pfCard" style="${mode === 'managed' ? '' : 'display:none'}">
94
- <h3>厂商档案</h3>
95
- <div class="desc">保存多套厂商 / 中转站凭证,每个档案可维护多个候选模型(如 fable / opus / sonnet / haiku)。「设为当前」整套切换;点候选模型即时切换当前模型。档案在抽屉「保存」后即可切换。</div>
96
- <div class="list-toolbar"><button class="btn primary" id="pfAdd">+ 新增档案</button></div>
97
- <table><thead><tr><th style="width:120px">名称</th><th style="width:100px">凭证类型</th><th>BASE_URL</th><th>模型(点击切换)</th><th style="width:190px">操作</th></tr></thead>
98
- <tbody id="pfBody"></tbody></table>
138
+ ${saveBarHtml('claude')}
139
+ <footer class="panel-foot">凭证与 BASE_URL 由所选厂商档案统一维护(本页只读展示);托管模式与本机 ~/.claude 完全隔离;模式切换需重启,模型选择即时生效。</footer>
99
140
  </div>`;
100
- document.querySelectorAll('input[name=mode]').forEach((r) => r.onchange = () => {
101
- c.mode = document.querySelector('input[name=mode]:checked').value;
141
+ bindSaveBar(pane, 'claude');
142
+ pane.querySelectorAll('input[name=clMode]').forEach((r) => r.onchange = () => {
143
+ c.mode = pane.querySelector('input[name=clMode]:checked').value;
102
144
  cardDirty('claude', c.mode === 'managed'); // inherit 无 managed 表单语义:不标记未保存
103
145
  renderClaude(el); // 模式切换影响多卡显隐,整页重渲染(radio 状态由 c.mode 驱动)
104
146
  });
105
- bindSaveBar(el, 'claude');
106
- const updCredLabel = () => {
107
- const v = document.querySelector('input[name=cred]:checked').value;
108
- $('#credLabel').innerHTML = (v === 'api_key' ? 'API Key' : 'Auth Token')
109
- + (v === 'auth_token' && hasToken ? `(已设置 ${esc(c.auth_token.secretHint)},留空不改)` : '')
110
- + (v === 'api_key' && hasKey ? `(已设置 ${esc(c.api_key.secretHint)},留空不改)` : '');
147
+ // 档案单选小卡:点击 = 整套切换(走后端端点,凭证不出进程);编辑中先收尾
148
+ pane.querySelectorAll('[data-clpf]').forEach((cardP) => cardP.onclick = async () => {
149
+ const p = profiles[Number(cardP.dataset.clpf)];
150
+ if (isDrawerOpen()) return toast('请先完成档案编辑(保存或取消)', true);
151
+ if (S.dirtyCards.size && !(await saveDoc())) return; // 保存失败已 toast 原因,保持编辑态不切换
152
+ try {
153
+ const r = await api('POST', '/api/claude/use-profile', { name: p.name });
154
+ toast(r.message || `已切换到档案「${p.name}」`);
155
+ await refresh();
156
+ } catch (e) { toast(e.message, true); }
157
+ });
158
+ const modelSel = pane.querySelector('#clModel');
159
+ if (modelSel) modelSel.onchange = async () => {
160
+ if (!activePf) return;
161
+ try {
162
+ const r = await api('POST', '/api/claude/use-profile', { name: activePf.name, model: modelSel.value });
163
+ toast(r.message || '已切换模型');
164
+ await refresh();
165
+ } catch (e) { toast(e.message, true); }
111
166
  };
112
- document.querySelectorAll('input[name=cred]').forEach((r) => r.onchange = () => { updCredLabel(); cardDirty('claude', true); });
113
- $('#credVal').oninput = () => {
114
- const v = $('#credVal').value.trim();
115
- const key = document.querySelector('input[name=cred]:checked').value;
167
+ const gotoPf = pane.querySelector('#clGotoPf');
168
+ if (gotoPf) gotoPf.onclick = () => { claudeTab = 'pf'; renderClaude(el); };
169
+ // ---- 高级折叠内的手填通道(语义与旧版一致) ----
170
+ const credType = () => pane.querySelector('input[name=cred]:checked')?.value || 'auth_token';
171
+ pane.querySelectorAll('input[name=cred]').forEach((r) => r.onchange = () => {
172
+ const v = credType();
173
+ const t = v === 'auth_token' && c.auth_token?.secretSet ? `(已设置 ${esc(c.auth_token.secretHint)},留空不改)` : '';
174
+ const k = v === 'api_key' && c.api_key?.secretSet ? `(已设置 ${esc(c.api_key.secretHint)},留空不改)` : '';
175
+ const lb = pane.querySelector('#credLabel');
176
+ if (lb) lb.textContent = `凭证${t || k || '(留空不改)'}`;
177
+ cardDirty('claude', true);
178
+ });
179
+ const credVal = pane.querySelector('#credVal');
180
+ if (credVal) credVal.oninput = () => {
181
+ const v = credVal.value.trim();
182
+ const key = credType();
116
183
  const other = key === 'auth_token' ? 'api_key' : 'auth_token';
117
- if (v) { c[key] = v; c[other] = ''; } else { c[key] = { secretSet: key === 'auth_token' ? hasToken : hasKey }; }
184
+ const hasT = c.auth_token?.secretSet, hasK = c.api_key?.secretSet;
185
+ if (v) { c[key] = v; c[other] = ''; } else { c[key] = { secretSet: key === 'auth_token' ? hasT : hasK }; }
118
186
  cardDirty('claude', true);
119
187
  };
120
- $('#baseUrl').oninput = () => { c.base_url = $('#baseUrl').value.trim() || undefined; cardDirty('claude', true); };
121
- $('#model').oninput = () => { c.model = $('#model').value.trim() || undefined; cardDirty('claude', true); };
122
- // ---- 环境变量行编辑(claude.env):对象 ↔ 行数组,KEY/VALUE 实时写回(同 apps 抽屉 env 模式) ----
188
+ const baseUrl = pane.querySelector('#baseUrl');
189
+ if (baseUrl) baseUrl.oninput = () => { c.base_url = baseUrl.value.trim() || undefined; cardDirty('claude', true); };
190
+ const modelInput = pane.querySelector('#model');
191
+ if (modelInput) modelInput.oninput = () => { c.model = modelInput.value.trim() || undefined; cardDirty('claude', true); };
192
+ const btnModels = pane.querySelector('#btnModels');
193
+ if (btnModels) btnModels.onclick = () => {
194
+ const typed = credVal?.value.trim() || '';
195
+ const key = credType();
196
+ pullModels(() => ({ base_url: baseUrl?.value.trim() || '', ...(typed ? { [key]: typed } : {}) }), btnModels, 'modelList');
197
+ };
198
+ // ---- 环境变量行编辑(claude.env):对象 ↔ 行数组,KEY/VALUE 实时写回 ----
123
199
  const cEnvRows = Object.entries(c.env || {}).map(([key, value]) => ({ key, value: String(value) }));
124
200
  const syncClaudeEnv = () => {
125
201
  const obj = {};
@@ -127,7 +203,7 @@ function renderClaude(el) {
127
203
  if (Object.keys(obj).length) c.env = obj; else delete c.env;
128
204
  };
129
205
  const renderClaudeEnv = () => {
130
- const tb = $('#claudeEnvBody');
206
+ const tb = pane.querySelector('#claudeEnvBody');
131
207
  if (!tb) return;
132
208
  tb.innerHTML = cEnvRows.length ? cEnvRows.map((r, i) => `
133
209
  <tr>
@@ -147,89 +223,76 @@ function renderClaude(el) {
147
223
  renderClaudeEnv();
148
224
  });
149
225
  };
150
- const envAdd = $('#claudeEnvAdd');
151
- if (envAdd) envAdd.onclick = () => { cEnvRows.push({ key: '', value: '' }); renderClaudeEnv(); tbFocusLastKey(); };
152
- const tbFocusLastKey = () => {
153
- const inputs = document.querySelectorAll('#claudeEnvBody input[data-col=key]');
154
- const last = inputs[inputs.length - 1];
155
- if (last) last.focus();
156
- };
157
226
  renderClaudeEnv();
158
- $('#btnModels').onclick = () => {
159
- const typed = $('#credVal').value.trim();
160
- const key = document.querySelector('input[name=cred]:checked').value;
161
- pullModels(() => ({ base_url: $('#baseUrl').value.trim(), ...(typed ? { [key]: typed } : {}) }), $('#btnModels'), 'modelList');
162
- };
163
- // ---- 厂商档案列表 ----
164
- const renderProfiles = () => {
165
- const body = $('#pfBody');
166
- if (!body) return;
167
- if (!c.profiles.length) { body.innerHTML = '<tr><td colspan="5" class="hint">尚无档案,点右上「+ 新增档案」创建</td></tr>'; return; }
168
- const inUse = (p) => {
169
- // 前端无凭证明文,用尾 4 位提示 + baseUrl 近似判断「使用中」(当前模型在 chips 上单独高亮)
170
- const credSame = (p.auth_token?.secretHint && cur.authToken?.secretHint === p.auth_token.secretHint)
171
- || (p.api_key?.secretHint && cur.apiKey?.secretHint === p.api_key.secretHint);
172
- return credSame && (p.base_url || '') === (cur.baseUrl || '');
173
- };
174
- const modelChips = (p, active, i) => {
175
- const list = [...(p.models || []), ...(p.model && !(p.models || []).includes(p.model) ? [p.model] : [])];
176
- return list.map((m) => {
177
- const { name, tag } = splitModelTag(m);
178
- return `<button class="btn sm${active && cur.model === m ? ' primary' : ''}" data-op="usemodel" data-i="${i}" data-m="${esc(m)}" title="点击切换为当前模型">${esc(name)}${tag ? ' <span class="tag ok">1M</span>' : ''}${active && cur.model === m ? ' ✓' : ''}</button>`;
179
- }).join(' ')
180
- || '<span class="hint">—</span>';
181
- };
182
- body.innerHTML = c.profiles.map((p, i) => {
183
- const active = inUse(p);
227
+ const envAdd = pane.querySelector('#claudeEnvAdd');
228
+ if (envAdd) envAdd.onclick = () => { cEnvRows.push({ key: '', value: '' }); renderClaudeEnv(); };
229
+ }
230
+
231
+ /** 厂商档案 Tab:加宽卡片网格(420px),卡上候选模型点击即切换当前模型 */
232
+ function renderPfPane(pane, c, mode, el) {
233
+ const cur = S.status?.claude?.current || {};
234
+ pane.innerHTML = `
235
+ <div class="cardgrid wide">
236
+ ${c.profiles.map((p, i) => {
237
+ const active = inUseProfile(p, cur);
238
+ const models = [...(p.models || []), ...(p.model && !(p.models || []).includes(p.model) ? [p.model] : [])];
184
239
  return `
185
- <tr>
186
- <td><b>${esc(p.name)}</b>${active ? '<br><span class="tag ok">使用中</span>' : ''}</td>
187
- <td>${p.auth_token?.secretSet ? 'AUTH_TOKEN' : p.api_key?.secretSet ? 'API_KEY' : '<span class="hint">未设凭证</span>'}</td>
188
- <td>${p.base_url ? `<code>${esc(p.base_url)}</code>` : '<span class="hint">官方</span>'}</td>
189
- <td>${modelChips(p, active, i)}</td>
190
- <td>
191
- <button class="btn sm" data-op="edit" data-i="${i}">编辑</button>
192
- <button class="btn sm danger" data-op="del" data-i="${i}">删除</button>
193
- </td>
194
- </tr>`;
195
- }).join('');
196
- body.querySelectorAll('button[data-op]').forEach((b) => b.onclick = async () => {
197
- const i = Number(b.dataset.i);
198
- const p = c.profiles[i];
199
- if (b.dataset.op === 'usemodel') {
200
- // 切换 = 点击模型 chip(「设为当前」按钮已移除:默认模型必在候选集内,点其 chip 等效激活)。
201
- // 走后端端点(档案凭证明文不出进程);编辑中先收尾,未保存改动自动落盘再切(如认证模式切换)
202
- if (!$('#drawerMask').hidden) return toast('请先完成档案编辑(保存或取消)', true);
203
- if (S.dirtyCards.size && !(await saveDoc())) return; // 保存失败已 toast 原因,保持编辑态不切换
204
- try {
205
- const r = await api('POST', '/api/claude/use-profile', { name: p.name, model: b.dataset.m });
206
- toast(r.message || '已切换');
207
- await refresh();
208
- } catch (e) { toast(e.message, true); }
209
- return;
210
- }
211
- if (b.dataset.op === 'del') {
212
- if (!(await confirmDialog({
213
- title: '删除档案',
214
- message: `确认删除档案「${esc(p.name || '未命名')}」?删除将立即保存生效。`,
215
- danger: true,
216
- confirmText: '删除',
217
- }))) return;
218
- c.profiles.splice(i, 1);
219
- await saveDoc();
220
- return;
221
- }
222
- openProfileDrawer(c, i, el);
223
- });
224
- };
225
- renderProfiles();
226
- $('#pfAdd').onclick = () => {
240
+ <div class="acard" style="cursor:default">
241
+ <div class="acard-head"><b>${esc(p.name || '(未命名)')}</b>${active ? '<span class="tag ok">使用中</span>' : ''}</div>
242
+ <div class="acard-meta">
243
+ <span>${svg(IC.key)}${p.auth_token?.secretSet ? 'AUTH_TOKEN' : p.api_key?.secretSet ? 'API_KEY' : '未设凭证'}</span>
244
+ <span>${svg(IC.globe)}${p.base_url ? `<span class="mono">${esc(p.base_url)}</span>` : '官方默认'}</span>
245
+ </div>
246
+ <div style="display:flex;flex-wrap:wrap;gap:6px">
247
+ ${models.map((m) => {
248
+ const { name, tag } = splitModelTag(m);
249
+ return `<button class="btn sm ${active && cur.model === m ? 'primary' : ''}" data-usemodel="${i}" data-m="${esc(m)}" title="点击切换为当前模型">${esc(name)}${tag ? ' <span class="tag ok">1M</span>' : ''}${active && cur.model === m ? ' ✓' : ''}</button>`;
250
+ }).join('') || '<span class="hint">无候选模型(编辑档案添加)</span>'}
251
+ </div>
252
+ <div class="acard-foot">
253
+ <button class="btn sm" data-pfedit="${i}">编辑</button>
254
+ <button class="btn sm danger" data-pfdel="${i}">删除</button>
255
+ <span class="mut" style="font-size:11px;align-self:center;margin-left:auto">默认 ${esc(p.model ? splitModelTag(p.model).name : '—')}</span>
256
+ </div>
257
+ </div>`;
258
+ }).join('')}
259
+ <div class="acard newcard" id="pfAdd">${svg(IC.plus, 18)}<span>新增档案</span></div>
260
+ </div>
261
+ <div class="mini-note" style="margin-top:10px">保存多套厂商 / 中转站凭证,每个档案可维护多个候选模型;「生效配置」页签选档案即整套切换,点候选模型即时切换当前模型。档案在抽屉「保存」后即可切换。</div>`;
262
+ $('#pageActions').innerHTML = `<button class="btn primary" id="pfAddTop">${svg(IC.plus, 14)} 新增档案</button>`;
263
+ pane.querySelectorAll('[data-usemodel]').forEach((b) => b.onclick = async () => {
264
+ const p = c.profiles[Number(b.dataset.usemodel)];
265
+ if (isDrawerOpen()) return toast('请先完成档案编辑(保存或取消)', true);
266
+ if (S.dirtyCards.size && !(await saveDoc())) return;
267
+ try {
268
+ const r = await api('POST', '/api/claude/use-profile', { name: p.name, model: b.dataset.m });
269
+ toast(r.message || '已切换');
270
+ await refresh();
271
+ } catch (e) { toast(e.message, true); }
272
+ });
273
+ pane.querySelectorAll('[data-pfedit]').forEach((b) => b.onclick = () => openProfileDrawer(c, Number(b.dataset.pfedit), el));
274
+ pane.querySelectorAll('[data-pfdel]').forEach((b) => b.onclick = async () => {
275
+ const i = Number(b.dataset.pfdel);
276
+ const p = c.profiles[i];
277
+ if (!(await confirmDialog({
278
+ title: '删除档案',
279
+ message: `确认删除档案「${esc(p.name || '未命名')}」?删除将立即保存生效。`,
280
+ danger: true,
281
+ confirmText: '删除',
282
+ }))) return;
283
+ c.profiles.splice(i, 1);
284
+ await saveDoc();
285
+ renderClaude(el);
286
+ });
287
+ const add = () => {
227
288
  const snap = snapDoc(); // 快照先于 push:取消时连同新增空档案一并回滚
228
289
  c.profiles.push({ name: '' });
229
- renderClaude(el);
230
290
  openProfileDrawer(c, c.profiles.length - 1, el, snap);
231
291
  };
232
- applyDirty(); // 模式切换等 handler 先 cardDirty 再整卡重渲染:回放防止保存条/圆点随重建丢失
292
+ const pfAdd = pane.querySelector('#pfAdd');
293
+ if (pfAdd) pfAdd.onclick = add;
294
+ const pfAddTop = $('#pfAddTop');
295
+ if (pfAddTop) pfAddTop.onclick = add;
233
296
  }
234
297
 
235
298
  /** 档案编辑抽屉:实时 mutate profile 对象,「保存」一次落盘;凭证留空 = 保持不变(PUT 按 name 对齐回填)。
@@ -242,69 +305,68 @@ function openProfileDrawer(c, i, el, snap) {
242
305
  title: `厂商档案${p.name ? `:${p.name}` : '(新档案)'}`,
243
306
  snap: snap ?? snapDoc(),
244
307
  bodyHtml: `
245
- <label>档案名(显示用,如「官方」「中转站A」)</label>
308
+ <label class="fld">档案名(显示用,如「官方」「中转站A」)</label>
246
309
  <input type="text" id="dwPfName" value="${esc(p.name || '')}" placeholder="中转站A">
310
+ <label class="fld">凭证类型</label>
247
311
  <div class="radio-row">
248
312
  <label><input type="radio" name="dwCred" value="auth_token" ${ctype === 'auth_token' ? 'checked' : ''}> AUTH_TOKEN(Bearer,中转站常用)</label>
249
313
  <label><input type="radio" name="dwCred" value="api_key" ${ctype === 'api_key' ? 'checked' : ''}> API_KEY(官方,x-api-key)</label>
250
314
  </div>
251
- <label id="dwPfCredLabel">${ctype === 'api_key' ? 'API Key' : 'Auth Token'} ${hasT || hasK ? '(已设置,留空不改)' : ''}</label>
315
+ <label class="fld" id="dwPfCredLabel">${ctype === 'api_key' ? 'API Key' : 'Auth Token'} ${hasT || hasK ? '(已设置,留空不改)' : ''}</label>
252
316
  <input type="password" id="dwPfCred" placeholder="${hasT || hasK ? '留空保持不变' : 'sk-…'}">
253
- <label>BASE_URL(中转站端点,官方留空)</label>
317
+ <label class="fld">BASE_URL(官方留空)</label>
254
318
  <input type="text" id="dwPfBase" value="${esc(p.base_url || '')}" placeholder="https://relay.example.com">
255
- <h4 style="margin:18px 0 4px;font-size:13.5px">候选模型(fable / opus / sonnet / haiku 等,各存一条;列表页点击即切换当前模型)</h4>
256
- <div class="chips" id="dwPfModels"></div>
257
- <div class="row" style="align-items:end;max-width:640px">
258
- <div style="flex:1"><input type="text" id="dwPfNewModel" list="dwPfModelList" placeholder="输入或下拉选择后点「添加」">
319
+ <label class="fld">候选模型 <span class="tip" data-tip="列表页点模型名即时切换当前模型;勾选 1M 添加的模型带 [1m] 后缀">${svg(IC.info, 13)}</span></label>
320
+ <div class="chips echips on-box" id="dwPfModels" style="margin:0"></div>
321
+ <div class="frow" style="margin-top:10px;max-width:640px">
322
+ <div><input type="text" id="dwPfNewModel" list="dwPfModelList" placeholder="输入或下拉选择后点「添加」">
259
323
  <datalist id="dwPfModelList"></datalist></div>
260
- <label style="display:flex;align-items:center;gap:4px;white-space:nowrap;padding-bottom:6px"><input type="checkbox" id="dwPfModel1M"> 1M 上下文</label>
324
+ <label style="display:flex;align-items:center;gap:4px;white-space:nowrap;padding-top:6px"><input type="checkbox" id="dwPfModel1M"> 1M 上下文</label>
261
325
  <div class="btn-wrap"><button class="btn" id="dwPfAddModel">添加</button></div>
262
326
  <div class="btn-wrap"><button class="btn" id="dwPfPull">拉取模型</button></div>
263
327
  </div>
264
328
  <div class="hint" style="margin-top:6px">勾选「1M 上下文」添加的模型名带 <code>[1m]</code> 后缀(Claude Code 官方 1M 上下文格式,切换后写入 ANTHROPIC_MODEL);勾选前请确认厂商支持 1M 上下文。</div>
265
- <div class="row" style="align-items:end;margin-top:12px;max-width:600px">
266
- <div style="flex:1"><label>默认模型(点「设为当前」时使用,通常为候选之一)</label>
267
- <input type="text" id="dwPfModel" list="dwPfModelsList" value="${esc(p.model || '')}" placeholder="留空 = 切换时不指定">
268
- <datalist id="dwPfModelsList"></datalist></div>
269
- </div>`,
270
- footHtml: `<button class="btn danger" id="dwPfDel">删除档案</button><span class="spacer"></span><button class="btn" id="dwPfCancel">取消</button><button class="btn primary" id="dwPfSave">保存</button>`,
271
- onMount: () => {
272
- document.querySelectorAll('input[name=dwCred]').forEach((r) => r.onchange = () => {
273
- const v = document.querySelector('input[name=dwCred]:checked').value;
329
+ <label class="fld" style="margin-top:14px">默认模型(点档案卡即用此模型切换,通常为候选之一)</label>
330
+ <input type="text" id="dwPfModel" list="dwPfModelsList" value="${esc(p.model || '')}" placeholder="留空 = 切换时不指定" style="max-width:420px">
331
+ <datalist id="dwPfModelsList"></datalist>`,
332
+ footHtml: `<button class="btn danger" id="dwPfDel">删除档案</button><span class="sp"></span><button class="btn" id="dwPfCancel">取消</button><button class="btn primary" id="dwPfSave">保存</button>`,
333
+ onMount: ({ body, foot }) => {
334
+ body.querySelectorAll('input[name=dwCred]').forEach((r) => r.onchange = () => {
335
+ const v = body.querySelector('input[name=dwCred]:checked').value;
274
336
  const t = v === 'auth_token' && hasT ? `(已设置 ${esc(p.auth_token.secretHint)},留空不改)` : '';
275
337
  const k = v === 'api_key' && hasK ? `(已设置 ${esc(p.api_key.secretHint)},留空不改)` : '';
276
- document.getElementById('dwPfCredLabel').textContent = `${v === 'api_key' ? 'API Key' : 'Auth Token'}${t || k}`;
338
+ body.querySelector('#dwPfCredLabel').textContent = `${v === 'api_key' ? 'API Key' : 'Auth Token'}${t || k}`;
277
339
  });
278
- document.getElementById('dwPfName').oninput = (e) => { p.name = e.target.value.trim(); };
279
- document.getElementById('dwPfCred').oninput = (e) => {
340
+ body.querySelector('#dwPfName').oninput = (e) => { p.name = e.target.value.trim(); };
341
+ body.querySelector('#dwPfCred').oninput = (e) => {
280
342
  const v = e.target.value.trim();
281
- const key = document.querySelector('input[name=dwCred]:checked').value;
343
+ const key = body.querySelector('input[name=dwCred]:checked').value;
282
344
  const other = key === 'auth_token' ? 'api_key' : 'auth_token';
283
345
  if (v) { p[key] = v; delete p[other]; }
284
346
  else if (!(p[key] && typeof p[key] === 'object')) delete p[key]; // 空输入:保留原脱敏对象(未修改语义)
285
347
  };
286
- document.getElementById('dwPfBase').oninput = (e) => { p.base_url = e.target.value.trim() || undefined; };
348
+ body.querySelector('#dwPfBase').oninput = (e) => { p.base_url = e.target.value.trim() || undefined; };
287
349
  // ---- 候选模型 chips:本地数组编辑,同步 p.models ----
288
350
  const models = [...(p.models || [])];
289
351
  const syncModels = () => {
290
352
  if (models.length) p.models = [...models];
291
353
  else delete p.models;
292
- const box = document.getElementById('dwPfModels');
354
+ const box = body.querySelector('#dwPfModels');
293
355
  box.innerHTML = models.map((m, mi) => {
294
356
  const { name, tag } = splitModelTag(m);
295
- return `<span class="chip">${esc(name)}${tag ? '<span class="tag ok" style="margin-left:4px">1M</span>' : ''}<button type="button" data-rm="${mi}" title="移除">×</button></span>`;
357
+ return `<span class="chip">${esc(name)}${tag ? '<span class="tag ok" style="margin-left:4px">1M</span>' : ''}<button type="button" class="x" data-rm="${mi}" title="移除">×</button></span>`;
296
358
  }).join('')
297
359
  || '<span class="hint">尚无候选模型(不影响使用,可随时添加)</span>';
298
360
  box.querySelectorAll('button[data-rm]').forEach((b) => b.onclick = () => {
299
361
  models.splice(Number(b.dataset.rm), 1);
300
362
  syncModels();
301
363
  });
302
- document.getElementById('dwPfModelsList').innerHTML = models.map((m) => `<option value="${esc(m)}"></option>`).join('');
364
+ body.querySelector('#dwPfModelsList').innerHTML = models.map((m) => `<option value="${esc(m)}"></option>`).join('');
303
365
  };
304
366
  syncModels();
305
367
  const addModel = () => {
306
- const inp = document.getElementById('dwPfNewModel');
307
- const cb = document.getElementById('dwPfModel1M');
368
+ const inp = body.querySelector('#dwPfNewModel');
369
+ const cb = body.querySelector('#dwPfModel1M');
308
370
  const v = inp.value.trim();
309
371
  if (!v) return;
310
372
  // 勾选 1M:拼官方 [1m] 后缀(输入已带后缀时幂等);添加后自动取消勾选
@@ -314,19 +376,19 @@ function openProfileDrawer(c, i, el, snap) {
314
376
  cb.checked = false;
315
377
  syncModels();
316
378
  };
317
- document.getElementById('dwPfAddModel').onclick = addModel;
318
- document.getElementById('dwPfNewModel').onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); addModel(); } };
319
- document.getElementById('dwPfModel').oninput = (e) => { p.model = e.target.value.trim() || undefined; };
320
- document.getElementById('dwPfPull').onclick = () => {
321
- const typed = document.getElementById('dwPfCred').value.trim();
322
- const key = document.querySelector('input[name=dwCred]:checked').value;
379
+ body.querySelector('#dwPfAddModel').onclick = addModel;
380
+ body.querySelector('#dwPfNewModel').onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); addModel(); } };
381
+ body.querySelector('#dwPfModel').oninput = (e) => { p.model = e.target.value.trim() || undefined; };
382
+ body.querySelector('#dwPfPull').onclick = () => {
383
+ const typed = body.querySelector('#dwPfCred').value.trim();
384
+ const key = body.querySelector('input[name=dwCred]:checked').value;
323
385
  pullModels(() => ({
324
- base_url: document.getElementById('dwPfBase').value.trim(),
386
+ base_url: body.querySelector('#dwPfBase').value.trim(),
325
387
  ...(p.name ? { profile_name: p.name } : {}),
326
388
  ...(typed ? { [key]: typed } : {}),
327
- }), document.getElementById('dwPfPull'), 'dwPfModelList');
389
+ }), body.querySelector('#dwPfPull'), 'dwPfModelList');
328
390
  };
329
- document.getElementById('dwPfDel').onclick = async () => {
391
+ foot.querySelector('#dwPfDel').onclick = async () => {
330
392
  if (!(await confirmDialog({
331
393
  title: '删除档案',
332
394
  message: `确认删除档案「${esc(p.name || '未命名')}」?删除将立即保存生效。`,
@@ -337,8 +399,8 @@ function openProfileDrawer(c, i, el, snap) {
337
399
  if (await saveDoc()) closeDrawer();
338
400
  else cancelDrawer();
339
401
  };
340
- document.getElementById('dwPfCancel').onclick = cancelDrawer;
341
- document.getElementById('dwPfSave').onclick = async () => {
402
+ foot.querySelector('#dwPfCancel').onclick = cancelDrawer;
403
+ foot.querySelector('#dwPfSave').onclick = async () => {
342
404
  if (await saveDoc()) closeDrawer();
343
405
  };
344
406
  },
@@ -348,6 +410,7 @@ function openProfileDrawer(c, i, el, snap) {
348
410
  export const page = {
349
411
  id: 'claude',
350
412
  title: 'Claude 认证',
413
+ desc: '认证模式、生效凭证与厂商档案;bridge 托管时从档案中选择,无须手填',
351
414
  icon: '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
352
415
  render: renderClaude,
353
416
  };