@jesonliu/lark-claudecode-bridge 0.17.0 → 0.18.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/README.md +6 -4
- package/assets/web/css/main.css +111 -0
- package/assets/web/index.html +8 -1743
- package/assets/web/js/bootstrap.js +97 -0
- package/assets/web/js/core.js +73 -0
- package/assets/web/js/main.js +75 -0
- package/assets/web/js/pages/apps.js +206 -0
- package/assets/web/js/pages/claude.js +322 -0
- package/assets/web/js/pages/overview.js +214 -0
- package/assets/web/js/pages/permissions.js +109 -0
- package/assets/web/js/pages/plugins.js +285 -0
- package/assets/web/js/pages/slash.js +128 -0
- package/assets/web/js/pages/workspaces.js +100 -0
- package/assets/web/js/ui.js +123 -0
- package/dist/executor/claude-executor.js +26 -1
- package/dist/executor/claude-executor.js.map +1 -1
- package/dist/executor/permission-gate.d.ts +4 -4
- package/dist/executor/permission-gate.js +13 -5
- package/dist/executor/permission-gate.js.map +1 -1
- package/dist/executor/plugin-discovery.d.ts +8 -3
- package/dist/executor/plugin-discovery.js +50 -18
- package/dist/executor/plugin-discovery.js.map +1 -1
- package/dist/executor/plugin-manager.js +3 -2
- package/dist/executor/plugin-manager.js.map +1 -1
- package/dist/gateway/card-builder.d.ts +22 -5
- package/dist/gateway/card-builder.js +106 -64
- package/dist/gateway/card-builder.js.map +1 -1
- package/dist/gateway/feishu-gateway.js +11 -1
- package/dist/gateway/feishu-gateway.js.map +1 -1
- package/dist/gateway/progress-card.d.ts +19 -1
- package/dist/gateway/progress-card.js +47 -3
- package/dist/gateway/progress-card.js.map +1 -1
- package/dist/index.js +37 -46
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +25 -4
- package/dist/web/server.js +63 -10
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// ============ Claude 认证 ============
|
|
2
|
+
import { S, $, esc, toast, api, snapDoc, saveDoc, cardDirty, applyDirty, refresh } from '../core.js';
|
|
3
|
+
import { openDrawer, closeDrawer, cancelDrawer, saveBarHtml, dirtyDotHtml, bindSaveBar } from '../ui.js';
|
|
4
|
+
|
|
5
|
+
/** 模型拉取公共逻辑:getBody() 组请求体(凭证仅携带本次输入的明文,空则不带给后端回落磁盘值) */
|
|
6
|
+
async function pullModels(getBody, btn, datalistId) {
|
|
7
|
+
btn.disabled = true;
|
|
8
|
+
const old = btn.textContent;
|
|
9
|
+
btn.textContent = '拉取中…';
|
|
10
|
+
try {
|
|
11
|
+
const r = await api('POST', '/api/models/fetch', getBody());
|
|
12
|
+
const dl = document.getElementById(datalistId);
|
|
13
|
+
if (dl) dl.innerHTML = (r.models || []).map((m) => `<option value="${esc(m)}"></option>`).join('');
|
|
14
|
+
toast(`获取到 ${(r.models || []).length} 个模型:在模型框下拉选择,或继续手动输入`);
|
|
15
|
+
} catch (e) { toast(e.message, true); }
|
|
16
|
+
btn.disabled = false;
|
|
17
|
+
btn.textContent = old;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderClaude(el) {
|
|
21
|
+
const c = S.doc.claude || (S.doc.claude = {});
|
|
22
|
+
c.profiles = c.profiles || [];
|
|
23
|
+
const mode = c.mode || 'inherit';
|
|
24
|
+
const hasToken = c.auth_token?.secretSet, hasKey = c.api_key?.secretSet;
|
|
25
|
+
const credType = hasToken ? 'auth_token' : 'api_key';
|
|
26
|
+
const cur = S.status?.claude?.current || {};
|
|
27
|
+
const hint = (h) => h?.secretSet ? `已设置(${esc(h.secretHint)})` : '未设置';
|
|
28
|
+
el.innerHTML = `
|
|
29
|
+
<div class="card">
|
|
30
|
+
<h3>当前生效配置</h3>
|
|
31
|
+
<div class="desc">认证模式与实际生效的值(凭证脱敏显示)。managed 模式改动保存后对后续任务即生效;模式切换需重启。</div>
|
|
32
|
+
<table>
|
|
33
|
+
<tr><td style="width:150px">认证模式</td><td>${mode === 'managed' ? 'bridge 托管(managed)' : '继承本机 ~/.claude(inherit)'}</td></tr>
|
|
34
|
+
<tr><td>BASE_URL</td><td>${cur.baseUrl ? `<code>${esc(cur.baseUrl)}</code>` : '<span class="hint">官方默认(api.anthropic.com)</span>'}</td></tr>
|
|
35
|
+
<tr><td>模型</td><td>${cur.model ? `<code>${esc(cur.model)}</code>` : '<span class="hint">未设置(CLI 默认)</span>'}</td></tr>
|
|
36
|
+
<tr><td>Auth Token</td><td>${hint(cur.authToken)}</td></tr>
|
|
37
|
+
<tr><td>API Key</td><td>${hint(cur.apiKey)}</td></tr>
|
|
38
|
+
<tr><td>自定义环境变量</td><td>${cur.envCount ? `${cur.envCount} 个(写入托管 settings.json env 块,MCP 工具可用)` : '<span class="hint">无</span>'}</td></tr>
|
|
39
|
+
<tr><td>settings.json</td><td><code>${esc(S.status?.claude?.settingsPath || '—')}</code></td></tr>
|
|
40
|
+
</table>
|
|
41
|
+
${mode !== 'managed' ? '<div class="desc" style="margin-top:8px">inherit 模式上表为本机 ~/.claude 实际状态,本页不修改本机登录。要管理多厂商档案并一键切换,请切到 bridge 托管。</div>' : ''}
|
|
42
|
+
</div>
|
|
43
|
+
<div class="card">
|
|
44
|
+
<h3>认证模式${dirtyDotHtml('claude')}</h3>
|
|
45
|
+
<div class="radio-row">
|
|
46
|
+
<label><input type="radio" name="mode" value="inherit" ${mode === 'inherit' ? 'checked' : ''}> 继承本机 ~/.claude(已在本机 claude login)</label>
|
|
47
|
+
<label><input type="radio" name="mode" value="managed" ${mode === 'managed' ? 'checked' : ''}> bridge 托管(填 API Key / Token,无需本机登录)</label>
|
|
48
|
+
</div>
|
|
49
|
+
<div id="managedBox" style="${mode === 'managed' ? '' : 'display:none'}">
|
|
50
|
+
<div class="desc" style="margin:6px 0 2px">常规用法:在下方「厂商档案」保存凭证后点「设为当前」即可,无须手动填写。</div>
|
|
51
|
+
<details style="margin-top:4px">
|
|
52
|
+
<summary style="cursor:pointer;font-size:13px;color:var(--muted)">高级:手动编辑当前生效凭证</summary>
|
|
53
|
+
<div style="margin-top:10px">
|
|
54
|
+
<div class="radio-row">
|
|
55
|
+
<label><input type="radio" name="cred" value="auth_token" ${credType === 'auth_token' ? 'checked' : ''}> AUTH_TOKEN(中转站常用,Bearer)</label>
|
|
56
|
+
<label><input type="radio" name="cred" value="api_key" ${credType === 'api_key' ? 'checked' : ''}> API_KEY(官方,x-api-key)</label>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="row">
|
|
59
|
+
<div><label id="credLabel">Auth Token ${hasToken ? `(已设置 ${esc(c.auth_token.secretHint)},留空不改)` : ''}</label>
|
|
60
|
+
<input type="password" id="credVal" placeholder="${hasToken || hasKey ? '留空保持不变' : 'sk-…'}"></div>
|
|
61
|
+
<div><label>BASE_URL(中转站端点,官方留空)</label><input type="text" id="baseUrl" value="${esc(c.base_url || '')}" placeholder="https://relay.example.com"></div>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="row" style="align-items:end">
|
|
64
|
+
<div style="flex:1"><label>模型(写入 settings.json,可留空)</label>
|
|
65
|
+
<input type="text" id="model" list="modelList" value="${esc(c.model || '')}" placeholder="claude-sonnet-5 或任意厂商模型名">
|
|
66
|
+
<datalist id="modelList"></datalist></div>
|
|
67
|
+
<button class="btn" id="btnModels">拉取模型</button>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="hint" style="margin-top:6px">「拉取模型」用当前表单的 BASE_URL 与凭证(未填则用已保存值)请求模型列表:自动兼容带 / 不带 /v1 的地址与 Bearer / x-api-key 认证;失败可手动填写。</div>
|
|
70
|
+
</div>
|
|
71
|
+
</details>
|
|
72
|
+
<h4 style="margin:14px 0 2px;font-size:13.5px">环境变量(写入托管 settings.json,MCP 工具依赖的自定义变量在此配置)</h4>
|
|
73
|
+
<div class="hint" style="margin-bottom:6px">本机 ~/.claude/settings.json 已有的非认证键会自动继承;此处配置的键<b>优先级更高</b>(覆盖继承值)。认证相关(ANTHROPIC_AUTH_TOKEN / API_KEY / BASE_URL / MODEL)不在此生效,请用上方认证表单。</div>
|
|
74
|
+
<table><thead><tr><th style="width:38%">KEY</th><th>VALUE</th><th style="width:44px"></th></tr></thead><tbody id="claudeEnvBody"></tbody></table>
|
|
75
|
+
<button class="btn sm" id="claudeEnvAdd" style="margin-top:6px">+ 添加变量</button>
|
|
76
|
+
${saveBarHtml('claude')}
|
|
77
|
+
</div>
|
|
78
|
+
<div class="footer-note">managed 模式把认证写入 <code>~/.lark-claudecode-bridge/claude/settings.json</code>,与本机 ~/.claude 完全隔离;认证/模型改动保存后对后续任务即生效,模式切换需重启。</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="card" id="pfCard" style="${mode === 'managed' ? '' : 'display:none'}">
|
|
81
|
+
<h3>厂商档案</h3>
|
|
82
|
+
<div class="desc">保存多套厂商 / 中转站凭证,每个档案可维护多个候选模型(如 fable / opus / sonnet / haiku)。「设为当前」整套切换;点候选模型即时切换当前模型。档案在抽屉「保存」后即可切换。</div>
|
|
83
|
+
<div style="display:flex;justify-content:flex-end;margin-bottom:10px"><button class="btn sm primary" id="pfAdd">+ 新增档案</button></div>
|
|
84
|
+
<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>
|
|
85
|
+
<tbody id="pfBody"></tbody></table>
|
|
86
|
+
</div>`;
|
|
87
|
+
document.querySelectorAll('input[name=mode]').forEach((r) => r.onchange = () => {
|
|
88
|
+
c.mode = document.querySelector('input[name=mode]:checked').value;
|
|
89
|
+
cardDirty('claude', c.mode === 'managed'); // inherit 无 managed 表单语义:不标记未保存
|
|
90
|
+
renderClaude(el); // 模式切换影响多卡显隐,整页重渲染(radio 状态由 c.mode 驱动)
|
|
91
|
+
});
|
|
92
|
+
bindSaveBar(el, 'claude');
|
|
93
|
+
const updCredLabel = () => {
|
|
94
|
+
const v = document.querySelector('input[name=cred]:checked').value;
|
|
95
|
+
$('#credLabel').innerHTML = (v === 'api_key' ? 'API Key' : 'Auth Token')
|
|
96
|
+
+ (v === 'auth_token' && hasToken ? `(已设置 ${esc(c.auth_token.secretHint)},留空不改)` : '')
|
|
97
|
+
+ (v === 'api_key' && hasKey ? `(已设置 ${esc(c.api_key.secretHint)},留空不改)` : '');
|
|
98
|
+
};
|
|
99
|
+
document.querySelectorAll('input[name=cred]').forEach((r) => r.onchange = () => { updCredLabel(); cardDirty('claude', true); });
|
|
100
|
+
$('#credVal').oninput = () => {
|
|
101
|
+
const v = $('#credVal').value.trim();
|
|
102
|
+
const key = document.querySelector('input[name=cred]:checked').value;
|
|
103
|
+
const other = key === 'auth_token' ? 'api_key' : 'auth_token';
|
|
104
|
+
if (v) { c[key] = v; c[other] = ''; } else { c[key] = { secretSet: key === 'auth_token' ? hasToken : hasKey }; }
|
|
105
|
+
cardDirty('claude', true);
|
|
106
|
+
};
|
|
107
|
+
$('#baseUrl').oninput = () => { c.base_url = $('#baseUrl').value.trim() || undefined; cardDirty('claude', true); };
|
|
108
|
+
$('#model').oninput = () => { c.model = $('#model').value.trim() || undefined; cardDirty('claude', true); };
|
|
109
|
+
// ---- 环境变量行编辑(claude.env):对象 ↔ 行数组,KEY/VALUE 实时写回(同 apps 抽屉 env 模式) ----
|
|
110
|
+
const cEnvRows = Object.entries(c.env || {}).map(([key, value]) => ({ key, value: String(value) }));
|
|
111
|
+
const syncClaudeEnv = () => {
|
|
112
|
+
const obj = {};
|
|
113
|
+
for (const r of cEnvRows) if (r.key.trim()) obj[r.key.trim()] = r.value;
|
|
114
|
+
if (Object.keys(obj).length) c.env = obj; else delete c.env;
|
|
115
|
+
};
|
|
116
|
+
const renderClaudeEnv = () => {
|
|
117
|
+
const tb = $('#claudeEnvBody');
|
|
118
|
+
if (!tb) return;
|
|
119
|
+
tb.innerHTML = cEnvRows.length ? cEnvRows.map((r, i) => `
|
|
120
|
+
<tr>
|
|
121
|
+
<td><input type="text" data-cei="${i}" data-col="key" value="${esc(r.key)}" placeholder="SOME_KEY"></td>
|
|
122
|
+
<td><input type="text" data-cei="${i}" data-col="value" value="${esc(r.value)}" placeholder="some-value"></td>
|
|
123
|
+
<td><button class="btn sm danger" data-crm="${i}">删</button></td>
|
|
124
|
+
</tr>`).join('') : '<tr><td colspan="3" class="hint">未配置(本机 ~/.claude 的非认证键自动继承)</td></tr>';
|
|
125
|
+
tb.querySelectorAll('input[data-cei]').forEach((input) => input.oninput = () => {
|
|
126
|
+
cEnvRows[Number(input.dataset.cei)][input.dataset.col] = input.value;
|
|
127
|
+
syncClaudeEnv();
|
|
128
|
+
cardDirty('claude', true);
|
|
129
|
+
});
|
|
130
|
+
tb.querySelectorAll('button[data-crm]').forEach((b) => b.onclick = () => {
|
|
131
|
+
cEnvRows.splice(Number(b.dataset.crm), 1);
|
|
132
|
+
syncClaudeEnv();
|
|
133
|
+
cardDirty('claude', true);
|
|
134
|
+
renderClaudeEnv();
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
const envAdd = $('#claudeEnvAdd');
|
|
138
|
+
if (envAdd) envAdd.onclick = () => { cEnvRows.push({ key: '', value: '' }); renderClaudeEnv(); tbFocusLastKey(); };
|
|
139
|
+
const tbFocusLastKey = () => {
|
|
140
|
+
const inputs = document.querySelectorAll('#claudeEnvBody input[data-col=key]');
|
|
141
|
+
const last = inputs[inputs.length - 1];
|
|
142
|
+
if (last) last.focus();
|
|
143
|
+
};
|
|
144
|
+
renderClaudeEnv();
|
|
145
|
+
$('#btnModels').onclick = () => {
|
|
146
|
+
const typed = $('#credVal').value.trim();
|
|
147
|
+
const key = document.querySelector('input[name=cred]:checked').value;
|
|
148
|
+
pullModels(() => ({ base_url: $('#baseUrl').value.trim(), ...(typed ? { [key]: typed } : {}) }), $('#btnModels'), 'modelList');
|
|
149
|
+
};
|
|
150
|
+
// ---- 厂商档案列表 ----
|
|
151
|
+
const renderProfiles = () => {
|
|
152
|
+
const body = $('#pfBody');
|
|
153
|
+
if (!body) return;
|
|
154
|
+
if (!c.profiles.length) { body.innerHTML = '<tr><td colspan="5" class="hint">尚无档案,点右上「+ 新增档案」创建</td></tr>'; return; }
|
|
155
|
+
const inUse = (p) => {
|
|
156
|
+
// 前端无凭证明文,用尾 4 位提示 + baseUrl 近似判断「使用中」(当前模型在 chips 上单独高亮)
|
|
157
|
+
const credSame = (p.auth_token?.secretHint && cur.authToken?.secretHint === p.auth_token.secretHint)
|
|
158
|
+
|| (p.api_key?.secretHint && cur.apiKey?.secretHint === p.api_key.secretHint);
|
|
159
|
+
return credSame && (p.base_url || '') === (cur.baseUrl || '');
|
|
160
|
+
};
|
|
161
|
+
const modelChips = (p, active, i) => {
|
|
162
|
+
const list = [...(p.models || []), ...(p.model && !(p.models || []).includes(p.model) ? [p.model] : [])];
|
|
163
|
+
return list.map((m) => `<button class="btn sm${active && cur.model === m ? ' primary' : ''}" data-op="usemodel" data-i="${i}" data-m="${esc(m)}" title="点击切换为当前模型">${esc(m)}${active && cur.model === m ? ' ✓' : ''}</button>`).join(' ')
|
|
164
|
+
|| '<span class="hint">—</span>';
|
|
165
|
+
};
|
|
166
|
+
body.innerHTML = c.profiles.map((p, i) => {
|
|
167
|
+
const active = inUse(p);
|
|
168
|
+
return `
|
|
169
|
+
<tr>
|
|
170
|
+
<td><b>${esc(p.name)}</b>${active ? '<br><span class="tag ok">使用中</span>' : ''}</td>
|
|
171
|
+
<td>${p.auth_token?.secretSet ? 'AUTH_TOKEN' : p.api_key?.secretSet ? 'API_KEY' : '<span class="hint">未设凭证</span>'}</td>
|
|
172
|
+
<td>${p.base_url ? `<code>${esc(p.base_url)}</code>` : '<span class="hint">官方</span>'}</td>
|
|
173
|
+
<td>${modelChips(p, active, i)}</td>
|
|
174
|
+
<td>
|
|
175
|
+
<button class="btn sm" data-op="use" data-i="${i}">设为当前</button>
|
|
176
|
+
<button class="btn sm" data-op="edit" data-i="${i}">编辑</button>
|
|
177
|
+
<button class="btn sm danger" data-op="del" data-i="${i}">删除</button>
|
|
178
|
+
</td>
|
|
179
|
+
</tr>`;
|
|
180
|
+
}).join('');
|
|
181
|
+
body.querySelectorAll('button[data-op]').forEach((b) => b.onclick = async () => {
|
|
182
|
+
const i = Number(b.dataset.i);
|
|
183
|
+
const p = c.profiles[i];
|
|
184
|
+
if (b.dataset.op === 'use' || b.dataset.op === 'usemodel') {
|
|
185
|
+
// 切换走后端端点(档案凭证明文不出进程);编辑中先收尾,未保存改动自动落盘再切(如认证模式切换)
|
|
186
|
+
if (!$('#drawerMask').hidden) return toast('请先完成档案编辑(保存或取消)', true);
|
|
187
|
+
if (S.dirtyCards.size && !(await saveDoc())) return; // 保存失败已 toast 原因,保持编辑态不切换
|
|
188
|
+
try {
|
|
189
|
+
const reqBody = { name: p.name };
|
|
190
|
+
if (b.dataset.op === 'usemodel') reqBody.model = b.dataset.m;
|
|
191
|
+
else if (p.model || (p.models || [])[0]) reqBody.model = p.model || p.models[0];
|
|
192
|
+
const r = await api('POST', '/api/claude/use-profile', reqBody);
|
|
193
|
+
toast(r.message || '已切换');
|
|
194
|
+
await refresh();
|
|
195
|
+
} catch (e) { toast(e.message, true); }
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (b.dataset.op === 'del') {
|
|
199
|
+
if (!window.confirm(`确认删除档案「${p.name || '未命名'}」?删除将立即保存生效。`)) return;
|
|
200
|
+
c.profiles.splice(i, 1);
|
|
201
|
+
await saveDoc();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
openProfileDrawer(c, i, el);
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
renderProfiles();
|
|
208
|
+
$('#pfAdd').onclick = () => {
|
|
209
|
+
const snap = snapDoc(); // 快照先于 push:取消时连同新增空档案一并回滚
|
|
210
|
+
c.profiles.push({ name: '' });
|
|
211
|
+
renderClaude(el);
|
|
212
|
+
openProfileDrawer(c, c.profiles.length - 1, el, snap);
|
|
213
|
+
};
|
|
214
|
+
applyDirty(); // 模式切换等 handler 先 cardDirty 再整卡重渲染:回放防止保存条/圆点随重建丢失
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 档案编辑抽屉:实时 mutate profile 对象,「保存」一次落盘;凭证留空 = 保持不变(PUT 按 name 对齐回填)。
|
|
218
|
+
* 模型候选 chips:手输 / 「拉取模型」下拉添加,点 × 删除;默认模型(「设为当前」用)从候选中选 */
|
|
219
|
+
function openProfileDrawer(c, i, el, snap) {
|
|
220
|
+
const p = c.profiles[i];
|
|
221
|
+
const hasT = p.auth_token?.secretSet, hasK = p.api_key?.secretSet;
|
|
222
|
+
const ctype = hasT ? 'auth_token' : 'api_key';
|
|
223
|
+
openDrawer({
|
|
224
|
+
title: `厂商档案${p.name ? `:${p.name}` : '(新档案)'}`,
|
|
225
|
+
snap: snap ?? snapDoc(),
|
|
226
|
+
bodyHtml: `
|
|
227
|
+
<label>档案名(显示用,如「官方」「中转站A」)</label>
|
|
228
|
+
<input type="text" id="dwPfName" value="${esc(p.name || '')}" placeholder="中转站A">
|
|
229
|
+
<div class="radio-row">
|
|
230
|
+
<label><input type="radio" name="dwCred" value="auth_token" ${ctype === 'auth_token' ? 'checked' : ''}> AUTH_TOKEN(Bearer,中转站常用)</label>
|
|
231
|
+
<label><input type="radio" name="dwCred" value="api_key" ${ctype === 'api_key' ? 'checked' : ''}> API_KEY(官方,x-api-key)</label>
|
|
232
|
+
</div>
|
|
233
|
+
<label id="dwPfCredLabel">${ctype === 'api_key' ? 'API Key' : 'Auth Token'} ${hasT || hasK ? '(已设置,留空不改)' : ''}</label>
|
|
234
|
+
<input type="password" id="dwPfCred" placeholder="${hasT || hasK ? '留空保持不变' : 'sk-…'}">
|
|
235
|
+
<label>BASE_URL(中转站端点,官方留空)</label>
|
|
236
|
+
<input type="text" id="dwPfBase" value="${esc(p.base_url || '')}" placeholder="https://relay.example.com">
|
|
237
|
+
<h4 style="margin:18px 0 4px;font-size:13.5px">候选模型(fable / opus / sonnet / haiku 等,各存一条;列表页点击即切换当前模型)</h4>
|
|
238
|
+
<div class="chips" id="dwPfModels"></div>
|
|
239
|
+
<div class="row" style="align-items:end;max-width:600px">
|
|
240
|
+
<div style="flex:1"><input type="text" id="dwPfNewModel" list="dwPfModelList" placeholder="输入或下拉选择后点「添加」">
|
|
241
|
+
<datalist id="dwPfModelList"></datalist></div>
|
|
242
|
+
<div class="btn-wrap"><button class="btn" id="dwPfAddModel">添加</button></div>
|
|
243
|
+
<div class="btn-wrap"><button class="btn" id="dwPfPull">拉取模型</button></div>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="row" style="align-items:end;margin-top:12px;max-width:600px">
|
|
246
|
+
<div style="flex:1"><label>默认模型(点「设为当前」时使用,通常为候选之一)</label>
|
|
247
|
+
<input type="text" id="dwPfModel" list="dwPfModelsList" value="${esc(p.model || '')}" placeholder="留空 = 切换时不指定">
|
|
248
|
+
<datalist id="dwPfModelsList"></datalist></div>
|
|
249
|
+
</div>`,
|
|
250
|
+
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>`,
|
|
251
|
+
onMount: () => {
|
|
252
|
+
document.querySelectorAll('input[name=dwCred]').forEach((r) => r.onchange = () => {
|
|
253
|
+
const v = document.querySelector('input[name=dwCred]:checked').value;
|
|
254
|
+
const t = v === 'auth_token' && hasT ? `(已设置 ${esc(p.auth_token.secretHint)},留空不改)` : '';
|
|
255
|
+
const k = v === 'api_key' && hasK ? `(已设置 ${esc(p.api_key.secretHint)},留空不改)` : '';
|
|
256
|
+
document.getElementById('dwPfCredLabel').textContent = `${v === 'api_key' ? 'API Key' : 'Auth Token'}${t || k}`;
|
|
257
|
+
});
|
|
258
|
+
document.getElementById('dwPfName').oninput = (e) => { p.name = e.target.value.trim(); };
|
|
259
|
+
document.getElementById('dwPfCred').oninput = (e) => {
|
|
260
|
+
const v = e.target.value.trim();
|
|
261
|
+
const key = document.querySelector('input[name=dwCred]:checked').value;
|
|
262
|
+
const other = key === 'auth_token' ? 'api_key' : 'auth_token';
|
|
263
|
+
if (v) { p[key] = v; delete p[other]; }
|
|
264
|
+
else if (!(p[key] && typeof p[key] === 'object')) delete p[key]; // 空输入:保留原脱敏对象(未修改语义)
|
|
265
|
+
};
|
|
266
|
+
document.getElementById('dwPfBase').oninput = (e) => { p.base_url = e.target.value.trim() || undefined; };
|
|
267
|
+
// ---- 候选模型 chips:本地数组编辑,同步 p.models ----
|
|
268
|
+
const models = [...(p.models || [])];
|
|
269
|
+
const syncModels = () => {
|
|
270
|
+
if (models.length) p.models = [...models];
|
|
271
|
+
else delete p.models;
|
|
272
|
+
const box = document.getElementById('dwPfModels');
|
|
273
|
+
box.innerHTML = models.map((m, mi) =>
|
|
274
|
+
`<span class="chip">${esc(m)}<button type="button" data-rm="${mi}" title="移除">×</button></span>`).join('')
|
|
275
|
+
|| '<span class="hint">尚无候选模型(不影响使用,可随时添加)</span>';
|
|
276
|
+
box.querySelectorAll('button[data-rm]').forEach((b) => b.onclick = () => {
|
|
277
|
+
models.splice(Number(b.dataset.rm), 1);
|
|
278
|
+
syncModels();
|
|
279
|
+
});
|
|
280
|
+
document.getElementById('dwPfModelsList').innerHTML = models.map((m) => `<option value="${esc(m)}"></option>`).join('');
|
|
281
|
+
};
|
|
282
|
+
syncModels();
|
|
283
|
+
const addModel = () => {
|
|
284
|
+
const inp = document.getElementById('dwPfNewModel');
|
|
285
|
+
const v = inp.value.trim();
|
|
286
|
+
if (!v) return;
|
|
287
|
+
if (!models.includes(v)) models.push(v);
|
|
288
|
+
inp.value = '';
|
|
289
|
+
syncModels();
|
|
290
|
+
};
|
|
291
|
+
document.getElementById('dwPfAddModel').onclick = addModel;
|
|
292
|
+
document.getElementById('dwPfNewModel').onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); addModel(); } };
|
|
293
|
+
document.getElementById('dwPfModel').oninput = (e) => { p.model = e.target.value.trim() || undefined; };
|
|
294
|
+
document.getElementById('dwPfPull').onclick = () => {
|
|
295
|
+
const typed = document.getElementById('dwPfCred').value.trim();
|
|
296
|
+
const key = document.querySelector('input[name=dwCred]:checked').value;
|
|
297
|
+
pullModels(() => ({
|
|
298
|
+
base_url: document.getElementById('dwPfBase').value.trim(),
|
|
299
|
+
...(p.name ? { profile_name: p.name } : {}),
|
|
300
|
+
...(typed ? { [key]: typed } : {}),
|
|
301
|
+
}), document.getElementById('dwPfPull'), 'dwPfModelList');
|
|
302
|
+
};
|
|
303
|
+
document.getElementById('dwPfDel').onclick = async () => {
|
|
304
|
+
if (!window.confirm(`确认删除档案「${p.name || '未命名'}」?删除将立即保存生效。`)) return;
|
|
305
|
+
c.profiles.splice(i, 1);
|
|
306
|
+
if (await saveDoc()) closeDrawer();
|
|
307
|
+
else cancelDrawer();
|
|
308
|
+
};
|
|
309
|
+
document.getElementById('dwPfCancel').onclick = cancelDrawer;
|
|
310
|
+
document.getElementById('dwPfSave').onclick = async () => {
|
|
311
|
+
if (await saveDoc()) closeDrawer();
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const page = {
|
|
318
|
+
id: 'claude',
|
|
319
|
+
title: 'Claude 认证',
|
|
320
|
+
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"/>',
|
|
321
|
+
render: renderClaude,
|
|
322
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// ============ 概览 ============
|
|
2
|
+
// 桥接器启停 / 版本更新(宿主进程管理:embedded 页面随进程生死,独立页面经 PID 跨进程操作)
|
|
3
|
+
import { S, $, esc, toast, api, refresh } from '../core.js';
|
|
4
|
+
|
|
5
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
const UPD = { checkedAt: 0, data: null };
|
|
7
|
+
|
|
8
|
+
function showMask(inner) {
|
|
9
|
+
let m = $('#brMask');
|
|
10
|
+
if (!m) {
|
|
11
|
+
m = document.createElement('div');
|
|
12
|
+
m.id = 'brMask'; m.className = 'br-mask';
|
|
13
|
+
document.body.appendChild(m);
|
|
14
|
+
}
|
|
15
|
+
m.innerHTML = `<div class="br-mask-card">${inner}</div>`;
|
|
16
|
+
m.style.display = 'flex';
|
|
17
|
+
}
|
|
18
|
+
function hideMask() { const m = $('#brMask'); if (m) m.style.display = 'none'; }
|
|
19
|
+
|
|
20
|
+
/** 启停请求的容错:embedded 停止/重启时进程退出可能先于响应到达,网络断开视为已受理 */
|
|
21
|
+
async function bridgeActionSafe(op) {
|
|
22
|
+
try { return await api('POST', '/api/bridge/action', { op }); }
|
|
23
|
+
catch (e) {
|
|
24
|
+
if (/fetch|network|load failed/i.test(e.message)) return { ok: true, message: '已提交(连接关闭属预期)' };
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 等配置页服务恢复(重启轮询):连续两次成功才确认,排除旧进程残影 */
|
|
30
|
+
async function waitServerBack(timeoutMs = 45000) {
|
|
31
|
+
const t0 = Date.now();
|
|
32
|
+
await sleep(2500); // 留给旧进程退场:立刻探测可能命中尚未退出的旧 server
|
|
33
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
34
|
+
try {
|
|
35
|
+
await api('GET', '/api/status');
|
|
36
|
+
await sleep(1000);
|
|
37
|
+
await api('GET', '/api/status');
|
|
38
|
+
return;
|
|
39
|
+
} catch {}
|
|
40
|
+
await sleep(1500);
|
|
41
|
+
}
|
|
42
|
+
throw new Error('重启超时:请手动刷新页面查看状态');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function startFlow() {
|
|
46
|
+
try {
|
|
47
|
+
const r = await bridgeActionSafe('start');
|
|
48
|
+
toast(r.message || '✅ 已在后台启动');
|
|
49
|
+
setTimeout(() => refresh(), 2500); // 后台进程写 PID / 起长连接需数秒
|
|
50
|
+
setTimeout(() => refresh(), 7000);
|
|
51
|
+
} catch (e) { toast(e.message, true); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function stopFlow() {
|
|
55
|
+
const embedded = !!S.status?.embedded;
|
|
56
|
+
const msg = embedded
|
|
57
|
+
? '停止后本配置页将随桥接器进程一同关闭。\n之后可运行 lcb ui 重开配置页(并在其中重新启动桥接器)。确定停止?'
|
|
58
|
+
: '停止后机器人将不再响应消息。确定停止桥接器进程?';
|
|
59
|
+
if (!confirm(msg)) return;
|
|
60
|
+
try {
|
|
61
|
+
const r = await bridgeActionSafe('stop');
|
|
62
|
+
if (embedded) {
|
|
63
|
+
toast(r.message || '正在停止…');
|
|
64
|
+
await sleep(2500);
|
|
65
|
+
try { await api('GET', '/api/status'); await refresh(); } // 进程仍在(异常场景):正常刷新
|
|
66
|
+
catch { showMask('<b>桥接器已停止</b><div class="hint" style="margin-top:8px">本配置页已随进程离线。重新打开请运行 lcb ui;启动桥接器请运行 lcb start。</div>'); }
|
|
67
|
+
} else {
|
|
68
|
+
toast(r.message || '已发送停止信号');
|
|
69
|
+
setTimeout(() => refresh(), 1500); // 硬终止无 PID 清理:陈旧文件由探活兜底删除
|
|
70
|
+
setTimeout(() => refresh(), 4000);
|
|
71
|
+
}
|
|
72
|
+
} catch (e) { toast(e.message, true); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function restartFlow() {
|
|
76
|
+
if (S.status?.embedded) {
|
|
77
|
+
showMask('<div class="br-spin"></div>正在重启桥接器,页面将自动恢复…');
|
|
78
|
+
try { await bridgeActionSafe('restart'); } catch (e) { hideMask(); toast(e.message, true); return; }
|
|
79
|
+
try { await waitServerBack(); hideMask(); toast('✅ 桥接器已重启'); await refresh(); }
|
|
80
|
+
catch (e) { hideMask(); toast(e.message, true); }
|
|
81
|
+
} else {
|
|
82
|
+
try { const r = await bridgeActionSafe('restart'); toast(r.message || '正在重启…'); }
|
|
83
|
+
catch (e) { toast(e.message, true); return; }
|
|
84
|
+
setTimeout(() => refresh(), 4000);
|
|
85
|
+
setTimeout(() => refresh(), 10000);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderUpdateResult() {
|
|
90
|
+
const el = $('#updLatest');
|
|
91
|
+
const r = UPD.data;
|
|
92
|
+
if (!el) return;
|
|
93
|
+
if (!r) { el.innerHTML = '<span class="hint">未检查</span>'; return; }
|
|
94
|
+
el.innerHTML = r.hasUpdate
|
|
95
|
+
? `<span class="tag warn">有新版本</span> <span class="chip">v${esc(r.latest)}</span>`
|
|
96
|
+
: `<span class="tag ok">已是最新</span> <span class="chip">v${esc(r.latest)}</span>`;
|
|
97
|
+
const area = $('#updArea');
|
|
98
|
+
if (!area) return;
|
|
99
|
+
if (r.hasUpdate && r.mode === 'global') {
|
|
100
|
+
area.innerHTML = '<button class="btn primary" id="btnUpdRun">一键更新</button>';
|
|
101
|
+
$('#btnUpdRun').onclick = runUpdateFlow;
|
|
102
|
+
} else if (r.hasUpdate) {
|
|
103
|
+
area.innerHTML = '<span class="hint">当前非 npm 全局安装运行,请手动更新</span>';
|
|
104
|
+
} else area.innerHTML = '';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function doCheckUpdate() {
|
|
108
|
+
const el = $('#updLatest'); if (!el) return;
|
|
109
|
+
el.innerHTML = '<span class="hint">检查中…</span>';
|
|
110
|
+
try {
|
|
111
|
+
UPD.data = await api('GET', '/api/update/check');
|
|
112
|
+
UPD.checkedAt = Date.now();
|
|
113
|
+
renderUpdateResult();
|
|
114
|
+
} catch (e) {
|
|
115
|
+
el.innerHTML = `<span class="hint">检查失败:${esc(e.message)}</span>`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function runUpdateFlow() {
|
|
120
|
+
const running = S.status?.embedded || S.status?.bridge?.running;
|
|
121
|
+
const extra = running ? ',完成后自动重启桥接器(页面将短暂失联后自动恢复)' : '(桥接器未在运行,下次启动生效)';
|
|
122
|
+
if (!confirm(`将通过 npm 安装最新版本${extra},可能需要 1-2 分钟。继续?`)) return;
|
|
123
|
+
const btn = $('#btnUpdRun');
|
|
124
|
+
if (btn) { btn.disabled = true; btn.textContent = '更新中…'; }
|
|
125
|
+
try {
|
|
126
|
+
await api('POST', '/api/update/run');
|
|
127
|
+
UPD.checkedAt = 0;
|
|
128
|
+
if (running) await restartFlow();
|
|
129
|
+
else toast('✅ 更新完成,下次启动生效');
|
|
130
|
+
await refresh();
|
|
131
|
+
} catch (e) {
|
|
132
|
+
toast(`更新失败:${e.message}`, true);
|
|
133
|
+
renderUpdateResult(); // 恢复按钮
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const page = {
|
|
138
|
+
id: 'overview',
|
|
139
|
+
title: '概览',
|
|
140
|
+
icon: '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
|
|
141
|
+
async render(el) {
|
|
142
|
+
const st = S.status || {};
|
|
143
|
+
const claudeTag = st.claude?.hasAuth === null ? '<span class="tag off">未知</span>'
|
|
144
|
+
: st.claude?.hasAuth ? '<span class="tag ok">已就绪</span>' : '<span class="tag err">未认证</span>';
|
|
145
|
+
const stale = !st.bridge; // 旧版本进程(API 无 bridge 字段):进程托管功能整体不可用
|
|
146
|
+
const br = stale ? { running: st.embedded === true, pid: null, spawnable: false } : st.bridge;
|
|
147
|
+
const ctl = stale
|
|
148
|
+
? '<div class="hint" style="margin-top:10px">⚠️ 配置页服务进程为旧版本,页面启停 / 重启 / 更新不可用:请重启桥接器(lcb start)后刷新本页。</div>'
|
|
149
|
+
: br.spawnable === false
|
|
150
|
+
? '<div class="hint" style="margin-top:10px">当前运行方式不支持页面托管启停(如源码运行),请在服务器上手动运行 lcb start。</div>'
|
|
151
|
+
: `<div style="display:flex; gap:10px; margin-top:12px">${
|
|
152
|
+
br.running
|
|
153
|
+
? '<button class="btn" id="btnBrRestart">重启</button><button class="btn danger" id="btnBrStop">停止</button>'
|
|
154
|
+
: '<button class="btn primary" id="btnBrStart">启动</button>'
|
|
155
|
+
}</div>
|
|
156
|
+
<div class="hint" style="margin-top:8px">${st.embedded
|
|
157
|
+
? '本页随桥接器进程运行:停止后页面将离线(可运行 lcb ui 重开);重启会短暂失联后自动恢复。'
|
|
158
|
+
: '本页独立运行(lcb ui):在此启动的桥接器为后台进程,运行日志见 ~/.lark-claudecode-bridge/bridge.log。'}</div>`;
|
|
159
|
+
el.innerHTML = `
|
|
160
|
+
<div class="card">
|
|
161
|
+
<h3>运行状态</h3>
|
|
162
|
+
<table>
|
|
163
|
+
<tr><td>版本</td><td>v${esc(st.version)}</td></tr>
|
|
164
|
+
<tr><td>模式</td><td>${st.embedded ? '随 lcb start 常驻' : 'lcb ui 独立(桥接器未随本页启动)'}</td></tr>
|
|
165
|
+
<tr><td>桥接器进程</td><td>${stale
|
|
166
|
+
? (st.embedded ? '<span class="tag warn">运行中(旧版本进程)</span>' : '<span class="tag off">状态未知(旧版本进程)</span>')
|
|
167
|
+
: br.running
|
|
168
|
+
? `<span class="tag ok">运行中</span> <span class="hint">PID ${esc(br.pid)}</span>`
|
|
169
|
+
: '<span class="tag off">未运行</span>'}</td></tr>
|
|
170
|
+
<tr><td>配置文件</td><td><code>${esc(st.configPath)}</code></td></tr>
|
|
171
|
+
<tr><td>配置页</td><td><code>${esc(location.host)}</code></td></tr>
|
|
172
|
+
<tr><td>Claude 认证</td><td>${claudeTag}(模式:${esc(st.claude?.mode || 'inherit')})</td></tr>
|
|
173
|
+
</table>
|
|
174
|
+
${ctl}
|
|
175
|
+
<div class="desc" style="margin-top:10px">${st.claude?.hasAuth === false ? '⚠️ 未检测到认证:到「Claude 认证」页填写,或在配置页所在机器执行 claude login。' : ''}</div>
|
|
176
|
+
</div>
|
|
177
|
+
<div class="card">
|
|
178
|
+
<h3>版本与更新</h3>
|
|
179
|
+
<table>
|
|
180
|
+
<tr><td>当前版本</td><td><span class="chip">v${esc(st.version)}</span></td></tr>
|
|
181
|
+
<tr><td>最新版本</td><td id="updLatest"><span class="hint">未检查</span></td></tr>
|
|
182
|
+
</table>
|
|
183
|
+
<div style="display:flex; gap:10px; margin-top:12px; align-items:center">
|
|
184
|
+
<button class="btn" id="btnUpdCheck">检查更新</button>
|
|
185
|
+
<span id="updArea"></span>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
<div class="card">
|
|
189
|
+
<h3>机器人应用</h3>
|
|
190
|
+
<table><thead><tr><th>名称</th><th>App ID</th><th>状态</th></tr></thead><tbody>
|
|
191
|
+
${(st.apps || []).map((a) => `<tr><td>${esc(a.name)}</td><td><code>${esc(a.appId)}</code></td><td>${
|
|
192
|
+
a.started === true ? '<span class="tag ok">运行中</span>' : a.started === false ? '<span class="tag err">启动失败</span>' : '<span class="tag off">未随本页启动</span>'
|
|
193
|
+
}</td></tr>`).join('') || '<tr><td colspan="3" class="hint">无</td></tr>'}
|
|
194
|
+
</tbody></table>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="card">
|
|
197
|
+
<h3>常用命令</h3>
|
|
198
|
+
<div class="desc">在飞书聊天框输入 <code>/</code> 触发;其它 / 开头消息原文透传给 Claude Code。斜杠面板需先到「斜杠命令」页注册(补齐内置命令)。</div>
|
|
199
|
+
<table><thead><tr><th style="width:150px">命令</th><th>说明</th></tr></thead><tbody>
|
|
200
|
+
${(st.commands || []).map((c) => `<tr><td><code>/${esc(c.command)}</code></td><td>${esc(c.description)}</td></tr>`).join('') || '<tr><td colspan="2" class="hint">无</td></tr>'}
|
|
201
|
+
</tbody></table>
|
|
202
|
+
</div>
|
|
203
|
+
<div class="card">
|
|
204
|
+
<h3>常用指引</h3>
|
|
205
|
+
<div class="desc">飞书端与机器人对话即可使用;<code>/help</code> 查看全部命令。配置修改后:<b>应用 / 认证 / server 段需重启 lcb start</b>,工作区 / 权限 / 触发词下一条消息自动生效。</div>
|
|
206
|
+
</div>`;
|
|
207
|
+
// 启停按钮 + 版本更新;自动检查 30s 节流(切回概览 tab 不重复打 npm)
|
|
208
|
+
const bind = (id, fn) => { const b = $(id); if (b) b.onclick = fn; };
|
|
209
|
+
bind('#btnBrStart', startFlow); bind('#btnBrStop', stopFlow); bind('#btnBrRestart', restartFlow);
|
|
210
|
+
bind('#btnUpdCheck', doCheckUpdate);
|
|
211
|
+
if (UPD.data && Date.now() - UPD.checkedAt < 30000) renderUpdateResult();
|
|
212
|
+
else doCheckUpdate();
|
|
213
|
+
},
|
|
214
|
+
};
|