agent-loft 0.2.26 → 0.2.28

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
@@ -40,14 +40,14 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
40
40
  - 思考和工具调用显示在实时刷新的过程卡片里,最终回复是普通消息。
41
41
  - 陌生人发消息会拿到配对码,在设置页批准后才能使用;每个机器人单独授权。
42
42
  - **Web 工作台**:电脑和手机都能用,界面参照 ChatGPT。
43
- - 回复逐字显示,markdown 和代码高亮,思考过程和工具调用可以折叠;每条回复下方显示用时、费用和完成时间,可以复制,也可以「从这里分叉」成一条新会话(原会话不动)。
43
+ - 回复逐字显示,markdown 和代码高亮,思考过程和工具调用可以折叠;每条回复下方显示用时、费用和完成时间,可以复制,也可以「从这里分叉」成一条新会话(原会话不动);自己发的消息也能一键复制。
44
44
  - 授权卡、提问卡直接在页面上点。
45
45
  - 附件:粘贴、拖拽,手机上可以拍照;可以预览和下载。附件存在服务端的专用上传目录里,开新会话或切换目录时清理。
46
46
  - 语音输入:用浏览器自带的语音识别(Chrome / Edge / Safari,需要 HTTPS)。
47
47
  - 新对话页:点大标题里的目录名,在目录树里逐层选工作目录;输入框上方选 agent,右下角选模型和思考程度。会话进行中用 `/agent` 切换 agent,模型和思考程度随时可换。
48
48
  - 输入框里的小圆环显示上下文窗口占用;模型按钮显示实际的模型和思考程度(如「Opus 5.5 high」)。
49
49
  - 滚动和 ChatGPT 一样:发出的问题顶到最上面、下面留白,回复长到屏幕底部才跟着往下滚;回复过程中自己滚动了就不再自动滚,下次发消息再恢复。
50
- - 定时发送:输入框左下角的时钟按钮,选好时间(有「额度重置后」的快捷选项)由服务端到点发出,关掉页面也会发。适合额度用完、要等刷新才能接着干活的时候。
50
+ - 定时发送:右上角「更多」→「定时发送」,默认排在 5 小时额度重置后 5 分钟(弹窗里提示 5 小时 / 7 天额度的重置时间),由服务端到点发出,关掉页面也会发。适合额度用完、要等刷新才能接着干活的时候。
51
51
  - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
52
52
  - 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,以及多久后重置)。
53
53
  - 「添加到主屏幕」后当 App 用;切到后台再回来会自动重连,补上错过的内容。
@@ -56,8 +56,8 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
56
56
  - **授权**:允许一次 / 本会话全部允许 / 总是允许(按钮上写明放行范围)/ 拒绝。`/autoedit on` 让 Claude Code 在这条会话里改工作目录内的文件不再询问(Bash 仍询问)。超时一律按拒绝处理。
57
57
  - **设置都在网页上**:工作目录、会话闲置过期时间、局域网访问和端口、各 agent 的默认模型和思考程度、可选模型、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、通知设备、登录设备管理、用户管理、服务端升级。
58
58
  - **多用户**(信任型):管理员在 设置 → 用户 里给别人建账号。每个人只看得到自己的对话、授权和通知,设置只有管理员能改,见下面「安全」。
59
- - **聊天模式 / 工作模式**:输入框右下角的「💬 聊天 / 🛠 工作」,或者发 `/chat`、`/work`,同一条对话随时切换、上下文接着。聊天模式换成精简的系统提示,只留联网搜索,不加载技能、MCP、插件,不执行命令、不读写本机文件(文本附件的内容直接放进消息),每轮的固定开销小得多:实测一句话 Claude 约 1.6 万 → 2 千 token,Codex 约 2 万 → 1.2 万(Codex 的基础指令改不了)。适合日常聊天、查资料;要动手干活时切回工作模式。切换后第一轮要重建提示缓存,会贵一些;在工作模式里读过的大段内容还留在历史里。
60
- - **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
59
+ - **聊天模式 / 工作模式**:右上角的「💬 聊天 / 🛠 工作」,或者发 `/chat`、`/work`,同一条对话随时切换、上下文接着。聊天模式换成精简的系统提示,只留联网搜索,不加载技能、MCP、插件,不执行命令、不读写本机文件(文本附件的内容直接放进消息),每轮的固定开销小得多:实测一句话 Claude 约 1.6 万 → 2 千 token,Codex 约 2 万 → 1.2 万(Codex 的基础指令改不了)。适合日常聊天、查资料;要动手干活时切回工作模式。切换后第一轮要重建提示缓存,会贵一些;在工作模式里读过的大段内容还留在历史里。
60
+ - **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;整理哪些对话也能选:全部/只整理聊天模式的/只整理工作模式的;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
61
61
  - **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
62
62
 
63
63
  ## 安装
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -224,7 +224,7 @@ export class ClaudeDriver implements AgentDriver {
224
224
  }
225
225
 
226
226
  async statusLines(): Promise<string[]> {
227
- return describeUsage(await readUsage());
227
+ return config().web.quotaEnabled ? describeUsage(await readUsage()) : [];
228
228
  }
229
229
 
230
230
  async quota(): Promise<QuotaInfo | null> {
@@ -212,7 +212,7 @@ export class CodexDriver implements AgentDriver {
212
212
  }
213
213
 
214
214
  async statusLines(): Promise<string[]> {
215
- const quota = describeRateLimits(await readRateLimits());
215
+ const quota = config().web.quotaEnabled ? describeRateLimits(await readRateLimits()) : [];
216
216
  return [`沙箱:${config().agents.codex.sandbox}`, ...(quota.length > 0 ? ["", ...quota] : [])];
217
217
  }
218
218
 
@@ -218,6 +218,7 @@ function applyConfig(input: Partial<AppConfig>): void {
218
218
  if (Number.isInteger(Number(port)) && Number(port) >= 1 && Number(port) <= 65535) next.web.port = Number(port);
219
219
  const q = Number(next.web.quotaRefreshMin);
220
220
  next.web.quotaRefreshMin = Number.isFinite(q) ? Math.max(0, Math.min(1440, Math.round(q))) : 10;
221
+ next.web.quotaEnabled = next.web.quotaEnabled !== false;
221
222
  }
222
223
  if (input.commands) {
223
224
  // 只收字符串数组,别的一律当没传
@@ -484,6 +485,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
484
485
  bots: admin ? config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })) : [],
485
486
  workspaceRoot: config().workspaceRoot,
486
487
  quotaRefreshMin: config().web.quotaRefreshMin,
488
+ quotaEnabled: config().web.quotaEnabled,
487
489
  conversations: hub.list(visible),
488
490
  pending: interactions.listPending().filter((i) => visible(getConversation(i.convKey))),
489
491
  user: publicMe(user),
@@ -783,6 +785,11 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
783
785
  }
784
786
  if (p === "/api/quota" && method === "GET") {
785
787
  // ?agent=claude,codex:只问这几个。桌面端连着多台同账号的服务器时,同一个账号只在一台上问
788
+ // 设置里关了查额度:不去问任何 agent
789
+ if (!config().web.quotaEnabled) {
790
+ sendJson(res, 200, { quotas: [], at: Date.now(), disabled: true });
791
+ return;
792
+ }
786
793
  const only = url.searchParams.get("agent")?.split(",").filter(Boolean);
787
794
  const results = await Promise.all(
788
795
  hub.enabledAgents().filter((d) => !only || only.includes(d.id)).map(async (d) => {
@@ -67,6 +67,8 @@ export type AppConfig = {
67
67
  trustedProxies: string[];
68
68
  /** 侧栏额度空闲时多久自动刷新一次(分钟),0 = 不自动刷新 */
69
69
  quotaRefreshMin: number;
70
+ /** 查不查 agent 的账号额度。关了侧栏、/status、桌面端都不再去问 agent */
71
+ quotaEnabled: boolean;
70
72
  };
71
73
  /** 输入「/」时命令列表的顺序和隐藏项,按 agent 分开(设置 → 命令) */
72
74
  commands: { claude: CommandPrefs; codex: CommandPrefs };
@@ -96,7 +98,7 @@ export function defaultConfig(): AppConfig {
96
98
  models: [],
97
99
  },
98
100
  },
99
- web: { enabled: true, host: "0.0.0.0", port: 8800, passwordHash: "", trustedProxies: ["127.0.0.1", "::1"], quotaRefreshMin: 10 },
101
+ web: { enabled: true, host: "0.0.0.0", port: 8800, passwordHash: "", trustedProxies: ["127.0.0.1", "::1"], quotaRefreshMin: 10, quotaEnabled: true },
100
102
  commands: { claude: { order: [], hidden: [], custom: [] }, codex: { order: [], hidden: [], custom: [] } },
101
103
  feishu: { watchdogMs: 10 * 60 * 1000, bots: [] },
102
104
  };
@@ -8,6 +8,7 @@
8
8
  // 3. 整理:把上次整理之后新说的话交给模型(用户在记忆设置里选 agent 和模型,默认最省的),
9
9
  // 让它对照现有记忆给出新增 / 更新 / 删除。用户自己加的条目它动不了。什么时候整理也由用户定
10
10
  // (CurateSettings.when):每段聊完后 / 每天一次把当天的合起来 / 只在点「现在整理」时 / 不整理。
11
+ // 整理哪些也由用户定(CurateSettings.scope):全部 / 只整理聊天模式的 / 只整理工作模式的。
11
12
  import { hub } from "./hub.js";
12
13
  import {
13
14
  appendRecords,
@@ -27,6 +28,7 @@ import {
27
28
  readMemory,
28
29
  removeMemoryItem,
29
30
  updateMemoryItem,
31
+ type CurateScope,
30
32
  type CurateSettings,
31
33
  type UserMemory,
32
34
  } from "./memory.js";
@@ -361,12 +363,18 @@ export async function curateBatch(
361
363
  return log;
362
364
  }
363
365
 
364
- /** 某个用户等着整理的对话。删掉的对话直接记成整理过;正在回复的这次先不碰 */
366
+ /** 这段对话在不在用户选的整理范围里(按现在的模式算,中途切过模式的整段归现在的) */
367
+ function inScope(conv: { mode?: "chat" }, scope: CurateScope): boolean {
368
+ return scope === "all" || (scope === "chat") === (conv.mode === "chat");
369
+ }
370
+
371
+ /** 某个用户等着整理的对话。删掉的、不在整理范围里的直接记成整理过(之后改范围也不补整理);正在回复的这次先不碰 */
365
372
  function collect(userId: string, settleMs: number, now?: number): Pending[] {
373
+ const scope = readMemory(userId).curate.scope;
366
374
  const out: Pending[] = [];
367
375
  for (const p of pendingConversations(userId, settleMs, now)) {
368
376
  const conv = hub.conversation(p.key);
369
- if (!conv || conv.hidden) {
377
+ if (!conv || conv.hidden || !inScope(conv, scope)) {
370
378
  markExtracted(userId, p.key, p.last);
371
379
  continue;
372
380
  }
@@ -426,7 +434,11 @@ export function curateNow(userId: string, opts: { now?: number } = {}): Promise<
426
434
 
427
435
  /** 还没整理的对话有几段(设置页显示「有 N 段对话等着整理」) */
428
436
  export function pendingCount(userId: string): number {
429
- return pendingConversations(userId, 0).filter((p) => !hub.conversation(p.key)?.hidden).length;
437
+ const scope = readMemory(userId).curate.scope;
438
+ return pendingConversations(userId, 0).filter((p) => {
439
+ const conv = hub.conversation(p.key);
440
+ return conv && !conv.hidden && inScope(conv, scope);
441
+ }).length;
430
442
  }
431
443
 
432
444
  export function startMemoryCurator(): void {
@@ -44,8 +44,12 @@ export type UserMemory = {
44
44
  */
45
45
  export type CurateWhen = "idle" | "daily" | "manual" | "off";
46
46
 
47
+ /** 整理哪些对话:全部 / 只整理聊天模式的 / 只整理工作模式的(按整理那一刻对话的模式算) */
48
+ export type CurateScope = "all" | "chat" | "work";
49
+
47
50
  export type CurateSettings = {
48
51
  when: CurateWhen;
52
+ scope: CurateScope;
49
53
  idleMinutes: number;
50
54
  dailyHour: number;
51
55
  /** auto:Claude 能用就用 Claude,否则 Codex */
@@ -54,17 +58,19 @@ export type CurateSettings = {
54
58
  model: string;
55
59
  };
56
60
 
57
- export const DEFAULT_CURATE: CurateSettings = { when: "daily", idleMinutes: 30, dailyHour: 4, agent: "auto", model: "" };
61
+ export const DEFAULT_CURATE: CurateSettings = { when: "daily", scope: "all", idleMinutes: 30, dailyHour: 4, agent: "auto", model: "" };
58
62
 
59
63
  function cleanCurate(raw: Partial<CurateSettings> | undefined, base: CurateSettings): CurateSettings {
60
64
  const r = raw ?? {};
61
65
  const when = (["idle", "daily", "manual", "off"] as const).find((w) => w === r.when) ?? base.when;
66
+ const scope = (["all", "chat", "work"] as const).find((s) => s === r.scope) ?? base.scope;
62
67
  const idle = Math.round(Number(r.idleMinutes ?? base.idleMinutes));
63
68
  const hour = Math.round(Number(r.dailyHour ?? base.dailyHour));
64
69
  const agent = (["auto", "claude", "codex"] as const).find((a) => a === r.agent) ?? base.agent;
65
70
  const model = typeof r.model === "string" ? r.model.trim().slice(0, 100) : base.model;
66
71
  return {
67
72
  when,
73
+ scope,
68
74
  idleMinutes: Number.isFinite(idle) ? Math.max(5, Math.min(24 * 60, idle)) : base.idleMinutes,
69
75
  dailyHour: Number.isFinite(hour) ? Math.max(0, Math.min(23, hour)) : base.dailyHour,
70
76
  agent,
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
+ more: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>',
41
42
  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>',
42
43
  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>',
43
44
  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>',
@@ -574,6 +575,7 @@ async function boot() {
574
575
  S.bots = data.bots;
575
576
  S.workspaceRoot = data.workspaceRoot;
576
577
  S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
578
+ S.quotaEnabled = data.quotaEnabled !== false;
577
579
  S.convs = new Map(data.conversations.map((c) => [c.key, c]));
578
580
  S.pending = new Map(data.pending.map((i) => [i.id, i]));
579
581
  const enabled = usableAgents().map((a) => a.id);
@@ -683,6 +685,8 @@ function renderLayout() {
683
685
  h("div", { class: "side-resizer", title: "拖动调整宽度,双击恢复默认", onpointerdown: startSideResize, ondblclick: () => setSideWidth(null) }),
684
686
  );
685
687
  ui.topbar = h("div", { class: "topbar" });
688
+ // 聊天 / 工作切换在顶栏右上角;先建好,renderTopbar 每次把它放回去
689
+ ui.modeSlot = h("span", { class: "mode-slot" });
686
690
  ui.msgInner = h("div", { class: "messages-inner" });
687
691
  ui.spacer = h("div", { class: "messages-spacer", "aria-hidden": "true" });
688
692
  ui.messages = h("div", { class: "messages", tabindex: "-1" }, ui.msgInner, ui.spacer);
@@ -746,14 +750,20 @@ function stopMarquee() {
746
750
  // - 有会话跑完一轮:10 秒后拉;若那时服务端缓存还没过期,就推迟到过期之后,保证拿到新数据;
747
751
  // - 切回前台:距上次超过一个刷新间隔就拉;
748
752
  // - 点一下额度区域:立刻拉(可能拿到 60 秒内的缓存)。
753
+ // 设置 → 通用里关了「查询额度」(S.quotaEnabled):以上全都不做,侧栏也不显示额度。
749
754
  // 在桌面端里(desktopQuota)以上全都不做:桌面端统一去查(同一个账号的多台服务器只问一台),
750
755
  // 查好推过来;点一下额度区域是请桌面端去刷新。
751
756
 
752
757
  const quotaState = { data: null, at: 0, loading: false, timer: 0, idle: 0 };
753
758
  const QUOTA_SERVER_CACHE = 60_000;
754
- const quotaInterval = () => (S.quotaRefreshMin ?? 10) * 60_000;
759
+ const quotaInterval = () => (S.quotaEnabled ? (S.quotaRefreshMin ?? 10) * 60_000 : 0);
755
760
 
756
761
  async function loadQuota() {
762
+ if (!S.quotaEnabled) {
763
+ quotaState.data = null;
764
+ renderQuota();
765
+ return;
766
+ }
757
767
  if (desktopQuota) {
758
768
  ui.quota?.classList.add("loading");
759
769
  desktop.refreshQuota();
@@ -800,7 +810,7 @@ function scheduleIdleQuota() {
800
810
  /** 一轮跑完:10 秒后拉;服务端缓存那时还没过期就推迟到过期之后 */
801
811
  function scheduleQuota() {
802
812
  // 桌面端里:它从页面报的运行数看出跑完了一轮,自己会去查
803
- if (desktopQuota) return;
813
+ if (desktopQuota || !S.quotaEnabled) return;
804
814
  const wait = Math.max(10_000, quotaState.at + QUOTA_SERVER_CACHE + 1000 - Date.now());
805
815
  clearTimeout(quotaState.timer);
806
816
  quotaState.timer = setTimeout(() => void loadQuota(), wait);
@@ -828,7 +838,7 @@ function quotaLevel(p) {
828
838
 
829
839
  function renderQuota() {
830
840
  if (!ui.quota) return;
831
- const list = quotaState.data ?? [];
841
+ const list = S.quotaEnabled ? (quotaState.data ?? []) : [];
832
842
  if (list.length === 0) return fill(ui.quota);
833
843
  const rows = list.map((q) =>
834
844
  h(
@@ -1290,10 +1300,22 @@ function renderTopbar() {
1290
1300
  fill(ui.topbar,
1291
1301
  h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
1292
1302
  h("div", { class: "top-title" }, c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null),
1303
+ ui.modeSlot,
1304
+ h("button", { class: "icon-btn top-more", title: "更多", onclick: (e) => openTopMore(e.currentTarget) }, icon("more")),
1293
1305
  );
1294
1306
  renderContext();
1295
1307
  }
1296
1308
 
1309
+ /** 顶栏右上角「更多」 */
1310
+ function openTopMore(anchor) {
1311
+ if (ui.menu?.anchor === anchor) return closeMenu();
1312
+ showMenu(anchor, [{ label: "定时发送…", onclick: () => openSchedule() }]);
1313
+ ui.menu.anchor = anchor;
1314
+ // 贴着按钮右边对齐,别超出屏幕
1315
+ const r = anchor.getBoundingClientRect();
1316
+ ui.menu.style.left = `${Math.max(8, r.right - ui.menu.offsetWidth)}px`;
1317
+ }
1318
+
1297
1319
  function baseName(p) {
1298
1320
  return (p ?? "").split(/[\\/]/).filter(Boolean).pop() || p || "";
1299
1321
  }
@@ -2024,6 +2046,9 @@ function renderItem(item) {
2024
2046
  !item.text && origin ? h("span", { class: "origin" }, origin) : null,
2025
2047
  renderUserFiles(item.files),
2026
2048
  item.text ? h("div", { class: "msg-user" }, origin ? h("span", { class: "origin" }, origin) : null, item.text) : null,
2049
+ item.text
2050
+ ? h("div", { class: "user-actions" }, h("button", { class: "icon-btn", title: "复制", onclick: () => copyText(item.text) }, icon("copy")))
2051
+ : null,
2027
2052
  );
2028
2053
  }
2029
2054
  case "ai":
@@ -2279,10 +2304,10 @@ function renderInteraction(item) {
2279
2304
  : h(
2280
2305
  "div",
2281
2306
  { class: "ix-buttons" },
2282
- h("button", { class: "btn accent", onclick: () => ixOp(ix.id, "decide", { decision: "allow-once" }) }, "允许一次"),
2283
2307
  spec.allowSession
2284
- ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
2308
+ ? h("button", { class: "btn accent", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
2285
2309
  : null,
2310
+ h("button", { class: `btn${spec.allowSession ? "" : " accent"}`, onclick: () => ixOp(ix.id, "decide", { decision: "allow-once" }) }, "允许一次"),
2286
2311
  // 会落盘、对所有人生效的「总是允许」只给管理员
2287
2312
  spec.alwaysLabel && (!spec.alwaysGlobal || isAdmin())
2288
2313
  ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
@@ -2380,7 +2405,6 @@ function renderComposer() {
2380
2405
  : null;
2381
2406
  ui.ctxBar = h("div", { class: "ctx-bar" });
2382
2407
  ui.modelSlot = h("span", { class: "model-slot" });
2383
- ui.modeSlot = h("span", { class: "mode-slot" });
2384
2408
  ui.ctxRing = h("span", { class: "ctx-ring-slot" });
2385
2409
  ui.chips = h("div", { class: "chips" });
2386
2410
  ui.schedBar = h("div", { class: "sched-bar", hidden: true });
@@ -2407,11 +2431,9 @@ function renderComposer() {
2407
2431
  "div",
2408
2432
  { class: "composer-row" },
2409
2433
  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")),
2411
2434
  ui.fileInput,
2412
2435
  h("span", { class: "spacer" }),
2413
2436
  ui.ctxRing,
2414
- ui.modeSlot,
2415
2437
  ui.modelSlot,
2416
2438
  ui.micBtn,
2417
2439
  ui.sendBtn,
@@ -2489,33 +2511,25 @@ function openSchedule(edit) {
2489
2511
  const text = h("textarea", { class: "sched-input", rows: 4, placeholder: "到点要发的内容" });
2490
2512
  text.value = edit ? edit.text : ui.input.value;
2491
2513
  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 的额度窗口重置后一分钟,再加几个常用的
2514
+ // 当前 agent 的额度窗口(5 小时 / 7 天)什么时候重置:小字提示,默认排在 5 小时窗口重置后 5 分钟
2495
2515
  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
- );
2516
+ const windows = (quotaState.data ?? []).find((q) => q.agent === agent)?.windows ?? [];
2517
+ const resets = windows.filter((w) => /^(5h|7d)$/.test(w.short) && w.resetsAt > Date.now());
2518
+ const session = resets.find((w) => w.short === "5h");
2519
+ when.value = toLocalInput(edit ? edit.at : session ? session.resetsAt + 5 * 60_000 : Date.now() + 5 * 3600_000);
2512
2520
  const body = h(
2513
2521
  "div",
2514
2522
  { class: "modal-body sched-body" },
2515
2523
  text,
2516
- h("div", { class: "sched-presets" }, presets.map((p) => h("button", { class: "btn", onclick: () => setAt(p.at) }, p.label))),
2517
2524
  h("label", { class: "sched-row" }, h("span", {}, "发送时间"), when),
2518
- h("div", { class: "muted" }, "到点由服务端发出,关掉页面也会发。到点时 agent 还在忙的话,会排在当前这轮之后。"),
2525
+ h(
2526
+ "div",
2527
+ { class: "muted sched-hint" },
2528
+ resets.length
2529
+ ? `额度重置:${resets.map((w) => `${w.short === "5h" ? "5 小时" : "7 天"}额度 ${fmtWhen(w.resetsAt)}`).join(",")}。`
2530
+ : "",
2531
+ "到点由服务端发出,关掉页面也会发。",
2532
+ ),
2519
2533
  );
2520
2534
  const go = h("button", { class: "btn primary" }, edit ? "保存" : "定时发送");
2521
2535
  const close = modal({ title: edit ? "编辑定时消息" : "定时发送", body, foot: h("div", { class: "modal-foot" }, go) });
@@ -3828,9 +3842,14 @@ async function saveConfig(cfg, patch) {
3828
3842
  S.agents = boot2.agents;
3829
3843
  S.bots = boot2.bots;
3830
3844
  S.workspaceRoot = boot2.workspaceRoot;
3831
- if (boot2.quotaRefreshMin !== S.quotaRefreshMin) {
3845
+ const quotaOn = boot2.quotaEnabled !== false;
3846
+ if (boot2.quotaRefreshMin !== S.quotaRefreshMin || quotaOn !== S.quotaEnabled) {
3847
+ const turnedOn = quotaOn && !S.quotaEnabled;
3832
3848
  S.quotaRefreshMin = boot2.quotaRefreshMin ?? 10;
3833
- scheduleIdleQuota();
3849
+ S.quotaEnabled = quotaOn;
3850
+ // 刚打开就马上查一次;关掉就清掉侧栏的额度
3851
+ if (turnedOn || !quotaOn) void loadQuota();
3852
+ else scheduleIdleQuota();
3834
3853
  }
3835
3854
  renderTopbar();
3836
3855
  renderSidebar();
@@ -3904,6 +3923,7 @@ function generalTab(cfg, panel) {
3904
3923
  const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
3905
3924
  const ttl = h("input", { type: "number", min: 0, value: Math.round(cfg.sessionTtlMs / 3600000) });
3906
3925
  const quotaMin = h("input", { type: "number", min: 0, max: 1440, value: cfg.web?.quotaRefreshMin ?? 10 });
3926
+ const quotaOn = h("input", { type: "checkbox", checked: cfg.web?.quotaEnabled !== false });
3907
3927
  panel.append(
3908
3928
  h(
3909
3929
  "div",
@@ -3912,6 +3932,8 @@ function generalTab(cfg, panel) {
3912
3932
  field("工作目录(workspace)", root, "所有会话的默认目录,/cd 只能在它之内"),
3913
3933
  field("授权等待超时(分钟)", approval, "超时按拒绝处理"),
3914
3934
  field("会话闲置多久自动开新会话(小时)", ttl, "0 表示永不过期"),
3935
+ h("label", { class: "check" }, quotaOn, "查询账号额度"),
3936
+ h("div", { class: "muted" }, "关掉后侧栏、/status、桌面端都不再去问 agent 的额度(Claude 每次要临时起一个 CLI 进程才查得到)"),
3915
3937
  field(
3916
3938
  "额度自动刷新间隔(分钟)",
3917
3939
  quotaMin,
@@ -3931,7 +3953,7 @@ function generalTab(cfg, panel) {
3931
3953
  workspaceRoot: root.value,
3932
3954
  approvalTimeoutMs: Number(approval.value) * 60000,
3933
3955
  sessionTtlMs: Number(ttl.value) * 3600000,
3934
- web: { quotaRefreshMin: Number(quotaMin.value) },
3956
+ web: { quotaRefreshMin: Number(quotaMin.value), quotaEnabled: quotaOn.checked },
3935
3957
  }),
3936
3958
  },
3937
3959
  "保存",
@@ -5175,6 +5197,16 @@ function curateSection(mem, reload) {
5175
5197
  ],
5176
5198
  (v) => save({ when: v }),
5177
5199
  );
5200
+ // 换了范围,等着整理的段数也跟着变:重新读一遍
5201
+ const scope = select(
5202
+ c.scope,
5203
+ [
5204
+ ["all", "全部对话"],
5205
+ ["chat", "只整理聊天模式的对话"],
5206
+ ["work", "只整理工作模式的对话"],
5207
+ ],
5208
+ (v) => save({ scope: v }).then(reload),
5209
+ );
5178
5210
  const timing =
5179
5211
  c.when === "idle"
5180
5212
  ? field("停下来多久算聊完", select(c.idleMinutes, [10, 30, 60, 120, 240].map((m) => [m, m < 60 ? `${m} 分钟` : `${m / 60} 小时`]), (v) => save({ idleMinutes: Number(v) })))
@@ -5252,6 +5284,7 @@ function curateSection(mem, reload) {
5252
5284
  }[c.when],
5253
5285
  ),
5254
5286
  timing,
5287
+ c.when === "off" ? null : field("整理哪些对话", scope, "按整理时对话所在的模式算;不在范围里的对话直接跳过,之后改回来也不会补整理"),
5255
5288
  c.when === "off" ? null : field("用哪个 agent", agent),
5256
5289
  c.when === "off" ? null : model,
5257
5290
  h("div", { class: "row", style: { alignItems: "center", gap: "10px" } }, nowBtn, h("span", { class: "muted" }, `${mem.pending ? `有 ${mem.pending} 段对话等着整理。` : ""}${lastText}`)),
package/web/index.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="zh-CN">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
6
6
  <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
7
7
  <meta name="theme-color" content="#1f1f1f" media="(prefers-color-scheme: dark)" />
8
8
  <meta name="apple-mobile-web-app-capable" content="yes" />
package/web/style.css CHANGED
@@ -1682,6 +1682,35 @@ a.att-file:hover {
1682
1682
  }
1683
1683
  }
1684
1684
 
1685
+ /* 用户消息下面的复制:hover 时才出来,和回复的操作栏一样用 opacity 占着位置 */
1686
+ .user-actions {
1687
+ display: flex;
1688
+ margin-top: -4px;
1689
+ opacity: 0;
1690
+ transition: opacity 0.15s;
1691
+ }
1692
+
1693
+ .msg-user-group:hover .user-actions,
1694
+ .user-actions:focus-within {
1695
+ opacity: 1;
1696
+ }
1697
+
1698
+ @media (hover: none) {
1699
+ .user-actions {
1700
+ opacity: 1;
1701
+ }
1702
+ }
1703
+
1704
+ .user-actions .icon-btn {
1705
+ width: 26px;
1706
+ height: 26px;
1707
+ }
1708
+
1709
+ .user-actions .icon-btn svg {
1710
+ width: 15px;
1711
+ height: 15px;
1712
+ }
1713
+
1685
1714
  .ai-actions .footer-note {
1686
1715
  margin-left: 4px;
1687
1716
  }
@@ -2038,6 +2067,16 @@ button.ctx-item:hover {
2038
2067
  color: var(--accent);
2039
2068
  }
2040
2069
 
2070
+ .topbar .mode-slot {
2071
+ display: inline-flex;
2072
+ flex-shrink: 0;
2073
+ }
2074
+
2075
+ .topbar .top-more svg {
2076
+ width: 20px;
2077
+ height: 20px;
2078
+ }
2079
+
2041
2080
  .composer.drag {
2042
2081
  border-color: var(--accent);
2043
2082
  }
@@ -3432,14 +3471,9 @@ button.list-item:hover {
3432
3471
  font: inherit;
3433
3472
  }
3434
3473
 
3435
- .sched-presets {
3436
- display: flex;
3437
- flex-wrap: wrap;
3438
- gap: 6px;
3439
- }
3440
-
3441
- .sched-presets .btn {
3442
- font-size: 13px;
3474
+ .sched-hint {
3475
+ font-size: 12px;
3476
+ line-height: 1.6;
3443
3477
  }
3444
3478
 
3445
3479
  .sched-row {
@@ -3458,3 +3492,9 @@ button.list-item:hover {
3458
3492
  font: inherit;
3459
3493
  color-scheme: light dark;
3460
3494
  }
3495
+
3496
+ /* iOS 上双击会放大页面:关掉双击缩放(双指缩放照常) */
3497
+ html,
3498
+ body {
3499
+ touch-action: manipulation;
3500
+ }