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