agent-loft 0.2.23 → 0.2.25
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 +23 -3
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +41 -0
- package/src/agents/claude/session.ts +68 -7
- package/src/agents/codex/driver.ts +55 -3
- package/src/agents/codex/session.ts +134 -7
- package/src/channels/feishu/bot.ts +91 -23
- package/src/channels/web/api.ts +183 -3
- package/src/cli/user.ts +3 -0
- package/src/core/chat-history.ts +250 -0
- package/src/core/chat-mode.ts +62 -0
- package/src/core/command-catalog.ts +3 -0
- package/src/core/commands.ts +4 -0
- package/src/core/feishu-links.ts +119 -0
- package/src/core/hub.ts +131 -1
- package/src/core/memory-curator.ts +447 -0
- package/src/core/memory-mcp.ts +37 -0
- package/src/core/memory.ts +311 -0
- package/src/core/personal.ts +44 -0
- package/src/core/store.ts +9 -2
- package/src/core/types.ts +34 -0
- package/src/core/uploads.ts +56 -3
- package/src/main.ts +4 -0
- package/src/mcp/memory-server.ts +168 -0
- package/web/app.js +553 -10
- package/web/style.css +49 -2
package/web/app.js
CHANGED
|
@@ -245,6 +245,8 @@ const S = {
|
|
|
245
245
|
pending: new Map(),
|
|
246
246
|
current: null,
|
|
247
247
|
draftAgent: localStorage.getItem("aim.agent") || null,
|
|
248
|
+
/** 新对话用聊天模式还是工作模式,记住上次的选择 */
|
|
249
|
+
draftMode: localStorage.getItem("aim.mode") === "chat" ? "chat" : "work",
|
|
248
250
|
/** 新对话(还没发消息)选的工作目录,null 表示工作区根目录。开新对话时沿用上次选的 */
|
|
249
251
|
draftCwd: null,
|
|
250
252
|
items: [],
|
|
@@ -273,7 +275,7 @@ const desktopQuota = typeof desktop?.onQuota === "function";
|
|
|
273
275
|
// 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 style.css 的 .in-desktop)
|
|
274
276
|
if (desktop) document.documentElement.classList.add("in-desktop");
|
|
275
277
|
/**
|
|
276
|
-
*
|
|
278
|
+
* 新版桌面端顶栏的「更多」里能打开每台服务器的设置:侧栏底部的设置按钮就不显示了(额度照常显示,数据由桌面端推)。
|
|
277
279
|
* 老版本桌面端没有这个入口,照旧显示,免得设置打不开。
|
|
278
280
|
*/
|
|
279
281
|
const desktopSettings = typeof desktop?.onOpenSettings === "function";
|
|
@@ -633,6 +635,21 @@ function renderLayout() {
|
|
|
633
635
|
showMenu({ x: e.clientX, y: e.clientY }, [{ label: "历史会话…", onclick: () => openBotConversations(bot) }]);
|
|
634
636
|
return;
|
|
635
637
|
}
|
|
638
|
+
// 项目目录那一行:调整顺序(触屏上拖不动,靠这个菜单)
|
|
639
|
+
const dir = e.target.closest?.("[data-dir]")?.dataset.dir;
|
|
640
|
+
if (dir) {
|
|
641
|
+
const i = projDirs.indexOf(dir);
|
|
642
|
+
const moves = [
|
|
643
|
+
...(i > 0 ? [{ label: "置顶", onclick: () => moveProjectDir(dir, null) }, { label: "上移", onclick: () => moveProjectDir(dir, projDirs[i - 1]) }] : []),
|
|
644
|
+
...(i >= 0 && i < projDirs.length - 1 ? [{ label: "下移", onclick: () => moveProjectDir(dir, projDirs[i + 1], true) }] : []),
|
|
645
|
+
];
|
|
646
|
+
const reset = sideState.order.length ? [{ label: "恢复按最近活跃排序", onclick: resetProjectOrder }] : [];
|
|
647
|
+
const items = moves.length && reset.length ? [...moves, "sep", ...reset] : [...moves, ...reset];
|
|
648
|
+
if (!items.length) return;
|
|
649
|
+
e.preventDefault();
|
|
650
|
+
showMenu({ x: e.clientX, y: e.clientY }, items);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
636
653
|
const key = e.target.closest?.("[data-key]")?.dataset.key;
|
|
637
654
|
if (!key || !S.convs.has(key)) return;
|
|
638
655
|
e.preventDefault();
|
|
@@ -997,7 +1014,43 @@ function agentShort(id) {
|
|
|
997
1014
|
}
|
|
998
1015
|
|
|
999
1016
|
/** 侧栏里展开 / 收起的状态:哪些项目目录展开了、「最近」和各目录是否显示全部 */
|
|
1000
|
-
const sideState = { open: [], more: [], folded: [], ...JSON.parse(localStorage.getItem("aim.side") || "{}") };
|
|
1017
|
+
const sideState = { open: [], more: [], folded: [], order: [], ...JSON.parse(localStorage.getItem("aim.side") || "{}") };
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* 「项目」目录的顺序:没手动排过时按最近活跃;拖过之后按 sideState.order,
|
|
1021
|
+
* 之后新出现的目录排在已排好的后面(同样按活跃),不会挤掉用户置顶的目录
|
|
1022
|
+
*/
|
|
1023
|
+
function orderProjectDirs(dirs) {
|
|
1024
|
+
const rank = new Map(sideState.order.map((d, i) => [d, i]));
|
|
1025
|
+
const ranked = dirs.filter((d) => rank.has(d)).sort((a, b) => rank.get(a) - rank.get(b));
|
|
1026
|
+
return [...ranked, ...dirs.filter((d) => !rank.has(d))];
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** 当前侧栏里显示的目录顺序(renderSidebar 时记下,拖拽 / 右键菜单用) */
|
|
1030
|
+
let projDirs = [];
|
|
1031
|
+
|
|
1032
|
+
/** 把目录 dir 挪到 target 前面(after 为 true 时挪到后面);target 为 null 表示挪到最前 */
|
|
1033
|
+
function moveProjectDir(dir, target, after = false) {
|
|
1034
|
+
if (dir === target) return;
|
|
1035
|
+
const list = projDirs.filter((d) => d !== dir);
|
|
1036
|
+
const idx = target === null ? 0 : list.indexOf(target);
|
|
1037
|
+
if (idx < 0) return;
|
|
1038
|
+
list.splice(idx + (after ? 1 : 0), 0, dir);
|
|
1039
|
+
// 暂时没显示出来的目录(会话都被隐藏了之类)保留原来的名次,排在后面
|
|
1040
|
+
sideState.order = [...list, ...sideState.order.filter((d) => !list.includes(d))];
|
|
1041
|
+
localStorage.setItem("aim.side", JSON.stringify(sideState));
|
|
1042
|
+
renderSidebar();
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function resetProjectOrder() {
|
|
1046
|
+
sideState.order = [];
|
|
1047
|
+
localStorage.setItem("aim.side", JSON.stringify(sideState));
|
|
1048
|
+
renderSidebar();
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** 正在拖的目录。拖的过程中侧栏不重绘(重绘会换掉被拖的元素,拖拽就断了),松手后补一次 */
|
|
1052
|
+
let dirDrag = null;
|
|
1053
|
+
let sidebarStale = false;
|
|
1001
1054
|
|
|
1002
1055
|
/** 「最近」「项目」分组标题:点击折叠 / 展开,hover 时显示箭头 */
|
|
1003
1056
|
function groupHead(label, id, folded) {
|
|
@@ -1032,6 +1085,11 @@ function reportDesktop() {
|
|
|
1032
1085
|
|
|
1033
1086
|
function renderSidebar() {
|
|
1034
1087
|
if (!ui.convList) return;
|
|
1088
|
+
if (dirDrag) {
|
|
1089
|
+
sidebarStale = true;
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
sidebarStale = false;
|
|
1035
1093
|
const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
|
|
1036
1094
|
const visible = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt);
|
|
1037
1095
|
const cur = S.current ? S.convs.get(S.current) : null;
|
|
@@ -1115,17 +1173,68 @@ function renderSidebar() {
|
|
|
1115
1173
|
}
|
|
1116
1174
|
const projFolded = sideState.folded.includes("@projects");
|
|
1117
1175
|
if (byDir.size) nodes.push(groupHead("项目", "@projects", projFolded));
|
|
1176
|
+
projDirs = orderProjectDirs([...byDir.keys()]);
|
|
1118
1177
|
if (byDir.size && !projFolded) {
|
|
1119
|
-
|
|
1178
|
+
const clearDrop = () => ui.convList.querySelectorAll(".drop-before, .drop-after").forEach((el) => el.classList.remove("drop-before", "drop-after"));
|
|
1179
|
+
const endDrag = () => {
|
|
1180
|
+
dirDrag = null;
|
|
1181
|
+
clearDrop();
|
|
1182
|
+
if (sidebarStale) renderSidebar();
|
|
1183
|
+
};
|
|
1184
|
+
for (const dir of projDirs) {
|
|
1185
|
+
const convs = byDir.get(dir);
|
|
1120
1186
|
const expanded = sideState.open.includes(dir);
|
|
1121
1187
|
const showAll = sideState.more.includes(dir);
|
|
1188
|
+
// 鼠标在目标行的上半截就插到它前面,下半截插到后面
|
|
1189
|
+
const isAfter = (e, row) => {
|
|
1190
|
+
const r = row.getBoundingClientRect();
|
|
1191
|
+
return e.clientY > r.top + r.height / 2;
|
|
1192
|
+
};
|
|
1122
1193
|
nodes.push(
|
|
1123
1194
|
h(
|
|
1124
1195
|
"div",
|
|
1125
|
-
{
|
|
1196
|
+
{
|
|
1197
|
+
class: "proj-row",
|
|
1198
|
+
"data-dir": dir,
|
|
1199
|
+
draggable: "true",
|
|
1200
|
+
ondragstart: (e) => {
|
|
1201
|
+
dirDrag = dir;
|
|
1202
|
+
e.dataTransfer.effectAllowed = "move";
|
|
1203
|
+
e.dataTransfer.setData("text/plain", dir);
|
|
1204
|
+
e.currentTarget.classList.add("dragging");
|
|
1205
|
+
stopMarquee();
|
|
1206
|
+
},
|
|
1207
|
+
ondragend: (e) => {
|
|
1208
|
+
e.currentTarget.classList.remove("dragging");
|
|
1209
|
+
endDrag();
|
|
1210
|
+
},
|
|
1211
|
+
ondragover: (e) => {
|
|
1212
|
+
if (!dirDrag) return;
|
|
1213
|
+
e.preventDefault();
|
|
1214
|
+
e.dataTransfer.dropEffect = "move";
|
|
1215
|
+
const after = isAfter(e, e.currentTarget);
|
|
1216
|
+
if (!e.currentTarget.classList.contains(after ? "drop-after" : "drop-before")) {
|
|
1217
|
+
clearDrop();
|
|
1218
|
+
if (dirDrag !== dir) e.currentTarget.classList.add(after ? "drop-after" : "drop-before");
|
|
1219
|
+
}
|
|
1220
|
+
},
|
|
1221
|
+
ondragleave: (e) => {
|
|
1222
|
+
if (!e.currentTarget.contains(e.relatedTarget)) e.currentTarget.classList.remove("drop-before", "drop-after");
|
|
1223
|
+
},
|
|
1224
|
+
ondrop: (e) => {
|
|
1225
|
+
if (!dirDrag) return;
|
|
1226
|
+
e.preventDefault();
|
|
1227
|
+
const from = dirDrag;
|
|
1228
|
+
const after = isAfter(e, e.currentTarget);
|
|
1229
|
+
dirDrag = null;
|
|
1230
|
+
clearDrop();
|
|
1231
|
+
if (from !== dir) moveProjectDir(from, dir, after);
|
|
1232
|
+
else if (sidebarStale) renderSidebar();
|
|
1233
|
+
},
|
|
1234
|
+
},
|
|
1126
1235
|
h(
|
|
1127
1236
|
"button",
|
|
1128
|
-
{ class: "conv-item proj", title: dir
|
|
1237
|
+
{ class: "conv-item proj", title: `${dir}\n拖动可调整顺序,右键可置顶`, onclick: () => toggleSide("open", dir) },
|
|
1129
1238
|
h("span", { class: "proj-icon", html: expanded ? ICON.folderOpen : ICON.folder }),
|
|
1130
1239
|
h("span", { class: "title" }, baseName(dir)),
|
|
1131
1240
|
expanded ? null : status(convs.map((c) => c.key)),
|
|
@@ -1322,6 +1431,7 @@ function renderContext() {
|
|
|
1322
1431
|
effort = (c ? (c.runtimeEffort ?? c.effort) : pick.effort) ?? defaults.effort;
|
|
1323
1432
|
}
|
|
1324
1433
|
renderCtxRing();
|
|
1434
|
+
renderModePill(c);
|
|
1325
1435
|
fill(
|
|
1326
1436
|
ui.modelSlot,
|
|
1327
1437
|
model
|
|
@@ -1336,6 +1446,60 @@ function renderContext() {
|
|
|
1336
1446
|
);
|
|
1337
1447
|
}
|
|
1338
1448
|
|
|
1449
|
+
/** 输入框里的「聊天 / 工作」切换。会话里切换走服务端(同一条对话接着聊),新对话里只是记下选择 */
|
|
1450
|
+
function renderModePill(c) {
|
|
1451
|
+
if (!ui.modeSlot) return;
|
|
1452
|
+
const mode = c ? (c.mode ?? "work") : S.draftMode;
|
|
1453
|
+
const chat = mode === "chat";
|
|
1454
|
+
fill(
|
|
1455
|
+
ui.modeSlot,
|
|
1456
|
+
h(
|
|
1457
|
+
"button",
|
|
1458
|
+
{
|
|
1459
|
+
class: `mode-pill${chat ? " chat" : ""}`,
|
|
1460
|
+
title: chat
|
|
1461
|
+
? "聊天模式:精简的系统提示,只能联网搜索,不执行命令、不读写文件,每轮开销小很多。点击切到工作模式"
|
|
1462
|
+
: "工作模式:agent 原装的全部能力。点击切到聊天模式(更省、更快,适合日常聊天和查资料)",
|
|
1463
|
+
onclick: () => void toggleMode(),
|
|
1464
|
+
},
|
|
1465
|
+
chat ? "💬 聊天" : "🛠 工作",
|
|
1466
|
+
),
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
async function toggleMode() {
|
|
1471
|
+
const c = S.current ? S.convs.get(S.current) : null;
|
|
1472
|
+
const next = (c ? (c.mode ?? "work") : S.draftMode) === "chat" ? "work" : "chat";
|
|
1473
|
+
if (!c) {
|
|
1474
|
+
S.draftMode = next;
|
|
1475
|
+
localStorage.setItem("aim.mode", next);
|
|
1476
|
+
renderContext();
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
// 聊过的聊天对话切到工作模式:工作模式会在对话里留下一大段 Claude Code / Codex 的背景,
|
|
1480
|
+
// 之后再切回聊天模式每轮还得带着它,就省不了了。先问一句,建议另开一条工作对话
|
|
1481
|
+
if (next === "work" && S.items.length > 0) {
|
|
1482
|
+
const choice = await confirmDialog({
|
|
1483
|
+
title: "在这条对话里切到工作模式?",
|
|
1484
|
+
message:
|
|
1485
|
+
"切过去以后,这条对话里会留下工作模式的一大段背景(约 1.7 万 token),之后再切回聊天模式每轮也得带着,就省不了了。切换后的第一轮也会稍贵一些。\n\n要干活的话,建议新开一条工作对话(目录和 agent 跟这条一样),这条留着继续聊天。",
|
|
1486
|
+
confirmText: "新开工作对话",
|
|
1487
|
+
altText: "就在这里切",
|
|
1488
|
+
});
|
|
1489
|
+
if (choice === "alt") await doAction("mode", "work");
|
|
1490
|
+
else if (choice === true) {
|
|
1491
|
+
S.draftAgent = c.agent;
|
|
1492
|
+
S.draftCwd = c.cwd;
|
|
1493
|
+
S.draftMode = "work";
|
|
1494
|
+
localStorage.setItem("aim.mode", "work");
|
|
1495
|
+
newDraft();
|
|
1496
|
+
toast("已新开工作对话,目录和 agent 跟原来那条一样");
|
|
1497
|
+
}
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
await doAction("mode", next);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1339
1503
|
/** 84321 → 84k,1234567 → 1.2M */
|
|
1340
1504
|
function fmtTokens(n) {
|
|
1341
1505
|
if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;
|
|
@@ -2135,6 +2299,7 @@ function renderComposer() {
|
|
|
2135
2299
|
: null;
|
|
2136
2300
|
ui.ctxBar = h("div", { class: "ctx-bar" });
|
|
2137
2301
|
ui.modelSlot = h("span", { class: "model-slot" });
|
|
2302
|
+
ui.modeSlot = h("span", { class: "mode-slot" });
|
|
2138
2303
|
ui.ctxRing = h("span", { class: "ctx-ring-slot" });
|
|
2139
2304
|
ui.chips = h("div", { class: "chips" });
|
|
2140
2305
|
ui.slashMenu = h("div", { class: "slash-menu", hidden: true });
|
|
@@ -2163,6 +2328,7 @@ function renderComposer() {
|
|
|
2163
2328
|
ui.fileInput,
|
|
2164
2329
|
h("span", { class: "spacer" }),
|
|
2165
2330
|
ui.ctxRing,
|
|
2331
|
+
ui.modeSlot,
|
|
2166
2332
|
ui.modelSlot,
|
|
2167
2333
|
ui.micBtn,
|
|
2168
2334
|
ui.sendBtn,
|
|
@@ -2425,7 +2591,10 @@ async function ensureConversation() {
|
|
|
2425
2591
|
if (!S.draftAgent) throw new Error("没有可用的 agent");
|
|
2426
2592
|
let info;
|
|
2427
2593
|
try {
|
|
2428
|
-
info = await api("/api/conversations", {
|
|
2594
|
+
info = await api("/api/conversations", {
|
|
2595
|
+
method: "POST",
|
|
2596
|
+
body: { agent: S.draftAgent, cwd: S.draftCwd ?? undefined, mode: S.draftMode },
|
|
2597
|
+
});
|
|
2429
2598
|
} catch (err) {
|
|
2430
2599
|
// 选的目录没了(被删、或工作区改了):退回根目录,让用户重新确认
|
|
2431
2600
|
if (err.status === 400 && S.draftCwd) {
|
|
@@ -3177,10 +3346,12 @@ function renameConv(key = S.current) {
|
|
|
3177
3346
|
* 确认弹窗(替代浏览器自带的 confirm),样式和重命名弹窗一致。
|
|
3178
3347
|
* 回车确认、Esc / 点外面取消;resolve 成 true / false
|
|
3179
3348
|
*/
|
|
3180
|
-
|
|
3349
|
+
/** altText:给了就多一个按钮,点它返回 "alt"(确定返回 true,取消返回 false) */
|
|
3350
|
+
function confirmDialog({ title, message, sub, confirmText = "确定", danger = false, altText }) {
|
|
3181
3351
|
return new Promise((resolve) => {
|
|
3182
3352
|
const ok = h("button", { class: `btn ${danger ? "danger-fill" : "primary"}` }, confirmText);
|
|
3183
3353
|
const cancel = h("button", { class: "btn ghost" }, "取消");
|
|
3354
|
+
const alt = altText ? h("button", { class: "btn", onclick: () => done("alt") }, altText) : null;
|
|
3184
3355
|
const done = (v) => {
|
|
3185
3356
|
overlay.classList.add("leaving");
|
|
3186
3357
|
setTimeout(() => overlay.remove(), 120);
|
|
@@ -3207,7 +3378,7 @@ function confirmDialog({ title, message, sub, confirmText = "确定", danger = f
|
|
|
3207
3378
|
h("h3", {}, title),
|
|
3208
3379
|
sub ? h("p", { class: "dialog-sub" }, sub) : null,
|
|
3209
3380
|
message ? h("p", { class: "dialog-msg" }, message) : null,
|
|
3210
|
-
h("div", { class: "dialog-foot" }, cancel, ok),
|
|
3381
|
+
h("div", { class: "dialog-foot" }, cancel, alt, ok),
|
|
3211
3382
|
),
|
|
3212
3383
|
);
|
|
3213
3384
|
document.body.append(overlay);
|
|
@@ -3247,6 +3418,7 @@ async function openSettings() {
|
|
|
3247
3418
|
// 普通成员只能管自己的通知、密码和登录设备;其余设置都是管理员的
|
|
3248
3419
|
if (!isAdmin()) {
|
|
3249
3420
|
const tabs = [
|
|
3421
|
+
["记忆", () => memoryTab(panel)],
|
|
3250
3422
|
["通知", () => notifyTab(panel)],
|
|
3251
3423
|
["账号", () => securityTab(panel)],
|
|
3252
3424
|
];
|
|
@@ -3272,6 +3444,7 @@ async function openSettings() {
|
|
|
3272
3444
|
["命令", () => commandsTab(cfg, panel)],
|
|
3273
3445
|
["飞书机器人", () => feishuTab(cfg, panel)],
|
|
3274
3446
|
["授权", () => authTab(panel)],
|
|
3447
|
+
["记忆", () => memoryTab(panel)],
|
|
3275
3448
|
["通知", () => notifyTab(panel)],
|
|
3276
3449
|
["账号", () => securityTab(panel)],
|
|
3277
3450
|
["用户", () => usersTab(panel)],
|
|
@@ -4552,6 +4725,7 @@ async function securityTab(panel) {
|
|
|
4552
4725
|
),
|
|
4553
4726
|
),
|
|
4554
4727
|
),
|
|
4728
|
+
feishuLinkSection(),
|
|
4555
4729
|
h(
|
|
4556
4730
|
"div",
|
|
4557
4731
|
{ class: "section" },
|
|
@@ -4601,6 +4775,354 @@ async function securityTab(panel) {
|
|
|
4601
4775
|
);
|
|
4602
4776
|
}
|
|
4603
4777
|
|
|
4778
|
+
// ---------- 记忆(每个人自己的) ----------
|
|
4779
|
+
|
|
4780
|
+
async function memoryTab(panel) {
|
|
4781
|
+
const box = h("div", { class: "section" }, h("h4", {}, "记忆"), h("span", { class: "spinner" }));
|
|
4782
|
+
fill(panel, box);
|
|
4783
|
+
let mem;
|
|
4784
|
+
try {
|
|
4785
|
+
mem = await api("/api/account/memory");
|
|
4786
|
+
} catch (err) {
|
|
4787
|
+
fill(box, h("h4", {}, "记忆"), h("div", { class: "error-text" }, err.message));
|
|
4788
|
+
return;
|
|
4789
|
+
}
|
|
4790
|
+
const reload = () => void memoryTab(panel);
|
|
4791
|
+
const run = (btn, fn) => async () => {
|
|
4792
|
+
btn.disabled = true;
|
|
4793
|
+
try {
|
|
4794
|
+
if ((await fn()) !== false) reload();
|
|
4795
|
+
} catch (err) {
|
|
4796
|
+
toast(err.message);
|
|
4797
|
+
btn.disabled = false;
|
|
4798
|
+
}
|
|
4799
|
+
};
|
|
4800
|
+
|
|
4801
|
+
const enabled = h("input", { type: "checkbox", checked: mem.enabled });
|
|
4802
|
+
enabled.onchange = async () => {
|
|
4803
|
+
try {
|
|
4804
|
+
await api("/api/account/memory", { method: "PUT", body: { enabled: enabled.checked } });
|
|
4805
|
+
toast(enabled.checked ? "记忆已开启" : "记忆已关闭,已有的内容保留");
|
|
4806
|
+
} catch (err) {
|
|
4807
|
+
enabled.checked = !enabled.checked;
|
|
4808
|
+
toast(err.message);
|
|
4809
|
+
}
|
|
4810
|
+
};
|
|
4811
|
+
|
|
4812
|
+
/** 一个开关:改了立刻存 */
|
|
4813
|
+
const toggle = (key, label, on, off) => {
|
|
4814
|
+
const box = h("input", { type: "checkbox", checked: mem[key] });
|
|
4815
|
+
box.onchange = async () => {
|
|
4816
|
+
try {
|
|
4817
|
+
await api("/api/account/memory", { method: "PUT", body: { [key]: box.checked } });
|
|
4818
|
+
toast(box.checked ? on : off);
|
|
4819
|
+
} catch (err) {
|
|
4820
|
+
box.checked = !box.checked;
|
|
4821
|
+
toast(err.message);
|
|
4822
|
+
}
|
|
4823
|
+
};
|
|
4824
|
+
return h("label", { class: "check" }, box, label);
|
|
4825
|
+
};
|
|
4826
|
+
const history = await api("/api/account/history").catch(() => null);
|
|
4827
|
+
const clearHistory = h("button", { class: "btn small" }, "清空聊天记录索引");
|
|
4828
|
+
clearHistory.onclick = run(clearHistory, async () => {
|
|
4829
|
+
const sure = await confirmDialog({
|
|
4830
|
+
title: "清空聊天记录索引?",
|
|
4831
|
+
message: "agent 将搜不到以前的聊天(对话本身不删,侧栏里照样能打开)。之后的聊天还会继续记进来。",
|
|
4832
|
+
confirmText: "清空",
|
|
4833
|
+
danger: true,
|
|
4834
|
+
});
|
|
4835
|
+
if (!sure) return false;
|
|
4836
|
+
await api("/api/account/history", { method: "DELETE" });
|
|
4837
|
+
toast("已清空");
|
|
4838
|
+
});
|
|
4839
|
+
|
|
4840
|
+
const about = h("textarea", { rows: 5, maxlength: mem.limits.about, placeholder: "比如:我叫小明,在深圳做后端开发,家里有台 QNAP NAS。回答尽量简短,代码用 TypeScript。" });
|
|
4841
|
+
about.value = mem.about;
|
|
4842
|
+
const saveAbout = h("button", { class: "btn primary" }, "保存");
|
|
4843
|
+
saveAbout.onclick = run(saveAbout, async () => {
|
|
4844
|
+
await api("/api/account/memory", { method: "PUT", body: { about: about.value } });
|
|
4845
|
+
toast("已保存,之后的对话会用上");
|
|
4846
|
+
});
|
|
4847
|
+
|
|
4848
|
+
const newItem = h("input", { maxlength: mem.limits.item, style: { flex: "1", minWidth: "0" }, placeholder: "手动加一条,比如:我的软路由是 iStoreOS,地址 192.168.8.2" });
|
|
4849
|
+
const addBtn = h("button", { class: "btn" }, "添加");
|
|
4850
|
+
addBtn.onclick = run(addBtn, async () => {
|
|
4851
|
+
if (!newItem.value.trim()) return false;
|
|
4852
|
+
await api("/api/account/memory/items", { method: "POST", body: { text: newItem.value } });
|
|
4853
|
+
});
|
|
4854
|
+
newItem.onkeydown = (e) => {
|
|
4855
|
+
if (e.key === "Enter" && !e.isComposing) addBtn.click();
|
|
4856
|
+
};
|
|
4857
|
+
|
|
4858
|
+
const items = [...mem.items].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
4859
|
+
const itemRow = (it) => {
|
|
4860
|
+
const from = { user: "你加的", agent: "agent 在聊天里记的", auto: "聊完后自动整理的" }[it.source] ?? "agent 记的";
|
|
4861
|
+
const body = h("div", { class: "grow" }, h("div", { class: "t", style: { whiteSpace: "pre-wrap" } }, it.text), h("div", { class: "s" }, `${from} · ${relTime(it.updatedAt)}`));
|
|
4862
|
+
const edit = h("button", { class: "btn small" }, "编辑");
|
|
4863
|
+
const del = h("button", { class: "btn small danger" }, "删除");
|
|
4864
|
+
edit.onclick = () => {
|
|
4865
|
+
const input = h("textarea", { rows: 2, maxlength: mem.limits.item });
|
|
4866
|
+
input.value = it.text;
|
|
4867
|
+
const ok = h("button", { class: "btn small primary" }, "保存");
|
|
4868
|
+
ok.onclick = run(ok, () => api(`/api/account/memory/items/${it.id}`, { method: "PATCH", body: { text: input.value } }));
|
|
4869
|
+
fill(body, input, h("div", { class: "row" }, ok, h("button", { class: "btn small", onclick: reload }, "取消")));
|
|
4870
|
+
input.focus();
|
|
4871
|
+
};
|
|
4872
|
+
del.onclick = run(del, () => api(`/api/account/memory/items/${it.id}`, { method: "DELETE" }));
|
|
4873
|
+
return h("div", { class: "list-item" }, body, edit, del);
|
|
4874
|
+
};
|
|
4875
|
+
|
|
4876
|
+
const clearBtn = h("button", { class: "btn danger" }, "清空全部记忆");
|
|
4877
|
+
clearBtn.onclick = run(clearBtn, async () => {
|
|
4878
|
+
const sure = await confirmDialog({
|
|
4879
|
+
title: "清空全部记忆?",
|
|
4880
|
+
message: "「关于我」和所有记住的事都会删除,不能恢复。",
|
|
4881
|
+
confirmText: "清空",
|
|
4882
|
+
danger: true,
|
|
4883
|
+
});
|
|
4884
|
+
if (!sure) return false;
|
|
4885
|
+
await api("/api/account/memory", { method: "DELETE" });
|
|
4886
|
+
toast("已清空");
|
|
4887
|
+
});
|
|
4888
|
+
|
|
4889
|
+
fill(
|
|
4890
|
+
panel,
|
|
4891
|
+
h(
|
|
4892
|
+
"div",
|
|
4893
|
+
{ class: "section" },
|
|
4894
|
+
h("h4", {}, "记忆", h("span", { class: "grow" }), h("label", { class: "check" }, enabled, "开启")),
|
|
4895
|
+
h(
|
|
4896
|
+
"div",
|
|
4897
|
+
{ class: "muted" },
|
|
4898
|
+
"像 ChatGPT 的记忆:对话时 agent 会参考你的「关于我」和记住的事,聊天里遇到长期有用的信息也会主动记一条(会在回复里告诉你)。Claude 和 Codex、Web 和飞书共用这一份。",
|
|
4899
|
+
),
|
|
4900
|
+
h(
|
|
4901
|
+
"div",
|
|
4902
|
+
{ class: "muted" },
|
|
4903
|
+
"用在哪些对话:你在 Web 上的对话;在「账号」里绑定了飞书后,和机器人的私聊也会用。飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它。",
|
|
4904
|
+
),
|
|
4905
|
+
),
|
|
4906
|
+
h(
|
|
4907
|
+
"div",
|
|
4908
|
+
{ class: "section" },
|
|
4909
|
+
h("h4", {}, "自动"),
|
|
4910
|
+
curateSection(mem, reload),
|
|
4911
|
+
toggle("chatHistory", "参考以前的聊天", "已开启:agent 能搜你以前的聊天了", "已关闭:agent 不再搜以前的聊天"),
|
|
4912
|
+
h(
|
|
4913
|
+
"div",
|
|
4914
|
+
{ class: "muted" },
|
|
4915
|
+
`agent 遇到「上次说的那个……」时,可以搜你以前的聊天(只有你自己的对话,被删掉的对话不算)。${history ? `现在收录了 ${history.conversations} 段对话、${history.count} 句话。` : ""}`,
|
|
4916
|
+
),
|
|
4917
|
+
h("div", { class: "row" }, clearHistory),
|
|
4918
|
+
),
|
|
4919
|
+
h("div", { class: "section" }, h("h4", {}, "关于我"), field("写给 agent 看的自我介绍:你是谁、在做什么、希望它怎么回答", about, `每次对话都会带上,最多 ${mem.limits.about} 字`), h("div", { class: "row" }, saveAbout)),
|
|
4920
|
+
h(
|
|
4921
|
+
"div",
|
|
4922
|
+
{ class: "section" },
|
|
4923
|
+
h("h4", {}, `记住的事(${items.length})`),
|
|
4924
|
+
h("div", { class: "row" }, newItem, addBtn),
|
|
4925
|
+
items.length ? items.map(itemRow) : h("div", { class: "muted" }, "还没有。聊天时说「记住……」,或者在上面手动加。"),
|
|
4926
|
+
),
|
|
4927
|
+
h(
|
|
4928
|
+
"div",
|
|
4929
|
+
{ class: "section" },
|
|
4930
|
+
h("div", { class: "muted" }, "注意:和多用户一样,这不是安全隔离 —— 工作模式下 agent 能读到这台机器上的文件,也包括记忆文件。"),
|
|
4931
|
+
h("div", { class: "row" }, clearBtn),
|
|
4932
|
+
),
|
|
4933
|
+
);
|
|
4934
|
+
}
|
|
4935
|
+
|
|
4936
|
+
/** 记忆页里的「整理记忆」:什么时候整理、用哪个 agent 的哪个模型、现在整理、上次的结果 */
|
|
4937
|
+
function curateSection(mem, reload) {
|
|
4938
|
+
const c = { ...mem.curate };
|
|
4939
|
+
const box = h("div");
|
|
4940
|
+
const save = async (patch) => {
|
|
4941
|
+
try {
|
|
4942
|
+
await api("/api/account/memory", { method: "PUT", body: { curate: patch } });
|
|
4943
|
+
Object.assign(c, patch);
|
|
4944
|
+
if (patch.agent === "auto") c.model = "";
|
|
4945
|
+
render();
|
|
4946
|
+
toast("已保存");
|
|
4947
|
+
} catch (err) {
|
|
4948
|
+
toast(err.message);
|
|
4949
|
+
render();
|
|
4950
|
+
}
|
|
4951
|
+
};
|
|
4952
|
+
const select = (value, options, onchange) =>
|
|
4953
|
+
h("select", { onchange: (e) => onchange(e.target.value) }, options.map(([v, label]) => h("option", { value: v, selected: String(v) === String(value) }, label)));
|
|
4954
|
+
|
|
4955
|
+
const render = () => {
|
|
4956
|
+
const when = select(
|
|
4957
|
+
c.when,
|
|
4958
|
+
[
|
|
4959
|
+
["daily", "每天一次,把当天聊过的合起来整理(推荐)"],
|
|
4960
|
+
["idle", "每段对话聊完后就整理"],
|
|
4961
|
+
["manual", "只在我点「现在整理」时"],
|
|
4962
|
+
["off", "不整理"],
|
|
4963
|
+
],
|
|
4964
|
+
(v) => save({ when: v }),
|
|
4965
|
+
);
|
|
4966
|
+
const timing =
|
|
4967
|
+
c.when === "idle"
|
|
4968
|
+
? field("停下来多久算聊完", select(c.idleMinutes, [10, 30, 60, 120, 240].map((m) => [m, m < 60 ? `${m} 分钟` : `${m / 60} 小时`]), (v) => save({ idleMinutes: Number(v) })))
|
|
4969
|
+
: c.when === "daily"
|
|
4970
|
+
? field(
|
|
4971
|
+
"几点整理(服务器所在时区)",
|
|
4972
|
+
select(c.dailyHour, Array.from({ length: 24 }, (_, i) => [i, `${i} 点`]), (v) => save({ dailyHour: Number(v) })),
|
|
4973
|
+
"这个点之后第一次检查时整理;服务器那会儿没开机的话,开机后补上",
|
|
4974
|
+
)
|
|
4975
|
+
: null;
|
|
4976
|
+
const agent = select(
|
|
4977
|
+
c.agent,
|
|
4978
|
+
[
|
|
4979
|
+
["auto", "自动:有 Claude 就用 Claude,否则 Codex"],
|
|
4980
|
+
["claude", "Claude Code"],
|
|
4981
|
+
["codex", "Codex"],
|
|
4982
|
+
],
|
|
4983
|
+
(v) => save({ agent: v, model: "" }),
|
|
4984
|
+
);
|
|
4985
|
+
const model =
|
|
4986
|
+
c.agent === "auto"
|
|
4987
|
+
? null
|
|
4988
|
+
: (() => {
|
|
4989
|
+
const sel = h("select", { onchange: (e) => save({ model: e.target.value }) }, h("option", { value: "" }, c.agent === "claude" ? "默认:Haiku(最省)" : "默认模型,思考程度 low(最省)"));
|
|
4990
|
+
if (c.model) sel.append(h("option", { value: c.model, selected: true }, c.model));
|
|
4991
|
+
agentCatalog(c.agent)
|
|
4992
|
+
.then((models) => {
|
|
4993
|
+
for (const m of models) {
|
|
4994
|
+
if (m.id === "default" || m.id === c.model) continue;
|
|
4995
|
+
sel.append(h("option", { value: m.id }, m.label || m.id));
|
|
4996
|
+
}
|
|
4997
|
+
})
|
|
4998
|
+
.catch(() => sel.append(h("option", { value: "", disabled: true }, "(这个 agent 没启用,读不到模型列表)")));
|
|
4999
|
+
return field("模型", sel, "整理只是读一遍对话、改几条记忆,用便宜的模型就够了;贵的模型会多占订阅额度");
|
|
5000
|
+
})();
|
|
5001
|
+
|
|
5002
|
+
const nowBtn = h("button", { class: "btn small", disabled: c.when === "off" || !mem.enabled }, "现在整理");
|
|
5003
|
+
nowBtn.onclick = async () => {
|
|
5004
|
+
nowBtn.disabled = true;
|
|
5005
|
+
nowBtn.textContent = "整理中…";
|
|
5006
|
+
try {
|
|
5007
|
+
const r = await api("/api/account/memory/curate", { method: "POST" });
|
|
5008
|
+
toast(
|
|
5009
|
+
r.error
|
|
5010
|
+
? `整理失败:${r.error}`
|
|
5011
|
+
: r.conversations
|
|
5012
|
+
? `整理了 ${r.conversations} 段对话:新增 ${r.added.length}、更新 ${r.updated.length}、删除 ${r.removed}`
|
|
5013
|
+
: "没有需要整理的对话(正在回复的那段先不整理)",
|
|
5014
|
+
);
|
|
5015
|
+
reload();
|
|
5016
|
+
} catch (err) {
|
|
5017
|
+
toast(err.message);
|
|
5018
|
+
nowBtn.disabled = false;
|
|
5019
|
+
nowBtn.textContent = "现在整理";
|
|
5020
|
+
}
|
|
5021
|
+
};
|
|
5022
|
+
const last = mem.lastCuration;
|
|
5023
|
+
const lastText = last
|
|
5024
|
+
? last.error
|
|
5025
|
+
? `上次整理(${relTime(last.at)})没成功:${last.error}`
|
|
5026
|
+
: `上次整理:${relTime(last.at)},${last.conversations} 段对话${last.by ? `,用 ${last.by}` : ""},新增 ${last.added.length}、更新 ${last.updated.length}、删除 ${last.removed}`
|
|
5027
|
+
: "还没整理过";
|
|
5028
|
+
|
|
5029
|
+
fill(
|
|
5030
|
+
box,
|
|
5031
|
+
field("整理记忆", when),
|
|
5032
|
+
h(
|
|
5033
|
+
"div",
|
|
5034
|
+
{ class: "muted" },
|
|
5035
|
+
{
|
|
5036
|
+
daily: "攒一天再整理:每次整理都要把你现有的记忆整份带上,合起来一次最省额度,几段对话放在一起看也更容易合并重复。改了什么只显示在这里,不打扰对话。",
|
|
5037
|
+
idle: "每段对话停下来就整理一次,改了什么会在那条对话里告诉你。最及时,但聊得多时调用次数也多。",
|
|
5038
|
+
manual: "聊过的内容会攒着,你点「现在整理」时一次整理完。",
|
|
5039
|
+
off: "不整理,聊过的内容也不攒着。聊天里 agent 照样可以自己用 remember 记东西。",
|
|
5040
|
+
}[c.when],
|
|
5041
|
+
),
|
|
5042
|
+
timing,
|
|
5043
|
+
c.when === "off" ? null : field("用哪个 agent", agent),
|
|
5044
|
+
c.when === "off" ? null : model,
|
|
5045
|
+
h("div", { class: "row", style: { alignItems: "center", gap: "10px" } }, nowBtn, h("span", { class: "muted" }, `${mem.pending ? `有 ${mem.pending} 段对话等着整理。` : ""}${lastText}`)),
|
|
5046
|
+
h("div", { class: "muted" }, "整理时会对照现有记忆补充、更新或删掉过时的条目;你自己加的条目它不会动。"),
|
|
5047
|
+
);
|
|
5048
|
+
};
|
|
5049
|
+
render();
|
|
5050
|
+
return box;
|
|
5051
|
+
}
|
|
5052
|
+
|
|
5053
|
+
/** 「账号」页里的飞书绑定:Web 上生成绑定码,再在飞书私聊里发 /bind,两边都证明是本人 */
|
|
5054
|
+
function feishuLinkSection() {
|
|
5055
|
+
const box = h("div", { class: "section" });
|
|
5056
|
+
const load = async () => {
|
|
5057
|
+
let data;
|
|
5058
|
+
try {
|
|
5059
|
+
data = await api("/api/account/feishu");
|
|
5060
|
+
} catch (err) {
|
|
5061
|
+
fill(box, h("h4", {}, "飞书账号"), h("div", { class: "error-text" }, err.message));
|
|
5062
|
+
return;
|
|
5063
|
+
}
|
|
5064
|
+
// 没有飞书机器人、也没绑过:整节不显示
|
|
5065
|
+
if (!data.bots.length && !data.links.length) return fill(box);
|
|
5066
|
+
const codeBox = h("div");
|
|
5067
|
+
const gen = h("button", { class: "btn" }, data.links.length ? "再绑一个" : "生成绑定码");
|
|
5068
|
+
gen.onclick = async () => {
|
|
5069
|
+
gen.disabled = true;
|
|
5070
|
+
try {
|
|
5071
|
+
const { code, expiresAt } = await api("/api/account/feishu/code", { method: "POST" });
|
|
5072
|
+
const cmd = `/bind ${code}`;
|
|
5073
|
+
const copy = h("button", { class: "btn small" }, "复制");
|
|
5074
|
+
copy.onclick = () => copyText(cmd, copy);
|
|
5075
|
+
fill(
|
|
5076
|
+
codeBox,
|
|
5077
|
+
h(
|
|
5078
|
+
"div",
|
|
5079
|
+
{ class: "list-item" },
|
|
5080
|
+
h(
|
|
5081
|
+
"div",
|
|
5082
|
+
{ class: "grow" },
|
|
5083
|
+
h("div", { class: "t" }, h("code", {}, cmd)),
|
|
5084
|
+
h("div", { class: "s" }, `在飞书里私聊机器人(${data.bots.map((b) => b.name).join("、") || "飞书机器人"})发这条消息,${Math.round((expiresAt - Date.now()) / 60000)} 分钟内有效。没用过机器人的话,要先让管理员批准你的配对码。`),
|
|
5085
|
+
),
|
|
5086
|
+
copy,
|
|
5087
|
+
),
|
|
5088
|
+
);
|
|
5089
|
+
} catch (err) {
|
|
5090
|
+
toast(err.message);
|
|
5091
|
+
}
|
|
5092
|
+
gen.disabled = false;
|
|
5093
|
+
};
|
|
5094
|
+
fill(
|
|
5095
|
+
box,
|
|
5096
|
+
h("h4", {}, "飞书账号"),
|
|
5097
|
+
h("div", { class: "muted" }, "绑定后,你和飞书机器人的私聊就是你的个人会话:用你的记忆,/sessions 只列你的会话,/web 能接上你自己的 Web 会话。群聊不受影响。"),
|
|
5098
|
+
data.links.map((l) => {
|
|
5099
|
+
const rm = h("button", { class: "btn small danger" }, "解除绑定");
|
|
5100
|
+
rm.onclick = async () => {
|
|
5101
|
+
rm.disabled = true;
|
|
5102
|
+
try {
|
|
5103
|
+
await api("/api/account/feishu", { method: "DELETE", body: { botId: l.botId, openId: l.openId } });
|
|
5104
|
+
toast("已解除,那个私聊不再用你的记忆");
|
|
5105
|
+
void load();
|
|
5106
|
+
} catch (err) {
|
|
5107
|
+
toast(err.message);
|
|
5108
|
+
rm.disabled = false;
|
|
5109
|
+
}
|
|
5110
|
+
};
|
|
5111
|
+
return h(
|
|
5112
|
+
"div",
|
|
5113
|
+
{ class: "list-item" },
|
|
5114
|
+
h("div", { class: "grow" }, h("div", { class: "t" }, l.botName), h("div", { class: "s" }, `${l.openIdShort} · 绑定于 ${relTime(l.boundAt)}`)),
|
|
5115
|
+
rm,
|
|
5116
|
+
);
|
|
5117
|
+
}),
|
|
5118
|
+
h("div", { class: "row" }, gen),
|
|
5119
|
+
codeBox,
|
|
5120
|
+
);
|
|
5121
|
+
};
|
|
5122
|
+
void load();
|
|
5123
|
+
return box;
|
|
5124
|
+
}
|
|
5125
|
+
|
|
4604
5126
|
// ---------- 用户(管理员) ----------
|
|
4605
5127
|
|
|
4606
5128
|
async function usersTab(panel) {
|
|
@@ -4663,11 +5185,30 @@ async function usersTab(panel) {
|
|
|
4663
5185
|
roleBtn.onclick = act(roleBtn, () => patch(u, { role: u.role === "admin" ? "member" : "admin" }));
|
|
4664
5186
|
const disBtn = h("button", { class: "btn small", disabled: self }, u.disabled ? "启用" : "停用");
|
|
4665
5187
|
disBtn.onclick = act(disBtn, () => patch(u, { disabled: !u.disabled }));
|
|
5188
|
+
const memBtn = h("button", { class: "btn small", title: "删除他的「关于我」和所有记住的事。管理员看不到记忆的内容" }, "清空记忆");
|
|
5189
|
+
memBtn.onclick = act(memBtn, async () => {
|
|
5190
|
+
const sure = await confirmDialog({
|
|
5191
|
+
title: `清空「${u.name}」的记忆?`,
|
|
5192
|
+
message: "他的「关于我」和所有记住的事都会删除,不能恢复。",
|
|
5193
|
+
confirmText: "清空",
|
|
5194
|
+
danger: true,
|
|
5195
|
+
});
|
|
5196
|
+
if (!sure) return false;
|
|
5197
|
+
await api(`/api/users/${encodeURIComponent(u.id)}/memory`, { method: "DELETE" });
|
|
5198
|
+
toast("已清空");
|
|
5199
|
+
});
|
|
5200
|
+
const linkBtn = u.feishuLinks ? h("button", { class: "btn small" }, "解除飞书绑定") : null;
|
|
5201
|
+
if (linkBtn) {
|
|
5202
|
+
linkBtn.onclick = act(linkBtn, async () => {
|
|
5203
|
+
await api(`/api/users/${encodeURIComponent(u.id)}/feishu`, { method: "DELETE" });
|
|
5204
|
+
toast("已解除,他和机器人的私聊交还给管理员");
|
|
5205
|
+
});
|
|
5206
|
+
}
|
|
4666
5207
|
const rmBtn = h("button", { class: "btn small danger", disabled: self }, "删除");
|
|
4667
5208
|
rmBtn.onclick = act(rmBtn, async () => {
|
|
4668
5209
|
const sure = await confirmDialog({
|
|
4669
5210
|
title: `删除用户「${u.name}」?`,
|
|
4670
|
-
message: "
|
|
5211
|
+
message: "他的设备会立刻下线,记忆和飞书绑定一起删除。他的对话不会删除,之后归管理员。",
|
|
4671
5212
|
confirmText: "删除",
|
|
4672
5213
|
danger: true,
|
|
4673
5214
|
});
|
|
@@ -4690,12 +5231,14 @@ async function usersTab(panel) {
|
|
|
4690
5231
|
h("span", { class: "status-tag" }, u.role === "admin" ? "管理员" : "成员"),
|
|
4691
5232
|
u.disabled ? h("span", { class: "status-tag bad" }, "已停用") : null,
|
|
4692
5233
|
),
|
|
4693
|
-
h("div", { class: "s" }, `创建于 ${relTime(u.createdAt)} · ${u.devices}
|
|
5234
|
+
h("div", { class: "s" }, `创建于 ${relTime(u.createdAt)} · ${u.devices} 台设备已登录${u.feishuLinks ? ` · 绑定了 ${u.feishuLinks} 个飞书账号` : ""}`),
|
|
4694
5235
|
extra,
|
|
4695
5236
|
),
|
|
4696
5237
|
pwBtn,
|
|
4697
5238
|
roleBtn,
|
|
4698
5239
|
disBtn,
|
|
5240
|
+
memBtn,
|
|
5241
|
+
linkBtn,
|
|
4699
5242
|
rmBtn,
|
|
4700
5243
|
);
|
|
4701
5244
|
}),
|