agent-loft 0.2.29 → 0.2.30
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/package.json +1 -1
- package/src/channels/web/server.ts +1 -1
- package/web/app.js +8 -5560
- package/web/css/base.css +291 -0
- package/web/css/composer.css +506 -0
- package/web/css/loader.css +196 -0
- package/web/css/messages.css +734 -0
- package/web/css/mobile.css +100 -0
- package/web/css/overlay.css +437 -0
- package/web/css/schedule.css +93 -0
- package/web/css/settings.css +335 -0
- package/web/css/sidebar.css +593 -0
- package/web/css/topbar.css +205 -0
- package/web/index.html +11 -1
- package/web/lib/api.js +26 -0
- package/web/lib/dom.js +101 -0
- package/web/lib/markdown.js +52 -0
- package/web/lib/overlay.js +144 -0
- package/web/lib/push.js +70 -0
- package/web/lib/time.js +60 -0
- package/web/lib/ws.js +232 -0
- package/web/settings/agents.js +210 -0
- package/web/settings/commands.js +411 -0
- package/web/settings/common.js +42 -0
- package/web/settings/feishu.js +181 -0
- package/web/settings/general.js +184 -0
- package/web/settings/index.js +63 -0
- package/web/settings/memory.js +366 -0
- package/web/settings/notify.js +80 -0
- package/web/settings/security.js +188 -0
- package/web/settings/users.js +189 -0
- package/web/state.js +44 -0
- package/web/views/auth.js +61 -0
- package/web/views/composer.js +324 -0
- package/web/views/conv-actions.js +404 -0
- package/web/views/interaction.js +140 -0
- package/web/views/layout.js +186 -0
- package/web/views/messages.js +510 -0
- package/web/views/quota.js +145 -0
- package/web/views/schedule.js +120 -0
- package/web/views/sidebar.js +455 -0
- package/web/views/slash.js +185 -0
- package/web/views/topbar.js +394 -0
- package/web/views/voice.js +189 -0
- package/web/style.css +0 -3500
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { fill, h, icon, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { agentLabel } from "../views/sidebar.js";
|
|
4
|
+
import { confirmDialog, modal } from "../lib/overlay.js";
|
|
5
|
+
import { commandUsage, currentAgentId, oneLine, slash } from "../views/slash.js";
|
|
6
|
+
import { field, saveConfig } from "./common.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 默认模型 + 默认思考程度的联动下拉框。
|
|
10
|
+
* 模型列表从 agent 实时拿(Claude 要起一次进程,可能要几秒),拿到前先显示当前值;
|
|
11
|
+
* 拿不到就退回文本框手填,不挡住保存。思考程度的选项跟着所选模型变。
|
|
12
|
+
* 已保存但不在列表里的值(比如手填过的完整模型名)会作为「当前值」保留,不会被悄悄清掉。
|
|
13
|
+
*/
|
|
14
|
+
export function modelPicker(agent, model, effort, defaultLabel) {
|
|
15
|
+
const modelSlot = h("div", { class: "picker" });
|
|
16
|
+
const effortSlot = h("div", { class: "picker" });
|
|
17
|
+
let getModel = () => model;
|
|
18
|
+
let getEffort = () => effort;
|
|
19
|
+
|
|
20
|
+
const option = (value, label, selected) => h("option", { value, selected: value === selected }, label);
|
|
21
|
+
const withCurrent = (values, current) => (current && !values.includes(current) ? [current] : []);
|
|
22
|
+
|
|
23
|
+
const asText = (reason) => {
|
|
24
|
+
const m = h("input", { value: getModel(), placeholder: `留空${defaultLabel}` });
|
|
25
|
+
const e = h("input", { value: getEffort(), placeholder: "low / medium / high …,留空默认" });
|
|
26
|
+
fill(modelSlot, m, reason ? h("small", {}, reason) : null);
|
|
27
|
+
effortSlot.replaceChildren(e);
|
|
28
|
+
getModel = () => m.value.trim();
|
|
29
|
+
getEffort = () => e.value.trim();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const loading = (text) => {
|
|
33
|
+
modelSlot.replaceChildren(h("select", { disabled: true }, option("", text, "")));
|
|
34
|
+
effortSlot.replaceChildren(h("select", { disabled: true }, option("", effort || "默认", "")));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const asSelect = (models) => {
|
|
38
|
+
// Claude 自带一项 "default",和「留空」是一回事,不重复列
|
|
39
|
+
const listed = models.filter((m) => m.id !== "default");
|
|
40
|
+
const fallback = models.find((m) => m.id === "default" || m.isDefault);
|
|
41
|
+
const modelSel = h(
|
|
42
|
+
"select",
|
|
43
|
+
{},
|
|
44
|
+
option("", `默认(${defaultLabel.replace(/^跟随\s*/, "")})`, model),
|
|
45
|
+
withCurrent(listed.map((m) => m.id), model).map((v) => option(v, `${v}(当前值,不在列表中)`, model)),
|
|
46
|
+
listed.map((m) => option(m.id, m.label && m.label !== m.id ? `${m.label} · ${m.id}` : m.id, model)),
|
|
47
|
+
);
|
|
48
|
+
const effortSel = h("select", {});
|
|
49
|
+
const renderEfforts = () => {
|
|
50
|
+
const picked = modelSel.value ? models.find((m) => m.id === modelSel.value) : fallback;
|
|
51
|
+
const efforts = picked?.efforts ?? [];
|
|
52
|
+
const keep = effortSel.value || effort;
|
|
53
|
+
// 子项里有数组:replaceChildren 不展开数组,会把它变成文本节点(select 里直接被忽略)
|
|
54
|
+
fill(
|
|
55
|
+
effortSel,
|
|
56
|
+
option("", "默认", keep),
|
|
57
|
+
withCurrent(efforts, keep).map((v) => option(v, `${v}(当前值,该模型可能不支持)`, keep)),
|
|
58
|
+
efforts.map((v) => option(v, v, keep)),
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
modelSel.addEventListener("change", renderEfforts);
|
|
62
|
+
renderEfforts();
|
|
63
|
+
modelSlot.replaceChildren(modelSel);
|
|
64
|
+
effortSlot.replaceChildren(effortSel);
|
|
65
|
+
getModel = () => modelSel.value;
|
|
66
|
+
getEffort = () => effortSel.value;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
loading(model ? `${model}(正在读取模型列表…)` : "正在读取模型列表…");
|
|
70
|
+
api(`/api/agents/${agent}/models`)
|
|
71
|
+
.then((data) => (data?.models?.length ? asSelect(data.models) : asText("没读到模型列表,请手填")))
|
|
72
|
+
.catch((err) => asText(err.status === 409 ? err.message : `读取模型列表失败,请手填:${err.message ?? err}`));
|
|
73
|
+
|
|
74
|
+
return { modelEl: modelSlot, effortEl: effortSlot, model: () => getModel(), effort: () => getEffort() };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** agent-loft 内置命令名(和服务端 BUILTIN_COMMANDS 一致),自定义命令不能用 */
|
|
78
|
+
const BUILTIN_NAMES = ["new", "status", "stop", "model", "effort", "sessions", "resume", "fork", "agent", "claude", "codex", "cd", "pwd", "autoedit", "web", "detach", "approve", "help"];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 添加 / 编辑一条自定义命令。onSave 返回 true 才关窗;失败(比如服务端校验没过)留着让用户改。
|
|
82
|
+
* 规则和服务端一致:名字只能小写字母、数字、- _(飞书指令同款规则),不能和其它命令重名。
|
|
83
|
+
*/
|
|
84
|
+
function customDialog({ agent, entry, taken }, onSave) {
|
|
85
|
+
const name = h("input", { value: entry?.name ?? "", placeholder: "比如 review", autocomplete: "off", spellcheck: "false" });
|
|
86
|
+
const desc = h("input", { value: entry?.description ?? "", placeholder: "在命令列表里显示的一句话说明(可选)", maxlength: 200 });
|
|
87
|
+
const prompt = h("textarea", { rows: 8, placeholder: "发送这条命令时,实际发给 agent 的内容" });
|
|
88
|
+
prompt.value = entry?.prompt ?? "";
|
|
89
|
+
const err = h("div", { class: "error-text" });
|
|
90
|
+
const ok = h("button", { class: "btn primary" }, entry ? "保存" : "添加");
|
|
91
|
+
const builtin = new Set(BUILTIN_NAMES);
|
|
92
|
+
const submit = async () => {
|
|
93
|
+
const n = name.value.trim().replace(/^[//]/, "").toLowerCase();
|
|
94
|
+
const p = prompt.value.trim();
|
|
95
|
+
let problem = "";
|
|
96
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(n)) problem = "命令名只能用小写字母、数字、- 和 _,以字母或数字开头,最长 32 个字符";
|
|
97
|
+
else if (builtin.has(n)) problem = `/${n} 是 agent-loft 内置命令,换个名字`;
|
|
98
|
+
else if (taken.includes(n)) problem = `已经有 /${n} 这条命令了,换个名字`;
|
|
99
|
+
else if (!p) problem = "提示词不能为空";
|
|
100
|
+
err.textContent = problem;
|
|
101
|
+
if (problem) return;
|
|
102
|
+
ok.disabled = true;
|
|
103
|
+
const saved = await onSave({ name: n, description: desc.value.trim(), prompt: p });
|
|
104
|
+
ok.disabled = false;
|
|
105
|
+
if (saved) close();
|
|
106
|
+
};
|
|
107
|
+
ok.onclick = () => void submit();
|
|
108
|
+
const close = modal({
|
|
109
|
+
title: entry ? `编辑 /${entry.name}` : `添加命令 · ${agentLabel(agent)}`,
|
|
110
|
+
body: h(
|
|
111
|
+
"div",
|
|
112
|
+
{ class: "modal-body" },
|
|
113
|
+
field("命令名", h("div", { class: "cmd-name-input" }, h("span", {}, "/"), name), "小写字母、数字、- 和 _,最长 32 个字符(和飞书指令规则一致,才能同步到飞书)"),
|
|
114
|
+
field("说明", desc),
|
|
115
|
+
field(
|
|
116
|
+
"提示词",
|
|
117
|
+
prompt,
|
|
118
|
+
"发送「/命令名 参数」时,把这段话发给 agent。提示词里写 $ARGUMENTS 会换成参数;不写的话,参数接在提示词后面。",
|
|
119
|
+
),
|
|
120
|
+
err,
|
|
121
|
+
),
|
|
122
|
+
foot: h("div", { class: "modal-foot" }, ok),
|
|
123
|
+
});
|
|
124
|
+
setTimeout(() => (entry ? prompt : name).focus(), 0);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 设置 → 命令:每个 agent 一张表,拖动或用箭头排序,取消勾选就不在「/」列表里出现。
|
|
129
|
+
* 默认 agent-loft 内置命令按常用程度排在最前,agent 自己的命令排在后面且默认不勾(服务端 buildCatalog 决定)。
|
|
130
|
+
* 保存时把整张表的顺序存下来;以后新装的 skill 不在表里,会自动接在最后、默认不勾。
|
|
131
|
+
* 用户可以自己添加命令(一段预设的提示词),自己加的可以编辑、删除,增删改后立即保存。
|
|
132
|
+
*/
|
|
133
|
+
export function commandsTab(cfg, panel) {
|
|
134
|
+
const agentIds = Object.keys(cfg.agents);
|
|
135
|
+
let agent = currentAgentId();
|
|
136
|
+
if (!agentIds.includes(agent)) agent = agentIds[0];
|
|
137
|
+
let list = [];
|
|
138
|
+
let filter = "";
|
|
139
|
+
let dragFrom = -1;
|
|
140
|
+
|
|
141
|
+
const tabs = h("div", { class: "row" });
|
|
142
|
+
const search = h("input", {
|
|
143
|
+
class: "input",
|
|
144
|
+
placeholder: "筛选命令(只影响显示,排序仍按整张表)",
|
|
145
|
+
oninput: () => {
|
|
146
|
+
filter = search.value.trim().toLowerCase();
|
|
147
|
+
renderList();
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
const listBox = h("div", { class: "cmd-list" });
|
|
151
|
+
|
|
152
|
+
const move = (from, to) => {
|
|
153
|
+
if (to < 0 || to >= list.length || from === to) return;
|
|
154
|
+
const [item] = list.splice(from, 1);
|
|
155
|
+
list.splice(to, 0, item);
|
|
156
|
+
renderList();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const renderTabs = () =>
|
|
160
|
+
fill(
|
|
161
|
+
tabs,
|
|
162
|
+
...agentIds.map((id) =>
|
|
163
|
+
h(
|
|
164
|
+
"button",
|
|
165
|
+
{ class: `btn small${id === agent ? " primary" : ""}`, onclick: () => id !== agent && load(id) },
|
|
166
|
+
agentLabel(id),
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const renderList = () => {
|
|
172
|
+
const rows = list
|
|
173
|
+
.map((c, i) => ({ c, i }))
|
|
174
|
+
.filter(({ c }) => !filter || c.name.toLowerCase().includes(filter) || c.description.toLowerCase().includes(filter));
|
|
175
|
+
fill(
|
|
176
|
+
listBox,
|
|
177
|
+
...rows.map(({ c, i }) => {
|
|
178
|
+
const shown = h("input", {
|
|
179
|
+
type: "checkbox",
|
|
180
|
+
checked: !c.hidden,
|
|
181
|
+
title: "在「/」列表里显示",
|
|
182
|
+
onchange: () => {
|
|
183
|
+
c.hidden = !shown.checked;
|
|
184
|
+
row.classList.toggle("off", c.hidden);
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
const row = h(
|
|
188
|
+
"div",
|
|
189
|
+
{
|
|
190
|
+
class: `cmd-row${c.hidden ? " off" : ""}`,
|
|
191
|
+
draggable: filter ? false : "true",
|
|
192
|
+
ondragstart: (e) => {
|
|
193
|
+
dragFrom = i;
|
|
194
|
+
e.dataTransfer.effectAllowed = "move";
|
|
195
|
+
row.classList.add("dragging");
|
|
196
|
+
},
|
|
197
|
+
ondragend: () => row.classList.remove("dragging"),
|
|
198
|
+
ondragover: (e) => {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
e.dataTransfer.dropEffect = "move";
|
|
201
|
+
},
|
|
202
|
+
ondrop: (e) => {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
if (dragFrom >= 0) move(dragFrom, i);
|
|
205
|
+
dragFrom = -1;
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
h("span", { class: "cmd-grip", title: filter ? "筛选时不能拖动" : "拖动排序" }, "⋮⋮"),
|
|
209
|
+
shown,
|
|
210
|
+
h(
|
|
211
|
+
"div",
|
|
212
|
+
{ class: "grow" },
|
|
213
|
+
h(
|
|
214
|
+
"div",
|
|
215
|
+
{ class: "t" },
|
|
216
|
+
h("code", {}, commandUsage(c)),
|
|
217
|
+
c.source === "agent" ? h("span", { class: "badge" }, agentLabel(agent)) : null,
|
|
218
|
+
c.source === "custom" ? h("span", { class: "badge custom" }, "自定义") : null,
|
|
219
|
+
),
|
|
220
|
+
h("div", { class: "s" }, oneLine(c.description || c.prompt || "", 100) || "(无说明)"),
|
|
221
|
+
),
|
|
222
|
+
c.source === "custom"
|
|
223
|
+
? [
|
|
224
|
+
h("button", { class: "icon-btn", title: "编辑", onclick: () => editCustom(c) }, icon("edit")),
|
|
225
|
+
h("button", { class: "icon-btn danger", title: "删除", onclick: () => deleteCustom(c) }, icon("trash")),
|
|
226
|
+
]
|
|
227
|
+
: null,
|
|
228
|
+
h("button", { class: "icon-btn", title: "置顶", onclick: () => move(i, 0) }, "⤒"),
|
|
229
|
+
h("button", { class: "icon-btn", title: "上移", onclick: () => move(i, i - 1) }, "↑"),
|
|
230
|
+
h("button", { class: "icon-btn", title: "下移", onclick: () => move(i, i + 1) }, "↓"),
|
|
231
|
+
);
|
|
232
|
+
return row;
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const save = async (prefs) => {
|
|
238
|
+
const ok = await saveConfig(cfg, { commands: { [agent]: prefs } });
|
|
239
|
+
slash.cache.clear();
|
|
240
|
+
// 服务端 3 秒防抖后才开始同步,稍等再看状态
|
|
241
|
+
if (ok) setTimeout(() => void refreshFeishu(), 3500);
|
|
242
|
+
return ok;
|
|
243
|
+
};
|
|
244
|
+
const prefsOf = (rows) => ({
|
|
245
|
+
order: rows.map((c) => c.name),
|
|
246
|
+
hidden: rows.filter((c) => c.hidden).map((c) => c.name),
|
|
247
|
+
custom: rows.filter((c) => c.source === "custom").map(({ name, description, prompt }) => ({ name, description, prompt })),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ---- 自定义命令:增删改都立即保存(连同当前的排序和显示),保存失败就不动列表 ----
|
|
251
|
+
const commit = async (rows) => {
|
|
252
|
+
if (!(await save(prefsOf(rows)))) return false;
|
|
253
|
+
list = rows;
|
|
254
|
+
renderList();
|
|
255
|
+
return true;
|
|
256
|
+
};
|
|
257
|
+
const addCustom = () =>
|
|
258
|
+
customDialog({ agent, taken: list.map((c) => c.name) }, async (entry) => {
|
|
259
|
+
// 新命令放在已有的自定义命令后面;还没有的话放在 agent-loft 内置命令后面
|
|
260
|
+
const rows = [...list];
|
|
261
|
+
const lastCustom = rows.findLastIndex((c) => c.source === "custom");
|
|
262
|
+
const at = lastCustom >= 0 ? lastCustom + 1 : rows.findLastIndex((c) => c.source === "agent-loft") + 1;
|
|
263
|
+
rows.splice(at, 0, { ...entry, source: "custom", runnable: true, hidden: false });
|
|
264
|
+
return commit(rows);
|
|
265
|
+
});
|
|
266
|
+
const editCustom = (c) =>
|
|
267
|
+
customDialog({ agent, entry: c, taken: list.filter((x) => x !== c).map((x) => x.name) }, (entry) =>
|
|
268
|
+
commit(list.map((x) => (x === c ? { ...x, ...entry } : x))),
|
|
269
|
+
);
|
|
270
|
+
const deleteCustom = async (c) => {
|
|
271
|
+
const sure = await confirmDialog({
|
|
272
|
+
title: `删除 /${c.name}?`,
|
|
273
|
+
message: "删除后在「/」列表和飞书指令面板里都不再出现。",
|
|
274
|
+
confirmText: "删除",
|
|
275
|
+
danger: true,
|
|
276
|
+
});
|
|
277
|
+
if (sure) await commit(list.filter((x) => x !== c));
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// ---- 飞书指令面板同步状态 ----
|
|
281
|
+
const feishuBox = h("div", { class: "cmd-feishu" });
|
|
282
|
+
let feishuTimer = null;
|
|
283
|
+
const describeSlash = (st) => {
|
|
284
|
+
if (st.state === "syncing") return "同步中…(重建时每条间隔 1 秒,命令多要一两分钟)";
|
|
285
|
+
if (st.state === "error") return `同步失败:${st.error}`;
|
|
286
|
+
if (st.state === "done") {
|
|
287
|
+
const when = new Date(st.at).toLocaleTimeString();
|
|
288
|
+
const skipped = st.skipped?.length
|
|
289
|
+
? `;${st.skipped.length} 个名字不符合飞书规则(只能小写字母、数字、- _,最长 32)没注册:${st.skipped.slice(0, 8).join("、")}${st.skipped.length > 8 ? " 等" : ""}`
|
|
290
|
+
: "";
|
|
291
|
+
return `${when} ${st.changed ? "已同步" : "已是最新"},共 ${st.registered} 条${skipped}。飞书端约 5~8 分钟后生效。`;
|
|
292
|
+
}
|
|
293
|
+
return "等待首次同步";
|
|
294
|
+
};
|
|
295
|
+
async function refreshFeishu() {
|
|
296
|
+
let data;
|
|
297
|
+
try {
|
|
298
|
+
data = await api("/api/feishu/slash");
|
|
299
|
+
} catch (err) {
|
|
300
|
+
fill(feishuBox, h("div", { class: "error-text" }, err.message));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
fill(
|
|
304
|
+
feishuBox,
|
|
305
|
+
data.bots.length === 0
|
|
306
|
+
? h("div", { class: "muted" }, "没有运行中的飞书机器人。")
|
|
307
|
+
: data.bots.map((b) =>
|
|
308
|
+
h(
|
|
309
|
+
"div",
|
|
310
|
+
{ class: `muted${b.status.state === "error" ? " error-text" : ""}` },
|
|
311
|
+
h("b", {}, b.name),
|
|
312
|
+
":",
|
|
313
|
+
describeSlash(b.status),
|
|
314
|
+
),
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
clearTimeout(feishuTimer);
|
|
318
|
+
feishuTimer = data.bots.some((b) => b.status.state === "syncing") ? setTimeout(() => feishuBox.isConnected && void refreshFeishu(), 2000) : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function load(id) {
|
|
322
|
+
agent = id;
|
|
323
|
+
renderTabs();
|
|
324
|
+
fill(listBox, h("span", { class: "spinner" }));
|
|
325
|
+
try {
|
|
326
|
+
const data = await api(`/api/agents/${id}/commands`);
|
|
327
|
+
list = data.commands;
|
|
328
|
+
renderList();
|
|
329
|
+
} catch (err) {
|
|
330
|
+
fill(listBox, h("div", { class: "error-text" }, err.message));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
panel.append(
|
|
335
|
+
h(
|
|
336
|
+
"div",
|
|
337
|
+
{ class: "section" },
|
|
338
|
+
h("h4", {}, "命令列表"),
|
|
339
|
+
h(
|
|
340
|
+
"div",
|
|
341
|
+
{ class: "muted" },
|
|
342
|
+
"在输入框里打「/」时弹出的命令,按这里的顺序排列;保存后会自动同步到飞书机器人的指令面板。取消勾选的不会出现在列表里,但照样可以手动输入。带 agent 名标记的是 agent 自己的命令(skills 等),默认不勾选,需要的话勾上并保存;它们会原样转给 agent。",
|
|
343
|
+
),
|
|
344
|
+
tabs,
|
|
345
|
+
h(
|
|
346
|
+
"div",
|
|
347
|
+
{ class: "row cmd-tools" },
|
|
348
|
+
search,
|
|
349
|
+
h("button", { class: "btn", onclick: () => addCustom() }, icon("plus"), "添加命令"),
|
|
350
|
+
),
|
|
351
|
+
listBox,
|
|
352
|
+
h(
|
|
353
|
+
"div",
|
|
354
|
+
{ class: "row" },
|
|
355
|
+
h(
|
|
356
|
+
"button",
|
|
357
|
+
{
|
|
358
|
+
class: "btn primary",
|
|
359
|
+
onclick: () => save(prefsOf(list)),
|
|
360
|
+
},
|
|
361
|
+
"保存",
|
|
362
|
+
),
|
|
363
|
+
h(
|
|
364
|
+
"button",
|
|
365
|
+
{
|
|
366
|
+
class: "btn",
|
|
367
|
+
onclick: async () => {
|
|
368
|
+
if (!(await confirmDialog({ title: "恢复默认?", message: `把 ${agentLabel(agent)} 的命令顺序和显示恢复成默认(自定义命令保留)。`, confirmText: "恢复默认" }))) return;
|
|
369
|
+
await save({ order: [], hidden: [] });
|
|
370
|
+
await load(agent);
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
"恢复默认",
|
|
374
|
+
),
|
|
375
|
+
),
|
|
376
|
+
),
|
|
377
|
+
h(
|
|
378
|
+
"div",
|
|
379
|
+
{ class: "section" },
|
|
380
|
+
h(
|
|
381
|
+
"h4",
|
|
382
|
+
{},
|
|
383
|
+
"飞书指令面板",
|
|
384
|
+
h("span", { class: "grow" }),
|
|
385
|
+
h(
|
|
386
|
+
"button",
|
|
387
|
+
{
|
|
388
|
+
class: "btn small",
|
|
389
|
+
onclick: async () => {
|
|
390
|
+
try {
|
|
391
|
+
await api("/api/feishu/slash/sync", { method: "POST" });
|
|
392
|
+
setTimeout(() => void refreshFeishu(), 500);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
toast(err.message);
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
"立即同步",
|
|
399
|
+
),
|
|
400
|
+
),
|
|
401
|
+
h(
|
|
402
|
+
"div",
|
|
403
|
+
{ class: "muted" },
|
|
404
|
+
"飞书输入框打「/」看到的指令。面板是每个机器人一份:默认 agent 的命令在前,其它 agent 的命令接在后面。启动时、保存命令设置后、以及每 6 小时会自动同步。",
|
|
405
|
+
),
|
|
406
|
+
feishuBox,
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
void load(agent);
|
|
410
|
+
void refreshFeishu();
|
|
411
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { h, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { S } from "../state.js";
|
|
4
|
+
import { renderSidebar } from "../views/sidebar.js";
|
|
5
|
+
import { loadQuota, scheduleIdleQuota } from "../views/quota.js";
|
|
6
|
+
import { catalogCache, catalogDefaults, catalogLoaded, renderContext, renderTopbar } from "../views/topbar.js";
|
|
7
|
+
|
|
8
|
+
export async function saveConfig(cfg, patch) {
|
|
9
|
+
try {
|
|
10
|
+
const next = await api("/api/config", { method: "PUT", body: patch });
|
|
11
|
+
Object.assign(cfg, next);
|
|
12
|
+
toast("已保存");
|
|
13
|
+
// 默认模型 / 可选模型可能改了:模型列表和默认值重新拿
|
|
14
|
+
catalogCache.clear();
|
|
15
|
+
catalogLoaded.clear();
|
|
16
|
+
catalogDefaults.clear();
|
|
17
|
+
const boot2 = await api("/api/bootstrap");
|
|
18
|
+
S.agents = boot2.agents;
|
|
19
|
+
S.bots = boot2.bots;
|
|
20
|
+
S.workspaceRoot = boot2.workspaceRoot;
|
|
21
|
+
const quotaOn = boot2.quotaEnabled !== false;
|
|
22
|
+
if (boot2.quotaRefreshMin !== S.quotaRefreshMin || quotaOn !== S.quotaEnabled) {
|
|
23
|
+
const turnedOn = quotaOn && !S.quotaEnabled;
|
|
24
|
+
S.quotaRefreshMin = boot2.quotaRefreshMin ?? 10;
|
|
25
|
+
S.quotaEnabled = quotaOn;
|
|
26
|
+
// 刚打开就马上查一次;关掉就清掉侧栏的额度
|
|
27
|
+
if (turnedOn || !quotaOn) void loadQuota();
|
|
28
|
+
else scheduleIdleQuota();
|
|
29
|
+
}
|
|
30
|
+
renderTopbar();
|
|
31
|
+
renderSidebar();
|
|
32
|
+
renderContext();
|
|
33
|
+
return true;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
toast(err.message);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function field(label, input, hint) {
|
|
41
|
+
return h("label", { class: "field" }, h("span", {}, label), input, hint ? h("small", {}, hint) : null);
|
|
42
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { fill, h, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { S } from "../state.js";
|
|
4
|
+
import { confirmDialog, modal } from "../lib/overlay.js";
|
|
5
|
+
import { field, saveConfig } from "./common.js";
|
|
6
|
+
|
|
7
|
+
export function feishuTab(cfg, panel) {
|
|
8
|
+
const bots = structuredClone(cfg.feishu.bots);
|
|
9
|
+
const statusBox = new Map();
|
|
10
|
+
const render = () => {
|
|
11
|
+
fill(panel,
|
|
12
|
+
h(
|
|
13
|
+
"div",
|
|
14
|
+
{ class: "row" },
|
|
15
|
+
h("button", { class: "btn accent", onclick: () => registerBot() }, "扫码创建新机器人"),
|
|
16
|
+
h(
|
|
17
|
+
"button",
|
|
18
|
+
{
|
|
19
|
+
class: "btn",
|
|
20
|
+
onclick: () => {
|
|
21
|
+
bots.push({ id: "", name: "新机器人", enabled: true, appId: "", appSecret: "", domain: "feishu", allowFrom: [], agents: ["claude"], defaultAgent: "claude", _new: true });
|
|
22
|
+
render();
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
"手动添加",
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
...bots.map((b, i) => botSection(b, i)),
|
|
29
|
+
bots.length === 0 ? h("div", { class: "muted" }, "还没有飞书机器人。") : null,
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
const botSection = (b, i) => {
|
|
33
|
+
const name = h("input", { value: b.name });
|
|
34
|
+
const appId = h("input", { value: b.appId, placeholder: "cli_xxx" });
|
|
35
|
+
const secret = h("input", { value: b.appSecret, placeholder: "App Secret", type: "password", autocomplete: "off" });
|
|
36
|
+
const domain = h("select", {}, h("option", { value: "feishu", selected: b.domain !== "lark" }, "飞书"), h("option", { value: "lark", selected: b.domain === "lark" }, "Lark"));
|
|
37
|
+
const allow = h("textarea", { rows: 2, placeholder: "ou_xxx,一行一个" });
|
|
38
|
+
allow.value = b.allowFrom.join("\n");
|
|
39
|
+
const enabled = h("input", { type: "checkbox", checked: b.enabled });
|
|
40
|
+
const agentChecks = S.agents.map((a) => {
|
|
41
|
+
const box = h("input", { type: "checkbox", checked: b.agents.includes(a.id), value: a.id });
|
|
42
|
+
return { id: a.id, box, el: h("label", { class: "check" }, box, a.label) };
|
|
43
|
+
});
|
|
44
|
+
const def = h("select", {}, S.agents.map((a) => h("option", { value: a.id, selected: a.id === b.defaultAgent }, a.label)));
|
|
45
|
+
const status = h("span", { class: "status-tag" }, "…");
|
|
46
|
+
statusBox.set(b.id, status);
|
|
47
|
+
const collect = () => ({
|
|
48
|
+
...b,
|
|
49
|
+
name: name.value.trim(),
|
|
50
|
+
appId: appId.value.trim(),
|
|
51
|
+
appSecret: secret.value.trim(),
|
|
52
|
+
domain: domain.value,
|
|
53
|
+
allowFrom: allow.value.split(/[\s,]+/).filter(Boolean),
|
|
54
|
+
enabled: enabled.checked,
|
|
55
|
+
agents: agentChecks.filter((c) => c.box.checked).map((c) => c.id),
|
|
56
|
+
defaultAgent: def.value,
|
|
57
|
+
});
|
|
58
|
+
return h(
|
|
59
|
+
"div",
|
|
60
|
+
{ class: "section" },
|
|
61
|
+
h("h4", {}, b.name || "机器人", b._new ? null : status, h("span", { class: "grow" }), h("label", { class: "check" }, enabled, "启用")),
|
|
62
|
+
field("名称", name),
|
|
63
|
+
h("div", { class: "row" }, h("div", { style: { flex: 1 } }, field("App ID", appId)), h("div", { style: { flex: 1 } }, field("App Secret", secret))),
|
|
64
|
+
field("域名", domain),
|
|
65
|
+
field("可用的 Agent", h("div", { class: "row" }, agentChecks.map((c) => c.el)), "勾一个 = 一个机器人对应一个 agent;勾多个可在会话里用 /agent 切换"),
|
|
66
|
+
field("默认 Agent", def),
|
|
67
|
+
field("Owner 白名单(open_id)", allow, "陌生人发消息会拿到配对码,在「授权」里批准即可,不必手填"),
|
|
68
|
+
h(
|
|
69
|
+
"div",
|
|
70
|
+
{ class: "row" },
|
|
71
|
+
h(
|
|
72
|
+
"button",
|
|
73
|
+
{
|
|
74
|
+
class: "btn primary",
|
|
75
|
+
onclick: async () => {
|
|
76
|
+
bots[i] = collect();
|
|
77
|
+
delete bots[i]._new;
|
|
78
|
+
await saveConfig(cfg, { feishu: { bots } });
|
|
79
|
+
feishuTab(cfg, (fill(panel, ), panel));
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
"保存",
|
|
83
|
+
),
|
|
84
|
+
h(
|
|
85
|
+
"button",
|
|
86
|
+
{
|
|
87
|
+
class: "btn",
|
|
88
|
+
onclick: async () => {
|
|
89
|
+
const v = collect();
|
|
90
|
+
try {
|
|
91
|
+
const r = await api("/api/feishu/test", { method: "POST", body: { id: b.id, appId: v.appId, appSecret: v.appSecret, domain: v.domain } });
|
|
92
|
+
toast(r.ok ? "✅ 凭据有效" : `❌ ${r.message}`);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
toast(err.message);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
"测试凭据",
|
|
99
|
+
),
|
|
100
|
+
h(
|
|
101
|
+
"button",
|
|
102
|
+
{
|
|
103
|
+
class: "btn danger",
|
|
104
|
+
onclick: async () => {
|
|
105
|
+
if (!(await confirmDialog({ title: `删除机器人「${b.name}」?`, confirmText: "删除", danger: true }))) return;
|
|
106
|
+
bots.splice(i, 1);
|
|
107
|
+
if (!b._new) await saveConfig(cfg, { feishu: { bots } });
|
|
108
|
+
render();
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
"删除",
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
const registerBot = async () => {
|
|
117
|
+
const nameInput = h("input", { value: "Agent 助手" });
|
|
118
|
+
const checks = S.agents.filter((a) => a.enabled).map((a) => ({ id: a.id, box: h("input", { type: "checkbox", checked: true }), label: a.label }));
|
|
119
|
+
const body = h(
|
|
120
|
+
"div",
|
|
121
|
+
{ class: "modal-body" },
|
|
122
|
+
h("div", { class: "muted" }, "用飞书扫码,会自动创建一个飞书应用(机器人),并把扫码的你设为 owner。"),
|
|
123
|
+
field("机器人名称", nameInput),
|
|
124
|
+
field("可用的 Agent", h("div", { class: "row" }, checks.map((c) => h("label", { class: "check" }, c.box, c.label)))),
|
|
125
|
+
);
|
|
126
|
+
const go = h("button", { class: "btn primary" }, "生成二维码");
|
|
127
|
+
const closeReg = modal({ title: "扫码创建飞书机器人", body, foot: h("div", { class: "modal-foot" }, go) });
|
|
128
|
+
go.onclick = async () => {
|
|
129
|
+
go.disabled = true;
|
|
130
|
+
try {
|
|
131
|
+
const reg = await api("/api/feishu/register", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
body: { name: nameInput.value, agents: checks.filter((c) => c.box.checked).map((c) => c.id) },
|
|
134
|
+
});
|
|
135
|
+
const state = h("div", { class: "muted", style: { textAlign: "center" } }, "等待扫码确认…");
|
|
136
|
+
fill(body,
|
|
137
|
+
h("div", { class: "qr", html: reg.qrSvg }),
|
|
138
|
+
h("div", { class: "row", style: { justifyContent: "center" } }, h("a", { href: reg.qrUrl, target: "_blank", rel: "noopener" }, "手机上直接打开链接")),
|
|
139
|
+
state,
|
|
140
|
+
);
|
|
141
|
+
go.remove();
|
|
142
|
+
const poll = async () => {
|
|
143
|
+
if (!body.isConnected) return;
|
|
144
|
+
try {
|
|
145
|
+
const r = await api(`/api/feishu/register/${reg.id}`);
|
|
146
|
+
if (r.status === "pending") return setTimeout(poll, 3000);
|
|
147
|
+
if (r.status === "success") {
|
|
148
|
+
toast("✅ 机器人已创建并启动");
|
|
149
|
+
closeReg();
|
|
150
|
+
const fresh = await api("/api/config");
|
|
151
|
+
Object.assign(cfg, fresh);
|
|
152
|
+
await saveConfig(cfg, {});
|
|
153
|
+
feishuTab(cfg, (fill(panel, ), panel));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
state.textContent = { access_denied: "已拒绝授权", expired: "二维码已过期", timeout: "超时了" }[r.status] ?? `出错:${r.message ?? r.status}`;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
state.textContent = err.message;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
setTimeout(poll, 3000);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
toast(err.message);
|
|
164
|
+
go.disabled = false;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
render();
|
|
169
|
+
api("/api/feishu/status")
|
|
170
|
+
.then((list) => {
|
|
171
|
+
for (const s of list) {
|
|
172
|
+
const el = statusBox.get(s.id);
|
|
173
|
+
if (!el) continue;
|
|
174
|
+
const label = { connected: "已连接", connecting: "连接中", reconnecting: "重连中", failed: "连接失败", stopped: "已停止" }[s.state] ?? s.state;
|
|
175
|
+
el.textContent = label;
|
|
176
|
+
el.className = `status-tag ${s.state === "connected" ? "ok" : s.state === "failed" ? "bad" : ""}`;
|
|
177
|
+
if (s.error) el.title = s.error;
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
.catch(() => {});
|
|
181
|
+
}
|