agent-loft 0.2.39 → 0.2.41

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.
@@ -1,109 +1,57 @@
1
1
  import { fill, h, toast } from "../lib/dom.js";
2
2
  import { api } from "../lib/api.js";
3
- import { desktop, desktopQuota } from "../state.js";
4
- import { field, saveConfig } from "./common.js";
3
+ import { desktop } from "../state.js";
4
+ import { field, saveButton, saveConfig } from "./common.js";
5
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) {
6
+ /** 设置 → 对话:所有会话共用的规矩——在哪个目录、多久算新会话、授权卡等多久、Claude 的「总是允许」 */
7
+ export async function conversationTab(cfg, page) {
60
8
  const root = h("input", { value: cfg.workspaceRoot });
61
- const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
62
9
  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(
10
+ const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
11
+ const allowBox = h("div", { class: "section", "data-form": "" }, h("h4", {}, "Claude「总是允许」名单"), h("span", { class: "spinner" }));
12
+ page.append(
66
13
  h(
67
14
  "div",
68
- { class: "section" },
69
- h("h4", {}, "通用"),
15
+ { class: "section", "data-form": "" },
16
+ h("h4", {}, "会话"),
70
17
  field("工作目录(workspace)", root, "所有会话的默认目录,/cd 只能在它之内"),
71
- field("授权等待超时(分钟)", approval, "超时按拒绝处理"),
72
18
  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
- ),
19
+ field("授权卡等待多久(分钟)", approval, "agent 要执行命令、改文件时弹出的授权卡,没人处理超过这么久就按拒绝处理"),
82
20
  h(
83
21
  "div",
84
22
  { 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
- "保存",
23
+ saveButton(() =>
24
+ saveConfig(cfg, {
25
+ workspaceRoot: root.value,
26
+ sessionTtlMs: Number(ttl.value) * 3600000,
27
+ approvalTimeoutMs: Number(approval.value) * 60000,
28
+ }),
98
29
  ),
99
30
  ),
100
31
  ),
101
- remoteSection(cfg),
32
+ allowBox,
33
+ );
34
+ const allowlist = await api("/api/claude/allowlist");
35
+ const allowText = h("textarea", { rows: 8 });
36
+ allowText.value = allowlist.join("\n");
37
+ fill(
38
+ allowBox,
39
+ h("h4", {}, "Claude「总是允许」名单"),
40
+ h("div", { class: "muted" }, "授权卡上点过「总是允许」的会记在这里,以后同类操作不再弹卡。Bash:git 表示 git 开头的命令都放行;Edit:/路径 表示该目录下的修改都放行。一行一条,删掉即撤销。"),
41
+ allowText,
42
+ h(
43
+ "div",
44
+ { class: "row" },
45
+ saveButton(async () => {
46
+ await api("/api/claude/allowlist", { method: "PUT", body: { list: allowText.value.split("\n") } });
47
+ toast("已保存");
48
+ }),
49
+ ),
102
50
  );
103
51
  }
104
52
 
105
- /** 远程控制:终端、桌面各自的开关,没人连着多久结束,桌面从哪来。成员能不能用在「用户」里一个个开 */
106
- function remoteSection(cfg) {
53
+ /** 设置 → 远程控制:终端、桌面各自的开关,没人连着多久结束,桌面从哪来。成员能不能用在「用户」里一个个开 */
54
+ export function remoteTab(cfg, page) {
107
55
  const r = cfg.remote ?? {};
108
56
  const termOn = h("input", { type: "checkbox", checked: r.terminal !== false });
109
57
  const deskOn = h("input", { type: "checkbox", checked: r.desktop !== false });
@@ -131,11 +79,15 @@ function remoteSection(cfg) {
131
79
  "macOS:屏幕共享 →「电脑设置…」里勾选「VNC 显示程序可以使用密码控制屏幕」并设这个密码。开通了远程控制的成员连桌面时,页面会拿到这个密码",
132
80
  ),
133
81
  );
82
+ // 桌面关着时,桌面来源那几项用不上,收起来
83
+ const deskBox = h("div", { class: "field-group" }, field("桌面来源", source), autoBox, vncBox);
134
84
  const sync = () => {
135
85
  autoBox.hidden = source.value !== "auto";
136
86
  vncBox.hidden = source.value !== "vnc";
87
+ deskBox.hidden = !deskOn.checked;
137
88
  };
138
89
  source.addEventListener("change", sync);
90
+ deskOn.addEventListener("change", sync);
139
91
  sync();
140
92
  // 终端、桌面用不了时的原因(没装 node-pty、没有图形桌面、没装 x11vnc…)
141
93
  const status = h("div", { class: "error-text", hidden: true });
@@ -152,46 +104,40 @@ function remoteSection(cfg) {
152
104
  () => {},
153
105
  );
154
106
  void loadStatus();
155
- return h(
156
- "div",
157
- { class: "section" },
158
- h("h4", {}, "远程控制"),
107
+ page.append(
159
108
  h(
160
109
  "div",
161
- { class: "muted" },
162
- "侧栏「远程控制」里直接操作这台机器的桌面和终端,切回对话时连接留在后台。以运行 agent-loft 的系统用户执行,没有授权卡。管理员能用;成员要在「用户」里单独开通。",
163
- ),
164
- status,
165
- h("label", { class: "check" }, termOn, "网页终端"),
166
- h("label", { class: "check" }, deskOn, "远程桌面"),
167
- field("桌面来源", source),
168
- autoBox,
169
- vncBox,
170
- field("没有页面连着多久后结束(分钟)", keep, "关掉页面、断线后 shell 继续运行,重新打开能接着用;共享桌面用的 x11vnc 也是这么久没人连就停掉"),
171
- h(
172
- "div",
173
- { class: "row" },
110
+ { class: "section", "data-form": "" },
111
+ h("h4", {}, "远程控制"),
174
112
  h(
175
- "button",
176
- {
177
- class: "btn primary",
178
- onclick: async () => {
179
- const ok = await saveConfig(cfg, {
180
- remote: {
181
- terminal: termOn.checked,
182
- desktop: deskOn.checked,
183
- keepAliveMin: Number(keep.value),
184
- desktopSource: source.value,
185
- display: display.value.trim(),
186
- vncHost: host.value.trim(),
187
- vncPort: Number(port.value),
188
- vncPassword: password.value,
189
- },
190
- });
191
- if (ok) void loadStatus();
192
- },
193
- },
194
- "保存",
113
+ "div",
114
+ { class: "muted" },
115
+ "侧栏「远程控制」里直接操作这台机器的桌面和终端,切回对话时连接留在后台。以运行 agent-loft 的系统用户执行,没有授权卡。管理员能用;成员要在 管理 →「用户」里单独开通。",
116
+ ),
117
+ status,
118
+ h("label", { class: "check" }, termOn, "网页终端"),
119
+ h("label", { class: "check" }, deskOn, "远程桌面"),
120
+ deskBox,
121
+ field("没有页面连着多久后结束(分钟)", keep, "关掉页面、断线后 shell 继续运行,重新打开能接着用;共享桌面用的 x11vnc 也是这么久没人连就停掉"),
122
+ h(
123
+ "div",
124
+ { class: "row" },
125
+ saveButton(async () => {
126
+ const ok = await saveConfig(cfg, {
127
+ remote: {
128
+ terminal: termOn.checked,
129
+ desktop: deskOn.checked,
130
+ keepAliveMin: Number(keep.value),
131
+ desktopSource: source.value,
132
+ display: display.value.trim(),
133
+ vncHost: host.value.trim(),
134
+ vncPort: Number(port.value),
135
+ vncPassword: password.value,
136
+ },
137
+ });
138
+ if (ok) void loadStatus();
139
+ return ok;
140
+ }),
195
141
  ),
196
142
  ),
197
143
  );
@@ -209,7 +155,7 @@ function loopbackHost(host) {
209
155
  */
210
156
  export function networkSection(cfg) {
211
157
  const net = cfg.network ?? { lan: [], local: true, listening: null };
212
- const box = h("div", { class: "section" });
158
+ const box = h("div", { class: "section", "data-form": "" });
213
159
  const lanOn = h("input", { type: "checkbox", checked: !loopbackHost(cfg.web.host) });
214
160
  const port = h("input", { type: "number", min: 1, max: 65535, value: cfg.web.port });
215
161
  const hint = h("small", {});
@@ -241,17 +187,20 @@ export function networkSection(cfg) {
241
187
 
242
188
  const save = async () => {
243
189
  const p = Number(port.value);
244
- if (!Number.isInteger(p) || p < 1 || p > 65535) return toast("端口要是 1 到 65535 之间的整数");
190
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
191
+ toast("端口要是 1 到 65535 之间的整数");
192
+ return false;
193
+ }
245
194
  // 打开:原来是本机地址就改成所有网卡;原来就是某个具体地址(手改过配置)就保留
246
195
  const host = lanOn.checked ? (loopbackHost(cfg.web.host) ? "0.0.0.0" : cfg.web.host) : "127.0.0.1";
247
- if (host === cfg.web.host && p === cfg.web.port) return toast("没有改动");
196
+ if (host === cfg.web.host && p === cfg.web.port) return true;
248
197
  const oldPort = cfg.web.port;
249
198
  let next;
250
199
  try {
251
200
  next = await api("/api/config", { method: "PUT", body: { web: { host, port: p } } });
252
201
  } catch (err) {
253
202
  toast(err.message, 5000);
254
- return;
203
+ return false;
255
204
  }
256
205
  Object.assign(cfg, next);
257
206
  if (p !== oldPort && direct) {
@@ -262,10 +211,11 @@ export function networkSection(cfg) {
262
211
  // 桌面端里由它改掉存着的服务器地址再重新打开(直接跳过去会被当成外部链接)
263
212
  if (typeof desktop?.serverMoved === "function") desktop.serverMoved(url.origin);
264
213
  else setTimeout(() => (location.href = url.href), 600);
265
- return;
214
+ return true;
266
215
  }
267
216
  toast(p !== oldPort ? `已改为端口 ${p},记得把反向代理 / 隧道也指向它` : "已保存", p !== oldPort ? 6000 : 2600);
268
217
  box.replaceWith(networkSection(cfg));
218
+ return true;
269
219
  };
270
220
 
271
221
  fill(
@@ -274,7 +224,7 @@ export function networkSection(cfg) {
274
224
  h("label", { class: "check" }, lanOn, "允许局域网内的其他设备访问"),
275
225
  hint,
276
226
  field("端口", port, "改了立即生效,不用重启"),
277
- h("div", { class: "row" }, h("button", { class: "btn primary", onclick: () => void save() }, "保存")),
227
+ h("div", { class: "row" }, saveButton(save)),
278
228
  );
279
229
  return box;
280
230
  }
@@ -1,70 +1,129 @@
1
1
  import { fill, h } from "../lib/dom.js";
2
2
  import { api } from "../lib/api.js";
3
3
  import { isAdmin } from "../state.js";
4
- import { modal } from "../lib/overlay.js";
4
+ import { confirmDialog, modal } from "../lib/overlay.js";
5
5
  import { notifyTab } from "./notify.js";
6
- import { generalTab } from "./general.js";
6
+ import { conversationTab, remoteTab } from "./general.js";
7
7
  import { commandsTab } from "./commands.js";
8
8
  import { agentsTab } from "./agents.js";
9
9
  import { feishuTab } from "./feishu.js";
10
- import { authTab, securityTab } from "./security.js";
10
+ import { accountTab } from "./security.js";
11
11
  import { memoryTab } from "./memory.js";
12
12
  import { skillsTab } from "./skills.js";
13
13
  import { usageTab } from "./usage.js";
14
14
  import { systemTab, usersTab } from "./users.js";
15
+ import { hasDirty, markDirty } from "./common.js";
15
16
 
16
17
  // ---------- 设置 ----------
18
+ // 按「谁的、管什么」分组:我的(每个人自己的)、Agent、连接(飞书、远程控制)、管理。
19
+ // 普通成员只有「我的」这一组;其余都是管理员的,要先拿配置。
17
20
 
18
- /** tab:直接打开哪一页(标签名,如「技能」),不给就是第一页 */
21
+ const LAST_TAB = "aim.settingsTab";
22
+
23
+ /** 以前的标签名:外面(地址、别处的按钮)传进来的旧名字照样能打开对应的页 */
24
+ const RENAMED = { 通用: "对话", 授权: "对话", 飞书机器人: "飞书", 我的账号: "账号" };
25
+
26
+ function groupsFor(cfg) {
27
+ const mine = {
28
+ name: "我的",
29
+ tabs: [
30
+ ["账号", (p) => accountTab(p)],
31
+ ["通知", (p) => notifyTab(p)],
32
+ ["记忆", (p) => memoryTab(p)],
33
+ ["技能", (p) => skillsTab(p)],
34
+ ["用量", (p) => usageTab(p)],
35
+ ],
36
+ };
37
+ if (!cfg) return [mine];
38
+ return [
39
+ mine,
40
+ {
41
+ name: "Agent",
42
+ tabs: [
43
+ ["Agent", (p) => agentsTab(cfg, p)],
44
+ ["对话", (p) => conversationTab(cfg, p)],
45
+ ["命令", (p) => commandsTab(cfg, p)],
46
+ ],
47
+ },
48
+ {
49
+ name: "连接",
50
+ tabs: [
51
+ ["飞书", (p) => feishuTab(cfg, p)],
52
+ ["远程控制", (p) => remoteTab(cfg, p)],
53
+ ],
54
+ },
55
+ {
56
+ name: "管理",
57
+ tabs: [
58
+ ["用户", (p) => usersTab(p)],
59
+ ["系统", (p) => systemTab(cfg, p)],
60
+ ],
61
+ },
62
+ ];
63
+ }
64
+
65
+ /** tab:直接打开哪一页(标签名,如「技能」),不给就是上次看的那页 */
19
66
  export async function openSettings(tab) {
20
67
  const nav = h("div", { class: "settings-nav" });
21
68
  const panel = h("div", { class: "settings-body" });
69
+ let page = null;
70
+ const leaveOk = async () =>
71
+ !page ||
72
+ !hasDirty(page) ||
73
+ confirmDialog({ title: "有修改还没保存", message: "现在离开,这一页刚才改的内容不会保存。", confirmText: "不保存", danger: true });
22
74
  // 设置弹窗高度固定,切标签页时不跟着内容一伸一缩
23
- modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel) });
24
- // 普通成员只能管自己的记忆、技能、用量、通知、密码和登录设备;其余设置都是管理员的
25
- if (!isAdmin()) {
26
- const tabs = [
27
- ["记忆", () => memoryTab(panel)],
28
- ["技能", () => skillsTab(panel)],
29
- ["用量", () => usageTab(panel)],
30
- ["通知", () => notifyTab(panel)],
31
- ["账号", () => securityTab(panel)],
32
- ];
33
- const select = (i) => {
34
- nav.querySelectorAll("button").forEach((b, j) => b.classList.toggle("on", i === j));
35
- fill(panel);
36
- tabs[i][1]();
37
- };
38
- nav.append(...tabs.map(([name], i) => h("button", { onclick: () => select(i) }, name)));
39
- select(Math.max(0, tabs.findIndex(([name]) => name === tab)));
40
- return;
41
- }
42
- let cfg;
43
- try {
44
- cfg = await api("/api/config");
45
- } catch (err) {
46
- panel.append(h("div", { class: "error-text" }, err.message));
47
- return;
75
+ modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel), beforeClose: leaveOk });
76
+
77
+ // 带 data-form 的表单里改了东西:标成没保存(common.js 的 saveButton)
78
+ const onEdit = (e) => {
79
+ if (e.target.closest("[data-nodirty]")) return;
80
+ const form = e.target.closest("[data-form]");
81
+ if (form && !form.dataset.dirty) markDirty(form);
82
+ };
83
+ panel.addEventListener("input", onEdit);
84
+ panel.addEventListener("change", onEdit);
85
+
86
+ let cfg = null;
87
+ if (isAdmin()) {
88
+ try {
89
+ cfg = await api("/api/config");
90
+ } catch (err) {
91
+ panel.append(h("div", { class: "error-text" }, err.message));
92
+ return;
93
+ }
48
94
  }
49
- const tabs = [
50
- ["通用", () => generalTab(cfg, panel)],
51
- ["Agent", () => agentsTab(cfg, panel)],
52
- ["命令", () => commandsTab(cfg, panel)],
53
- ["技能", () => skillsTab(panel)],
54
- ["飞书机器人", () => feishuTab(cfg, panel)],
55
- ["授权", () => authTab(panel)],
56
- ["记忆", () => memoryTab(panel)],
57
- ["用量", () => usageTab(panel)],
58
- ["通知", () => notifyTab(panel)],
59
- ["账号", () => securityTab(panel)],
60
- ["用户", () => usersTab(panel)],
61
- ["系统", () => systemTab(cfg, panel)],
62
- ];
63
- const select = (i) => {
64
- nav.querySelectorAll("button").forEach((b, j) => b.classList.toggle("on", i === j));
65
- fill(panel, );
66
- tabs[i][1]();
95
+ const groups = groupsFor(cfg);
96
+ const tabs = groups.flatMap((g) => g.tabs);
97
+ const buttons = [];
98
+ let current = -1;
99
+
100
+ const select = async (i) => {
101
+ if (i === current || !(await leaveOk())) return;
102
+ current = i;
103
+ buttons.forEach((b, j) => b.classList.toggle("on", i === j));
104
+ buttons[i].scrollIntoView({ block: "nearest", inline: "nearest" });
105
+ localStorage.setItem(LAST_TAB, tabs[i][0]);
106
+ // 每页一个新容器:慢的页还在等接口时切走了,它回来后写进的是已经不在页面上的旧容器,不会串到新页里
107
+ page = h("div", { class: "settings-page" });
108
+ fill(panel, page);
109
+ panel.scrollTop = 0;
110
+ try {
111
+ await tabs[i][1](page);
112
+ } catch (err) {
113
+ if (page.isConnected) fill(page, h("div", { class: "error-text" }, err.message));
114
+ }
67
115
  };
68
- nav.append(...tabs.map(([name], i) => h("button", { onclick: () => select(i) }, name)));
69
- select(Math.max(0, tabs.findIndex(([name]) => name === tab)));
116
+
117
+ for (const g of groups) {
118
+ // 只有一组时不用标题
119
+ if (groups.length > 1) nav.append(h("div", { class: "settings-group" }, g.name));
120
+ for (const [name] of g.tabs) {
121
+ const i = buttons.length;
122
+ const b = h("button", { onclick: () => void select(i) }, name);
123
+ buttons.push(b);
124
+ nav.append(b);
125
+ }
126
+ }
127
+ const want = tab ? (RENAMED[tab] ?? tab) : localStorage.getItem(LAST_TAB);
128
+ void select(Math.max(0, tabs.findIndex(([name]) => name === want)));
70
129
  }
@@ -1,10 +1,9 @@
1
1
  import { fill, h, toast } from "../lib/dom.js";
2
2
  import { api } from "../lib/api.js";
3
3
  import { relTime } from "../lib/time.js";
4
- import { copyText } from "../lib/markdown.js";
5
4
  import { agentCatalog } from "../views/topbar.js";
6
5
  import { confirmDialog } from "../lib/overlay.js";
7
- import { field } from "./common.js";
6
+ import { field, saveButton } from "./common.js";
8
7
 
9
8
  // ---------- 记忆(每个人自己的) ----------
10
9
 
@@ -70,8 +69,7 @@ export async function memoryTab(panel) {
70
69
 
71
70
  const about = h("textarea", { rows: 5, maxlength: mem.limits.about, placeholder: "比如:我叫小明,在深圳做后端开发,家里有台 QNAP NAS。回答尽量简短,代码用 TypeScript。" });
72
71
  about.value = mem.about;
73
- const saveAbout = h("button", { class: "btn primary" }, "保存");
74
- saveAbout.onclick = run(saveAbout, async () => {
72
+ const saveAbout = saveButton(async () => {
75
73
  await api("/api/account/memory", { method: "PUT", body: { about: about.value } });
76
74
  toast("已保存,之后的对话会用上");
77
75
  });
@@ -147,7 +145,7 @@ export async function memoryTab(panel) {
147
145
  ),
148
146
  h("div", { class: "row" }, clearHistory),
149
147
  ),
150
- h("div", { class: "section" }, h("h4", {}, "关于我"), field("写给 agent 看的自我介绍:你是谁、在做什么、希望它怎么回答", about, `每次对话都会带上,最多 ${mem.limits.about} 字`), h("div", { class: "row" }, saveAbout)),
148
+ h("div", { class: "section", "data-form": "" }, h("h4", {}, "关于我"), field("写给 agent 看的自我介绍:你是谁、在做什么、希望它怎么回答", about, `每次对话都会带上,最多 ${mem.limits.about} 字`), h("div", { class: "row" }, saveAbout)),
151
149
  h(
152
150
  "div",
153
151
  { class: "section" },
@@ -291,76 +289,3 @@ function curateSection(mem, reload) {
291
289
  render();
292
290
  return box;
293
291
  }
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
- }
@@ -50,6 +50,19 @@ export async function notifyTab(panel) {
50
50
  support.ok
51
51
  ? h("div", { class: "row" }, here ? offBtn : onBtn, data.subscriptions.length ? testBtn : null)
52
52
  : h("div", { class: "error-text" }, support.why),
53
+ // 推送用不了(比如不是 HTTPS):至少让页面开着、切到别的窗口时能弹提醒
54
+ !support.ok && "Notification" in window && Notification.permission === "default"
55
+ ? h(
56
+ "div",
57
+ { class: "row" },
58
+ h(
59
+ "button",
60
+ { class: "btn", onclick: async () => (await Notification.requestPermission(), void render()) },
61
+ "允许这个页面弹出提醒",
62
+ ),
63
+ h("span", { class: "muted" }, "页面开着、但你在看别的窗口时,需要授权会弹出浏览器提醒"),
64
+ )
65
+ : null,
53
66
  support.ok && Notification.permission === "denied"
54
67
  ? h("div", { class: "error-text" }, "这个设备的通知权限已被拒绝,需要到系统设置(iPhone:设置 → 通知 → Agent Loft)里重新打开。")
55
68
  : null,