agent-loft 0.2.24 → 0.2.26
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 +24 -2
- 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 +209 -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 +138 -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/scheduled.ts +113 -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 +12 -0
- package/src/mcp/memory-server.ts +168 -0
- package/web/app.js +669 -25
- package/web/style.css +160 -0
package/web/app.js
CHANGED
|
@@ -38,6 +38,7 @@ const ICON = {
|
|
|
38
38
|
stop: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1.5"/></svg>',
|
|
39
39
|
clip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.6-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>',
|
|
40
40
|
mic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
|
|
41
|
+
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
|
|
41
42
|
down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg>',
|
|
42
43
|
gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/></svg>',
|
|
43
44
|
fork: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10"/><path d="M18 9c0 5-6 4-11.2 8.6"/></svg>',
|
|
@@ -245,6 +246,8 @@ const S = {
|
|
|
245
246
|
pending: new Map(),
|
|
246
247
|
current: null,
|
|
247
248
|
draftAgent: localStorage.getItem("aim.agent") || null,
|
|
249
|
+
/** 新对话用聊天模式还是工作模式,记住上次的选择 */
|
|
250
|
+
draftMode: localStorage.getItem("aim.mode") === "chat" ? "chat" : "work",
|
|
248
251
|
/** 新对话(还没发消息)选的工作目录,null 表示工作区根目录。开新对话时沿用上次选的 */
|
|
249
252
|
draftCwd: null,
|
|
250
253
|
items: [],
|
|
@@ -459,6 +462,7 @@ function onWsMessage(msg) {
|
|
|
459
462
|
if (msg.info.key === S.current) {
|
|
460
463
|
renderTopbar();
|
|
461
464
|
setBusy(msg.info.busy);
|
|
465
|
+
renderScheduled();
|
|
462
466
|
}
|
|
463
467
|
scheduleSidebar();
|
|
464
468
|
return;
|
|
@@ -680,7 +684,11 @@ function renderLayout() {
|
|
|
680
684
|
);
|
|
681
685
|
ui.topbar = h("div", { class: "topbar" });
|
|
682
686
|
ui.msgInner = h("div", { class: "messages-inner" });
|
|
683
|
-
ui.
|
|
687
|
+
ui.spacer = h("div", { class: "messages-spacer", "aria-hidden": "true" });
|
|
688
|
+
ui.messages = h("div", { class: "messages", tabindex: "-1" }, ui.msgInner, ui.spacer);
|
|
689
|
+
ui.toBottom = h("button", { class: "to-bottom", title: "回到底部", hidden: true, onclick: () => scrollToBottom() }, icon("down"));
|
|
690
|
+
ui.follow = true;
|
|
691
|
+
bindScrollWatch();
|
|
684
692
|
ui.composerWrap = h("div", { class: "composer-wrap" });
|
|
685
693
|
ui.main = h("main", { class: "main" }, ui.topbar, ui.messages, ui.composerWrap);
|
|
686
694
|
ui.layout = h("div", { class: "layout" }, ui.sidebar, h("div", { class: "scrim", onclick: closeSide }), ui.main);
|
|
@@ -1429,6 +1437,7 @@ function renderContext() {
|
|
|
1429
1437
|
effort = (c ? (c.runtimeEffort ?? c.effort) : pick.effort) ?? defaults.effort;
|
|
1430
1438
|
}
|
|
1431
1439
|
renderCtxRing();
|
|
1440
|
+
renderModePill(c);
|
|
1432
1441
|
fill(
|
|
1433
1442
|
ui.modelSlot,
|
|
1434
1443
|
model
|
|
@@ -1443,6 +1452,60 @@ function renderContext() {
|
|
|
1443
1452
|
);
|
|
1444
1453
|
}
|
|
1445
1454
|
|
|
1455
|
+
/** 输入框里的「聊天 / 工作」切换。会话里切换走服务端(同一条对话接着聊),新对话里只是记下选择 */
|
|
1456
|
+
function renderModePill(c) {
|
|
1457
|
+
if (!ui.modeSlot) return;
|
|
1458
|
+
const mode = c ? (c.mode ?? "work") : S.draftMode;
|
|
1459
|
+
const chat = mode === "chat";
|
|
1460
|
+
fill(
|
|
1461
|
+
ui.modeSlot,
|
|
1462
|
+
h(
|
|
1463
|
+
"button",
|
|
1464
|
+
{
|
|
1465
|
+
class: `mode-pill${chat ? " chat" : ""}`,
|
|
1466
|
+
title: chat
|
|
1467
|
+
? "聊天模式:精简的系统提示,只能联网搜索,不执行命令、不读写文件,每轮开销小很多。点击切到工作模式"
|
|
1468
|
+
: "工作模式:agent 原装的全部能力。点击切到聊天模式(更省、更快,适合日常聊天和查资料)",
|
|
1469
|
+
onclick: () => void toggleMode(),
|
|
1470
|
+
},
|
|
1471
|
+
chat ? "💬 聊天" : "🛠 工作",
|
|
1472
|
+
),
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
async function toggleMode() {
|
|
1477
|
+
const c = S.current ? S.convs.get(S.current) : null;
|
|
1478
|
+
const next = (c ? (c.mode ?? "work") : S.draftMode) === "chat" ? "work" : "chat";
|
|
1479
|
+
if (!c) {
|
|
1480
|
+
S.draftMode = next;
|
|
1481
|
+
localStorage.setItem("aim.mode", next);
|
|
1482
|
+
renderContext();
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
// 聊过的聊天对话切到工作模式:工作模式会在对话里留下一大段 Claude Code / Codex 的背景,
|
|
1486
|
+
// 之后再切回聊天模式每轮还得带着它,就省不了了。先问一句,建议另开一条工作对话
|
|
1487
|
+
if (next === "work" && S.items.length > 0) {
|
|
1488
|
+
const choice = await confirmDialog({
|
|
1489
|
+
title: "在这条对话里切到工作模式?",
|
|
1490
|
+
message:
|
|
1491
|
+
"切过去以后,这条对话里会留下工作模式的一大段背景(约 1.7 万 token),之后再切回聊天模式每轮也得带着,就省不了了。切换后的第一轮也会稍贵一些。\n\n要干活的话,建议新开一条工作对话(目录和 agent 跟这条一样),这条留着继续聊天。",
|
|
1492
|
+
confirmText: "新开工作对话",
|
|
1493
|
+
altText: "就在这里切",
|
|
1494
|
+
});
|
|
1495
|
+
if (choice === "alt") await doAction("mode", "work");
|
|
1496
|
+
else if (choice === true) {
|
|
1497
|
+
S.draftAgent = c.agent;
|
|
1498
|
+
S.draftCwd = c.cwd;
|
|
1499
|
+
S.draftMode = "work";
|
|
1500
|
+
localStorage.setItem("aim.mode", "work");
|
|
1501
|
+
newDraft();
|
|
1502
|
+
toast("已新开工作对话,目录和 agent 跟原来那条一样");
|
|
1503
|
+
}
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
await doAction("mode", next);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1446
1509
|
/** 84321 → 84k,1234567 → 1.2M */
|
|
1447
1510
|
function fmtTokens(n) {
|
|
1448
1511
|
if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;
|
|
@@ -1654,7 +1717,11 @@ function newDraft() {
|
|
|
1654
1717
|
S.turnAi = null;
|
|
1655
1718
|
turnStartAt = null;
|
|
1656
1719
|
S.uploads = [];
|
|
1720
|
+
S.pinNext = false;
|
|
1721
|
+
ui.pinEl = null;
|
|
1722
|
+
ui.follow = true;
|
|
1657
1723
|
setBusy(false);
|
|
1724
|
+
renderScheduled();
|
|
1658
1725
|
history.replaceState(null, "", location.pathname);
|
|
1659
1726
|
WS.presence();
|
|
1660
1727
|
renderTopbar();
|
|
@@ -1694,7 +1761,11 @@ async function openConversation(key) {
|
|
|
1694
1761
|
S.turnAi = null;
|
|
1695
1762
|
turnStartAt = null;
|
|
1696
1763
|
S.uploads = [];
|
|
1764
|
+
S.pinNext = false;
|
|
1765
|
+
ui.pinEl = null;
|
|
1766
|
+
ui.follow = true;
|
|
1697
1767
|
renderChips();
|
|
1768
|
+
renderScheduled();
|
|
1698
1769
|
history.replaceState(null, "", `#${encodeURIComponent(key)}`);
|
|
1699
1770
|
WS.presence();
|
|
1700
1771
|
renderTopbar();
|
|
@@ -1743,18 +1814,82 @@ async function openConversation(key) {
|
|
|
1743
1814
|
setBusy(data.info.busy);
|
|
1744
1815
|
renderTopbar();
|
|
1745
1816
|
WS.subscribe(key, S.seq.get(key));
|
|
1746
|
-
|
|
1817
|
+
renderScheduled();
|
|
1818
|
+
scrollToBottom();
|
|
1747
1819
|
}
|
|
1748
1820
|
|
|
1749
1821
|
// ---------- 消息渲染 ----------
|
|
1750
1822
|
|
|
1751
|
-
|
|
1823
|
+
// 滚动规则(和 ChatGPT 一样):
|
|
1824
|
+
// - 自己发出一条消息后,把它顶到视口最上面,下面留白;回复往下长,长到屏幕底部之后才跟着往下滚。
|
|
1825
|
+
// 留白靠消息列表后面的 ui.spacer 撑出来:高度 = 刚好够那条消息滚到顶,回复越长它越矮,到 0 为止。
|
|
1826
|
+
// - 回复过程中只要用户自己滚了(滚轮、触摸、拖滚动条、键盘翻页),就不再自动滚,直到下一次发消息。
|
|
1827
|
+
// ui.follow 记着现在要不要自动滚。
|
|
1828
|
+
|
|
1829
|
+
function nearBottom(gap = 120) {
|
|
1752
1830
|
const m = ui.messages;
|
|
1753
|
-
return m.scrollHeight - m.scrollTop - m.clientHeight <
|
|
1831
|
+
return m.scrollHeight - m.scrollTop - m.clientHeight < gap;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/** 内容变了之后:重算留白,要跟随的话贴到底(有留白时「底」正好就是被顶上去的那条消息) */
|
|
1835
|
+
function afterLayout() {
|
|
1836
|
+
if (!ui.messages) return;
|
|
1837
|
+
fitSpacer();
|
|
1838
|
+
if (ui.follow && performance.now() > (ui.smoothUntil ?? 0)) ui.messages.scrollTop = ui.messages.scrollHeight;
|
|
1839
|
+
updateToBottomBtn();
|
|
1754
1840
|
}
|
|
1755
1841
|
|
|
1756
|
-
function
|
|
1757
|
-
|
|
1842
|
+
function fitSpacer() {
|
|
1843
|
+
const pin = ui.pinEl;
|
|
1844
|
+
if (!pin?.isConnected) {
|
|
1845
|
+
ui.pinEl = null;
|
|
1846
|
+
ui.spacer.style.height = "0px";
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
// 顶栏是叠在消息区上面的:让出它的高度,再留一点空
|
|
1850
|
+
const covered = Math.max(0, ui.topbar.getBoundingClientRect().bottom - ui.messages.getBoundingClientRect().top);
|
|
1851
|
+
const top = Math.max(0, pin.offsetTop - covered - 12);
|
|
1852
|
+
const need = top + ui.messages.clientHeight - ui.msgInner.offsetHeight;
|
|
1853
|
+
ui.spacer.style.height = `${Math.max(0, Math.ceil(need))}px`;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/** 回到跟随模式并贴到底:打开会话、点「回到底部」时用 */
|
|
1857
|
+
function scrollToBottom() {
|
|
1858
|
+
ui.follow = true;
|
|
1859
|
+
afterLayout();
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/** 把刚发出的这条消息顶到最上面 */
|
|
1863
|
+
function pinToTop(el) {
|
|
1864
|
+
ui.pinEl = el;
|
|
1865
|
+
ui.follow = true;
|
|
1866
|
+
fitSpacer();
|
|
1867
|
+
ui.smoothUntil = performance.now() + 450;
|
|
1868
|
+
ui.messages.scrollTo({ top: ui.messages.scrollHeight, behavior: "smooth" });
|
|
1869
|
+
updateToBottomBtn();
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/** 用户自己动了滚动:停止自动滚动 */
|
|
1873
|
+
function userScrolled() {
|
|
1874
|
+
ui.follow = false;
|
|
1875
|
+
ui.smoothUntil = 0;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function updateToBottomBtn() {
|
|
1879
|
+
if (ui.toBottom) ui.toBottom.hidden = nearBottom(200);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function bindScrollWatch() {
|
|
1883
|
+
const m = ui.messages;
|
|
1884
|
+
m.addEventListener("wheel", userScrolled, { passive: true });
|
|
1885
|
+
m.addEventListener("touchmove", userScrolled, { passive: true });
|
|
1886
|
+
// 点在滚动条上时事件目标就是滚动容器本身(点在内容上的是里面的元素)
|
|
1887
|
+
m.addEventListener("pointerdown", (e) => e.target === m && userScrolled());
|
|
1888
|
+
m.addEventListener("keydown", (e) => {
|
|
1889
|
+
if (["PageUp", "PageDown", "ArrowUp", "ArrowDown", "Home", "End", " "].includes(e.key)) userScrolled();
|
|
1890
|
+
});
|
|
1891
|
+
m.addEventListener("scroll", updateToBottomBtn, { passive: true });
|
|
1892
|
+
addEventListener("resize", () => afterLayout());
|
|
1758
1893
|
}
|
|
1759
1894
|
|
|
1760
1895
|
/** 只有最后一条回复默认显示底部的操作栏(复制 / 分叉 / 用时…),前面的 hover 才出来 */
|
|
@@ -1772,13 +1907,12 @@ function markLatestAi() {
|
|
|
1772
1907
|
|
|
1773
1908
|
function addItem(item) {
|
|
1774
1909
|
ui.msgInner.querySelector(".empty")?.remove();
|
|
1775
|
-
const stick = nearBottom();
|
|
1776
1910
|
item.el = renderItem(item);
|
|
1777
1911
|
ui.msgInner.append(item.el);
|
|
1778
1912
|
S.items.push(item);
|
|
1779
1913
|
markLatestAi();
|
|
1780
1914
|
updateCtxVisibility();
|
|
1781
|
-
|
|
1915
|
+
afterLayout();
|
|
1782
1916
|
return item;
|
|
1783
1917
|
}
|
|
1784
1918
|
|
|
@@ -1790,7 +1924,6 @@ function updateItem(item) {
|
|
|
1790
1924
|
flushScheduled = true;
|
|
1791
1925
|
requestAnimationFrame(() => {
|
|
1792
1926
|
flushScheduled = false;
|
|
1793
|
-
const stick = nearBottom();
|
|
1794
1927
|
for (const it of dirty) {
|
|
1795
1928
|
if (!it.el?.isConnected) continue;
|
|
1796
1929
|
const next = renderItem(it);
|
|
@@ -1799,7 +1932,7 @@ function updateItem(item) {
|
|
|
1799
1932
|
}
|
|
1800
1933
|
dirty.clear();
|
|
1801
1934
|
markLatestAi();
|
|
1802
|
-
|
|
1935
|
+
afterLayout();
|
|
1803
1936
|
});
|
|
1804
1937
|
}
|
|
1805
1938
|
|
|
@@ -1997,7 +2130,12 @@ function applyEvent(env) {
|
|
|
1997
2130
|
}
|
|
1998
2131
|
// 上一轮还没结束时发的消息会排队,不改正在进行那一轮的开始时间
|
|
1999
2132
|
if (!S.turnAi || S.turnAi.done) turnStartAt = env.ts;
|
|
2000
|
-
addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
|
|
2133
|
+
const it = addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
|
|
2134
|
+
// 这个页面刚发的:顶到最上面
|
|
2135
|
+
if (S.pinNext && ev.origin?.channel === "web") {
|
|
2136
|
+
S.pinNext = false;
|
|
2137
|
+
pinToTop(it.el);
|
|
2138
|
+
}
|
|
2001
2139
|
currentAi(env.ts);
|
|
2002
2140
|
return;
|
|
2003
2141
|
}
|
|
@@ -2242,8 +2380,10 @@ function renderComposer() {
|
|
|
2242
2380
|
: null;
|
|
2243
2381
|
ui.ctxBar = h("div", { class: "ctx-bar" });
|
|
2244
2382
|
ui.modelSlot = h("span", { class: "model-slot" });
|
|
2383
|
+
ui.modeSlot = h("span", { class: "mode-slot" });
|
|
2245
2384
|
ui.ctxRing = h("span", { class: "ctx-ring-slot" });
|
|
2246
2385
|
ui.chips = h("div", { class: "chips" });
|
|
2386
|
+
ui.schedBar = h("div", { class: "sched-bar", hidden: true });
|
|
2247
2387
|
ui.slashMenu = h("div", { class: "slash-menu", hidden: true });
|
|
2248
2388
|
ui.composer = h(
|
|
2249
2389
|
"div",
|
|
@@ -2267,20 +2407,145 @@ function renderComposer() {
|
|
|
2267
2407
|
"div",
|
|
2268
2408
|
{ class: "composer-row" },
|
|
2269
2409
|
h("button", { class: "icon-btn add-btn", title: "添加附件", onclick: () => ui.fileInput.click() }, icon("plus")),
|
|
2410
|
+
h("button", { class: "icon-btn add-btn", title: "定时发送", onclick: () => openSchedule() }, icon("clock")),
|
|
2270
2411
|
ui.fileInput,
|
|
2271
2412
|
h("span", { class: "spacer" }),
|
|
2272
2413
|
ui.ctxRing,
|
|
2414
|
+
ui.modeSlot,
|
|
2273
2415
|
ui.modelSlot,
|
|
2274
2416
|
ui.micBtn,
|
|
2275
2417
|
ui.sendBtn,
|
|
2276
2418
|
),
|
|
2277
2419
|
);
|
|
2278
2420
|
fill(ui.composerWrap,
|
|
2279
|
-
|
|
2421
|
+
ui.toBottom,
|
|
2422
|
+
h("div", { class: "composer-box" }, ui.schedBar, ui.ctxBar, ui.composer),
|
|
2280
2423
|
);
|
|
2281
2424
|
ui.input.addEventListener("input", updateSendBtn);
|
|
2282
2425
|
updateSendBtn();
|
|
2283
2426
|
renderContext();
|
|
2427
|
+
renderScheduled();
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// ---------- 定时发送 ----------
|
|
2431
|
+
// 额度用完、要等刷新才能接着干活时:先把下一条排好,到点由服务端发出(页面关了也会发)。只支持文字。
|
|
2432
|
+
|
|
2433
|
+
function fmtWhen(ms) {
|
|
2434
|
+
const d = new Date(ms);
|
|
2435
|
+
const now = new Date();
|
|
2436
|
+
const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
2437
|
+
const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
2438
|
+
const diff = Math.round((day(d) - day(now)) / 86400000);
|
|
2439
|
+
if (diff === 0) return `今天 ${hm}`;
|
|
2440
|
+
if (diff === 1) return `明天 ${hm}`;
|
|
2441
|
+
if (diff === 2) return `后天 ${hm}`;
|
|
2442
|
+
return `${d.getMonth() + 1}月${d.getDate()}日 ${hm}`;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/** <input type="datetime-local"> 要的本地时间格式 */
|
|
2446
|
+
function toLocalInput(ms) {
|
|
2447
|
+
const d = new Date(ms);
|
|
2448
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
2449
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/** 输入框上方:这条对话排着的定时消息,可以取消 */
|
|
2453
|
+
function renderScheduled() {
|
|
2454
|
+
if (!ui.schedBar) return;
|
|
2455
|
+
const list = (S.current && S.convs.get(S.current)?.scheduled) || [];
|
|
2456
|
+
ui.schedBar.hidden = list.length === 0;
|
|
2457
|
+
fill(ui.schedBar,
|
|
2458
|
+
list.map((m) =>
|
|
2459
|
+
h(
|
|
2460
|
+
"div",
|
|
2461
|
+
{ class: "sched-item", title: m.text },
|
|
2462
|
+
icon("clock"),
|
|
2463
|
+
h("span", { class: "sched-when" }, fmtWhen(m.at)),
|
|
2464
|
+
h("span", { class: "sched-text" }, oneLine(m.text, 200)),
|
|
2465
|
+
h("button", { class: "sched-act", title: "改时间或内容", onclick: () => openSchedule(m) }, "编辑"),
|
|
2466
|
+
h("button", { class: "sched-act", title: "取消这条定时消息", onclick: () => cancelScheduled(m.id) }, "取消"),
|
|
2467
|
+
),
|
|
2468
|
+
),
|
|
2469
|
+
);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
async function cancelScheduled(id, quiet) {
|
|
2473
|
+
const key = S.current;
|
|
2474
|
+
if (!key) return false;
|
|
2475
|
+
try {
|
|
2476
|
+
const info = await api(`/api/conversations/${encodeURIComponent(key)}/scheduled?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
2477
|
+
S.convs.set(info.key, info);
|
|
2478
|
+
renderScheduled();
|
|
2479
|
+
return true;
|
|
2480
|
+
} catch (err) {
|
|
2481
|
+
if (!quiet) toast(err.message);
|
|
2482
|
+
return false;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/** 选时间的弹窗。edit:改已经排着的那条(先删旧的再排新的) */
|
|
2487
|
+
function openSchedule(edit) {
|
|
2488
|
+
if (!edit && S.uploads.length) return toast("定时发送暂不支持附件,先移除附件");
|
|
2489
|
+
const text = h("textarea", { class: "sched-input", rows: 4, placeholder: "到点要发的内容" });
|
|
2490
|
+
text.value = edit ? edit.text : ui.input.value;
|
|
2491
|
+
const when = h("input", { type: "datetime-local", class: "sched-time" });
|
|
2492
|
+
const setAt = (ms) => (when.value = toLocalInput(ms));
|
|
2493
|
+
setAt(edit ? edit.at : Date.now() + 5 * 3600_000);
|
|
2494
|
+
// 快捷时间:当前 agent 的额度窗口重置后一分钟,再加几个常用的
|
|
2495
|
+
const agent = currentAgentId();
|
|
2496
|
+
const presets = [];
|
|
2497
|
+
for (const q of quotaState.data ?? []) {
|
|
2498
|
+
if (q.agent !== agent) continue;
|
|
2499
|
+
for (const w of q.windows) {
|
|
2500
|
+
if (w.resetsAt && w.resetsAt > Date.now()) presets.push({ label: `${w.label}重置后(${fmtWhen(w.resetsAt + 60_000)})`, at: w.resetsAt + 60_000 });
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
const tomorrow9 = new Date();
|
|
2504
|
+
tomorrow9.setDate(tomorrow9.getDate() + 1);
|
|
2505
|
+
tomorrow9.setHours(9, 0, 0, 0);
|
|
2506
|
+
presets.push(
|
|
2507
|
+
{ label: "1 小时后", at: Date.now() + 3600_000 },
|
|
2508
|
+
{ label: "3 小时后", at: Date.now() + 3 * 3600_000 },
|
|
2509
|
+
{ label: "5 小时后", at: Date.now() + 5 * 3600_000 },
|
|
2510
|
+
{ label: "明早 9:00", at: tomorrow9.getTime() },
|
|
2511
|
+
);
|
|
2512
|
+
const body = h(
|
|
2513
|
+
"div",
|
|
2514
|
+
{ class: "modal-body sched-body" },
|
|
2515
|
+
text,
|
|
2516
|
+
h("div", { class: "sched-presets" }, presets.map((p) => h("button", { class: "btn", onclick: () => setAt(p.at) }, p.label))),
|
|
2517
|
+
h("label", { class: "sched-row" }, h("span", {}, "发送时间"), when),
|
|
2518
|
+
h("div", { class: "muted" }, "到点由服务端发出,关掉页面也会发。到点时 agent 还在忙的话,会排在当前这轮之后。"),
|
|
2519
|
+
);
|
|
2520
|
+
const go = h("button", { class: "btn primary" }, edit ? "保存" : "定时发送");
|
|
2521
|
+
const close = modal({ title: edit ? "编辑定时消息" : "定时发送", body, foot: h("div", { class: "modal-foot" }, go) });
|
|
2522
|
+
text.focus();
|
|
2523
|
+
go.onclick = async () => {
|
|
2524
|
+
const at = new Date(when.value).getTime();
|
|
2525
|
+
if (!text.value.trim()) return toast("内容不能为空");
|
|
2526
|
+
if (!Number.isFinite(at)) return toast("选一下发送时间");
|
|
2527
|
+
if (at <= Date.now()) return toast("发送时间要在现在之后");
|
|
2528
|
+
go.disabled = true;
|
|
2529
|
+
try {
|
|
2530
|
+
const key = await ensureConversation();
|
|
2531
|
+
// 先排新的再删旧的:中途失败也不会把原来那条弄丢
|
|
2532
|
+
const info = await api(`/api/conversations/${encodeURIComponent(key)}/scheduled`, { method: "POST", body: { text: text.value, at } });
|
|
2533
|
+
S.convs.set(info.key, info);
|
|
2534
|
+
if (edit) await cancelScheduled(edit.id, true);
|
|
2535
|
+
renderScheduled();
|
|
2536
|
+
// 用的是输入框里的草稿:排好了就清掉
|
|
2537
|
+
if (!edit && text.value === ui.input.value) {
|
|
2538
|
+
ui.input.value = "";
|
|
2539
|
+
autosize();
|
|
2540
|
+
updateSendBtn();
|
|
2541
|
+
}
|
|
2542
|
+
close();
|
|
2543
|
+
toast(`已排好,${fmtWhen(at)} 发送`);
|
|
2544
|
+
} catch (err) {
|
|
2545
|
+
go.disabled = false;
|
|
2546
|
+
toast(err.message);
|
|
2547
|
+
}
|
|
2548
|
+
};
|
|
2284
2549
|
}
|
|
2285
2550
|
|
|
2286
2551
|
// ---------- 语音输入(浏览器自带 Web Speech API) ----------
|
|
@@ -2532,7 +2797,10 @@ async function ensureConversation() {
|
|
|
2532
2797
|
if (!S.draftAgent) throw new Error("没有可用的 agent");
|
|
2533
2798
|
let info;
|
|
2534
2799
|
try {
|
|
2535
|
-
info = await api("/api/conversations", {
|
|
2800
|
+
info = await api("/api/conversations", {
|
|
2801
|
+
method: "POST",
|
|
2802
|
+
body: { agent: S.draftAgent, cwd: S.draftCwd ?? undefined, mode: S.draftMode },
|
|
2803
|
+
});
|
|
2536
2804
|
} catch (err) {
|
|
2537
2805
|
// 选的目录没了(被删、或工作区改了):退回根目录,让用户重新确认
|
|
2538
2806
|
if (err.status === 400 && S.draftCwd) {
|
|
@@ -2620,12 +2888,15 @@ async function sendMessage() {
|
|
|
2620
2888
|
let key;
|
|
2621
2889
|
let res;
|
|
2622
2890
|
setSending(true);
|
|
2891
|
+
// 发出去之后恢复自动滚动;回显的那条用户消息到了就顶到最上面(见 applyEvent)
|
|
2892
|
+
S.pinNext = true;
|
|
2623
2893
|
try {
|
|
2624
2894
|
key = await ensureConversation();
|
|
2625
2895
|
if ("Notification" in window && Notification.permission === "default") void Notification.requestPermission();
|
|
2626
2896
|
// 命令(比如 /status 要现读额度)可能比普通发送慢,多给点时间
|
|
2627
2897
|
res = await WS.request({ t: "send", key, text, uploads: S.uploads.map((u) => u.id) }, 45000);
|
|
2628
2898
|
} catch (err) {
|
|
2899
|
+
S.pinNext = false;
|
|
2629
2900
|
toast(err.message);
|
|
2630
2901
|
return;
|
|
2631
2902
|
} finally {
|
|
@@ -2641,9 +2912,12 @@ async function sendMessage() {
|
|
|
2641
2912
|
S.uploads = [];
|
|
2642
2913
|
renderChips();
|
|
2643
2914
|
updateSendBtn();
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2915
|
+
if (res?.reply) {
|
|
2916
|
+
// 命令不进会话记录,没有回显:直接贴到底看结果
|
|
2917
|
+
S.pinNext = false;
|
|
2918
|
+
await showCommandReply(text.trim(), res.reply, res.info);
|
|
2919
|
+
scrollToBottom();
|
|
2920
|
+
} else if (!ui.pinEl) ui.follow = true;
|
|
2647
2921
|
}
|
|
2648
2922
|
|
|
2649
2923
|
/** 会改变当前会话(换会话、换目录、换 agent)的命令,执行完要重拉一遍 */
|
|
@@ -3284,10 +3558,12 @@ function renameConv(key = S.current) {
|
|
|
3284
3558
|
* 确认弹窗(替代浏览器自带的 confirm),样式和重命名弹窗一致。
|
|
3285
3559
|
* 回车确认、Esc / 点外面取消;resolve 成 true / false
|
|
3286
3560
|
*/
|
|
3287
|
-
|
|
3561
|
+
/** altText:给了就多一个按钮,点它返回 "alt"(确定返回 true,取消返回 false) */
|
|
3562
|
+
function confirmDialog({ title, message, sub, confirmText = "确定", danger = false, altText }) {
|
|
3288
3563
|
return new Promise((resolve) => {
|
|
3289
3564
|
const ok = h("button", { class: `btn ${danger ? "danger-fill" : "primary"}` }, confirmText);
|
|
3290
3565
|
const cancel = h("button", { class: "btn ghost" }, "取消");
|
|
3566
|
+
const alt = altText ? h("button", { class: "btn", onclick: () => done("alt") }, altText) : null;
|
|
3291
3567
|
const done = (v) => {
|
|
3292
3568
|
overlay.classList.add("leaving");
|
|
3293
3569
|
setTimeout(() => overlay.remove(), 120);
|
|
@@ -3314,7 +3590,7 @@ function confirmDialog({ title, message, sub, confirmText = "确定", danger = f
|
|
|
3314
3590
|
h("h3", {}, title),
|
|
3315
3591
|
sub ? h("p", { class: "dialog-sub" }, sub) : null,
|
|
3316
3592
|
message ? h("p", { class: "dialog-msg" }, message) : null,
|
|
3317
|
-
h("div", { class: "dialog-foot" }, cancel, ok),
|
|
3593
|
+
h("div", { class: "dialog-foot" }, cancel, alt, ok),
|
|
3318
3594
|
),
|
|
3319
3595
|
);
|
|
3320
3596
|
document.body.append(overlay);
|
|
@@ -3354,6 +3630,7 @@ async function openSettings() {
|
|
|
3354
3630
|
// 普通成员只能管自己的通知、密码和登录设备;其余设置都是管理员的
|
|
3355
3631
|
if (!isAdmin()) {
|
|
3356
3632
|
const tabs = [
|
|
3633
|
+
["记忆", () => memoryTab(panel)],
|
|
3357
3634
|
["通知", () => notifyTab(panel)],
|
|
3358
3635
|
["账号", () => securityTab(panel)],
|
|
3359
3636
|
];
|
|
@@ -3379,6 +3656,7 @@ async function openSettings() {
|
|
|
3379
3656
|
["命令", () => commandsTab(cfg, panel)],
|
|
3380
3657
|
["飞书机器人", () => feishuTab(cfg, panel)],
|
|
3381
3658
|
["授权", () => authTab(panel)],
|
|
3659
|
+
["记忆", () => memoryTab(panel)],
|
|
3382
3660
|
["通知", () => notifyTab(panel)],
|
|
3383
3661
|
["账号", () => securityTab(panel)],
|
|
3384
3662
|
["用户", () => usersTab(panel)],
|
|
@@ -4659,6 +4937,7 @@ async function securityTab(panel) {
|
|
|
4659
4937
|
),
|
|
4660
4938
|
),
|
|
4661
4939
|
),
|
|
4940
|
+
feishuLinkSection(),
|
|
4662
4941
|
h(
|
|
4663
4942
|
"div",
|
|
4664
4943
|
{ class: "section" },
|
|
@@ -4708,6 +4987,354 @@ async function securityTab(panel) {
|
|
|
4708
4987
|
);
|
|
4709
4988
|
}
|
|
4710
4989
|
|
|
4990
|
+
// ---------- 记忆(每个人自己的) ----------
|
|
4991
|
+
|
|
4992
|
+
async function memoryTab(panel) {
|
|
4993
|
+
const box = h("div", { class: "section" }, h("h4", {}, "记忆"), h("span", { class: "spinner" }));
|
|
4994
|
+
fill(panel, box);
|
|
4995
|
+
let mem;
|
|
4996
|
+
try {
|
|
4997
|
+
mem = await api("/api/account/memory");
|
|
4998
|
+
} catch (err) {
|
|
4999
|
+
fill(box, h("h4", {}, "记忆"), h("div", { class: "error-text" }, err.message));
|
|
5000
|
+
return;
|
|
5001
|
+
}
|
|
5002
|
+
const reload = () => void memoryTab(panel);
|
|
5003
|
+
const run = (btn, fn) => async () => {
|
|
5004
|
+
btn.disabled = true;
|
|
5005
|
+
try {
|
|
5006
|
+
if ((await fn()) !== false) reload();
|
|
5007
|
+
} catch (err) {
|
|
5008
|
+
toast(err.message);
|
|
5009
|
+
btn.disabled = false;
|
|
5010
|
+
}
|
|
5011
|
+
};
|
|
5012
|
+
|
|
5013
|
+
const enabled = h("input", { type: "checkbox", checked: mem.enabled });
|
|
5014
|
+
enabled.onchange = async () => {
|
|
5015
|
+
try {
|
|
5016
|
+
await api("/api/account/memory", { method: "PUT", body: { enabled: enabled.checked } });
|
|
5017
|
+
toast(enabled.checked ? "记忆已开启" : "记忆已关闭,已有的内容保留");
|
|
5018
|
+
} catch (err) {
|
|
5019
|
+
enabled.checked = !enabled.checked;
|
|
5020
|
+
toast(err.message);
|
|
5021
|
+
}
|
|
5022
|
+
};
|
|
5023
|
+
|
|
5024
|
+
/** 一个开关:改了立刻存 */
|
|
5025
|
+
const toggle = (key, label, on, off) => {
|
|
5026
|
+
const box = h("input", { type: "checkbox", checked: mem[key] });
|
|
5027
|
+
box.onchange = async () => {
|
|
5028
|
+
try {
|
|
5029
|
+
await api("/api/account/memory", { method: "PUT", body: { [key]: box.checked } });
|
|
5030
|
+
toast(box.checked ? on : off);
|
|
5031
|
+
} catch (err) {
|
|
5032
|
+
box.checked = !box.checked;
|
|
5033
|
+
toast(err.message);
|
|
5034
|
+
}
|
|
5035
|
+
};
|
|
5036
|
+
return h("label", { class: "check" }, box, label);
|
|
5037
|
+
};
|
|
5038
|
+
const history = await api("/api/account/history").catch(() => null);
|
|
5039
|
+
const clearHistory = h("button", { class: "btn small" }, "清空聊天记录索引");
|
|
5040
|
+
clearHistory.onclick = run(clearHistory, async () => {
|
|
5041
|
+
const sure = await confirmDialog({
|
|
5042
|
+
title: "清空聊天记录索引?",
|
|
5043
|
+
message: "agent 将搜不到以前的聊天(对话本身不删,侧栏里照样能打开)。之后的聊天还会继续记进来。",
|
|
5044
|
+
confirmText: "清空",
|
|
5045
|
+
danger: true,
|
|
5046
|
+
});
|
|
5047
|
+
if (!sure) return false;
|
|
5048
|
+
await api("/api/account/history", { method: "DELETE" });
|
|
5049
|
+
toast("已清空");
|
|
5050
|
+
});
|
|
5051
|
+
|
|
5052
|
+
const about = h("textarea", { rows: 5, maxlength: mem.limits.about, placeholder: "比如:我叫小明,在深圳做后端开发,家里有台 QNAP NAS。回答尽量简短,代码用 TypeScript。" });
|
|
5053
|
+
about.value = mem.about;
|
|
5054
|
+
const saveAbout = h("button", { class: "btn primary" }, "保存");
|
|
5055
|
+
saveAbout.onclick = run(saveAbout, async () => {
|
|
5056
|
+
await api("/api/account/memory", { method: "PUT", body: { about: about.value } });
|
|
5057
|
+
toast("已保存,之后的对话会用上");
|
|
5058
|
+
});
|
|
5059
|
+
|
|
5060
|
+
const newItem = h("input", { maxlength: mem.limits.item, style: { flex: "1", minWidth: "0" }, placeholder: "手动加一条,比如:我的软路由是 iStoreOS,地址 192.168.8.2" });
|
|
5061
|
+
const addBtn = h("button", { class: "btn" }, "添加");
|
|
5062
|
+
addBtn.onclick = run(addBtn, async () => {
|
|
5063
|
+
if (!newItem.value.trim()) return false;
|
|
5064
|
+
await api("/api/account/memory/items", { method: "POST", body: { text: newItem.value } });
|
|
5065
|
+
});
|
|
5066
|
+
newItem.onkeydown = (e) => {
|
|
5067
|
+
if (e.key === "Enter" && !e.isComposing) addBtn.click();
|
|
5068
|
+
};
|
|
5069
|
+
|
|
5070
|
+
const items = [...mem.items].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
5071
|
+
const itemRow = (it) => {
|
|
5072
|
+
const from = { user: "你加的", agent: "agent 在聊天里记的", auto: "聊完后自动整理的" }[it.source] ?? "agent 记的";
|
|
5073
|
+
const body = h("div", { class: "grow" }, h("div", { class: "t", style: { whiteSpace: "pre-wrap" } }, it.text), h("div", { class: "s" }, `${from} · ${relTime(it.updatedAt)}`));
|
|
5074
|
+
const edit = h("button", { class: "btn small" }, "编辑");
|
|
5075
|
+
const del = h("button", { class: "btn small danger" }, "删除");
|
|
5076
|
+
edit.onclick = () => {
|
|
5077
|
+
const input = h("textarea", { rows: 2, maxlength: mem.limits.item });
|
|
5078
|
+
input.value = it.text;
|
|
5079
|
+
const ok = h("button", { class: "btn small primary" }, "保存");
|
|
5080
|
+
ok.onclick = run(ok, () => api(`/api/account/memory/items/${it.id}`, { method: "PATCH", body: { text: input.value } }));
|
|
5081
|
+
fill(body, input, h("div", { class: "row" }, ok, h("button", { class: "btn small", onclick: reload }, "取消")));
|
|
5082
|
+
input.focus();
|
|
5083
|
+
};
|
|
5084
|
+
del.onclick = run(del, () => api(`/api/account/memory/items/${it.id}`, { method: "DELETE" }));
|
|
5085
|
+
return h("div", { class: "list-item" }, body, edit, del);
|
|
5086
|
+
};
|
|
5087
|
+
|
|
5088
|
+
const clearBtn = h("button", { class: "btn danger" }, "清空全部记忆");
|
|
5089
|
+
clearBtn.onclick = run(clearBtn, async () => {
|
|
5090
|
+
const sure = await confirmDialog({
|
|
5091
|
+
title: "清空全部记忆?",
|
|
5092
|
+
message: "「关于我」和所有记住的事都会删除,不能恢复。",
|
|
5093
|
+
confirmText: "清空",
|
|
5094
|
+
danger: true,
|
|
5095
|
+
});
|
|
5096
|
+
if (!sure) return false;
|
|
5097
|
+
await api("/api/account/memory", { method: "DELETE" });
|
|
5098
|
+
toast("已清空");
|
|
5099
|
+
});
|
|
5100
|
+
|
|
5101
|
+
fill(
|
|
5102
|
+
panel,
|
|
5103
|
+
h(
|
|
5104
|
+
"div",
|
|
5105
|
+
{ class: "section" },
|
|
5106
|
+
h("h4", {}, "记忆", h("span", { class: "grow" }), h("label", { class: "check" }, enabled, "开启")),
|
|
5107
|
+
h(
|
|
5108
|
+
"div",
|
|
5109
|
+
{ class: "muted" },
|
|
5110
|
+
"像 ChatGPT 的记忆:对话时 agent 会参考你的「关于我」和记住的事,聊天里遇到长期有用的信息也会主动记一条(会在回复里告诉你)。Claude 和 Codex、Web 和飞书共用这一份。",
|
|
5111
|
+
),
|
|
5112
|
+
h(
|
|
5113
|
+
"div",
|
|
5114
|
+
{ class: "muted" },
|
|
5115
|
+
"用在哪些对话:你在 Web 上的对话;在「账号」里绑定了飞书后,和机器人的私聊也会用。飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它。",
|
|
5116
|
+
),
|
|
5117
|
+
),
|
|
5118
|
+
h(
|
|
5119
|
+
"div",
|
|
5120
|
+
{ class: "section" },
|
|
5121
|
+
h("h4", {}, "自动"),
|
|
5122
|
+
curateSection(mem, reload),
|
|
5123
|
+
toggle("chatHistory", "参考以前的聊天", "已开启:agent 能搜你以前的聊天了", "已关闭:agent 不再搜以前的聊天"),
|
|
5124
|
+
h(
|
|
5125
|
+
"div",
|
|
5126
|
+
{ class: "muted" },
|
|
5127
|
+
`agent 遇到「上次说的那个……」时,可以搜你以前的聊天(只有你自己的对话,被删掉的对话不算)。${history ? `现在收录了 ${history.conversations} 段对话、${history.count} 句话。` : ""}`,
|
|
5128
|
+
),
|
|
5129
|
+
h("div", { class: "row" }, clearHistory),
|
|
5130
|
+
),
|
|
5131
|
+
h("div", { class: "section" }, h("h4", {}, "关于我"), field("写给 agent 看的自我介绍:你是谁、在做什么、希望它怎么回答", about, `每次对话都会带上,最多 ${mem.limits.about} 字`), h("div", { class: "row" }, saveAbout)),
|
|
5132
|
+
h(
|
|
5133
|
+
"div",
|
|
5134
|
+
{ class: "section" },
|
|
5135
|
+
h("h4", {}, `记住的事(${items.length})`),
|
|
5136
|
+
h("div", { class: "row" }, newItem, addBtn),
|
|
5137
|
+
items.length ? items.map(itemRow) : h("div", { class: "muted" }, "还没有。聊天时说「记住……」,或者在上面手动加。"),
|
|
5138
|
+
),
|
|
5139
|
+
h(
|
|
5140
|
+
"div",
|
|
5141
|
+
{ class: "section" },
|
|
5142
|
+
h("div", { class: "muted" }, "注意:和多用户一样,这不是安全隔离 —— 工作模式下 agent 能读到这台机器上的文件,也包括记忆文件。"),
|
|
5143
|
+
h("div", { class: "row" }, clearBtn),
|
|
5144
|
+
),
|
|
5145
|
+
);
|
|
5146
|
+
}
|
|
5147
|
+
|
|
5148
|
+
/** 记忆页里的「整理记忆」:什么时候整理、用哪个 agent 的哪个模型、现在整理、上次的结果 */
|
|
5149
|
+
function curateSection(mem, reload) {
|
|
5150
|
+
const c = { ...mem.curate };
|
|
5151
|
+
const box = h("div");
|
|
5152
|
+
const save = async (patch) => {
|
|
5153
|
+
try {
|
|
5154
|
+
await api("/api/account/memory", { method: "PUT", body: { curate: patch } });
|
|
5155
|
+
Object.assign(c, patch);
|
|
5156
|
+
if (patch.agent === "auto") c.model = "";
|
|
5157
|
+
render();
|
|
5158
|
+
toast("已保存");
|
|
5159
|
+
} catch (err) {
|
|
5160
|
+
toast(err.message);
|
|
5161
|
+
render();
|
|
5162
|
+
}
|
|
5163
|
+
};
|
|
5164
|
+
const select = (value, options, onchange) =>
|
|
5165
|
+
h("select", { onchange: (e) => onchange(e.target.value) }, options.map(([v, label]) => h("option", { value: v, selected: String(v) === String(value) }, label)));
|
|
5166
|
+
|
|
5167
|
+
const render = () => {
|
|
5168
|
+
const when = select(
|
|
5169
|
+
c.when,
|
|
5170
|
+
[
|
|
5171
|
+
["daily", "每天一次,把当天聊过的合起来整理(推荐)"],
|
|
5172
|
+
["idle", "每段对话聊完后就整理"],
|
|
5173
|
+
["manual", "只在我点「现在整理」时"],
|
|
5174
|
+
["off", "不整理"],
|
|
5175
|
+
],
|
|
5176
|
+
(v) => save({ when: v }),
|
|
5177
|
+
);
|
|
5178
|
+
const timing =
|
|
5179
|
+
c.when === "idle"
|
|
5180
|
+
? field("停下来多久算聊完", select(c.idleMinutes, [10, 30, 60, 120, 240].map((m) => [m, m < 60 ? `${m} 分钟` : `${m / 60} 小时`]), (v) => save({ idleMinutes: Number(v) })))
|
|
5181
|
+
: c.when === "daily"
|
|
5182
|
+
? field(
|
|
5183
|
+
"几点整理(服务器所在时区)",
|
|
5184
|
+
select(c.dailyHour, Array.from({ length: 24 }, (_, i) => [i, `${i} 点`]), (v) => save({ dailyHour: Number(v) })),
|
|
5185
|
+
"这个点之后第一次检查时整理;服务器那会儿没开机的话,开机后补上",
|
|
5186
|
+
)
|
|
5187
|
+
: null;
|
|
5188
|
+
const agent = select(
|
|
5189
|
+
c.agent,
|
|
5190
|
+
[
|
|
5191
|
+
["auto", "自动:有 Claude 就用 Claude,否则 Codex"],
|
|
5192
|
+
["claude", "Claude Code"],
|
|
5193
|
+
["codex", "Codex"],
|
|
5194
|
+
],
|
|
5195
|
+
(v) => save({ agent: v, model: "" }),
|
|
5196
|
+
);
|
|
5197
|
+
const model =
|
|
5198
|
+
c.agent === "auto"
|
|
5199
|
+
? null
|
|
5200
|
+
: (() => {
|
|
5201
|
+
const sel = h("select", { onchange: (e) => save({ model: e.target.value }) }, h("option", { value: "" }, c.agent === "claude" ? "默认:Haiku(最省)" : "默认模型,思考程度 low(最省)"));
|
|
5202
|
+
if (c.model) sel.append(h("option", { value: c.model, selected: true }, c.model));
|
|
5203
|
+
agentCatalog(c.agent)
|
|
5204
|
+
.then((models) => {
|
|
5205
|
+
for (const m of models) {
|
|
5206
|
+
if (m.id === "default" || m.id === c.model) continue;
|
|
5207
|
+
sel.append(h("option", { value: m.id }, m.label || m.id));
|
|
5208
|
+
}
|
|
5209
|
+
})
|
|
5210
|
+
.catch(() => sel.append(h("option", { value: "", disabled: true }, "(这个 agent 没启用,读不到模型列表)")));
|
|
5211
|
+
return field("模型", sel, "整理只是读一遍对话、改几条记忆,用便宜的模型就够了;贵的模型会多占订阅额度");
|
|
5212
|
+
})();
|
|
5213
|
+
|
|
5214
|
+
const nowBtn = h("button", { class: "btn small", disabled: c.when === "off" || !mem.enabled }, "现在整理");
|
|
5215
|
+
nowBtn.onclick = async () => {
|
|
5216
|
+
nowBtn.disabled = true;
|
|
5217
|
+
nowBtn.textContent = "整理中…";
|
|
5218
|
+
try {
|
|
5219
|
+
const r = await api("/api/account/memory/curate", { method: "POST" });
|
|
5220
|
+
toast(
|
|
5221
|
+
r.error
|
|
5222
|
+
? `整理失败:${r.error}`
|
|
5223
|
+
: r.conversations
|
|
5224
|
+
? `整理了 ${r.conversations} 段对话:新增 ${r.added.length}、更新 ${r.updated.length}、删除 ${r.removed}`
|
|
5225
|
+
: "没有需要整理的对话(正在回复的那段先不整理)",
|
|
5226
|
+
);
|
|
5227
|
+
reload();
|
|
5228
|
+
} catch (err) {
|
|
5229
|
+
toast(err.message);
|
|
5230
|
+
nowBtn.disabled = false;
|
|
5231
|
+
nowBtn.textContent = "现在整理";
|
|
5232
|
+
}
|
|
5233
|
+
};
|
|
5234
|
+
const last = mem.lastCuration;
|
|
5235
|
+
const lastText = last
|
|
5236
|
+
? last.error
|
|
5237
|
+
? `上次整理(${relTime(last.at)})没成功:${last.error}`
|
|
5238
|
+
: `上次整理:${relTime(last.at)},${last.conversations} 段对话${last.by ? `,用 ${last.by}` : ""},新增 ${last.added.length}、更新 ${last.updated.length}、删除 ${last.removed}`
|
|
5239
|
+
: "还没整理过";
|
|
5240
|
+
|
|
5241
|
+
fill(
|
|
5242
|
+
box,
|
|
5243
|
+
field("整理记忆", when),
|
|
5244
|
+
h(
|
|
5245
|
+
"div",
|
|
5246
|
+
{ class: "muted" },
|
|
5247
|
+
{
|
|
5248
|
+
daily: "攒一天再整理:每次整理都要把你现有的记忆整份带上,合起来一次最省额度,几段对话放在一起看也更容易合并重复。改了什么只显示在这里,不打扰对话。",
|
|
5249
|
+
idle: "每段对话停下来就整理一次,改了什么会在那条对话里告诉你。最及时,但聊得多时调用次数也多。",
|
|
5250
|
+
manual: "聊过的内容会攒着,你点「现在整理」时一次整理完。",
|
|
5251
|
+
off: "不整理,聊过的内容也不攒着。聊天里 agent 照样可以自己用 remember 记东西。",
|
|
5252
|
+
}[c.when],
|
|
5253
|
+
),
|
|
5254
|
+
timing,
|
|
5255
|
+
c.when === "off" ? null : field("用哪个 agent", agent),
|
|
5256
|
+
c.when === "off" ? null : model,
|
|
5257
|
+
h("div", { class: "row", style: { alignItems: "center", gap: "10px" } }, nowBtn, h("span", { class: "muted" }, `${mem.pending ? `有 ${mem.pending} 段对话等着整理。` : ""}${lastText}`)),
|
|
5258
|
+
h("div", { class: "muted" }, "整理时会对照现有记忆补充、更新或删掉过时的条目;你自己加的条目它不会动。"),
|
|
5259
|
+
);
|
|
5260
|
+
};
|
|
5261
|
+
render();
|
|
5262
|
+
return box;
|
|
5263
|
+
}
|
|
5264
|
+
|
|
5265
|
+
/** 「账号」页里的飞书绑定:Web 上生成绑定码,再在飞书私聊里发 /bind,两边都证明是本人 */
|
|
5266
|
+
function feishuLinkSection() {
|
|
5267
|
+
const box = h("div", { class: "section" });
|
|
5268
|
+
const load = async () => {
|
|
5269
|
+
let data;
|
|
5270
|
+
try {
|
|
5271
|
+
data = await api("/api/account/feishu");
|
|
5272
|
+
} catch (err) {
|
|
5273
|
+
fill(box, h("h4", {}, "飞书账号"), h("div", { class: "error-text" }, err.message));
|
|
5274
|
+
return;
|
|
5275
|
+
}
|
|
5276
|
+
// 没有飞书机器人、也没绑过:整节不显示
|
|
5277
|
+
if (!data.bots.length && !data.links.length) return fill(box);
|
|
5278
|
+
const codeBox = h("div");
|
|
5279
|
+
const gen = h("button", { class: "btn" }, data.links.length ? "再绑一个" : "生成绑定码");
|
|
5280
|
+
gen.onclick = async () => {
|
|
5281
|
+
gen.disabled = true;
|
|
5282
|
+
try {
|
|
5283
|
+
const { code, expiresAt } = await api("/api/account/feishu/code", { method: "POST" });
|
|
5284
|
+
const cmd = `/bind ${code}`;
|
|
5285
|
+
const copy = h("button", { class: "btn small" }, "复制");
|
|
5286
|
+
copy.onclick = () => copyText(cmd, copy);
|
|
5287
|
+
fill(
|
|
5288
|
+
codeBox,
|
|
5289
|
+
h(
|
|
5290
|
+
"div",
|
|
5291
|
+
{ class: "list-item" },
|
|
5292
|
+
h(
|
|
5293
|
+
"div",
|
|
5294
|
+
{ class: "grow" },
|
|
5295
|
+
h("div", { class: "t" }, h("code", {}, cmd)),
|
|
5296
|
+
h("div", { class: "s" }, `在飞书里私聊机器人(${data.bots.map((b) => b.name).join("、") || "飞书机器人"})发这条消息,${Math.round((expiresAt - Date.now()) / 60000)} 分钟内有效。没用过机器人的话,要先让管理员批准你的配对码。`),
|
|
5297
|
+
),
|
|
5298
|
+
copy,
|
|
5299
|
+
),
|
|
5300
|
+
);
|
|
5301
|
+
} catch (err) {
|
|
5302
|
+
toast(err.message);
|
|
5303
|
+
}
|
|
5304
|
+
gen.disabled = false;
|
|
5305
|
+
};
|
|
5306
|
+
fill(
|
|
5307
|
+
box,
|
|
5308
|
+
h("h4", {}, "飞书账号"),
|
|
5309
|
+
h("div", { class: "muted" }, "绑定后,你和飞书机器人的私聊就是你的个人会话:用你的记忆,/sessions 只列你的会话,/web 能接上你自己的 Web 会话。群聊不受影响。"),
|
|
5310
|
+
data.links.map((l) => {
|
|
5311
|
+
const rm = h("button", { class: "btn small danger" }, "解除绑定");
|
|
5312
|
+
rm.onclick = async () => {
|
|
5313
|
+
rm.disabled = true;
|
|
5314
|
+
try {
|
|
5315
|
+
await api("/api/account/feishu", { method: "DELETE", body: { botId: l.botId, openId: l.openId } });
|
|
5316
|
+
toast("已解除,那个私聊不再用你的记忆");
|
|
5317
|
+
void load();
|
|
5318
|
+
} catch (err) {
|
|
5319
|
+
toast(err.message);
|
|
5320
|
+
rm.disabled = false;
|
|
5321
|
+
}
|
|
5322
|
+
};
|
|
5323
|
+
return h(
|
|
5324
|
+
"div",
|
|
5325
|
+
{ class: "list-item" },
|
|
5326
|
+
h("div", { class: "grow" }, h("div", { class: "t" }, l.botName), h("div", { class: "s" }, `${l.openIdShort} · 绑定于 ${relTime(l.boundAt)}`)),
|
|
5327
|
+
rm,
|
|
5328
|
+
);
|
|
5329
|
+
}),
|
|
5330
|
+
h("div", { class: "row" }, gen),
|
|
5331
|
+
codeBox,
|
|
5332
|
+
);
|
|
5333
|
+
};
|
|
5334
|
+
void load();
|
|
5335
|
+
return box;
|
|
5336
|
+
}
|
|
5337
|
+
|
|
4711
5338
|
// ---------- 用户(管理员) ----------
|
|
4712
5339
|
|
|
4713
5340
|
async function usersTab(panel) {
|
|
@@ -4770,11 +5397,30 @@ async function usersTab(panel) {
|
|
|
4770
5397
|
roleBtn.onclick = act(roleBtn, () => patch(u, { role: u.role === "admin" ? "member" : "admin" }));
|
|
4771
5398
|
const disBtn = h("button", { class: "btn small", disabled: self }, u.disabled ? "启用" : "停用");
|
|
4772
5399
|
disBtn.onclick = act(disBtn, () => patch(u, { disabled: !u.disabled }));
|
|
5400
|
+
const memBtn = h("button", { class: "btn small", title: "删除他的「关于我」和所有记住的事。管理员看不到记忆的内容" }, "清空记忆");
|
|
5401
|
+
memBtn.onclick = act(memBtn, async () => {
|
|
5402
|
+
const sure = await confirmDialog({
|
|
5403
|
+
title: `清空「${u.name}」的记忆?`,
|
|
5404
|
+
message: "他的「关于我」和所有记住的事都会删除,不能恢复。",
|
|
5405
|
+
confirmText: "清空",
|
|
5406
|
+
danger: true,
|
|
5407
|
+
});
|
|
5408
|
+
if (!sure) return false;
|
|
5409
|
+
await api(`/api/users/${encodeURIComponent(u.id)}/memory`, { method: "DELETE" });
|
|
5410
|
+
toast("已清空");
|
|
5411
|
+
});
|
|
5412
|
+
const linkBtn = u.feishuLinks ? h("button", { class: "btn small" }, "解除飞书绑定") : null;
|
|
5413
|
+
if (linkBtn) {
|
|
5414
|
+
linkBtn.onclick = act(linkBtn, async () => {
|
|
5415
|
+
await api(`/api/users/${encodeURIComponent(u.id)}/feishu`, { method: "DELETE" });
|
|
5416
|
+
toast("已解除,他和机器人的私聊交还给管理员");
|
|
5417
|
+
});
|
|
5418
|
+
}
|
|
4773
5419
|
const rmBtn = h("button", { class: "btn small danger", disabled: self }, "删除");
|
|
4774
5420
|
rmBtn.onclick = act(rmBtn, async () => {
|
|
4775
5421
|
const sure = await confirmDialog({
|
|
4776
5422
|
title: `删除用户「${u.name}」?`,
|
|
4777
|
-
message: "
|
|
5423
|
+
message: "他的设备会立刻下线,记忆和飞书绑定一起删除。他的对话不会删除,之后归管理员。",
|
|
4778
5424
|
confirmText: "删除",
|
|
4779
5425
|
danger: true,
|
|
4780
5426
|
});
|
|
@@ -4797,12 +5443,14 @@ async function usersTab(panel) {
|
|
|
4797
5443
|
h("span", { class: "status-tag" }, u.role === "admin" ? "管理员" : "成员"),
|
|
4798
5444
|
u.disabled ? h("span", { class: "status-tag bad" }, "已停用") : null,
|
|
4799
5445
|
),
|
|
4800
|
-
h("div", { class: "s" }, `创建于 ${relTime(u.createdAt)} · ${u.devices}
|
|
5446
|
+
h("div", { class: "s" }, `创建于 ${relTime(u.createdAt)} · ${u.devices} 台设备已登录${u.feishuLinks ? ` · 绑定了 ${u.feishuLinks} 个飞书账号` : ""}`),
|
|
4801
5447
|
extra,
|
|
4802
5448
|
),
|
|
4803
5449
|
pwBtn,
|
|
4804
5450
|
roleBtn,
|
|
4805
5451
|
disBtn,
|
|
5452
|
+
memBtn,
|
|
5453
|
+
linkBtn,
|
|
4806
5454
|
rmBtn,
|
|
4807
5455
|
);
|
|
4808
5456
|
}),
|
|
@@ -4872,10 +5520,6 @@ async function systemTab(cfg, panel) {
|
|
|
4872
5520
|
|
|
4873
5521
|
// ---------- 启动 ----------
|
|
4874
5522
|
|
|
4875
|
-
boot()
|
|
4876
|
-
.then(() => {
|
|
4877
|
-
ui.messages?.addEventListener("scroll", () => (ui.stick = nearBottom()), { passive: true });
|
|
4878
|
-
})
|
|
4879
|
-
.catch((err) => {
|
|
5523
|
+
boot().catch((err) => {
|
|
4880
5524
|
fill($app, h("div", { class: "auth" }, h("div", { class: "auth-card" }, h("p", {}, `加载失败:${err.message}`))));
|
|
4881
5525
|
});
|