im-agent 0.1.0 → 0.2.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 +1 -1
- package/package.json +1 -1
- package/src/agents/claude/account.ts +100 -0
- package/src/agents/claude/driver.ts +47 -2
- package/src/agents/claude/permissions.ts +35 -2
- package/src/agents/claude/probe.ts +54 -0
- package/src/agents/claude/session.ts +19 -9
- package/src/agents/codex/driver.ts +4 -0
- package/src/channels/feishu/bot.ts +60 -0
- package/src/channels/feishu/cards.ts +50 -0
- package/src/channels/feishu/envelope.ts +6 -1
- package/src/channels/feishu/index.ts +11 -1
- package/src/channels/feishu/slash-sync.ts +192 -0
- package/src/channels/web/api.ts +54 -0
- package/src/channels/web/server.ts +12 -0
- package/src/core/command-catalog.ts +112 -0
- package/src/core/commands.ts +21 -16
- package/src/core/config.ts +5 -0
- package/src/core/hub.ts +55 -0
- package/src/core/store.ts +2 -0
- package/src/core/types.ts +9 -0
- package/web/app.js +541 -15
- package/web/style.css +147 -0
package/web/app.js
CHANGED
|
@@ -220,7 +220,7 @@ const WS = {
|
|
|
220
220
|
if (this.sock?.readyState === 1) this.sock.send(JSON.stringify(msg));
|
|
221
221
|
},
|
|
222
222
|
|
|
223
|
-
request(msg) {
|
|
223
|
+
request(msg, timeoutMs = 15000) {
|
|
224
224
|
return new Promise((resolve, reject) => {
|
|
225
225
|
if (this.sock?.readyState !== 1) {
|
|
226
226
|
reject(new Error("还没连上服务器,稍等再试"));
|
|
@@ -231,7 +231,7 @@ const WS = {
|
|
|
231
231
|
this.sock.send(JSON.stringify({ ...msg, rid }));
|
|
232
232
|
setTimeout(() => {
|
|
233
233
|
if (this.waiters.delete(rid)) reject(new Error("服务器没有响应"));
|
|
234
|
-
},
|
|
234
|
+
}, timeoutMs);
|
|
235
235
|
});
|
|
236
236
|
},
|
|
237
237
|
|
|
@@ -1035,8 +1035,16 @@ function renderComposer() {
|
|
|
1035
1035
|
ui.input = h("textarea", {
|
|
1036
1036
|
rows: 1,
|
|
1037
1037
|
placeholder: "给 agent 发消息",
|
|
1038
|
-
oninput:
|
|
1038
|
+
oninput: () => {
|
|
1039
|
+
autosize();
|
|
1040
|
+
void updateSlashMenu();
|
|
1041
|
+
},
|
|
1042
|
+
onblur: () => setTimeout(closeSlashMenu, 150),
|
|
1039
1043
|
onkeydown: (e) => {
|
|
1044
|
+
if (slashKeydown(e)) {
|
|
1045
|
+
e.preventDefault();
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1040
1048
|
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && !isTouch) {
|
|
1041
1049
|
e.preventDefault();
|
|
1042
1050
|
void sendMessage();
|
|
@@ -1061,6 +1069,7 @@ function renderComposer() {
|
|
|
1061
1069
|
});
|
|
1062
1070
|
ui.sendBtn = h("button", { class: "send-btn", title: "发送", onclick: () => (S.busy && !hasDraft() ? stopTurn() : sendMessage()) });
|
|
1063
1071
|
ui.chips = h("div", { class: "chips" });
|
|
1072
|
+
ui.slashMenu = h("div", { class: "slash-menu", hidden: true });
|
|
1064
1073
|
ui.composer = h(
|
|
1065
1074
|
"div",
|
|
1066
1075
|
{
|
|
@@ -1076,6 +1085,7 @@ function renderComposer() {
|
|
|
1076
1085
|
void addFiles([...(e.dataTransfer?.files ?? [])]);
|
|
1077
1086
|
},
|
|
1078
1087
|
},
|
|
1088
|
+
ui.slashMenu,
|
|
1079
1089
|
ui.chips,
|
|
1080
1090
|
ui.input,
|
|
1081
1091
|
h(
|
|
@@ -1194,11 +1204,14 @@ async function sendMessage() {
|
|
|
1194
1204
|
toast("附件还在上传,稍等");
|
|
1195
1205
|
return;
|
|
1196
1206
|
}
|
|
1207
|
+
closeSlashMenu();
|
|
1197
1208
|
let key;
|
|
1209
|
+
let res;
|
|
1198
1210
|
try {
|
|
1199
1211
|
key = await ensureConversation();
|
|
1200
1212
|
if ("Notification" in window && Notification.permission === "default") void Notification.requestPermission();
|
|
1201
|
-
|
|
1213
|
+
// 命令(比如 /status 要现读额度)可能比普通发送慢,多给点时间
|
|
1214
|
+
res = await WS.request({ t: "send", key, text, uploads: S.uploads.map((u) => u.id) }, 45000);
|
|
1202
1215
|
} catch (err) {
|
|
1203
1216
|
toast(err.message);
|
|
1204
1217
|
return;
|
|
@@ -1207,10 +1220,221 @@ async function sendMessage() {
|
|
|
1207
1220
|
autosize();
|
|
1208
1221
|
S.uploads = [];
|
|
1209
1222
|
renderChips();
|
|
1223
|
+
updateSendBtn();
|
|
1210
1224
|
ui.stick = true;
|
|
1225
|
+
if (res?.reply) await showCommandReply(text.trim(), res.reply, res.info);
|
|
1211
1226
|
scrollToBottom(true);
|
|
1212
1227
|
}
|
|
1213
1228
|
|
|
1229
|
+
/** 会改变当前会话(换会话、换目录、换 agent)的命令,执行完要重拉一遍 */
|
|
1230
|
+
const RELOADING_COMMANDS = new Set(["new", "fork", "resume", "agent", "claude", "codex", "cd"]);
|
|
1231
|
+
|
|
1232
|
+
/** im-agent 命令的结果:只显示给发命令的这个页面,不进会话记录 */
|
|
1233
|
+
async function showCommandReply(text, reply, info) {
|
|
1234
|
+
if (info) S.convs.set(info.key, info);
|
|
1235
|
+
renderTopbar();
|
|
1236
|
+
const head = text.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
1237
|
+
if (RELOADING_COMMANDS.has(head) && S.current) await openConversation(S.current);
|
|
1238
|
+
const note = (md) => addItem({ type: "notice", text: md });
|
|
1239
|
+
switch (reply.type) {
|
|
1240
|
+
case "text":
|
|
1241
|
+
note(`\`${text}\`\n\n${reply.text}`);
|
|
1242
|
+
return;
|
|
1243
|
+
case "sessions":
|
|
1244
|
+
await openSessions(reply.allDirs);
|
|
1245
|
+
return;
|
|
1246
|
+
case "models":
|
|
1247
|
+
await openModelPicker();
|
|
1248
|
+
return;
|
|
1249
|
+
case "agents":
|
|
1250
|
+
note(
|
|
1251
|
+
`当前 agent:**${agentLabel(reply.current)}**\n\n可切换:${reply.agents.map((a) => `\`/${a.id}\` ${a.label}`).join(" ")}`,
|
|
1252
|
+
);
|
|
1253
|
+
return;
|
|
1254
|
+
case "commands":
|
|
1255
|
+
note(reply.commands.map((c) => `\`${commandUsage(c)}\` ${oneLine(c.description)}`).join("\n\n") || "没有可用命令");
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// ---------- 斜杠命令补全 ----------
|
|
1261
|
+
//
|
|
1262
|
+
// 输入框里只有「/xxx」(还没打空格)时弹出命令表,顺序就是设置 → 命令里排好的。
|
|
1263
|
+
// 列表按 agent + 会话目录缓存一分钟:Claude 的原生命令要起一次进程才问得到。
|
|
1264
|
+
|
|
1265
|
+
const slash = { open: false, items: [], index: 0, cache: new Map(), token: 0 };
|
|
1266
|
+
|
|
1267
|
+
function commandUsage(c) {
|
|
1268
|
+
return `/${c.name}${c.args ? ` ${c.args}` : ""}`;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function oneLine(text, max = 80) {
|
|
1272
|
+
const t = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
1273
|
+
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function currentAgentId() {
|
|
1277
|
+
const c = S.current ? S.convs.get(S.current) : null;
|
|
1278
|
+
return c?.agent ?? S.draftAgent ?? S.agents.find((a) => a.enabled)?.id ?? "claude";
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async function loadCommands(agent) {
|
|
1282
|
+
const cacheKey = `${agent}|${S.current ?? ""}`;
|
|
1283
|
+
const hit = slash.cache.get(cacheKey);
|
|
1284
|
+
if (hit && Date.now() - hit.at < 60_000) return hit.list;
|
|
1285
|
+
const q = S.current ? `?key=${encodeURIComponent(S.current)}` : "";
|
|
1286
|
+
const data = await api(`/api/agents/${agent}/commands${q}`);
|
|
1287
|
+
const list = data.commands.filter((c) => !c.hidden);
|
|
1288
|
+
slash.cache.set(cacheKey, { at: Date.now(), list });
|
|
1289
|
+
return list;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function slashQuery() {
|
|
1293
|
+
const v = ui.input.value;
|
|
1294
|
+
const m = /^[//]([^\s]*)$/.exec(v);
|
|
1295
|
+
return m ? m[1].toLowerCase() : null;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** 名字前缀匹配排最前,其次名字里包含,最后是说明里包含;同档内保持用户排的顺序 */
|
|
1299
|
+
function matchCommands(list, q) {
|
|
1300
|
+
if (!q) return list;
|
|
1301
|
+
const tiers = [[], [], []];
|
|
1302
|
+
for (const c of list) {
|
|
1303
|
+
const name = c.name.toLowerCase();
|
|
1304
|
+
if (name.startsWith(q)) tiers[0].push(c);
|
|
1305
|
+
else if (name.includes(q)) tiers[1].push(c);
|
|
1306
|
+
else if (c.description.toLowerCase().includes(q)) tiers[2].push(c);
|
|
1307
|
+
}
|
|
1308
|
+
return tiers.flat();
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async function updateSlashMenu() {
|
|
1312
|
+
const q = slashQuery();
|
|
1313
|
+
if (q === null) return closeSlashMenu();
|
|
1314
|
+
const token = ++slash.token;
|
|
1315
|
+
if (!slash.open) renderSlashMenu(null);
|
|
1316
|
+
let list;
|
|
1317
|
+
try {
|
|
1318
|
+
list = await loadCommands(currentAgentId());
|
|
1319
|
+
} catch (err) {
|
|
1320
|
+
if (token === slash.token) renderSlashMenu(null, err.message);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
// 等待期间又打了字:以最新的那次为准
|
|
1324
|
+
if (token !== slash.token || slashQuery() === null) return;
|
|
1325
|
+
const items = matchCommands(list, slashQuery());
|
|
1326
|
+
if (items.length === 0) return closeSlashMenu();
|
|
1327
|
+
slash.items = items;
|
|
1328
|
+
slash.index = Math.min(slash.index, items.length - 1);
|
|
1329
|
+
renderSlashMenu(items);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function renderSlashMenu(items, error) {
|
|
1333
|
+
slash.open = true;
|
|
1334
|
+
ui.slashMenu.hidden = false;
|
|
1335
|
+
if (!items) {
|
|
1336
|
+
fill(ui.slashMenu, h("div", { class: "slash-empty" }, error ? `读取命令失败:${error}` : h("span", { class: "spinner" })));
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
fill(
|
|
1340
|
+
ui.slashMenu,
|
|
1341
|
+
...items.map((c, i) =>
|
|
1342
|
+
h(
|
|
1343
|
+
"button",
|
|
1344
|
+
{
|
|
1345
|
+
class: `slash-item${i === slash.index ? " on" : ""}`,
|
|
1346
|
+
type: "button",
|
|
1347
|
+
// mousedown 而不是 click:不然输入框先失焦,手机上键盘会闪一下
|
|
1348
|
+
onmousedown: (e) => {
|
|
1349
|
+
e.preventDefault();
|
|
1350
|
+
void pickSlash(c, false);
|
|
1351
|
+
},
|
|
1352
|
+
onmousemove: () => {
|
|
1353
|
+
if (slash.index !== i) {
|
|
1354
|
+
slash.index = i;
|
|
1355
|
+
highlightSlash();
|
|
1356
|
+
}
|
|
1357
|
+
},
|
|
1358
|
+
},
|
|
1359
|
+
h(
|
|
1360
|
+
"div",
|
|
1361
|
+
{ class: "slash-head" },
|
|
1362
|
+
h("span", { class: "slash-name" }, `/${c.name}`),
|
|
1363
|
+
c.args ? h("span", { class: "slash-args" }, c.args) : null,
|
|
1364
|
+
c.source === "agent" ? h("span", { class: "badge" }, agentLabel(currentAgentId())) : null,
|
|
1365
|
+
),
|
|
1366
|
+
h("div", { class: "slash-desc" }, oneLine(c.description, 120) || "(无说明)"),
|
|
1367
|
+
),
|
|
1368
|
+
),
|
|
1369
|
+
);
|
|
1370
|
+
highlightSlash();
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function highlightSlash() {
|
|
1374
|
+
ui.slashMenu.querySelectorAll(".slash-item").forEach((el, i) => el.classList.toggle("on", i === slash.index));
|
|
1375
|
+
ui.slashMenu.querySelector(".slash-item.on")?.scrollIntoView({ block: "nearest" });
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function closeSlashMenu() {
|
|
1379
|
+
slash.open = false;
|
|
1380
|
+
slash.items = [];
|
|
1381
|
+
slash.index = 0;
|
|
1382
|
+
slash.token++;
|
|
1383
|
+
if (ui.slashMenu) ui.slashMenu.hidden = true;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* 选中一条命令。fillOnly(Tab)只补全;回车 / 点击时,不需要参数的直接发出去,
|
|
1388
|
+
* 需要参数的补全成「/name 」等用户接着打。
|
|
1389
|
+
*/
|
|
1390
|
+
async function pickSlash(c, fillOnly) {
|
|
1391
|
+
const needsArgs = !c.runnable;
|
|
1392
|
+
if (fillOnly || needsArgs) {
|
|
1393
|
+
ui.input.value = `/${c.name}${c.args ? " " : ""}`;
|
|
1394
|
+
closeSlashMenu();
|
|
1395
|
+
autosize();
|
|
1396
|
+
updateSendBtn();
|
|
1397
|
+
ui.input.focus();
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
ui.input.value = `/${c.name}`;
|
|
1401
|
+
closeSlashMenu();
|
|
1402
|
+
await sendMessage();
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** 菜单开着时接管方向键 / Tab / 回车 / Esc;返回 true 表示已处理 */
|
|
1406
|
+
function slashKeydown(e) {
|
|
1407
|
+
if (!slash.open || slash.items.length === 0) {
|
|
1408
|
+
if (slash.open && e.key === "Escape") {
|
|
1409
|
+
closeSlashMenu();
|
|
1410
|
+
return true;
|
|
1411
|
+
}
|
|
1412
|
+
return false;
|
|
1413
|
+
}
|
|
1414
|
+
if (e.isComposing) return false;
|
|
1415
|
+
switch (e.key) {
|
|
1416
|
+
case "ArrowDown":
|
|
1417
|
+
slash.index = (slash.index + 1) % slash.items.length;
|
|
1418
|
+
highlightSlash();
|
|
1419
|
+
return true;
|
|
1420
|
+
case "ArrowUp":
|
|
1421
|
+
slash.index = (slash.index - 1 + slash.items.length) % slash.items.length;
|
|
1422
|
+
highlightSlash();
|
|
1423
|
+
return true;
|
|
1424
|
+
case "Tab":
|
|
1425
|
+
void pickSlash(slash.items[slash.index], true);
|
|
1426
|
+
return true;
|
|
1427
|
+
case "Enter":
|
|
1428
|
+
if (e.shiftKey) return false;
|
|
1429
|
+
void pickSlash(slash.items[slash.index], false);
|
|
1430
|
+
return true;
|
|
1431
|
+
case "Escape":
|
|
1432
|
+
closeSlashMenu();
|
|
1433
|
+
return true;
|
|
1434
|
+
}
|
|
1435
|
+
return false;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1214
1438
|
async function stopTurn() {
|
|
1215
1439
|
await doAction("stop");
|
|
1216
1440
|
}
|
|
@@ -1424,6 +1648,7 @@ async function openSettings() {
|
|
|
1424
1648
|
const tabs = [
|
|
1425
1649
|
["通用", () => generalTab(cfg, panel)],
|
|
1426
1650
|
["Agent", () => agentsTab(cfg, panel)],
|
|
1651
|
+
["命令", () => commandsTab(cfg, panel)],
|
|
1427
1652
|
["飞书机器人", () => feishuTab(cfg, panel)],
|
|
1428
1653
|
["授权", () => authTab(panel)],
|
|
1429
1654
|
["安全", () => securityTab(panel)],
|
|
@@ -1491,11 +1716,313 @@ function generalTab(cfg, panel) {
|
|
|
1491
1716
|
);
|
|
1492
1717
|
}
|
|
1493
1718
|
|
|
1719
|
+
/**
|
|
1720
|
+
* 默认模型 + 默认思考程度的联动下拉框。
|
|
1721
|
+
* 模型列表从 agent 实时拿(Claude 要起一次进程,可能要几秒),拿到前先显示当前值;
|
|
1722
|
+
* 拿不到就退回文本框手填,不挡住保存。思考程度的选项跟着所选模型变。
|
|
1723
|
+
* 已保存但不在列表里的值(比如手填过的完整模型名)会作为「当前值」保留,不会被悄悄清掉。
|
|
1724
|
+
*/
|
|
1725
|
+
function modelPicker(agent, model, effort, defaultLabel) {
|
|
1726
|
+
const modelSlot = h("div", { class: "picker" });
|
|
1727
|
+
const effortSlot = h("div", { class: "picker" });
|
|
1728
|
+
let getModel = () => model;
|
|
1729
|
+
let getEffort = () => effort;
|
|
1730
|
+
|
|
1731
|
+
const option = (value, label, selected) => h("option", { value, selected: value === selected }, label);
|
|
1732
|
+
const withCurrent = (values, current) => (current && !values.includes(current) ? [current] : []);
|
|
1733
|
+
|
|
1734
|
+
const asText = (reason) => {
|
|
1735
|
+
const m = h("input", { value: getModel(), placeholder: `留空${defaultLabel}` });
|
|
1736
|
+
const e = h("input", { value: getEffort(), placeholder: "low / medium / high …,留空默认" });
|
|
1737
|
+
modelSlot.replaceChildren(m, reason ? h("small", {}, reason) : null);
|
|
1738
|
+
effortSlot.replaceChildren(e);
|
|
1739
|
+
getModel = () => m.value.trim();
|
|
1740
|
+
getEffort = () => e.value.trim();
|
|
1741
|
+
};
|
|
1742
|
+
|
|
1743
|
+
const loading = (text) => {
|
|
1744
|
+
modelSlot.replaceChildren(h("select", { disabled: true }, option("", text, "")));
|
|
1745
|
+
effortSlot.replaceChildren(h("select", { disabled: true }, option("", effort || "默认", "")));
|
|
1746
|
+
};
|
|
1747
|
+
|
|
1748
|
+
const asSelect = (models) => {
|
|
1749
|
+
// Claude 自带一项 "default",和「留空」是一回事,不重复列
|
|
1750
|
+
const listed = models.filter((m) => m.id !== "default");
|
|
1751
|
+
const fallback = models.find((m) => m.id === "default" || m.isDefault);
|
|
1752
|
+
const modelSel = h(
|
|
1753
|
+
"select",
|
|
1754
|
+
{},
|
|
1755
|
+
option("", `默认(${defaultLabel.replace(/^跟随\s*/, "")})`, model),
|
|
1756
|
+
withCurrent(listed.map((m) => m.id), model).map((v) => option(v, `${v}(当前值,不在列表中)`, model)),
|
|
1757
|
+
listed.map((m) => option(m.id, m.label && m.label !== m.id ? `${m.label} · ${m.id}` : m.id, model)),
|
|
1758
|
+
);
|
|
1759
|
+
const effortSel = h("select", {});
|
|
1760
|
+
const renderEfforts = () => {
|
|
1761
|
+
const picked = modelSel.value ? models.find((m) => m.id === modelSel.value) : fallback;
|
|
1762
|
+
const efforts = picked?.efforts ?? [];
|
|
1763
|
+
const keep = effortSel.value || effort;
|
|
1764
|
+
effortSel.replaceChildren(
|
|
1765
|
+
option("", "默认", keep),
|
|
1766
|
+
withCurrent(efforts, keep).map((v) => option(v, `${v}(当前值,该模型可能不支持)`, keep)),
|
|
1767
|
+
efforts.map((v) => option(v, v, keep)),
|
|
1768
|
+
);
|
|
1769
|
+
};
|
|
1770
|
+
modelSel.addEventListener("change", renderEfforts);
|
|
1771
|
+
renderEfforts();
|
|
1772
|
+
modelSlot.replaceChildren(modelSel);
|
|
1773
|
+
effortSlot.replaceChildren(effortSel);
|
|
1774
|
+
getModel = () => modelSel.value;
|
|
1775
|
+
getEffort = () => effortSel.value;
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
loading(model ? `${model}(正在读取模型列表…)` : "正在读取模型列表…");
|
|
1779
|
+
api(`/api/agents/${agent}/models`)
|
|
1780
|
+
.then((data) => (data?.models?.length ? asSelect(data.models) : asText("没读到模型列表,请手填")))
|
|
1781
|
+
.catch((err) => asText(err.status === 409 ? err.message : `读取模型列表失败,请手填:${err.message ?? err}`));
|
|
1782
|
+
|
|
1783
|
+
return { modelEl: modelSlot, effortEl: effortSlot, model: () => getModel(), effort: () => getEffort() };
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* 设置 → 命令:每个 agent 一张表,拖动或用箭头排序,取消勾选就不在「/」列表里出现。
|
|
1788
|
+
* 保存时把整张表的顺序存下来;以后新装的 skill 不在表里,会自动接在最后。
|
|
1789
|
+
*/
|
|
1790
|
+
function commandsTab(cfg, panel) {
|
|
1791
|
+
const agentIds = Object.keys(cfg.agents);
|
|
1792
|
+
let agent = currentAgentId();
|
|
1793
|
+
if (!agentIds.includes(agent)) agent = agentIds[0];
|
|
1794
|
+
let list = [];
|
|
1795
|
+
let filter = "";
|
|
1796
|
+
let dragFrom = -1;
|
|
1797
|
+
|
|
1798
|
+
const tabs = h("div", { class: "row" });
|
|
1799
|
+
const search = h("input", {
|
|
1800
|
+
class: "input",
|
|
1801
|
+
placeholder: "筛选命令(只影响显示,排序仍按整张表)",
|
|
1802
|
+
oninput: () => {
|
|
1803
|
+
filter = search.value.trim().toLowerCase();
|
|
1804
|
+
renderList();
|
|
1805
|
+
},
|
|
1806
|
+
});
|
|
1807
|
+
const listBox = h("div", { class: "cmd-list" });
|
|
1808
|
+
|
|
1809
|
+
const move = (from, to) => {
|
|
1810
|
+
if (to < 0 || to >= list.length || from === to) return;
|
|
1811
|
+
const [item] = list.splice(from, 1);
|
|
1812
|
+
list.splice(to, 0, item);
|
|
1813
|
+
renderList();
|
|
1814
|
+
};
|
|
1815
|
+
|
|
1816
|
+
const renderTabs = () =>
|
|
1817
|
+
fill(
|
|
1818
|
+
tabs,
|
|
1819
|
+
...agentIds.map((id) =>
|
|
1820
|
+
h(
|
|
1821
|
+
"button",
|
|
1822
|
+
{ class: `btn small${id === agent ? " primary" : ""}`, onclick: () => id !== agent && load(id) },
|
|
1823
|
+
agentLabel(id),
|
|
1824
|
+
),
|
|
1825
|
+
),
|
|
1826
|
+
);
|
|
1827
|
+
|
|
1828
|
+
const renderList = () => {
|
|
1829
|
+
const rows = list
|
|
1830
|
+
.map((c, i) => ({ c, i }))
|
|
1831
|
+
.filter(({ c }) => !filter || c.name.toLowerCase().includes(filter) || c.description.toLowerCase().includes(filter));
|
|
1832
|
+
fill(
|
|
1833
|
+
listBox,
|
|
1834
|
+
...rows.map(({ c, i }) => {
|
|
1835
|
+
const shown = h("input", {
|
|
1836
|
+
type: "checkbox",
|
|
1837
|
+
checked: !c.hidden,
|
|
1838
|
+
title: "在「/」列表里显示",
|
|
1839
|
+
onchange: () => {
|
|
1840
|
+
c.hidden = !shown.checked;
|
|
1841
|
+
row.classList.toggle("off", c.hidden);
|
|
1842
|
+
},
|
|
1843
|
+
});
|
|
1844
|
+
const row = h(
|
|
1845
|
+
"div",
|
|
1846
|
+
{
|
|
1847
|
+
class: `cmd-row${c.hidden ? " off" : ""}`,
|
|
1848
|
+
draggable: filter ? false : "true",
|
|
1849
|
+
ondragstart: (e) => {
|
|
1850
|
+
dragFrom = i;
|
|
1851
|
+
e.dataTransfer.effectAllowed = "move";
|
|
1852
|
+
row.classList.add("dragging");
|
|
1853
|
+
},
|
|
1854
|
+
ondragend: () => row.classList.remove("dragging"),
|
|
1855
|
+
ondragover: (e) => {
|
|
1856
|
+
e.preventDefault();
|
|
1857
|
+
e.dataTransfer.dropEffect = "move";
|
|
1858
|
+
},
|
|
1859
|
+
ondrop: (e) => {
|
|
1860
|
+
e.preventDefault();
|
|
1861
|
+
if (dragFrom >= 0) move(dragFrom, i);
|
|
1862
|
+
dragFrom = -1;
|
|
1863
|
+
},
|
|
1864
|
+
},
|
|
1865
|
+
h("span", { class: "cmd-grip", title: filter ? "筛选时不能拖动" : "拖动排序" }, "⋮⋮"),
|
|
1866
|
+
shown,
|
|
1867
|
+
h(
|
|
1868
|
+
"div",
|
|
1869
|
+
{ class: "grow" },
|
|
1870
|
+
h(
|
|
1871
|
+
"div",
|
|
1872
|
+
{ class: "t" },
|
|
1873
|
+
h("code", {}, commandUsage(c)),
|
|
1874
|
+
c.source === "agent" ? h("span", { class: "badge" }, agentLabel(agent)) : null,
|
|
1875
|
+
),
|
|
1876
|
+
h("div", { class: "s" }, oneLine(c.description, 100) || "(无说明)"),
|
|
1877
|
+
),
|
|
1878
|
+
h("button", { class: "icon-btn", title: "置顶", onclick: () => move(i, 0) }, "⤒"),
|
|
1879
|
+
h("button", { class: "icon-btn", title: "上移", onclick: () => move(i, i - 1) }, "↑"),
|
|
1880
|
+
h("button", { class: "icon-btn", title: "下移", onclick: () => move(i, i + 1) }, "↓"),
|
|
1881
|
+
);
|
|
1882
|
+
return row;
|
|
1883
|
+
}),
|
|
1884
|
+
);
|
|
1885
|
+
};
|
|
1886
|
+
|
|
1887
|
+
const save = async (prefs) => {
|
|
1888
|
+
await saveConfig(cfg, { commands: { [agent]: prefs } });
|
|
1889
|
+
slash.cache.clear();
|
|
1890
|
+
// 服务端 3 秒防抖后才开始同步,稍等再看状态
|
|
1891
|
+
setTimeout(() => void refreshFeishu(), 3500);
|
|
1892
|
+
};
|
|
1893
|
+
|
|
1894
|
+
// ---- 飞书指令面板同步状态 ----
|
|
1895
|
+
const feishuBox = h("div", { class: "cmd-feishu" });
|
|
1896
|
+
let feishuTimer = null;
|
|
1897
|
+
const describeSlash = (st) => {
|
|
1898
|
+
if (st.state === "syncing") return "同步中…(重建时每条间隔 1 秒,命令多要一两分钟)";
|
|
1899
|
+
if (st.state === "error") return `同步失败:${st.error}`;
|
|
1900
|
+
if (st.state === "done") {
|
|
1901
|
+
const when = new Date(st.at).toLocaleTimeString();
|
|
1902
|
+
const skipped = st.skipped?.length
|
|
1903
|
+
? `;${st.skipped.length} 个名字不符合飞书规则(只能小写字母、数字、- _,最长 32)没注册:${st.skipped.slice(0, 8).join("、")}${st.skipped.length > 8 ? " 等" : ""}`
|
|
1904
|
+
: "";
|
|
1905
|
+
return `${when} ${st.changed ? "已同步" : "已是最新"},共 ${st.registered} 条${skipped}。飞书端约 5~8 分钟后生效。`;
|
|
1906
|
+
}
|
|
1907
|
+
return "等待首次同步";
|
|
1908
|
+
};
|
|
1909
|
+
async function refreshFeishu() {
|
|
1910
|
+
let data;
|
|
1911
|
+
try {
|
|
1912
|
+
data = await api("/api/feishu/slash");
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
fill(feishuBox, h("div", { class: "error-text" }, err.message));
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
fill(
|
|
1918
|
+
feishuBox,
|
|
1919
|
+
data.bots.length === 0
|
|
1920
|
+
? h("div", { class: "muted" }, "没有运行中的飞书机器人。")
|
|
1921
|
+
: data.bots.map((b) =>
|
|
1922
|
+
h(
|
|
1923
|
+
"div",
|
|
1924
|
+
{ class: `muted${b.status.state === "error" ? " error-text" : ""}` },
|
|
1925
|
+
h("b", {}, b.name),
|
|
1926
|
+
":",
|
|
1927
|
+
describeSlash(b.status),
|
|
1928
|
+
),
|
|
1929
|
+
),
|
|
1930
|
+
);
|
|
1931
|
+
clearTimeout(feishuTimer);
|
|
1932
|
+
feishuTimer = data.bots.some((b) => b.status.state === "syncing") ? setTimeout(() => feishuBox.isConnected && void refreshFeishu(), 2000) : null;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
async function load(id) {
|
|
1936
|
+
agent = id;
|
|
1937
|
+
renderTabs();
|
|
1938
|
+
fill(listBox, h("span", { class: "spinner" }));
|
|
1939
|
+
try {
|
|
1940
|
+
const data = await api(`/api/agents/${id}/commands`);
|
|
1941
|
+
list = data.commands;
|
|
1942
|
+
renderList();
|
|
1943
|
+
} catch (err) {
|
|
1944
|
+
fill(listBox, h("div", { class: "error-text" }, err.message));
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
panel.append(
|
|
1949
|
+
h(
|
|
1950
|
+
"div",
|
|
1951
|
+
{ class: "section" },
|
|
1952
|
+
h("h4", {}, "命令列表"),
|
|
1953
|
+
h(
|
|
1954
|
+
"div",
|
|
1955
|
+
{ class: "muted" },
|
|
1956
|
+
"在输入框里打「/」时弹出的命令,按这里的顺序排列;保存后会自动同步到飞书机器人的指令面板。取消勾选的不会出现在列表里,但照样可以手动输入。带 agent 名标记的是 agent 自己的命令(skills 等),会原样转给 agent。",
|
|
1957
|
+
),
|
|
1958
|
+
tabs,
|
|
1959
|
+
search,
|
|
1960
|
+
listBox,
|
|
1961
|
+
h(
|
|
1962
|
+
"div",
|
|
1963
|
+
{ class: "row" },
|
|
1964
|
+
h(
|
|
1965
|
+
"button",
|
|
1966
|
+
{
|
|
1967
|
+
class: "btn primary",
|
|
1968
|
+
onclick: () => save({ order: list.map((c) => c.name), hidden: list.filter((c) => c.hidden).map((c) => c.name) }),
|
|
1969
|
+
},
|
|
1970
|
+
"保存",
|
|
1971
|
+
),
|
|
1972
|
+
h(
|
|
1973
|
+
"button",
|
|
1974
|
+
{
|
|
1975
|
+
class: "btn",
|
|
1976
|
+
onclick: async () => {
|
|
1977
|
+
if (!confirm(`把 ${agentLabel(agent)} 的命令顺序和显示恢复成默认?`)) return;
|
|
1978
|
+
await save({ order: [], hidden: [] });
|
|
1979
|
+
await load(agent);
|
|
1980
|
+
},
|
|
1981
|
+
},
|
|
1982
|
+
"恢复默认",
|
|
1983
|
+
),
|
|
1984
|
+
),
|
|
1985
|
+
),
|
|
1986
|
+
h(
|
|
1987
|
+
"div",
|
|
1988
|
+
{ class: "section" },
|
|
1989
|
+
h(
|
|
1990
|
+
"h4",
|
|
1991
|
+
{},
|
|
1992
|
+
"飞书指令面板",
|
|
1993
|
+
h("span", { class: "grow" }),
|
|
1994
|
+
h(
|
|
1995
|
+
"button",
|
|
1996
|
+
{
|
|
1997
|
+
class: "btn small",
|
|
1998
|
+
onclick: async () => {
|
|
1999
|
+
try {
|
|
2000
|
+
await api("/api/feishu/slash/sync", { method: "POST" });
|
|
2001
|
+
setTimeout(() => void refreshFeishu(), 500);
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
toast(err.message);
|
|
2004
|
+
}
|
|
2005
|
+
},
|
|
2006
|
+
},
|
|
2007
|
+
"立即同步",
|
|
2008
|
+
),
|
|
2009
|
+
),
|
|
2010
|
+
h(
|
|
2011
|
+
"div",
|
|
2012
|
+
{ class: "muted" },
|
|
2013
|
+
"飞书输入框打「/」看到的指令。面板是每个机器人一份:默认 agent 的命令在前,其它 agent 的命令接在后面。启动时、保存命令设置后、以及每 6 小时会自动同步。",
|
|
2014
|
+
),
|
|
2015
|
+
feishuBox,
|
|
2016
|
+
),
|
|
2017
|
+
);
|
|
2018
|
+
void load(agent);
|
|
2019
|
+
void refreshFeishu();
|
|
2020
|
+
}
|
|
2021
|
+
|
|
1494
2022
|
function agentsTab(cfg, panel) {
|
|
1495
2023
|
const a = structuredClone(cfg.agents);
|
|
1496
2024
|
const claudeOn = h("input", { type: "checkbox", checked: a.claude.enabled });
|
|
1497
|
-
const
|
|
1498
|
-
const claudeEffort = h("input", { value: a.claude.defaultEffort, placeholder: "low / medium / high / xhigh / max,留空默认" });
|
|
2025
|
+
const claudePick = modelPicker("claude", a.claude.defaultModel, a.claude.defaultEffort, "跟随 Claude Code 默认");
|
|
1499
2026
|
const codexOn = h("input", { type: "checkbox", checked: a.codex.enabled });
|
|
1500
2027
|
const codexPath = h("input", { value: a.codex.path });
|
|
1501
2028
|
const codexHome = h("input", { value: a.codex.home, placeholder: "留空用 ~/.codex" });
|
|
@@ -1504,15 +2031,14 @@ function agentsTab(cfg, panel) {
|
|
|
1504
2031
|
{},
|
|
1505
2032
|
["read-only", "workspace-write", "danger-full-access"].map((m) => h("option", { value: m, selected: m === a.codex.sandbox }, m)),
|
|
1506
2033
|
);
|
|
1507
|
-
const
|
|
1508
|
-
const codexEffort = h("input", { value: a.codex.defaultEffort, placeholder: "留空跟随 config.toml" });
|
|
2034
|
+
const codexPick = modelPicker("codex", a.codex.defaultModel, a.codex.defaultEffort, "跟随 config.toml");
|
|
1509
2035
|
panel.append(
|
|
1510
2036
|
h(
|
|
1511
2037
|
"div",
|
|
1512
2038
|
{ class: "section" },
|
|
1513
2039
|
h("h4", {}, "Claude Code", h("span", { class: "grow" }), h("label", { class: "check" }, claudeOn, "启用")),
|
|
1514
|
-
field("默认模型",
|
|
1515
|
-
field("默认思考程度",
|
|
2040
|
+
field("默认模型", claudePick.modelEl),
|
|
2041
|
+
field("默认思考程度", claudePick.effortEl),
|
|
1516
2042
|
),
|
|
1517
2043
|
h(
|
|
1518
2044
|
"div",
|
|
@@ -1521,8 +2047,8 @@ function agentsTab(cfg, panel) {
|
|
|
1521
2047
|
field("codex 可执行文件", codexPath, "以系统服务运行时 PATH 很短,建议写绝对路径"),
|
|
1522
2048
|
field("CODEX_HOME", codexHome),
|
|
1523
2049
|
field("沙箱", sandbox, "workspace-write:目录内可读写,越界才弹授权。danger-full-access 等于关掉沙箱"),
|
|
1524
|
-
field("默认模型",
|
|
1525
|
-
field("默认思考程度",
|
|
2050
|
+
field("默认模型", codexPick.modelEl),
|
|
2051
|
+
field("默认思考程度", codexPick.effortEl),
|
|
1526
2052
|
),
|
|
1527
2053
|
h(
|
|
1528
2054
|
"div",
|
|
@@ -1534,14 +2060,14 @@ function agentsTab(cfg, panel) {
|
|
|
1534
2060
|
onclick: () =>
|
|
1535
2061
|
saveConfig(cfg, {
|
|
1536
2062
|
agents: {
|
|
1537
|
-
claude: { enabled: claudeOn.checked, defaultModel:
|
|
2063
|
+
claude: { enabled: claudeOn.checked, defaultModel: claudePick.model(), defaultEffort: claudePick.effort() },
|
|
1538
2064
|
codex: {
|
|
1539
2065
|
enabled: codexOn.checked,
|
|
1540
2066
|
path: codexPath.value.trim() || "codex",
|
|
1541
2067
|
home: codexHome.value.trim(),
|
|
1542
2068
|
sandbox: sandbox.value,
|
|
1543
|
-
defaultModel:
|
|
1544
|
-
defaultEffort:
|
|
2069
|
+
defaultModel: codexPick.model(),
|
|
2070
|
+
defaultEffort: codexPick.effort(),
|
|
1545
2071
|
},
|
|
1546
2072
|
},
|
|
1547
2073
|
}),
|