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.
package/README.md CHANGED
@@ -59,16 +59,16 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
59
59
  - **系统通知**:Web 会话回复完成、出错、需要授权或提问时推送到手机 / 电脑(设置 → 通知 → 「在此设备开启通知」,可以发测试通知)。飞书会话由飞书提醒,正在前台看着的会话不推。需要 HTTPS 访问,iPhone 需 iOS 16.4+ 且从主屏幕图标打开。
60
60
  - **跨渠道同步**:飞书里的会话在 Web 上也能打开接着聊。同一个授权请求同时出现在飞书卡片和网页上,哪边先点算哪边,另一边自动变成「已处理」。
61
61
  - **授权**:允许一次 / 本会话全部允许 / 总是允许(按钮上写明放行范围)/ 拒绝。`/autoedit on` 让 Claude Code 在这条会话里改工作目录内的文件不再询问(Bash 仍询问)。超时一律按拒绝处理。
62
- - **设置都在网页上**:工作目录、会话闲置过期时间、局域网访问和端口、各 agent 的默认模型和思考程度、可选模型、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、通知设备、登录设备管理、用户管理、服务端升级。
62
+ - **设置都在网页上**,按用途分组:**我的**(账号和密码、飞书绑定、登录设备、通知、记忆、技能、用量,每个人都有);**Agent**(运行环境、各 agent 的默认模型和思考程度、可选模型、Codex 沙箱、额度查询;工作目录、会话闲置过期、授权卡超时和 Claude 的「总是允许」名单;「/」命令);**连接**(飞书机器人、配对批准、指令面板同步;远程控制);**管理**(用户、所有人的登录设备;服务端版本和升级、重启、局域网访问和端口)。改了没保存就切页或关掉设置会先提醒。
63
63
  - **多用户**(信任型):管理员在 设置 → 用户 里给别人建账号。每个人只看得到自己的对话、授权和通知,设置只有管理员能改,见下面「安全」。
64
64
  - **聊天模式 / 工作模式**:右上角「更多」里的「聊天模式」,或者发 `/chat`、`/work`,同一条对话随时切换、上下文接着。聊天模式换成精简的系统提示,只留联网搜索,不加载技能、MCP、插件,不执行命令、不读写本机文件(文本附件的内容直接放进消息),每轮的固定开销小得多:实测一句话 Claude 约 1.6 万 → 2 千 token,Codex 约 2 万 → 1.2 万(Codex 的基础指令改不了)。适合日常聊天、查资料;要动手干活时切回工作模式。切换后第一轮要重建提示缓存,会贵一些;在工作模式里读过的大段内容还留在历史里。
65
65
  - **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;整理哪些对话也能选:全部/只整理聊天模式的/只整理工作模式的;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
66
66
  - **定时任务**:像 ChatGPT 的 Tasks,侧栏「最近」上面的「定时任务」。到点把一段提示词交给 agent,结果发回对话:一次 / 每天 / 工作日 / 每周 / 每月 / 每隔几小时 / 自定义 cron,按服务端时区,两次至少隔 15 分钟。**有需要才提醒**(默认):agent 自己判断这次有没有要告诉你的事(比如「有新版本才告诉我」),没有就在回复第一行写 `[[静默]]`,推送、飞书都不发,对话里折叠成一行;没写暗号一律照常提醒。结果发到**专用对话**(任务自己的一条对话,第一次运行时新建、之后续用,可选聊天 / 工作模式和目录),或者**原来的对话**。也能直接在对话里说「每天早上 9 点帮我……」:agent 用 `create_task` / `update_task` / `delete_task` / `list_tasks` 工具管理,新建、修改、删除都会先弹授权卡让你确认(只有暂停不用;没有「总是允许」),任务自己运行的那一轮里不能再建、改、删任务。按人隔离:每人只看得到、改得了自己的任务(最多 30 个),删用户时一起删;到点再核对一次对话还是不是他的,对话被删或换了主人就暂停任务。到点对话在忙(正在回复或挂着授权)就等它,最多 30 分钟;服务停着时错过 2 小时以内的启动后补跑一次。工具和记忆一样只给有主人的对话,飞书群聊里没有。
67
67
  - **技能(skills)**:设置 → 技能。技能就是 Claude Code / Codex 用的那种 SKILL.md(可以带脚本和资料),由 agent-loft 按人管理,两个 agent 都能用。**来源**:在页面上写、上传技能包(`.zip` / `.skill` / 单个 SKILL.md / 整个文件夹),或者在对话里让 agent「把刚才的做法存成技能」(agent 用 `create_skill` / `update_skill`,先弹授权卡)。每人管自己的,不做分享和技能广场(以前从别人那装过的照常能用,可以卸载)。**使用**:工作模式下 agent 遇到对得上的事自己用;在输入框或定时任务的「要做的事」里打 `@` 选技能,点名这次用哪个(聊天模式下会把技能内容直接附上);飞书里发 `/skills` 看能 @ 哪些。**变量**:技能可以声明要用的环境变量(接口地址、密钥),每个人填自己的值,只注入自己的会话;密钥再写上「只允许发往」的地址就成了**受保护的密钥**——不进环境变量,agent 看不到值,只能用 `skill_request` 工具让 agent-loft 代发请求(在地址、请求头、请求体里写 `{{变量名}}`),只发往你确认过的地址、不跟随跳转、响应里出现的值会被抹掉;作者后来改了地址,要你再确认一次才放行。**机器上原有的技能**(`~/.claude/skills`、`~/.agents/skills`、插件市场和 claude.ai 同步的)只给管理员:成员的会话里看不到、调不了(Claude 用技能白名单,`/技能名` 也会被拦下;Codex 关掉它自己的技能清单),管理员在「设置 → 技能 → 本机技能」里能看到。
68
68
  - **远程控制**:侧栏「定时任务」下面的「远程控制」,直接操作这台机器的**桌面**和**终端**,上面一键切换;切回对话时连接留在后台(侧栏入口上亮个小绿点),再进来接着用。这台机器没有图形桌面(服务器、NAS)时只有终端。
69
- - 桌面:走 VNC 协议,网页里用 noVNC 显示,鼠标、键盘、剪贴板都能用,进来时不自动连、点「连接桌面」才连,可以缩放到窗口或按原始大小看、全屏(手机上也能,iPhone 上是铺满页面)、「只看不控制」、发送 Ctrl+Alt+Del 之类的组合键。手机上有两种手势(右上角切换,「?」看说明):**触控板模式**(默认)屏幕上有个指针,单指滑动移动、轻点单击、双指轻点右键、长按后拖动是按住左键拖、双指滑动滚动;**触摸模式**点哪就点哪。两种模式都能双指捏合在本地放大画面(放大后小按钮也点得准,右下角「还原」),画面下面一排 Esc / Tab / Ctrl / Alt / Win / 方向键,Ctrl、Alt、Win 点一下会粘住,带给下一个按键或点击;键盘按钮弹出软键盘打字,中文输入法也能用。Linux 上 agent-loft 在有人连时用 x11vnc 共享当前的桌面(Xorg 会话;只听 127.0.0.1、随机端口、每次随机密码,需要 `sudo apt install x11vnc`),没人连着 30 分钟后停掉;macOS 上在 设置 → 通用 → 远程控制 里改用「已有的 VNC 服务」,接系统自带的屏幕共享。
69
+ - 桌面:走 VNC 协议,网页里用 noVNC 显示,鼠标、键盘、剪贴板都能用,进来时不自动连、点「连接桌面」才连,可以缩放到窗口或按原始大小看、全屏(手机上也能,iPhone 上是铺满页面)、「只看不控制」、发送 Ctrl+Alt+Del 之类的组合键。手机上有两种手势(右上角切换,「?」看说明):**触控板模式**(默认)屏幕上有个指针,单指滑动移动、轻点单击、双指轻点右键、长按后拖动是按住左键拖、双指滑动滚动;**触摸模式**点哪就点哪。两种模式都能双指捏合在本地放大画面(放大后小按钮也点得准,右下角「还原」),画面下面一排 Esc / Tab / Ctrl / Alt / Win / 方向键,Ctrl、Alt、Win 点一下会粘住,带给下一个按键或点击;键盘按钮弹出软键盘打字,中文输入法也能用。Linux 上 agent-loft 在有人连时用 x11vnc 共享当前的桌面(Xorg 会话;只听 127.0.0.1、随机端口、每次随机密码,需要 `sudo apt install x11vnc`),没人连着 30 分钟后停掉;macOS 上在 设置 → 远程控制 里改用「已有的 VNC 服务」,接系统自带的屏幕共享。
70
70
  - 终端:新终端开在当前对话的工作目录里,可以开多个标签;`Ctrl+Shift+C` / `Ctrl+Shift+V` 复制粘贴。`` Ctrl+` `` 在对话下面拉出终端面板(高度可拖动),面板和远程控制里的是同一批终端。关掉页面、手机切到后台、断线都不结束 shell,重新打开时接着用、补上错过的输出,别的设备上也能接上;没有页面连着超过 30 分钟(设置里可改)才结束,点标签上的 × 立即结束。依赖可选的原生模块 `node-pty`:Linux 上安装时要现场编译(需要 `make`、`g++`、`python3`),没装好时终端用不了,其他功能照常。手机上单指上下滑翻看历史(在 less、vim 里是翻页),长按选中文字、接着拖动扩大选区,再点「复制」;终端下面多一排 Esc / Tab / Ctrl / Alt / 方向键 / Home / End / PgUp / PgDn / 粘贴。iOS 上软键盘弹出来时这排键会垫到键盘上面。
71
- - 管理员能用;成员要管理员在 设置 → 用户 里单独「开通远程控制」。终端、桌面在 设置 → 通用 → 远程控制 里各有总开关。
71
+ - 管理员能用;成员要管理员在 设置 → 用户 里单独「开通远程控制」。终端、桌面在 设置 → 远程控制 里各有总开关。
72
72
  - **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
73
73
 
74
74
  ## 安装
@@ -102,6 +102,7 @@ agent-loft service install # 注册为开机自启服务(systemd / laun
102
102
  - 菜单栏图标可以换成额度条:Claude、Codex 各是一只像素小宠物,看样子就知道是谁,身子是 5 小时额度,下面一条是周额度。桌面端设置 → 额度显示 →「菜单栏显示额度」,选所有服务器合起来(取最高),或者按服务器分别选 Claude / Codex(图标上不写服务器名);都不选就只显示图标。「额度条显示」选实心表示剩余额度(默认,像电池)还是已用额度。只有 macOS 有,Windows / Linux 的托盘图标太小放不下。
103
103
  - 支持前面挡着 Cloudflare Access 的服务器:验证页直接在窗口里完成,过期会提醒;也可以配 Service Token 免验证。
104
104
  - 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。Linux 上一样(需要先装好 Node.js 22+,开机自启用 systemd 用户服务);Windows 上服务端还没有开机自启,可以在 WSL 里装,桌面端会自动检测到 `127.0.0.1` 上的它。防休眠:本机有会话在跑时不休眠;另有永不休眠、定时休眠(设休眠时段,时段外不休眠,时段内本机没有会话在跑、屏幕锁上后让 Mac 睡下去)和合盖不休眠(macOS,要管理员密码)。
105
+ - 顶栏「更多」→「总量汇总」:把所有已添加服务器的用量加在一起看,按日 / 月 / 年统计 token 和金额,和网页设置 → 用量一样有柱状图和按 agent、模型、用户的分项,另外多一栏按服务器。不在线、没登录或服务端太旧的服务器不计入,页面上会注明。
105
106
  - 设置(`⌘,`)的服务器列表里展开某台服务器,能看它的 Agent 环境和版本,有新版本时点「升级服务端」;桌面端自己的更新在同一页的「更新」里点「更新并重启」。
106
107
 
107
108
  桌面端只是个壳(代码在 `desktop/`),服务端照常用 npm 安装和升级。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.39",
3
+ "version": "0.2.41",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -765,7 +765,7 @@ export class FeishuBot {
765
765
  "",
766
766
  "从现在起这个私聊是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话。群聊不受影响。",
767
767
  "",
768
- "在 Web 的「设置 → 我的账号」里可以随时解除绑定。",
768
+ "在 Web 的「设置 → 账号」里可以随时解除绑定。",
769
769
  ].join("\n");
770
770
  }
771
771
 
@@ -37,7 +37,7 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
37
37
  { name: "codex", description: "切换到 Codex", multiAgentOnly: true },
38
38
  { name: "web", description: "接上一条 Web 会话,在飞书里查看和回复", feishuOnly: true },
39
39
  { name: "detach", description: "不再接着 Web 会话,回到这个聊天自己的会话", feishuOnly: true },
40
- { name: "bind", args: "<绑定码>", description: "把这个飞书账号绑定到你的 agent-loft 用户(绑定码在 Web 的「设置 → 我的账号」里生成)", feishuOnly: true },
40
+ { name: "bind", args: "<绑定码>", description: "把这个飞书账号绑定到你的 agent-loft 用户(绑定码在 Web 的「设置 → 账号」里生成)", feishuOnly: true },
41
41
  { name: "help", description: "显示帮助" },
42
42
  { name: "approve", args: "<请求ID> allow-once|allow-always|allow-session|deny", description: "审批兜底(卡片失效时用)" },
43
43
  ];
@@ -100,7 +100,7 @@ export function redeemLinkCode(code: string, botId: string, openId: string): Use
100
100
  sweep();
101
101
  const key = code.trim().toUpperCase();
102
102
  const entry = codes.get(key);
103
- if (!entry) throw new Error("绑定码不对或已过期。请在 Web 上(设置 → 我的账号)重新生成一个。");
103
+ if (!entry) throw new Error("绑定码不对或已过期。请在 Web 上(设置 → 账号)重新生成一个。");
104
104
  const user = getUser(entry.userId);
105
105
  if (!user || user.disabled) {
106
106
  codes.delete(key);
@@ -49,35 +49,6 @@
49
49
  display: none;
50
50
  }
51
51
 
52
- /* 「/」「@」补全:键盘弹出后屏幕只剩一半,菜单改成一行一条、最多露出五条多一点,
53
- 露出半条好让人知道还能往下滑 */
54
- .slash-menu {
55
- bottom: calc(100% + 4px);
56
- max-height: min(196px, 30vh);
57
- padding: 4px;
58
- }
59
-
60
- .slash-item {
61
- display: flex;
62
- align-items: baseline;
63
- gap: 8px;
64
- padding: 7px 8px;
65
- }
66
-
67
- .slash-head {
68
- flex: none;
69
- max-width: 60%;
70
- }
71
-
72
- .slash-args {
73
- display: none;
74
- }
75
-
76
- .slash-desc {
77
- flex: 1;
78
- min-width: 0;
79
- }
80
-
81
52
  .ctx-bar {
82
53
  margin: 0 10px -22px;
83
54
  font-size: 13px;
@@ -125,5 +96,24 @@
125
96
 
126
97
  .settings-nav button {
127
98
  white-space: nowrap;
99
+ flex-shrink: 0;
100
+ }
101
+
102
+ /* 横排时分组标题换成一道竖线 */
103
+ .settings-group {
104
+ flex-shrink: 0;
105
+ width: 1px;
106
+ margin: 6px 4px;
107
+ padding: 0;
108
+ font-size: 0;
109
+ background: var(--border);
110
+ }
111
+
112
+ .settings-group:first-child {
113
+ display: none;
114
+ }
115
+
116
+ .settings-body {
117
+ padding: 12px;
128
118
  }
129
119
  }
@@ -334,6 +334,7 @@
334
334
  .remote-body.desk-full {
335
335
  position: fixed;
336
336
  inset: 0;
337
+ overflow: hidden;
337
338
  z-index: 35;
338
339
  background: #111;
339
340
  padding: env(safe-area-inset-top) 0 var(--kb-inset, 0px);
@@ -445,11 +446,12 @@
445
446
  resize: vertical;
446
447
  }
447
448
 
448
- /* 手机上收软键盘输入用:看不见,但要能拿到焦点 */
449
+ /* 手机上收软键盘输入用:看不见,但要能拿到焦点。
450
+ 放在顶上:放在底下会被键盘盖住,iOS 为了露出它把整页往上推(横屏时画面整个被推出屏幕) */
449
451
  .desk-keyinput {
450
452
  position: absolute;
451
453
  left: 0;
452
- bottom: 0;
454
+ top: 0;
453
455
  width: 1px;
454
456
  height: 1px;
455
457
  opacity: 0;
@@ -14,15 +14,31 @@
14
14
  flex-direction: column;
15
15
  gap: 2px;
16
16
  flex-shrink: 0;
17
+ overflow-y: auto;
18
+ }
19
+
20
+ /* 导航里的分组标题:我的 / Agent / 连接 / 管理 */
21
+ .settings-group {
22
+ padding: 12px 10px 4px;
23
+ font-size: 12px;
24
+ color: var(--fg-faint);
25
+ }
26
+
27
+ .settings-group:first-child {
28
+ padding-top: 4px;
17
29
  }
18
30
 
19
31
  .settings-nav button {
20
32
  text-align: left;
21
- padding: 8px 10px;
33
+ padding: 7px 10px;
22
34
  border-radius: 8px;
23
35
  font-size: 14px;
24
36
  }
25
37
 
38
+ .settings-nav button:hover {
39
+ background: var(--bg-hover);
40
+ }
41
+
26
42
  .settings-nav button.on {
27
43
  background: var(--bg-hover);
28
44
  font-weight: 600;
@@ -32,11 +48,24 @@
32
48
  flex: 1;
33
49
  overflow-y: auto;
34
50
  padding: 16px;
51
+ }
52
+
53
+ /* 一页的内容,和一组一起保存的几节(Agent 页的 Claude Code + Codex) */
54
+ .settings-page,
55
+ .settings-stack {
35
56
  display: flex;
36
57
  flex-direction: column;
37
58
  gap: 14px;
38
59
  }
39
60
 
61
+ .settings-page [hidden] {
62
+ display: none !important;
63
+ }
64
+
65
+ .settings-page .row > .grow {
66
+ flex: 1;
67
+ }
68
+
40
69
  .section {
41
70
  border: 1px solid var(--border);
42
71
  border-radius: 12px;
@@ -65,6 +94,17 @@
65
94
  flex-wrap: wrap;
66
95
  }
67
96
 
97
+ /* 用户列表:重置密码就地展开的输入框 */
98
+ .user-reset {
99
+ margin-top: 8px;
100
+ flex-wrap: nowrap;
101
+ }
102
+
103
+ .user-reset input {
104
+ flex: 1;
105
+ min-width: 0;
106
+ }
107
+
68
108
  .check {
69
109
  display: inline-flex;
70
110
  align-items: center;
@@ -53,15 +53,19 @@ export function closeMenu() {
53
53
  removeEventListener("blur", closeMenu);
54
54
  }
55
55
 
56
- export function modal({ title, body, foot, wide, tall }) {
56
+ /** beforeClose:点 × 或点外面关闭前先问它,返回(或 resolve 成)false 就不关。返回的 close 不问,直接关 */
57
+ export function modal({ title, body, foot, wide, tall, beforeClose }) {
57
58
  const close = () => overlay.remove();
59
+ const tryClose = async () => {
60
+ if (!beforeClose || (await beforeClose()) !== false) close();
61
+ };
58
62
  const overlay = h(
59
63
  "div",
60
- { class: "overlay", onclick: (e) => e.target === overlay && close() },
64
+ { class: "overlay", onclick: (e) => e.target === overlay && void tryClose() },
61
65
  h(
62
66
  "div",
63
67
  { class: `modal${wide ? " wide" : ""}${tall ? " tall" : ""}` },
64
- h("div", { class: "modal-head" }, h("h3", {}, title), h("button", { class: "icon-btn", onclick: close }, icon("x"))),
68
+ h("div", { class: "modal-head" }, h("h3", {}, title), h("button", { class: "icon-btn", onclick: () => void tryClose() }, icon("x"))),
65
69
  body,
66
70
  foot ?? null,
67
71
  ),
@@ -1,10 +1,10 @@
1
1
  import { fill, h, toast } from "../lib/dom.js";
2
2
  import { api } from "../lib/api.js";
3
3
  import { copyText } from "../lib/markdown.js";
4
- import { S } from "../state.js";
4
+ import { desktopQuota, S } from "../state.js";
5
5
  import { defaultEntryName, usableAgents } from "../views/topbar.js";
6
6
  import { renderEmpty } from "../views/messages.js";
7
- import { field, saveConfig } from "./common.js";
7
+ import { field, saveButton, saveConfig } from "./common.js";
8
8
  import { modelPicker } from "./commands.js";
9
9
 
10
10
  /**
@@ -133,8 +133,8 @@ function installCodexButton(after) {
133
133
  return h("div", {}, btn);
134
134
  }
135
135
 
136
- export function agentsTab(cfg, panel) {
137
- panel.append(agentHealthSection());
136
+ export function agentsTab(cfg, page) {
137
+ page.append(agentHealthSection());
138
138
  const a = structuredClone(cfg.agents);
139
139
  const claudeOn = h("input", { type: "checkbox", checked: a.claude.enabled });
140
140
  const claudePick = modelPicker("claude", a.claude.defaultModel, a.claude.defaultEffort, "跟随 Claude Code 默认");
@@ -151,60 +151,90 @@ export function agentsTab(cfg, panel) {
151
151
  const codexModels = modelChecklist("codex", a.codex.models ?? []);
152
152
  // 一组勾选框不能放进 <label>(点哪都会切到第一个),所以不用 field()
153
153
  const checksField = (list) => h("div", { class: "field" }, h("span", {}, "输入框可选模型"), list.el);
154
- panel.append(
154
+ // 两个 agent 一起保存:整组算一张表单
155
+ page.append(
155
156
  h(
156
157
  "div",
157
- { class: "section" },
158
- h("h4", {}, "Claude Code", h("span", { class: "grow" }), h("label", { class: "check" }, claudeOn, "启用")),
159
- field("默认模型", claudePick.modelEl),
160
- field("默认思考程度", claudePick.effortEl),
161
- checksField(claudeModels),
162
- ),
163
- h(
164
- "div",
165
- { class: "section" },
166
- h("h4", {}, "Codex", h("span", { class: "grow" }), h("label", { class: "check" }, codexOn, "启用")),
167
- field("codex 可执行文件", codexPath, "以系统服务运行时 PATH 很短,建议写绝对路径"),
168
- field("CODEX_HOME", codexHome),
169
- field("沙箱", sandbox, "workspace-write:目录内可读写,越界才弹授权。danger-full-access 等于关掉沙箱"),
170
- field("默认模型", codexPick.modelEl),
171
- field("默认思考程度", codexPick.effortEl),
172
- checksField(codexModels),
158
+ { class: "settings-stack", "data-form": "" },
159
+ h(
160
+ "div",
161
+ { class: "section" },
162
+ h("h4", {}, "Claude Code", h("span", { class: "grow" }), h("label", { class: "check" }, claudeOn, "启用")),
163
+ field("默认模型", claudePick.modelEl),
164
+ field("默认思考程度", claudePick.effortEl),
165
+ checksField(claudeModels),
166
+ ),
167
+ h(
168
+ "div",
169
+ { class: "section" },
170
+ h("h4", {}, "Codex", h("span", { class: "grow" }), h("label", { class: "check" }, codexOn, "启用")),
171
+ field("codex 可执行文件", codexPath, "以系统服务运行时 PATH 很短,建议写绝对路径"),
172
+ field("CODEX_HOME", codexHome),
173
+ field("沙箱", sandbox, "workspace-write:目录内可读写,越界才弹授权。danger-full-access 等于关掉沙箱"),
174
+ field("默认模型", codexPick.modelEl),
175
+ field("默认思考程度", codexPick.effortEl),
176
+ checksField(codexModels),
177
+ ),
178
+ h(
179
+ "div",
180
+ { class: "row" },
181
+ saveButton(() => {
182
+ const claudeList = claudeModels.value();
183
+ const codexList = codexModels.value();
184
+ if (!claudeList || !codexList) {
185
+ toast(`${!claudeList ? "Claude Code" : "Codex"} 至少勾选一个模型`);
186
+ return false;
187
+ }
188
+ return saveConfig(cfg, {
189
+ agents: {
190
+ claude: {
191
+ enabled: claudeOn.checked,
192
+ defaultModel: claudePick.model(),
193
+ defaultEffort: claudePick.effort(),
194
+ models: claudeList,
195
+ },
196
+ codex: {
197
+ enabled: codexOn.checked,
198
+ path: codexPath.value.trim() || "codex",
199
+ home: codexHome.value.trim(),
200
+ sandbox: sandbox.value,
201
+ defaultModel: codexPick.model(),
202
+ defaultEffort: codexPick.effort(),
203
+ models: codexList,
204
+ },
205
+ },
206
+ });
207
+ }, "保存 Agent 设置"),
208
+ ),
173
209
  ),
210
+ quotaSection(cfg),
211
+ );
212
+ }
213
+
214
+ /** 账号额度:侧栏底部、/status、桌面端显示的 Claude / Codex 订阅额度,查不查、多久查一次 */
215
+ function quotaSection(cfg) {
216
+ const quotaOn = h("input", { type: "checkbox", checked: cfg.web?.quotaEnabled !== false });
217
+ const quotaMin = h("input", { type: "number", min: 0, max: 1440, value: cfg.web?.quotaRefreshMin ?? 10 });
218
+ const minField = field(
219
+ "自动刷新间隔(分钟)",
220
+ quotaMin,
221
+ desktopQuota
222
+ ? "在桌面端里额度由桌面端统一刷新,以桌面端设置里的间隔为准;这里只对浏览器打开的页面生效"
223
+ : "侧栏底部的额度,空闲时多久刷新一次;0 表示不自动刷新(跑完一轮、点击额度区域仍会刷新)",
224
+ );
225
+ const sync = () => (minField.hidden = !quotaOn.checked);
226
+ quotaOn.addEventListener("change", sync);
227
+ sync();
228
+ return h(
229
+ "div",
230
+ { class: "section", "data-form": "" },
231
+ h("h4", {}, "账号额度", h("span", { class: "grow" }), h("label", { class: "check" }, quotaOn, "查询")),
232
+ h("div", { class: "muted" }, "侧栏底部、/status、桌面端显示的订阅额度(5 小时、每周)。关掉后都不再去问 agent(Claude 每次要临时起一个 CLI 进程才查得到)。"),
233
+ minField,
174
234
  h(
175
235
  "div",
176
236
  { class: "row" },
177
- h(
178
- "button",
179
- {
180
- class: "btn primary",
181
- onclick: () => {
182
- const claudeList = claudeModels.value();
183
- const codexList = codexModels.value();
184
- if (!claudeList || !codexList) return toast(`${!claudeList ? "Claude Code" : "Codex"} 至少勾选一个模型`);
185
- return saveConfig(cfg, {
186
- agents: {
187
- claude: {
188
- enabled: claudeOn.checked,
189
- defaultModel: claudePick.model(),
190
- defaultEffort: claudePick.effort(),
191
- models: claudeList,
192
- },
193
- codex: {
194
- enabled: codexOn.checked,
195
- path: codexPath.value.trim() || "codex",
196
- home: codexHome.value.trim(),
197
- sandbox: sandbox.value,
198
- defaultModel: codexPick.model(),
199
- defaultEffort: codexPick.effort(),
200
- models: codexList,
201
- },
202
- },
203
- });
204
- },
205
- },
206
- "保存",
207
- ),
237
+ saveButton(() => saveConfig(cfg, { web: { quotaRefreshMin: Number(quotaMin.value), quotaEnabled: quotaOn.checked } })),
208
238
  ),
209
239
  );
210
240
  }
@@ -1,9 +1,9 @@
1
- import { fill, h, icon, toast } from "../lib/dom.js";
1
+ import { fill, h, icon } from "../lib/dom.js";
2
2
  import { api } from "../lib/api.js";
3
3
  import { agentLabel } from "../views/sidebar.js";
4
4
  import { confirmDialog, modal } from "../lib/overlay.js";
5
5
  import { commandUsage, currentAgentId, oneLine, slash } from "../views/slash.js";
6
- import { field, saveConfig } from "./common.js";
6
+ import { field, hasDirty, markClean, markDirty, saveButton, saveConfig } from "./common.js";
7
7
 
8
8
  /**
9
9
  * 默认模型 + 默认思考程度的联动下拉框。
@@ -129,8 +129,9 @@ function customDialog({ agent, entry, taken }, onSave) {
129
129
  * 默认 agent-loft 内置命令按常用程度排在最前,agent 自己的命令排在后面且默认不勾(服务端 buildCatalog 决定)。
130
130
  * 保存时把整张表的顺序存下来;以后新装的 skill 不在表里,会自动接在最后、默认不勾。
131
131
  * 用户可以自己添加命令(一段预设的提示词),自己加的可以编辑、删除,增删改后立即保存。
132
+ * 保存后服务端会同步到飞书机器人的指令面板,同步状态在「飞书」页里看。
132
133
  */
133
- export function commandsTab(cfg, panel) {
134
+ export function commandsTab(cfg, page) {
134
135
  const agentIds = Object.keys(cfg.agents);
135
136
  let agent = currentAgentId();
136
137
  if (!agentIds.includes(agent)) agent = agentIds[0];
@@ -141,6 +142,7 @@ export function commandsTab(cfg, panel) {
141
142
  const tabs = h("div", { class: "row" });
142
143
  const search = h("input", {
143
144
  class: "input",
145
+ "data-nodirty": "",
144
146
  placeholder: "筛选命令(只影响显示,排序仍按整张表)",
145
147
  oninput: () => {
146
148
  filter = search.value.trim().toLowerCase();
@@ -154,6 +156,7 @@ export function commandsTab(cfg, panel) {
154
156
  const [item] = list.splice(from, 1);
155
157
  list.splice(to, 0, item);
156
158
  renderList();
159
+ markDirty(form);
157
160
  };
158
161
 
159
162
  const renderTabs = () =>
@@ -162,7 +165,7 @@ export function commandsTab(cfg, panel) {
162
165
  ...agentIds.map((id) =>
163
166
  h(
164
167
  "button",
165
- { class: `btn small${id === agent ? " primary" : ""}`, onclick: () => id !== agent && load(id) },
168
+ { class: `btn small${id === agent ? " primary" : ""}`, onclick: () => void switchAgent(id) },
166
169
  agentLabel(id),
167
170
  ),
168
171
  ),
@@ -237,8 +240,6 @@ export function commandsTab(cfg, panel) {
237
240
  const save = async (prefs) => {
238
241
  const ok = await saveConfig(cfg, { commands: { [agent]: prefs } });
239
242
  slash.cache.clear();
240
- // 服务端 3 秒防抖后才开始同步,稍等再看状态
241
- if (ok) setTimeout(() => void refreshFeishu(), 3500);
242
243
  return ok;
243
244
  };
244
245
  const prefsOf = (rows) => ({
@@ -252,6 +253,8 @@ export function commandsTab(cfg, panel) {
252
253
  if (!(await save(prefsOf(rows)))) return false;
253
254
  list = rows;
254
255
  renderList();
256
+ // 连同排序和显示一起存了
257
+ markClean(form);
255
258
  return true;
256
259
  };
257
260
  const addCustom = () =>
@@ -277,46 +280,17 @@ export function commandsTab(cfg, panel) {
277
280
  if (sure) await commit(list.filter((x) => x !== c));
278
281
  };
279
282
 
280
- // ---- 飞书指令面板同步状态 ----
281
- const feishuBox = h("div", { class: "cmd-feishu" });
282
- let feishuTimer = null;
283
- const describeSlash = (st) => {
284
- if (st.state === "syncing") return "同步中…(重建时每条间隔 1 秒,命令多要一两分钟)";
285
- if (st.state === "error") return `同步失败:${st.error}`;
286
- if (st.state === "done") {
287
- const when = new Date(st.at).toLocaleTimeString();
288
- const skipped = st.skipped?.length
289
- ? `;${st.skipped.length} 个名字不符合飞书规则(只能小写字母、数字、- _,最长 32)没注册:${st.skipped.slice(0, 8).join("、")}${st.skipped.length > 8 ? " 等" : ""}`
290
- : "";
291
- return `${when} ${st.changed ? "已同步" : "已是最新"},共 ${st.registered} 条${skipped}。飞书端约 5~8 分钟后生效。`;
292
- }
293
- return "等待首次同步";
294
- };
295
- async function refreshFeishu() {
296
- let data;
297
- try {
298
- data = await api("/api/feishu/slash");
299
- } catch (err) {
300
- fill(feishuBox, h("div", { class: "error-text" }, err.message));
283
+ /** 换一个 agent 的表:这张表还有没保存的改动就先问 */
284
+ const switchAgent = async (id) => {
285
+ if (id === agent) return;
286
+ if (
287
+ hasDirty(page) &&
288
+ !(await confirmDialog({ title: "排序还没保存", message: `${agentLabel(agent)} 的命令顺序和显示改了还没保存,切过去就不保存了。`, confirmText: "不保存", danger: true }))
289
+ )
301
290
  return;
302
- }
303
- fill(
304
- feishuBox,
305
- data.bots.length === 0
306
- ? h("div", { class: "muted" }, "没有运行中的飞书机器人。")
307
- : data.bots.map((b) =>
308
- h(
309
- "div",
310
- { class: `muted${b.status.state === "error" ? " error-text" : ""}` },
311
- h("b", {}, b.name),
312
- ":",
313
- describeSlash(b.status),
314
- ),
315
- ),
316
- );
317
- clearTimeout(feishuTimer);
318
- feishuTimer = data.bots.some((b) => b.status.state === "syncing") ? setTimeout(() => feishuBox.isConnected && void refreshFeishu(), 2000) : null;
319
- }
291
+ markClean(form);
292
+ void load(id);
293
+ };
320
294
 
321
295
  async function load(id) {
322
296
  agent = id;
@@ -331,81 +305,37 @@ export function commandsTab(cfg, panel) {
331
305
  }
332
306
  }
333
307
 
334
- panel.append(
308
+ const form = h(
309
+ "div",
310
+ { class: "section", "data-form": "" },
311
+ h("h4", {}, "命令列表"),
335
312
  h(
336
313
  "div",
337
- { class: "section" },
338
- h("h4", {}, "命令列表"),
339
- h(
340
- "div",
341
- { class: "muted" },
342
- "在输入框里打「/」时弹出的命令,按这里的顺序排列;保存后会自动同步到飞书机器人的指令面板。取消勾选的不会出现在列表里,但照样可以手动输入。带 agent 名标记的是 agent 自己的命令(skills 等),默认不勾选,需要的话勾上并保存;它们会原样转给 agent。",
343
- ),
344
- tabs,
345
- h(
346
- "div",
347
- { class: "row cmd-tools" },
348
- search,
349
- h("button", { class: "btn", onclick: () => addCustom() }, icon("plus"), "添加命令"),
350
- ),
351
- listBox,
352
- h(
353
- "div",
354
- { class: "row" },
355
- h(
356
- "button",
357
- {
358
- class: "btn primary",
359
- onclick: () => save(prefsOf(list)),
360
- },
361
- "保存",
362
- ),
363
- h(
364
- "button",
365
- {
366
- class: "btn",
367
- onclick: async () => {
368
- if (!(await confirmDialog({ title: "恢复默认?", message: `把 ${agentLabel(agent)} 的命令顺序和显示恢复成默认(自定义命令保留)。`, confirmText: "恢复默认" }))) return;
369
- await save({ order: [], hidden: [] });
370
- await load(agent);
371
- },
372
- },
373
- "恢复默认",
374
- ),
375
- ),
314
+ { class: "muted" },
315
+ "在输入框里打「/」时弹出的命令,按这里的顺序排列;保存后会自动同步到飞书机器人的指令面板(同步状态在「飞书」里看)。取消勾选的不会出现在列表里,但照样可以手动输入。带 agent 名标记的是 agent 自己的命令(skills 等),默认不勾选,需要的话勾上并保存;它们会原样转给 agent。",
376
316
  ),
317
+ tabs,
318
+ h("div", { class: "row cmd-tools" }, search, h("button", { class: "btn", onclick: () => addCustom() }, icon("plus"), "添加命令")),
319
+ listBox,
377
320
  h(
378
321
  "div",
379
- { class: "section" },
322
+ { class: "row" },
323
+ saveButton(() => save(prefsOf(list))),
380
324
  h(
381
- "h4",
382
- {},
383
- "飞书指令面板",
384
- h("span", { class: "grow" }),
385
- h(
386
- "button",
387
- {
388
- class: "btn small",
389
- onclick: async () => {
390
- try {
391
- await api("/api/feishu/slash/sync", { method: "POST" });
392
- setTimeout(() => void refreshFeishu(), 500);
393
- } catch (err) {
394
- toast(err.message);
395
- }
396
- },
325
+ "button",
326
+ {
327
+ class: "btn",
328
+ onclick: async () => {
329
+ if (!(await confirmDialog({ title: "恢复默认?", message: `把 ${agentLabel(agent)} 的命令顺序和显示恢复成默认(自定义命令保留)。`, confirmText: "恢复默认" }))) return;
330
+ if (!(await save({ order: [], hidden: [] }))) return;
331
+ markClean(form);
332
+ await load(agent);
397
333
  },
398
- "立即同步",
399
- ),
400
- ),
401
- h(
402
- "div",
403
- { class: "muted" },
404
- "飞书输入框打「/」看到的指令。面板是每个机器人一份:默认 agent 的命令在前,其它 agent 的命令接在后面。启动时、保存命令设置后、以及每 6 小时会自动同步。",
334
+ },
335
+ "恢复默认",
405
336
  ),
406
- feishuBox,
407
337
  ),
408
338
  );
339
+ page.append(form);
409
340
  void load(agent);
410
- void refreshFeishu();
411
341
  }