@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,97 @@
|
|
|
1
|
+
// ============ 首装向导(bootstrap;非 tab 页,由 main.js 在 firstRun 时渲染) ============
|
|
2
|
+
import { $, toast, api, refresh } from './core.js';
|
|
3
|
+
import { pickDirectory } from './ui.js';
|
|
4
|
+
|
|
5
|
+
export function renderBootstrap(el) {
|
|
6
|
+
el.innerHTML = `
|
|
7
|
+
<div class="card">
|
|
8
|
+
<h3>👋 欢迎使用 lark-claudecode-bridge</h3>
|
|
9
|
+
<div class="desc">首次安装:完成下面三步即可开始使用。需要先在 <a href="https://open.feishu.cn/app" target="_blank">飞书开放平台</a> 创建应用并启用机器人能力(详见 README)。建议为每个应用填写名字——概览页与飞书 /status 将显示该名字,缺省显示 App ID。</div>
|
|
10
|
+
<h3 style="margin-top:16px">① 飞书应用凭证</h3>
|
|
11
|
+
<div id="bsApps"></div>
|
|
12
|
+
<button class="btn sm" id="bsAddApp">+ 再添一个应用(多机器人)</button>
|
|
13
|
+
<h3 style="margin-top:20px">② 本机工作区(Claude 的工作目录)</h3>
|
|
14
|
+
<div class="row">
|
|
15
|
+
<div><label>名字(如 demo)</label><input type="text" id="bsWsName" placeholder="demo"></div>
|
|
16
|
+
<div><label>本机绝对路径</label>
|
|
17
|
+
<div class="input-btn">
|
|
18
|
+
<input type="text" id="bsWsPath" placeholder="F:\\workspace\\demo">
|
|
19
|
+
<button class="btn sm" id="bsWsBrowse" title="浏览选择目录">浏览</button>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
<h3 style="margin-top:20px">③ Claude 认证(可稍后在「Claude 认证」页配置)</h3>
|
|
24
|
+
<div class="radio-row">
|
|
25
|
+
<label><input type="radio" name="bsMode" value="skip" checked> 使用本机已有 ~/.claude 登录</label>
|
|
26
|
+
<label><input type="radio" name="bsMode" value="managed"> 填 API Key / Token(无需本机登录)</label>
|
|
27
|
+
</div>
|
|
28
|
+
<div id="bsManaged" style="display:none">
|
|
29
|
+
<div class="radio-row">
|
|
30
|
+
<label><input type="radio" name="bsCred" value="auth_token" checked> AUTH_TOKEN(中转站常用)</label>
|
|
31
|
+
<label><input type="radio" name="bsCred" value="api_key"> API_KEY(官方)</label>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="row">
|
|
34
|
+
<div><label id="bsCredLabel">Auth Token</label><input type="password" id="bsCred" placeholder="sk-…"></div>
|
|
35
|
+
<div><label>BASE_URL(中转站端点,官方留空)</label><input type="text" id="bsBaseUrl" placeholder="https://relay.example.com"></div>
|
|
36
|
+
</div>
|
|
37
|
+
<label>模型(如 claude-sonnet-5,可留空)</label><input type="text" id="bsModel" placeholder="claude-sonnet-5">
|
|
38
|
+
</div>
|
|
39
|
+
<div style="margin-top:20px">
|
|
40
|
+
<button class="btn primary" id="bsSubmit" style="padding:9px 28px">完成配置</button>
|
|
41
|
+
<span class="hint">提交后运行 <code>lcb start</code> 启动桥接器</span>
|
|
42
|
+
</div>
|
|
43
|
+
</div>`;
|
|
44
|
+
const appsBox = $('#bsApps');
|
|
45
|
+
const addAppRow = () => {
|
|
46
|
+
const div = document.createElement('div');
|
|
47
|
+
div.className = 'row';
|
|
48
|
+
div.style.marginBottom = '8px';
|
|
49
|
+
div.innerHTML = `
|
|
50
|
+
<div><label>App ID(cli_ 开头)</label><input type="text" class="bs-appid" placeholder="cli_aabbccddeeff0011"></div>
|
|
51
|
+
<div><label>应用名字(建议填写)</label><input type="text" class="bs-name" placeholder="如:素材收集(概览/状态页显示用)"></div>
|
|
52
|
+
<div><label>App Secret</label><input type="password" class="bs-secret" placeholder=""></div>
|
|
53
|
+
<div class="btn-wrap"><button class="btn sm danger bs-del">删除</button></div>`;
|
|
54
|
+
div.querySelector('.bs-del').onclick = () => div.remove();
|
|
55
|
+
appsBox.appendChild(div);
|
|
56
|
+
};
|
|
57
|
+
addAppRow();
|
|
58
|
+
$('#bsAddApp').onclick = addAppRow;
|
|
59
|
+
$('#bsWsBrowse').onclick = async () => {
|
|
60
|
+
const picked = await pickDirectory($('#bsWsPath').value.trim());
|
|
61
|
+
if (picked != null) $('#bsWsPath').value = picked;
|
|
62
|
+
};
|
|
63
|
+
document.querySelectorAll('input[name=bsMode]').forEach((r) => r.onchange = () => {
|
|
64
|
+
$('#bsManaged').style.display = $('[name=bsMode]:checked').value === 'managed' ? '' : 'none';
|
|
65
|
+
});
|
|
66
|
+
document.querySelectorAll('input[name=bsCred]').forEach((r) => r.onchange = () => {
|
|
67
|
+
$('#bsCredLabel').textContent = $('[name=bsCred]:checked').value === 'api_key' ? 'API Key' : 'Auth Token';
|
|
68
|
+
});
|
|
69
|
+
$('#bsSubmit').onclick = async () => {
|
|
70
|
+
const apps = [...appsBox.querySelectorAll('.row')].map((row) => {
|
|
71
|
+
const appId = row.querySelector('.bs-appid').value.trim();
|
|
72
|
+
const name = row.querySelector('.bs-name').value.trim();
|
|
73
|
+
const secret = row.querySelector('.bs-secret').value.trim();
|
|
74
|
+
return { app_id: appId, ...(name ? { name } : {}), app_secret: secret };
|
|
75
|
+
}).filter((a) => a.app_id && a.app_secret);
|
|
76
|
+
const ws = { name: $('#bsWsName').value.trim(), path: $('#bsWsPath').value.trim() };
|
|
77
|
+
if (!apps.length) return toast('至少需要一个有效的应用(App ID + App Secret)', true);
|
|
78
|
+
if (!ws.name || !ws.path) return toast('工作区名字与路径不能为空', true);
|
|
79
|
+
const body = { apps, workspace: ws };
|
|
80
|
+
if ($('[name=bsMode]:checked').value === 'managed') {
|
|
81
|
+
const cred = $('[name=bsCred]:checked').value;
|
|
82
|
+
const val = $('#bsCred').value.trim();
|
|
83
|
+
if (!val) return toast('请填写 Auth Token / API Key', true);
|
|
84
|
+
body.claude = { mode: 'managed', [cred]: val };
|
|
85
|
+
const baseUrl = $('#bsBaseUrl').value.trim();
|
|
86
|
+
const model = $('#bsModel').value.trim();
|
|
87
|
+
if (baseUrl) body.claude.base_url = baseUrl;
|
|
88
|
+
if (model) body.claude.model = model;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const r = await api('POST', '/api/bootstrap', body);
|
|
92
|
+
toast(r.message || '配置完成');
|
|
93
|
+
await refresh();
|
|
94
|
+
toast('✅ 配置已写入,请运行 lcb start(若正在运行请重启)');
|
|
95
|
+
} catch (e) { toast(e.message, true); }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ============ 公共底座:状态 / fetch / 保存 / 刷新 ============
|
|
2
|
+
// 所有页面模块与 ui 模块的公共依赖;依赖方向:core ← ui ← {pages, bootstrap} ← main(无环)
|
|
3
|
+
// 一步保存:无全局 savebar。抽屉编辑自带「保存/取消」(打开前拍快照);页内表单卡各自带
|
|
4
|
+
// 保存按钮(dirtyCards 跟踪未保存卡);行内低风险操作(权限增删)直接落盘
|
|
5
|
+
export const S = { status: null, doc: null, firstRun: false, dirtyCards: new Set() };
|
|
6
|
+
export const $ = (sel, el = document) => el.querySelector(sel);
|
|
7
|
+
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
8
|
+
|
|
9
|
+
export function toast(msg, isErr) {
|
|
10
|
+
const t = $('#toast');
|
|
11
|
+
t.textContent = msg;
|
|
12
|
+
t.className = 'show' + (isErr ? ' err' : '');
|
|
13
|
+
clearTimeout(t._h);
|
|
14
|
+
t._h = setTimeout(() => { t.className = ''; }, isErr ? 6000 : 3200);
|
|
15
|
+
}
|
|
16
|
+
export async function api(method, path, body) {
|
|
17
|
+
const res = await fetch(path, {
|
|
18
|
+
method,
|
|
19
|
+
headers: body !== undefined ? { 'Content-Type': 'application/json; charset=utf-8' } : undefined,
|
|
20
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
21
|
+
});
|
|
22
|
+
const data = await res.json().catch(() => ({}));
|
|
23
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
/** doc 深拷贝快照(抽屉取消回滚 / 打开前留存) */
|
|
27
|
+
export const snapDoc = () => JSON.parse(JSON.stringify(S.doc));
|
|
28
|
+
/** 页内表单卡未保存标记:cardDirty('ws', true) 显示卡头「未保存」小标 + 卡底保存行,并记入集合 */
|
|
29
|
+
export function cardDirty(id, on) {
|
|
30
|
+
if (on) S.dirtyCards.add(id); else S.dirtyCards.delete(id);
|
|
31
|
+
const dot = $(`#dirty-${id}`);
|
|
32
|
+
if (dot) dot.style.display = on ? 'inline' : 'none'; // '' 会回落到 CSS 默认 display:none,圆点永不显示
|
|
33
|
+
const bar = $(`#bar-${id}`);
|
|
34
|
+
if (bar) bar.style.display = on ? 'flex' : 'none';
|
|
35
|
+
}
|
|
36
|
+
/** 整卡重渲染(innerHTML 重建)后回放各卡 dirty 可见性:集合是真相源,新建的 bar/dot 默认隐藏 */
|
|
37
|
+
export function applyDirty() { for (const id of S.dirtyCards) cardDirty(id, true); }
|
|
38
|
+
/** 全量 PUT 落盘(所有保存路径共用);返回是否成功(失败保持当前编辑态) */
|
|
39
|
+
export async function saveDoc() {
|
|
40
|
+
if (S.firstRun) return false;
|
|
41
|
+
try {
|
|
42
|
+
const r = await api('PUT', '/api/config', S.doc);
|
|
43
|
+
if (r.restartRequired?.length) toast(`已保存。注意:${r.restartRequired.join('、')} 段改动需重启 lcb start 后生效`, true);
|
|
44
|
+
else toast('已保存(运行中的桥接器下一条消息自动生效)');
|
|
45
|
+
S.dirtyCards.clear();
|
|
46
|
+
await refresh();
|
|
47
|
+
return true;
|
|
48
|
+
} catch (e) { toast(`保存失败:${e.message}`, true); return false; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 视图刷新回调:main.js 启动时注入 render(core 不感知页面注册表,破 core→pages 依赖环) */
|
|
52
|
+
export const hooks = { rerender: () => {} };
|
|
53
|
+
|
|
54
|
+
export async function refresh() {
|
|
55
|
+
S.status = await api('GET', '/api/status').catch(() => null);
|
|
56
|
+
try {
|
|
57
|
+
S.doc = await api('GET', '/api/config');
|
|
58
|
+
S.firstRun = false;
|
|
59
|
+
$('#hFirstRun').textContent = '';
|
|
60
|
+
} catch (e) {
|
|
61
|
+
if (String(e.message).includes('bootstrap')) {
|
|
62
|
+
S.firstRun = true;
|
|
63
|
+
S.doc = null;
|
|
64
|
+
$('#hFirstRun').textContent = '首次安装向导';
|
|
65
|
+
} else { throw e; }
|
|
66
|
+
}
|
|
67
|
+
const st = S.status || {};
|
|
68
|
+
$('#hMeta').textContent = `v${st.version || '?'} · ${location.host}`;
|
|
69
|
+
// 版本错位检测:静态页每次现读磁盘(新),API 属于运行中进程(旧)——status 无 bridge
|
|
70
|
+
// 字段说明进程早于「页面托管启停」功能,提示重启而非让用户撞「未知端点」
|
|
71
|
+
$('#staleBar').hidden = !(S.status && S.status.bridge === undefined);
|
|
72
|
+
hooks.rerender();
|
|
73
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// ============ 入口:页面注册表 + hash 路由 + 启动 ============
|
|
2
|
+
// 新增配置页:js/pages/ 下新建模块(export const page = { id, title, icon, render(el) })
|
|
3
|
+
// 并在下方 import + PAGES 数组各加一行即可;后端 /js/* 静态服务自动覆盖新文件
|
|
4
|
+
import { S, $, esc, hooks, refresh } from './core.js';
|
|
5
|
+
import { initDrawer, isDrawerOpen, cancelDrawer } from './ui.js';
|
|
6
|
+
import { renderBootstrap } from './bootstrap.js';
|
|
7
|
+
import { page as pOverview } from './pages/overview.js';
|
|
8
|
+
import { page as pApps } from './pages/apps.js';
|
|
9
|
+
import { page as pWorkspaces } from './pages/workspaces.js';
|
|
10
|
+
import { page as pClaude } from './pages/claude.js';
|
|
11
|
+
import { page as pPermissions } from './pages/permissions.js';
|
|
12
|
+
import { page as pSlash } from './pages/slash.js';
|
|
13
|
+
import { page as pPlugins } from './pages/plugins.js';
|
|
14
|
+
|
|
15
|
+
const PAGES = [pOverview, pApps, pWorkspaces, pClaude, pPermissions, pSlash, pPlugins];
|
|
16
|
+
const pageById = new Map(PAGES.map((p) => [p.id, p]));
|
|
17
|
+
const DEFAULT_TAB = 'overview';
|
|
18
|
+
|
|
19
|
+
// 菜单图标包装:内联 SVG 线性图标(Lucide 风格,stroke:currentColor 自动跟随选中态配色)
|
|
20
|
+
const ico = (inner) => `<span class="ico"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg></span>`;
|
|
21
|
+
|
|
22
|
+
// ---- hash 路由:#claude 等;刷新 / 分享链接 / 前进后退均可定位 ----
|
|
23
|
+
const tabFromHash = () => { const id = location.hash.slice(1); return pageById.has(id) ? id : DEFAULT_TAB; };
|
|
24
|
+
let activeTab = tabFromHash(); // 首载即定位(首次 render 前)
|
|
25
|
+
let restoring = false; // 拒绝导航后回写 hash 触发的 hashchange 自抑制标志
|
|
26
|
+
|
|
27
|
+
/** 切页保护:抽屉编辑中 / 页内表单卡有未保存改动 → 确认后才切换(切走即丢弃) */
|
|
28
|
+
function confirmLeave() {
|
|
29
|
+
if (isDrawerOpen()) {
|
|
30
|
+
if (!window.confirm('编辑抽屉尚未保存,确定放弃修改并切换页面?')) return false;
|
|
31
|
+
cancelDrawer(); // 回滚抽屉编辑(内部 rerender 旧页),再由调用方 render 新页
|
|
32
|
+
} else if (S.dirtyCards.size) {
|
|
33
|
+
if (!window.confirm(`有 ${S.dirtyCards.size} 处未保存的修改,切换页面将丢弃。确定切换?`)) return false;
|
|
34
|
+
S.dirtyCards.clear();
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function render() {
|
|
40
|
+
renderNav();
|
|
41
|
+
if (S.firstRun) return renderBootstrap($('#main')); // 首装向导:无论 hash 是什么都渲染向导
|
|
42
|
+
$('#main').innerHTML = '';
|
|
43
|
+
void pageById.get(activeTab).render($('#main')); // fire-and-forget(plugins 页为 async,同旧版)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderNav() {
|
|
47
|
+
$('#nav').innerHTML = PAGES.map((p) =>
|
|
48
|
+
`<button data-k="${p.id}" class="${p.id === activeTab ? 'active' : ''}">${ico(p.icon)}<span>${p.title}</span></button>`).join('');
|
|
49
|
+
$('#nav').querySelectorAll('button').forEach((b) =>
|
|
50
|
+
b.onclick = () => tryActivate(b.dataset.k));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** nav 点击与 hashchange 的唯一汇聚点:确认 → 更新 activeTab + 同步 hash → 渲染 */
|
|
54
|
+
function tryActivate(tab) {
|
|
55
|
+
if (tab === activeTab) return; // nav 点击后写 hash 引发的 hashchange 在此早退(无双重渲染)
|
|
56
|
+
if (!confirmLeave()) {
|
|
57
|
+
// 拒绝导航:把 hash 回滚到当前页;回写引发的 hashchange 用 restoring 吞掉
|
|
58
|
+
if (location.hash.slice(1) !== activeTab) { restoring = true; location.hash = activeTab; }
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
activeTab = tab;
|
|
62
|
+
if (location.hash.slice(1) !== tab) location.hash = tab;
|
|
63
|
+
render();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
window.addEventListener('hashchange', () => {
|
|
67
|
+
if (restoring) { restoring = false; return; }
|
|
68
|
+
tryActivate(tabFromHash()); // 覆盖手改地址栏 / 分享链接 / 前进后退
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---- 启动 ----
|
|
72
|
+
window.__lcbBooted = true; // index.html 兜底脚本标志:module 加载成功
|
|
73
|
+
hooks.rerender = render; // 破环注入:core 的 refresh/cancelDrawer 经 hooks 触发重渲染
|
|
74
|
+
initDrawer(); // 抽屉全局监听(X / ESC / 点遮罩)一次性注册
|
|
75
|
+
refresh().catch((e) => { $('#main').innerHTML = `<div class="card"><h3>加载失败</h3><div class="desc">${esc(e.message)}</div></div>`; });
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// ============ 应用(列表 + 抽屉编辑;输入实时写回 S.doc.apps,抽屉「保存」一次落盘) ============
|
|
2
|
+
import { S, $, esc, toast, snapDoc, saveDoc } from '../core.js';
|
|
3
|
+
import { openDrawer, closeDrawer, cancelDrawer } from '../ui.js';
|
|
4
|
+
|
|
5
|
+
function renderApps(el) {
|
|
6
|
+
const apps = S.doc.apps || (S.doc.apps = []);
|
|
7
|
+
el.innerHTML = `
|
|
8
|
+
<div class="card">
|
|
9
|
+
<h3>飞书应用(机器人)</h3>
|
|
10
|
+
<div class="desc">每个应用一条独立长连接与会话池。点击行或「编辑」在抽屉中配置;App Secret 已脱敏,留空 = 保持不变。凭证 / 名称 / 域名 / 默认工作区 / 并发 / 人格 / 环境变量改动需重启 lcb start;触发词与显式插件热生效。</div>
|
|
11
|
+
<div style="display:flex;justify-content:flex-end;margin-bottom:10px">
|
|
12
|
+
<button class="btn primary" id="addApp">+ 新增应用</button>
|
|
13
|
+
</div>
|
|
14
|
+
<table>
|
|
15
|
+
<thead><tr><th>名称</th><th>App ID</th><th style="width:80px">域名</th><th>默认工作区</th><th style="width:110px">状态</th><th style="width:64px"></th></tr></thead>
|
|
16
|
+
<tbody id="appsBody"></tbody>
|
|
17
|
+
</table>
|
|
18
|
+
<div class="footer-note">⚠️ 应用段改动需重启 lcb start 后生效(触发词 / 显式插件除外,下一条消息自动生效)。</div>
|
|
19
|
+
</div>`;
|
|
20
|
+
const statusTag = (app) => {
|
|
21
|
+
const st = S.status?.apps?.find((s) => s.name === (app.name || app.app_id))?.started;
|
|
22
|
+
return st === true ? '<span class="tag ok">运行中</span>'
|
|
23
|
+
: st === false ? '<span class="tag err">启动失败</span>'
|
|
24
|
+
: '<span class="tag off">未随本页启动</span>';
|
|
25
|
+
};
|
|
26
|
+
$('#appsBody').innerHTML = apps.map((app, i) => `
|
|
27
|
+
<tr data-i="${i}" style="cursor:pointer">
|
|
28
|
+
<td><b>${esc(app.name || app.app_id || '(未命名)')}</b></td>
|
|
29
|
+
<td><code>${esc(app.app_id || '')}</code></td>
|
|
30
|
+
<td>${esc(app.domain === 'lark' ? 'lark' : 'feishu')}</td>
|
|
31
|
+
<td>${esc(app.default_workspace || '(全局默认)')}</td>
|
|
32
|
+
<td>${statusTag(app)}</td>
|
|
33
|
+
<td><button class="btn sm">编辑</button></td>
|
|
34
|
+
</tr>`).join('');
|
|
35
|
+
$('#appsBody').querySelectorAll('tr').forEach((tr) =>
|
|
36
|
+
tr.onclick = () => openAppDrawer(apps, Number(tr.dataset.i), el));
|
|
37
|
+
$('#addApp').onclick = () => {
|
|
38
|
+
const snap = snapDoc(); // 快照先于 push:取消时连同新增空行一起回滚
|
|
39
|
+
apps.push({ app_id: '', app_secret: '' });
|
|
40
|
+
renderApps(el);
|
|
41
|
+
openAppDrawer(apps, apps.length - 1, el, snap);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 应用编辑抽屉:输入实时 mutate app 对象,「保存」PUT 落盘(applySecrets / 注文保护链路零改动)。
|
|
47
|
+
* secret 语义逐字保留:非空 = 明文写入;空 = 保持脱敏对象(PUT 端按 app_id 回填磁盘现值)。
|
|
48
|
+
* 「取消」/ X / ESC = 回滚到打开前快照(新增未保存的应用行一并撤销)。
|
|
49
|
+
*/
|
|
50
|
+
function openAppDrawer(apps, idx, el, snap) {
|
|
51
|
+
const app = apps[idx];
|
|
52
|
+
const secret = app.app_secret || {};
|
|
53
|
+
const wsNames = (S.doc.workspaces || []).map((w) => w.name || '').filter(Boolean);
|
|
54
|
+
const globalDef = S.doc.defaults?.workspace || '';
|
|
55
|
+
openDrawer({
|
|
56
|
+
title: `应用:${app.name || app.app_id || '(新应用)'}`,
|
|
57
|
+
snap: snap ?? snapDoc(),
|
|
58
|
+
bodyHtml: `
|
|
59
|
+
<div class="row">
|
|
60
|
+
<div><label>应用名字(概览页与飞书 /status 显示用,缺省取 App ID)</label>
|
|
61
|
+
<input type="text" data-f="name" value="${esc(app.name || '')}" placeholder="素材收集"></div>
|
|
62
|
+
<div><label>App ID</label>
|
|
63
|
+
<input type="text" data-f="app_id" value="${esc(app.app_id || '')}" placeholder="cli_…"></div>
|
|
64
|
+
</div>
|
|
65
|
+
<label>App Secret ${secret.secretSet ? `(已设置 ${esc(secret.secretHint)},留空不改)` : ''}</label>
|
|
66
|
+
<input type="password" data-f="app_secret" placeholder="${secret.secretSet ? '••••••••(留空保持不变)' : '未设置'}">
|
|
67
|
+
<div class="row">
|
|
68
|
+
<div><label>域名</label><select data-f="domain">
|
|
69
|
+
<option value="" ${!app.domain || app.domain === 'feishu' ? 'selected' : ''}>feishu(国内)</option>
|
|
70
|
+
<option value="lark" ${app.domain === 'lark' ? 'selected' : ''}>lark(国际)</option>
|
|
71
|
+
</select></div>
|
|
72
|
+
<div><label>默认工作区${globalDef ? `(缺省用全局默认:${esc(globalDef)})` : '(缺省用全局默认)'}</label>
|
|
73
|
+
<select data-f="default_workspace">
|
|
74
|
+
<option value="" ${!app.default_workspace ? 'selected' : ''}>(全局默认)</option>
|
|
75
|
+
${wsNames.map((n) => `<option value="${esc(n)}" ${app.default_workspace === n ? 'selected' : ''}>${esc(n)}</option>`).join('')}
|
|
76
|
+
</select></div>
|
|
77
|
+
<div><label>并发上限(1-100)</label>
|
|
78
|
+
<input type="number" data-f="concurrency" min="1" max="100" value="${esc(app.concurrency ?? '')}" placeholder="缺省用全局"></div>
|
|
79
|
+
</div>
|
|
80
|
+
<label>人格补充(追加到该机器人每个会话的 system prompt,多机器人差异化定位;改动需重启生效)</label>
|
|
81
|
+
<textarea data-f="append_system_prompt" placeholder="你是一个素材收集助手…">${esc(app.append_system_prompt || '')}</textarea>
|
|
82
|
+
<h4 style="margin:18px 0 2px;font-size:13.5px">触发词(命中即改写消息后再发给 Claude;下一条消息热生效)</h4>
|
|
83
|
+
<div class="hint" style="margin-bottom:6px">match 以 <code>/</code> 开头 = 消息首词精确匹配,否则 = 关键词包含;rewrite 可用 <code>{text}</code>=原文全文、<code>{args}</code>=首词后的参数;本地命令(/stop 等)不受影响;按序首个命中生效。</div>
|
|
84
|
+
<table><thead><tr><th style="width:42%">match</th><th>rewrite</th><th style="width:44px"></th></tr></thead><tbody data-list="triggers"></tbody></table>
|
|
85
|
+
<button class="btn sm" data-add="triggers" style="margin-top:6px">+ 添加规则</button>
|
|
86
|
+
<h4 style="margin:18px 0 2px;font-size:13.5px">显式插件(本地插件源码目录;同名优先于 ~/.claude 自动发现;热生效)</h4>
|
|
87
|
+
<div class="hint" style="margin-bottom:6px">须为包含 <code>.claude-plugin/plugin.json</code> 的目录;一般用户无须配置,多用于开发期直指源码。</div>
|
|
88
|
+
<table><thead><tr><th style="width:30%">name</th><th>path</th><th style="width:44px"></th></tr></thead><tbody data-list="plugins"></tbody></table>
|
|
89
|
+
<button class="btn sm" data-add="plugins" style="margin-top:6px">+ 添加插件</button>
|
|
90
|
+
<h4 style="margin:18px 0 2px;font-size:13.5px">环境变量(注入该机器人 Claude 子进程,覆盖本机同名值;改动需重启生效)</h4>
|
|
91
|
+
<div class="hint" style="margin-bottom:6px">~/.claude/settings.json 已配置的键会被 CLI 自身应用且优先,无须在此重复。</div>
|
|
92
|
+
<table><thead><tr><th style="width:38%">KEY</th><th>VALUE</th><th style="width:44px"></th></tr></thead><tbody data-list="env"></tbody></table>
|
|
93
|
+
<button class="btn sm" data-add="env" style="margin-top:6px">+ 添加变量</button>`,
|
|
94
|
+
footHtml: `<button class="btn danger" id="dwDel">删除应用</button><span class="spacer"></span><button class="btn" id="dwCancel">取消</button><button class="btn primary" id="dwSave">保存</button>`,
|
|
95
|
+
onMount: ({ body, foot }) => {
|
|
96
|
+
// ---- 普通字段(语义与旧卡片版逐字一致;改动只进内存,抽屉「保存」落盘) ----
|
|
97
|
+
body.querySelectorAll('[data-f]').forEach((input) => {
|
|
98
|
+
input.oninput = input.onchange = () => {
|
|
99
|
+
const f = input.dataset.f;
|
|
100
|
+
if (f === 'default_workspace') {
|
|
101
|
+
if (input.value) app.default_workspace = input.value; else delete app.default_workspace;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (f === 'app_secret') {
|
|
105
|
+
if (input.value.trim()) { app.app_secret = input.value; }
|
|
106
|
+
else if (typeof app.app_secret === 'string') { app.app_secret = { secretSet: secret.secretSet }; }
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (f === 'concurrency') {
|
|
110
|
+
if (input.value.trim()) app.concurrency = Number(input.value); else delete app.concurrency;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (input.value.trim() || f === 'name' || f === 'app_id') { app[f] = input.value; }
|
|
114
|
+
else { delete app[f]; }
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
// ---- 触发词 / 显式插件:数组行编辑,实时写回 ----
|
|
118
|
+
const LIST_DEFS = {
|
|
119
|
+
triggers: { key: 'triggers', cols: [['match', '/produce 或 关键词'], ['rewrite', '请执行内容生产流程:{args}']] },
|
|
120
|
+
plugins: { key: 'plugins', cols: [['name', 'my-plugin'], ['path', 'F:/dev/my-plugin']] },
|
|
121
|
+
};
|
|
122
|
+
const renderPairList = (name) => {
|
|
123
|
+
const def = LIST_DEFS[name];
|
|
124
|
+
const list = app[def.key] || [];
|
|
125
|
+
const tb = body.querySelector(`[data-list="${name}"]`);
|
|
126
|
+
tb.innerHTML = list.map((item, i) => `
|
|
127
|
+
<tr>
|
|
128
|
+
${def.cols.map(([col, ph]) => `<td><input type="text" data-l="${i}" data-c="${col}" value="${esc(item?.[col] ?? '')}" placeholder="${esc(ph)}"></td>`).join('')}
|
|
129
|
+
<td><button class="btn sm danger" data-rm="${i}">删</button></td>
|
|
130
|
+
</tr>`).join('');
|
|
131
|
+
tb.querySelectorAll('input[data-l]').forEach((input) => input.oninput = () => {
|
|
132
|
+
app[def.key][Number(input.dataset.l)][input.dataset.c] = input.value;
|
|
133
|
+
});
|
|
134
|
+
tb.querySelectorAll('button[data-rm]').forEach((b) => b.onclick = () => {
|
|
135
|
+
app[def.key].splice(Number(b.dataset.rm), 1);
|
|
136
|
+
if (!app[def.key].length) delete app[def.key];
|
|
137
|
+
renderPairList(name);
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
for (const name of Object.keys(LIST_DEFS)) renderPairList(name);
|
|
141
|
+
// ---- 环境变量:对象 ↔ 行数组同步(key 改名 / 删除即时生效到 app.env) ----
|
|
142
|
+
const envRows = Object.entries(app.env || {}).map(([key, value]) => ({ key, value: String(value) }));
|
|
143
|
+
const syncEnv = () => {
|
|
144
|
+
const obj = {};
|
|
145
|
+
for (const r of envRows) if (r.key.trim()) obj[r.key.trim()] = r.value;
|
|
146
|
+
if (Object.keys(obj).length) app.env = obj; else delete app.env;
|
|
147
|
+
};
|
|
148
|
+
const renderEnv = () => {
|
|
149
|
+
const tb = body.querySelector('[data-list="env"]');
|
|
150
|
+
tb.innerHTML = envRows.map((r, i) => `
|
|
151
|
+
<tr>
|
|
152
|
+
<td><input type="text" data-ei="${i}" data-c="key" value="${esc(r.key)}" placeholder="SOME_KEY"></td>
|
|
153
|
+
<td><input type="text" data-ei="${i}" data-c="value" value="${esc(r.value)}" placeholder="some-value"></td>
|
|
154
|
+
<td><button class="btn sm danger" data-rm="${i}">删</button></td>
|
|
155
|
+
</tr>`).join('');
|
|
156
|
+
tb.querySelectorAll('input[data-ei]').forEach((input) => input.oninput = () => {
|
|
157
|
+
envRows[Number(input.dataset.ei)][input.dataset.c] = input.value;
|
|
158
|
+
syncEnv();
|
|
159
|
+
});
|
|
160
|
+
tb.querySelectorAll('button[data-rm]').forEach((b) => b.onclick = () => {
|
|
161
|
+
envRows.splice(Number(b.dataset.rm), 1);
|
|
162
|
+
syncEnv();
|
|
163
|
+
renderEnv();
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
renderEnv();
|
|
167
|
+
// ---- 添加行 ----
|
|
168
|
+
body.querySelectorAll('[data-add]').forEach((b) => b.onclick = () => {
|
|
169
|
+
const name = b.dataset.add;
|
|
170
|
+
if (name === 'env') { envRows.push({ key: '', value: '' }); renderEnv(); return; }
|
|
171
|
+
const def = LIST_DEFS[name];
|
|
172
|
+
(app[def.key] = app[def.key] || []).push({ [def.cols[0][0]]: '', [def.cols[1][0]]: '' });
|
|
173
|
+
renderPairList(name);
|
|
174
|
+
body.querySelector(`[data-list="${name}"] tbody input`)?.focus();
|
|
175
|
+
});
|
|
176
|
+
// ---- 删除 / 保存 / 取消 ----
|
|
177
|
+
foot.querySelector('#dwDel').onclick = async () => {
|
|
178
|
+
if (apps.length <= 1) return toast('至少保留一个应用', true);
|
|
179
|
+
if (!window.confirm(`确认删除应用「${app.name || app.app_id || '未命名'}」?删除将立即保存生效。`)) return;
|
|
180
|
+
apps.splice(idx, 1);
|
|
181
|
+
if (await saveDoc()) closeDrawer();
|
|
182
|
+
else cancelDrawer(); // 落盘失败:回滚删除,保留编辑现场让用户重试
|
|
183
|
+
};
|
|
184
|
+
foot.querySelector('#dwCancel').onclick = cancelDrawer;
|
|
185
|
+
foot.querySelector('#dwSave').onclick = async () => {
|
|
186
|
+
// 清理半填的空行:triggers/plugins 行两列全空剔除;env 以 key 非空为准(syncEnv 已过滤)。数组空则整键删除
|
|
187
|
+
for (const name of Object.keys(LIST_DEFS)) {
|
|
188
|
+
const def = LIST_DEFS[name];
|
|
189
|
+
const list = app[def.key];
|
|
190
|
+
if (!Array.isArray(list)) continue;
|
|
191
|
+
const filtered = list.filter((item) => def.cols.some(([col]) => String(item?.[col] ?? '').trim()));
|
|
192
|
+
if (filtered.length) app[def.key] = filtered;
|
|
193
|
+
else delete app[def.key];
|
|
194
|
+
}
|
|
195
|
+
if (await saveDoc()) closeDrawer();
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const page = {
|
|
202
|
+
id: 'apps',
|
|
203
|
+
title: '飞书应用',
|
|
204
|
+
icon: '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>',
|
|
205
|
+
render: renderApps,
|
|
206
|
+
};
|