agent-loft 0.2.29 → 0.2.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/channels/web/server.ts +1 -1
- package/web/app.js +8 -5560
- package/web/css/base.css +291 -0
- package/web/css/composer.css +506 -0
- package/web/css/loader.css +196 -0
- package/web/css/messages.css +734 -0
- package/web/css/mobile.css +100 -0
- package/web/css/overlay.css +437 -0
- package/web/css/schedule.css +93 -0
- package/web/css/settings.css +335 -0
- package/web/css/sidebar.css +593 -0
- package/web/css/topbar.css +205 -0
- package/web/index.html +11 -1
- package/web/lib/api.js +26 -0
- package/web/lib/dom.js +101 -0
- package/web/lib/markdown.js +52 -0
- package/web/lib/overlay.js +144 -0
- package/web/lib/push.js +70 -0
- package/web/lib/time.js +60 -0
- package/web/lib/ws.js +232 -0
- package/web/settings/agents.js +210 -0
- package/web/settings/commands.js +411 -0
- package/web/settings/common.js +42 -0
- package/web/settings/feishu.js +181 -0
- package/web/settings/general.js +184 -0
- package/web/settings/index.js +63 -0
- package/web/settings/memory.js +366 -0
- package/web/settings/notify.js +80 -0
- package/web/settings/security.js +188 -0
- package/web/settings/users.js +189 -0
- package/web/state.js +44 -0
- package/web/views/auth.js +61 -0
- package/web/views/composer.js +324 -0
- package/web/views/conv-actions.js +404 -0
- package/web/views/interaction.js +140 -0
- package/web/views/layout.js +186 -0
- package/web/views/messages.js +510 -0
- package/web/views/quota.js +145 -0
- package/web/views/schedule.js +120 -0
- package/web/views/sidebar.js +455 -0
- package/web/views/slash.js +185 -0
- package/web/views/topbar.js +394 -0
- package/web/views/voice.js +189 -0
- package/web/style.css +0 -3500
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { fill, h, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { desktop, desktopQuota } from "../state.js";
|
|
4
|
+
import { field, saveConfig } from "./common.js";
|
|
5
|
+
|
|
6
|
+
/** 服务端版本:有新版就能一键升级(npm install -g,系统服务下自动重启) */
|
|
7
|
+
export function serverVersionSection() {
|
|
8
|
+
const box = h("div", { class: "section" });
|
|
9
|
+
const load = async (refresh) => {
|
|
10
|
+
fill(box, h("h4", {}, "服务端版本", h("span", { class: "grow" }), h("span", { class: "spinner" })));
|
|
11
|
+
let v;
|
|
12
|
+
try {
|
|
13
|
+
v = await api(`/api/server/version${refresh ? "?refresh=1" : ""}`);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
fill(box, h("h4", {}, "服务端版本"), h("div", { class: "error-text" }, err.message));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const recheck = h("button", { class: "btn small", onclick: () => load(true) }, "检查更新");
|
|
19
|
+
const status = v.updateAvailable
|
|
20
|
+
? `有新版本 ${v.latest}`
|
|
21
|
+
: v.latest
|
|
22
|
+
? "已是最新版本"
|
|
23
|
+
: `查不到最新版本${v.latestError ? `:${v.latestError}` : ""}`;
|
|
24
|
+
const upgradeBtn =
|
|
25
|
+
v.updateAvailable && v.canUpgrade
|
|
26
|
+
? h(
|
|
27
|
+
"button",
|
|
28
|
+
{
|
|
29
|
+
class: "btn small primary",
|
|
30
|
+
onclick: async () => {
|
|
31
|
+
upgradeBtn.disabled = true;
|
|
32
|
+
upgradeBtn.textContent = "正在升级…";
|
|
33
|
+
try {
|
|
34
|
+
const r = await api("/api/server/upgrade", { method: "POST" });
|
|
35
|
+
toast(r.restarting ? `已升级到 ${r.to},服务正在重启,页面会自动重连` : `已升级到 ${r.to},重启 agent-loft 后生效`, 6000);
|
|
36
|
+
void load(false);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
toast(`升级失败:${err.message}`, 6000);
|
|
39
|
+
upgradeBtn.disabled = false;
|
|
40
|
+
upgradeBtn.textContent = "升级";
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
"升级",
|
|
45
|
+
)
|
|
46
|
+
: null;
|
|
47
|
+
fill(
|
|
48
|
+
box,
|
|
49
|
+
h("h4", {}, "服务端版本", h("span", { class: "grow" }), upgradeBtn, recheck),
|
|
50
|
+
h("div", { class: "health-meta" }, `当前 ${v.version} · ${status}`),
|
|
51
|
+
v.updateAvailable && !v.canUpgrade && v.reason ? h("div", { class: "health-meta" }, v.reason) : null,
|
|
52
|
+
v.updateAvailable && v.canUpgrade && !v.service ? h("div", { class: "health-meta" }, "agent-loft 是在前台手动运行的,升级后要自己重启一下。") : null,
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
void load(false);
|
|
56
|
+
return box;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function generalTab(cfg, panel) {
|
|
60
|
+
const root = h("input", { value: cfg.workspaceRoot });
|
|
61
|
+
const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
|
|
62
|
+
const ttl = h("input", { type: "number", min: 0, value: Math.round(cfg.sessionTtlMs / 3600000) });
|
|
63
|
+
const quotaMin = h("input", { type: "number", min: 0, max: 1440, value: cfg.web?.quotaRefreshMin ?? 10 });
|
|
64
|
+
const quotaOn = h("input", { type: "checkbox", checked: cfg.web?.quotaEnabled !== false });
|
|
65
|
+
panel.append(
|
|
66
|
+
h(
|
|
67
|
+
"div",
|
|
68
|
+
{ class: "section" },
|
|
69
|
+
h("h4", {}, "通用"),
|
|
70
|
+
field("工作目录(workspace)", root, "所有会话的默认目录,/cd 只能在它之内"),
|
|
71
|
+
field("授权等待超时(分钟)", approval, "超时按拒绝处理"),
|
|
72
|
+
field("会话闲置多久自动开新会话(小时)", ttl, "0 表示永不过期"),
|
|
73
|
+
h("label", { class: "check" }, quotaOn, "查询账号额度"),
|
|
74
|
+
h("div", { class: "muted" }, "关掉后侧栏、/status、桌面端都不再去问 agent 的额度(Claude 每次要临时起一个 CLI 进程才查得到)"),
|
|
75
|
+
field(
|
|
76
|
+
"额度自动刷新间隔(分钟)",
|
|
77
|
+
quotaMin,
|
|
78
|
+
desktopQuota
|
|
79
|
+
? "在桌面端里额度由桌面端统一刷新,以桌面端设置里的间隔为准;这里只对浏览器打开的页面生效"
|
|
80
|
+
: "侧栏底部的额度,空闲时多久刷新一次;0 表示不自动刷新(跑完一轮、点击额度区域仍会刷新)",
|
|
81
|
+
),
|
|
82
|
+
h(
|
|
83
|
+
"div",
|
|
84
|
+
{ class: "row" },
|
|
85
|
+
h(
|
|
86
|
+
"button",
|
|
87
|
+
{
|
|
88
|
+
class: "btn primary",
|
|
89
|
+
onclick: () =>
|
|
90
|
+
saveConfig(cfg, {
|
|
91
|
+
workspaceRoot: root.value,
|
|
92
|
+
approvalTimeoutMs: Number(approval.value) * 60000,
|
|
93
|
+
sessionTtlMs: Number(ttl.value) * 3600000,
|
|
94
|
+
web: { quotaRefreshMin: Number(quotaMin.value), quotaEnabled: quotaOn.checked },
|
|
95
|
+
}),
|
|
96
|
+
},
|
|
97
|
+
"保存",
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 只有本机能连的监听地址(和服务端 listen.ts 的判断一致) */
|
|
105
|
+
function loopbackHost(host) {
|
|
106
|
+
const v = String(host ?? "").trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
107
|
+
return v === "localhost" || v === "::1" || /^127\./.test(v);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 网络访问:要不要让局域网里的其他设备打开、监听哪个端口。保存后服务端当场换过去,不用重启;
|
|
112
|
+
* 端口被占用之类的失败会直接报出来,原来的照常工作。
|
|
113
|
+
*/
|
|
114
|
+
export function networkSection(cfg) {
|
|
115
|
+
const net = cfg.network ?? { lan: [], local: true, listening: null };
|
|
116
|
+
const box = h("div", { class: "section" });
|
|
117
|
+
const lanOn = h("input", { type: "checkbox", checked: !loopbackHost(cfg.web.host) });
|
|
118
|
+
const port = h("input", { type: "number", min: 1, max: 65535, value: cfg.web.port });
|
|
119
|
+
const hint = h("small", {});
|
|
120
|
+
// 现在这个页面是不是直接连的这个端口(不是经由反代 / 隧道)
|
|
121
|
+
const pagePort = Number(location.port || (location.protocol === "https:" ? 443 : 80));
|
|
122
|
+
const direct = pagePort === cfg.web.port;
|
|
123
|
+
|
|
124
|
+
const update = () => {
|
|
125
|
+
const p = Number(port.value) || cfg.web.port;
|
|
126
|
+
const lines = [];
|
|
127
|
+
if (lanOn.checked) {
|
|
128
|
+
lines.push(
|
|
129
|
+
net.lan.length
|
|
130
|
+
? `局域网里的其他设备用 ${net.lan.map((ip) => `http://${ip}:${p}`).join(" 或 ")} 打开`
|
|
131
|
+
: "这台机器现在没连局域网,连上后其他设备可以用它的局域网地址打开",
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
lines.push("只有这台机器自己能打开。这台机器上的反向代理、Cloudflare Tunnel 之类不受影响");
|
|
135
|
+
}
|
|
136
|
+
if (!net.local && !lanOn.checked && !loopbackHost(cfg.web.host)) {
|
|
137
|
+
lines.push("⚠️ 你现在是从局域网里的另一台设备访问的,不能在这里关掉,请到运行 agent-loft 的机器上操作");
|
|
138
|
+
}
|
|
139
|
+
if (!direct && p !== cfg.web.port) lines.push(`⚠️ 你是经由反向代理 / 隧道访问的,改端口后要把它指向新端口 ${p},否则这个页面会打不开`);
|
|
140
|
+
fill(hint, lines.map((l, i) => [i ? h("br") : null, l]));
|
|
141
|
+
};
|
|
142
|
+
lanOn.addEventListener("change", update);
|
|
143
|
+
port.addEventListener("input", update);
|
|
144
|
+
update();
|
|
145
|
+
|
|
146
|
+
const save = async () => {
|
|
147
|
+
const p = Number(port.value);
|
|
148
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) return toast("端口要是 1 到 65535 之间的整数");
|
|
149
|
+
// 打开:原来是本机地址就改成所有网卡;原来就是某个具体地址(手改过配置)就保留
|
|
150
|
+
const host = lanOn.checked ? (loopbackHost(cfg.web.host) ? "0.0.0.0" : cfg.web.host) : "127.0.0.1";
|
|
151
|
+
if (host === cfg.web.host && p === cfg.web.port) return toast("没有改动");
|
|
152
|
+
const oldPort = cfg.web.port;
|
|
153
|
+
let next;
|
|
154
|
+
try {
|
|
155
|
+
next = await api("/api/config", { method: "PUT", body: { web: { host, port: p } } });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
toast(err.message, 5000);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
Object.assign(cfg, next);
|
|
161
|
+
if (p !== oldPort && direct) {
|
|
162
|
+
// 旧端口已经不再接新连接:带着当前会话跳到新端口
|
|
163
|
+
const url = new URL(location.href);
|
|
164
|
+
url.port = String(p);
|
|
165
|
+
toast(`已改为端口 ${p},正在跳转…`);
|
|
166
|
+
// 桌面端里由它改掉存着的服务器地址再重新打开(直接跳过去会被当成外部链接)
|
|
167
|
+
if (typeof desktop?.serverMoved === "function") desktop.serverMoved(url.origin);
|
|
168
|
+
else setTimeout(() => (location.href = url.href), 600);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
toast(p !== oldPort ? `已改为端口 ${p},记得把反向代理 / 隧道也指向它` : "已保存", p !== oldPort ? 6000 : 2600);
|
|
172
|
+
box.replaceWith(networkSection(cfg));
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
fill(
|
|
176
|
+
box,
|
|
177
|
+
h("h4", {}, "网络访问"),
|
|
178
|
+
h("label", { class: "check" }, lanOn, "允许局域网内的其他设备访问"),
|
|
179
|
+
hint,
|
|
180
|
+
field("端口", port, "改了立即生效,不用重启"),
|
|
181
|
+
h("div", { class: "row" }, h("button", { class: "btn primary", onclick: () => void save() }, "保存")),
|
|
182
|
+
);
|
|
183
|
+
return box;
|
|
184
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { fill, h } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { isAdmin } from "../state.js";
|
|
4
|
+
import { modal } from "../lib/overlay.js";
|
|
5
|
+
import { notifyTab } from "./notify.js";
|
|
6
|
+
import { generalTab } from "./general.js";
|
|
7
|
+
import { commandsTab } from "./commands.js";
|
|
8
|
+
import { agentsTab } from "./agents.js";
|
|
9
|
+
import { feishuTab } from "./feishu.js";
|
|
10
|
+
import { authTab, securityTab } from "./security.js";
|
|
11
|
+
import { memoryTab } from "./memory.js";
|
|
12
|
+
import { systemTab, usersTab } from "./users.js";
|
|
13
|
+
|
|
14
|
+
// ---------- 设置 ----------
|
|
15
|
+
|
|
16
|
+
export async function openSettings() {
|
|
17
|
+
const nav = h("div", { class: "settings-nav" });
|
|
18
|
+
const panel = h("div", { class: "settings-body" });
|
|
19
|
+
// 设置弹窗高度固定,切标签页时不跟着内容一伸一缩
|
|
20
|
+
modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel) });
|
|
21
|
+
// 普通成员只能管自己的通知、密码和登录设备;其余设置都是管理员的
|
|
22
|
+
if (!isAdmin()) {
|
|
23
|
+
const tabs = [
|
|
24
|
+
["记忆", () => memoryTab(panel)],
|
|
25
|
+
["通知", () => notifyTab(panel)],
|
|
26
|
+
["账号", () => securityTab(panel)],
|
|
27
|
+
];
|
|
28
|
+
const select = (i) => {
|
|
29
|
+
nav.querySelectorAll("button").forEach((b, j) => b.classList.toggle("on", i === j));
|
|
30
|
+
fill(panel);
|
|
31
|
+
tabs[i][1]();
|
|
32
|
+
};
|
|
33
|
+
nav.append(...tabs.map(([name], i) => h("button", { onclick: () => select(i) }, name)));
|
|
34
|
+
select(0);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
let cfg;
|
|
38
|
+
try {
|
|
39
|
+
cfg = await api("/api/config");
|
|
40
|
+
} catch (err) {
|
|
41
|
+
panel.append(h("div", { class: "error-text" }, err.message));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const tabs = [
|
|
45
|
+
["通用", () => generalTab(cfg, panel)],
|
|
46
|
+
["Agent", () => agentsTab(cfg, panel)],
|
|
47
|
+
["命令", () => commandsTab(cfg, panel)],
|
|
48
|
+
["飞书机器人", () => feishuTab(cfg, panel)],
|
|
49
|
+
["授权", () => authTab(panel)],
|
|
50
|
+
["记忆", () => memoryTab(panel)],
|
|
51
|
+
["通知", () => notifyTab(panel)],
|
|
52
|
+
["账号", () => securityTab(panel)],
|
|
53
|
+
["用户", () => usersTab(panel)],
|
|
54
|
+
["系统", () => systemTab(cfg, panel)],
|
|
55
|
+
];
|
|
56
|
+
const select = (i) => {
|
|
57
|
+
nav.querySelectorAll("button").forEach((b, j) => b.classList.toggle("on", i === j));
|
|
58
|
+
fill(panel, );
|
|
59
|
+
tabs[i][1]();
|
|
60
|
+
};
|
|
61
|
+
nav.append(...tabs.map(([name], i) => h("button", { onclick: () => select(i) }, name)));
|
|
62
|
+
select(0);
|
|
63
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { fill, h, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { relTime } from "../lib/time.js";
|
|
4
|
+
import { copyText } from "../lib/markdown.js";
|
|
5
|
+
import { agentCatalog } from "../views/topbar.js";
|
|
6
|
+
import { confirmDialog } from "../lib/overlay.js";
|
|
7
|
+
import { field } from "./common.js";
|
|
8
|
+
|
|
9
|
+
// ---------- 记忆(每个人自己的) ----------
|
|
10
|
+
|
|
11
|
+
export async function memoryTab(panel) {
|
|
12
|
+
const box = h("div", { class: "section" }, h("h4", {}, "记忆"), h("span", { class: "spinner" }));
|
|
13
|
+
fill(panel, box);
|
|
14
|
+
let mem;
|
|
15
|
+
try {
|
|
16
|
+
mem = await api("/api/account/memory");
|
|
17
|
+
} catch (err) {
|
|
18
|
+
fill(box, h("h4", {}, "记忆"), h("div", { class: "error-text" }, err.message));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const reload = () => void memoryTab(panel);
|
|
22
|
+
const run = (btn, fn) => async () => {
|
|
23
|
+
btn.disabled = true;
|
|
24
|
+
try {
|
|
25
|
+
if ((await fn()) !== false) reload();
|
|
26
|
+
} catch (err) {
|
|
27
|
+
toast(err.message);
|
|
28
|
+
btn.disabled = false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const enabled = h("input", { type: "checkbox", checked: mem.enabled });
|
|
33
|
+
enabled.onchange = async () => {
|
|
34
|
+
try {
|
|
35
|
+
await api("/api/account/memory", { method: "PUT", body: { enabled: enabled.checked } });
|
|
36
|
+
toast(enabled.checked ? "记忆已开启" : "记忆已关闭,已有的内容保留");
|
|
37
|
+
} catch (err) {
|
|
38
|
+
enabled.checked = !enabled.checked;
|
|
39
|
+
toast(err.message);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** 一个开关:改了立刻存 */
|
|
44
|
+
const toggle = (key, label, on, off) => {
|
|
45
|
+
const box = h("input", { type: "checkbox", checked: mem[key] });
|
|
46
|
+
box.onchange = async () => {
|
|
47
|
+
try {
|
|
48
|
+
await api("/api/account/memory", { method: "PUT", body: { [key]: box.checked } });
|
|
49
|
+
toast(box.checked ? on : off);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
box.checked = !box.checked;
|
|
52
|
+
toast(err.message);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return h("label", { class: "check" }, box, label);
|
|
56
|
+
};
|
|
57
|
+
const history = await api("/api/account/history").catch(() => null);
|
|
58
|
+
const clearHistory = h("button", { class: "btn small" }, "清空聊天记录索引");
|
|
59
|
+
clearHistory.onclick = run(clearHistory, async () => {
|
|
60
|
+
const sure = await confirmDialog({
|
|
61
|
+
title: "清空聊天记录索引?",
|
|
62
|
+
message: "agent 将搜不到以前的聊天(对话本身不删,侧栏里照样能打开)。之后的聊天还会继续记进来。",
|
|
63
|
+
confirmText: "清空",
|
|
64
|
+
danger: true,
|
|
65
|
+
});
|
|
66
|
+
if (!sure) return false;
|
|
67
|
+
await api("/api/account/history", { method: "DELETE" });
|
|
68
|
+
toast("已清空");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const about = h("textarea", { rows: 5, maxlength: mem.limits.about, placeholder: "比如:我叫小明,在深圳做后端开发,家里有台 QNAP NAS。回答尽量简短,代码用 TypeScript。" });
|
|
72
|
+
about.value = mem.about;
|
|
73
|
+
const saveAbout = h("button", { class: "btn primary" }, "保存");
|
|
74
|
+
saveAbout.onclick = run(saveAbout, async () => {
|
|
75
|
+
await api("/api/account/memory", { method: "PUT", body: { about: about.value } });
|
|
76
|
+
toast("已保存,之后的对话会用上");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const newItem = h("input", { maxlength: mem.limits.item, style: { flex: "1", minWidth: "0" }, placeholder: "手动加一条,比如:我的软路由是 iStoreOS,地址 192.168.8.2" });
|
|
80
|
+
const addBtn = h("button", { class: "btn" }, "添加");
|
|
81
|
+
addBtn.onclick = run(addBtn, async () => {
|
|
82
|
+
if (!newItem.value.trim()) return false;
|
|
83
|
+
await api("/api/account/memory/items", { method: "POST", body: { text: newItem.value } });
|
|
84
|
+
});
|
|
85
|
+
newItem.onkeydown = (e) => {
|
|
86
|
+
if (e.key === "Enter" && !e.isComposing) addBtn.click();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const items = [...mem.items].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
90
|
+
const itemRow = (it) => {
|
|
91
|
+
const from = { user: "你加的", agent: "agent 在聊天里记的", auto: "聊完后自动整理的" }[it.source] ?? "agent 记的";
|
|
92
|
+
const body = h("div", { class: "grow" }, h("div", { class: "t", style: { whiteSpace: "pre-wrap" } }, it.text), h("div", { class: "s" }, `${from} · ${relTime(it.updatedAt)}`));
|
|
93
|
+
const edit = h("button", { class: "btn small" }, "编辑");
|
|
94
|
+
const del = h("button", { class: "btn small danger" }, "删除");
|
|
95
|
+
edit.onclick = () => {
|
|
96
|
+
const input = h("textarea", { rows: 2, maxlength: mem.limits.item });
|
|
97
|
+
input.value = it.text;
|
|
98
|
+
const ok = h("button", { class: "btn small primary" }, "保存");
|
|
99
|
+
ok.onclick = run(ok, () => api(`/api/account/memory/items/${it.id}`, { method: "PATCH", body: { text: input.value } }));
|
|
100
|
+
fill(body, input, h("div", { class: "row" }, ok, h("button", { class: "btn small", onclick: reload }, "取消")));
|
|
101
|
+
input.focus();
|
|
102
|
+
};
|
|
103
|
+
del.onclick = run(del, () => api(`/api/account/memory/items/${it.id}`, { method: "DELETE" }));
|
|
104
|
+
return h("div", { class: "list-item" }, body, edit, del);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const clearBtn = h("button", { class: "btn danger" }, "清空全部记忆");
|
|
108
|
+
clearBtn.onclick = run(clearBtn, async () => {
|
|
109
|
+
const sure = await confirmDialog({
|
|
110
|
+
title: "清空全部记忆?",
|
|
111
|
+
message: "「关于我」和所有记住的事都会删除,不能恢复。",
|
|
112
|
+
confirmText: "清空",
|
|
113
|
+
danger: true,
|
|
114
|
+
});
|
|
115
|
+
if (!sure) return false;
|
|
116
|
+
await api("/api/account/memory", { method: "DELETE" });
|
|
117
|
+
toast("已清空");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
fill(
|
|
121
|
+
panel,
|
|
122
|
+
h(
|
|
123
|
+
"div",
|
|
124
|
+
{ class: "section" },
|
|
125
|
+
h("h4", {}, "记忆", h("span", { class: "grow" }), h("label", { class: "check" }, enabled, "开启")),
|
|
126
|
+
h(
|
|
127
|
+
"div",
|
|
128
|
+
{ class: "muted" },
|
|
129
|
+
"像 ChatGPT 的记忆:对话时 agent 会参考你的「关于我」和记住的事,聊天里遇到长期有用的信息也会主动记一条(会在回复里告诉你)。Claude 和 Codex、Web 和飞书共用这一份。",
|
|
130
|
+
),
|
|
131
|
+
h(
|
|
132
|
+
"div",
|
|
133
|
+
{ class: "muted" },
|
|
134
|
+
"用在哪些对话:你在 Web 上的对话;在「账号」里绑定了飞书后,和机器人的私聊也会用。飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它。",
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
h(
|
|
138
|
+
"div",
|
|
139
|
+
{ class: "section" },
|
|
140
|
+
h("h4", {}, "自动"),
|
|
141
|
+
curateSection(mem, reload),
|
|
142
|
+
toggle("chatHistory", "参考以前的聊天", "已开启:agent 能搜你以前的聊天了", "已关闭:agent 不再搜以前的聊天"),
|
|
143
|
+
h(
|
|
144
|
+
"div",
|
|
145
|
+
{ class: "muted" },
|
|
146
|
+
`agent 遇到「上次说的那个……」时,可以搜你以前的聊天(只有你自己的对话,被删掉的对话不算)。${history ? `现在收录了 ${history.conversations} 段对话、${history.count} 句话。` : ""}`,
|
|
147
|
+
),
|
|
148
|
+
h("div", { class: "row" }, clearHistory),
|
|
149
|
+
),
|
|
150
|
+
h("div", { class: "section" }, h("h4", {}, "关于我"), field("写给 agent 看的自我介绍:你是谁、在做什么、希望它怎么回答", about, `每次对话都会带上,最多 ${mem.limits.about} 字`), h("div", { class: "row" }, saveAbout)),
|
|
151
|
+
h(
|
|
152
|
+
"div",
|
|
153
|
+
{ class: "section" },
|
|
154
|
+
h("h4", {}, `记住的事(${items.length})`),
|
|
155
|
+
h("div", { class: "row" }, newItem, addBtn),
|
|
156
|
+
items.length ? items.map(itemRow) : h("div", { class: "muted" }, "还没有。聊天时说「记住……」,或者在上面手动加。"),
|
|
157
|
+
),
|
|
158
|
+
h(
|
|
159
|
+
"div",
|
|
160
|
+
{ class: "section" },
|
|
161
|
+
h("div", { class: "muted" }, "注意:和多用户一样,这不是安全隔离 —— 工作模式下 agent 能读到这台机器上的文件,也包括记忆文件。"),
|
|
162
|
+
h("div", { class: "row" }, clearBtn),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 记忆页里的「整理记忆」:什么时候整理、用哪个 agent 的哪个模型、现在整理、上次的结果 */
|
|
168
|
+
function curateSection(mem, reload) {
|
|
169
|
+
const c = { ...mem.curate };
|
|
170
|
+
const box = h("div");
|
|
171
|
+
const save = async (patch) => {
|
|
172
|
+
try {
|
|
173
|
+
await api("/api/account/memory", { method: "PUT", body: { curate: patch } });
|
|
174
|
+
Object.assign(c, patch);
|
|
175
|
+
if (patch.agent === "auto") c.model = "";
|
|
176
|
+
render();
|
|
177
|
+
toast("已保存");
|
|
178
|
+
} catch (err) {
|
|
179
|
+
toast(err.message);
|
|
180
|
+
render();
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const select = (value, options, onchange) =>
|
|
184
|
+
h("select", { onchange: (e) => onchange(e.target.value) }, options.map(([v, label]) => h("option", { value: v, selected: String(v) === String(value) }, label)));
|
|
185
|
+
|
|
186
|
+
const render = () => {
|
|
187
|
+
const when = select(
|
|
188
|
+
c.when,
|
|
189
|
+
[
|
|
190
|
+
["daily", "每天一次,把当天聊过的合起来整理(推荐)"],
|
|
191
|
+
["idle", "每段对话聊完后就整理"],
|
|
192
|
+
["manual", "只在我点「现在整理」时"],
|
|
193
|
+
["off", "不整理"],
|
|
194
|
+
],
|
|
195
|
+
(v) => save({ when: v }),
|
|
196
|
+
);
|
|
197
|
+
// 换了范围,等着整理的段数也跟着变:重新读一遍
|
|
198
|
+
const scope = select(
|
|
199
|
+
c.scope,
|
|
200
|
+
[
|
|
201
|
+
["all", "全部对话"],
|
|
202
|
+
["chat", "只整理聊天模式的对话"],
|
|
203
|
+
["work", "只整理工作模式的对话"],
|
|
204
|
+
],
|
|
205
|
+
(v) => save({ scope: v }).then(reload),
|
|
206
|
+
);
|
|
207
|
+
const timing =
|
|
208
|
+
c.when === "idle"
|
|
209
|
+
? field("停下来多久算聊完", select(c.idleMinutes, [10, 30, 60, 120, 240].map((m) => [m, m < 60 ? `${m} 分钟` : `${m / 60} 小时`]), (v) => save({ idleMinutes: Number(v) })))
|
|
210
|
+
: c.when === "daily"
|
|
211
|
+
? field(
|
|
212
|
+
"几点整理(服务器所在时区)",
|
|
213
|
+
select(c.dailyHour, Array.from({ length: 24 }, (_, i) => [i, `${i} 点`]), (v) => save({ dailyHour: Number(v) })),
|
|
214
|
+
"这个点之后第一次检查时整理;服务器那会儿没开机的话,开机后补上",
|
|
215
|
+
)
|
|
216
|
+
: null;
|
|
217
|
+
const agent = select(
|
|
218
|
+
c.agent,
|
|
219
|
+
[
|
|
220
|
+
["auto", "自动:有 Claude 就用 Claude,否则 Codex"],
|
|
221
|
+
["claude", "Claude Code"],
|
|
222
|
+
["codex", "Codex"],
|
|
223
|
+
],
|
|
224
|
+
(v) => save({ agent: v, model: "" }),
|
|
225
|
+
);
|
|
226
|
+
const model =
|
|
227
|
+
c.agent === "auto"
|
|
228
|
+
? null
|
|
229
|
+
: (() => {
|
|
230
|
+
const sel = h("select", { onchange: (e) => save({ model: e.target.value }) }, h("option", { value: "" }, c.agent === "claude" ? "默认:Haiku(最省)" : "默认模型,思考程度 low(最省)"));
|
|
231
|
+
if (c.model) sel.append(h("option", { value: c.model, selected: true }, c.model));
|
|
232
|
+
agentCatalog(c.agent)
|
|
233
|
+
.then((models) => {
|
|
234
|
+
for (const m of models) {
|
|
235
|
+
if (m.id === "default" || m.id === c.model) continue;
|
|
236
|
+
sel.append(h("option", { value: m.id }, m.label || m.id));
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
.catch(() => sel.append(h("option", { value: "", disabled: true }, "(这个 agent 没启用,读不到模型列表)")));
|
|
240
|
+
return field("模型", sel, "整理只是读一遍对话、改几条记忆,用便宜的模型就够了;贵的模型会多占订阅额度");
|
|
241
|
+
})();
|
|
242
|
+
|
|
243
|
+
const nowBtn = h("button", { class: "btn small", disabled: c.when === "off" || !mem.enabled }, "现在整理");
|
|
244
|
+
nowBtn.onclick = async () => {
|
|
245
|
+
nowBtn.disabled = true;
|
|
246
|
+
nowBtn.textContent = "整理中…";
|
|
247
|
+
try {
|
|
248
|
+
const r = await api("/api/account/memory/curate", { method: "POST" });
|
|
249
|
+
toast(
|
|
250
|
+
r.error
|
|
251
|
+
? `整理失败:${r.error}`
|
|
252
|
+
: r.conversations
|
|
253
|
+
? `整理了 ${r.conversations} 段对话:新增 ${r.added.length}、更新 ${r.updated.length}、删除 ${r.removed}`
|
|
254
|
+
: "没有需要整理的对话(正在回复的那段先不整理)",
|
|
255
|
+
);
|
|
256
|
+
reload();
|
|
257
|
+
} catch (err) {
|
|
258
|
+
toast(err.message);
|
|
259
|
+
nowBtn.disabled = false;
|
|
260
|
+
nowBtn.textContent = "现在整理";
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const last = mem.lastCuration;
|
|
264
|
+
const lastText = last
|
|
265
|
+
? last.error
|
|
266
|
+
? `上次整理(${relTime(last.at)})没成功:${last.error}`
|
|
267
|
+
: `上次整理:${relTime(last.at)},${last.conversations} 段对话${last.by ? `,用 ${last.by}` : ""},新增 ${last.added.length}、更新 ${last.updated.length}、删除 ${last.removed}`
|
|
268
|
+
: "还没整理过";
|
|
269
|
+
|
|
270
|
+
fill(
|
|
271
|
+
box,
|
|
272
|
+
field("整理记忆", when),
|
|
273
|
+
h(
|
|
274
|
+
"div",
|
|
275
|
+
{ class: "muted" },
|
|
276
|
+
{
|
|
277
|
+
daily: "攒一天再整理:每次整理都要把你现有的记忆整份带上,合起来一次最省额度,几段对话放在一起看也更容易合并重复。改了什么只显示在这里,不打扰对话。",
|
|
278
|
+
idle: "每段对话停下来就整理一次,改了什么会在那条对话里告诉你。最及时,但聊得多时调用次数也多。",
|
|
279
|
+
manual: "聊过的内容会攒着,你点「现在整理」时一次整理完。",
|
|
280
|
+
off: "不整理,聊过的内容也不攒着。聊天里 agent 照样可以自己用 remember 记东西。",
|
|
281
|
+
}[c.when],
|
|
282
|
+
),
|
|
283
|
+
timing,
|
|
284
|
+
c.when === "off" ? null : field("整理哪些对话", scope, "按整理时对话所在的模式算;不在范围里的对话直接跳过,之后改回来也不会补整理"),
|
|
285
|
+
c.when === "off" ? null : field("用哪个 agent", agent),
|
|
286
|
+
c.when === "off" ? null : model,
|
|
287
|
+
h("div", { class: "row", style: { alignItems: "center", gap: "10px" } }, nowBtn, h("span", { class: "muted" }, `${mem.pending ? `有 ${mem.pending} 段对话等着整理。` : ""}${lastText}`)),
|
|
288
|
+
h("div", { class: "muted" }, "整理时会对照现有记忆补充、更新或删掉过时的条目;你自己加的条目它不会动。"),
|
|
289
|
+
);
|
|
290
|
+
};
|
|
291
|
+
render();
|
|
292
|
+
return box;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 「账号」页里的飞书绑定:Web 上生成绑定码,再在飞书私聊里发 /bind,两边都证明是本人 */
|
|
296
|
+
export function feishuLinkSection() {
|
|
297
|
+
const box = h("div", { class: "section" });
|
|
298
|
+
const load = async () => {
|
|
299
|
+
let data;
|
|
300
|
+
try {
|
|
301
|
+
data = await api("/api/account/feishu");
|
|
302
|
+
} catch (err) {
|
|
303
|
+
fill(box, h("h4", {}, "飞书账号"), h("div", { class: "error-text" }, err.message));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// 没有飞书机器人、也没绑过:整节不显示
|
|
307
|
+
if (!data.bots.length && !data.links.length) return fill(box);
|
|
308
|
+
const codeBox = h("div");
|
|
309
|
+
const gen = h("button", { class: "btn" }, data.links.length ? "再绑一个" : "生成绑定码");
|
|
310
|
+
gen.onclick = async () => {
|
|
311
|
+
gen.disabled = true;
|
|
312
|
+
try {
|
|
313
|
+
const { code, expiresAt } = await api("/api/account/feishu/code", { method: "POST" });
|
|
314
|
+
const cmd = `/bind ${code}`;
|
|
315
|
+
const copy = h("button", { class: "btn small" }, "复制");
|
|
316
|
+
copy.onclick = () => copyText(cmd, copy);
|
|
317
|
+
fill(
|
|
318
|
+
codeBox,
|
|
319
|
+
h(
|
|
320
|
+
"div",
|
|
321
|
+
{ class: "list-item" },
|
|
322
|
+
h(
|
|
323
|
+
"div",
|
|
324
|
+
{ class: "grow" },
|
|
325
|
+
h("div", { class: "t" }, h("code", {}, cmd)),
|
|
326
|
+
h("div", { class: "s" }, `在飞书里私聊机器人(${data.bots.map((b) => b.name).join("、") || "飞书机器人"})发这条消息,${Math.round((expiresAt - Date.now()) / 60000)} 分钟内有效。没用过机器人的话,要先让管理员批准你的配对码。`),
|
|
327
|
+
),
|
|
328
|
+
copy,
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
toast(err.message);
|
|
333
|
+
}
|
|
334
|
+
gen.disabled = false;
|
|
335
|
+
};
|
|
336
|
+
fill(
|
|
337
|
+
box,
|
|
338
|
+
h("h4", {}, "飞书账号"),
|
|
339
|
+
h("div", { class: "muted" }, "绑定后,你和飞书机器人的私聊就是你的个人会话:用你的记忆,/sessions 只列你的会话,/web 能接上你自己的 Web 会话。群聊不受影响。"),
|
|
340
|
+
data.links.map((l) => {
|
|
341
|
+
const rm = h("button", { class: "btn small danger" }, "解除绑定");
|
|
342
|
+
rm.onclick = async () => {
|
|
343
|
+
rm.disabled = true;
|
|
344
|
+
try {
|
|
345
|
+
await api("/api/account/feishu", { method: "DELETE", body: { botId: l.botId, openId: l.openId } });
|
|
346
|
+
toast("已解除,那个私聊不再用你的记忆");
|
|
347
|
+
void load();
|
|
348
|
+
} catch (err) {
|
|
349
|
+
toast(err.message);
|
|
350
|
+
rm.disabled = false;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
return h(
|
|
354
|
+
"div",
|
|
355
|
+
{ class: "list-item" },
|
|
356
|
+
h("div", { class: "grow" }, h("div", { class: "t" }, l.botName), h("div", { class: "s" }, `${l.openIdShort} · 绑定于 ${relTime(l.boundAt)}`)),
|
|
357
|
+
rm,
|
|
358
|
+
);
|
|
359
|
+
}),
|
|
360
|
+
h("div", { class: "row" }, gen),
|
|
361
|
+
codeBox,
|
|
362
|
+
);
|
|
363
|
+
};
|
|
364
|
+
void load();
|
|
365
|
+
return box;
|
|
366
|
+
}
|