agent-loft 0.2.27 → 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,7 +40,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
40
40
  - 思考和工具调用显示在实时刷新的过程卡片里,最终回复是普通消息。
41
41
  - 陌生人发消息会拿到配对码,在设置页批准后才能使用;每个机器人单独授权。
42
42
  - **Web 工作台**:电脑和手机都能用,界面参照 ChatGPT。
43
- - 回复逐字显示,markdown 和代码高亮,思考过程和工具调用可以折叠;每条回复下方显示用时、费用和完成时间,可以复制,也可以「从这里分叉」成一条新会话(原会话不动)。
43
+ - 回复逐字显示,markdown 和代码高亮,思考过程和工具调用可以折叠;每条回复下方显示用时、费用和完成时间,可以复制,也可以「从这里分叉」成一条新会话(原会话不动);自己发的消息也能一键复制。
44
44
  - 授权卡、提问卡直接在页面上点。
45
45
  - 附件:粘贴、拖拽,手机上可以拍照;可以预览和下载。附件存在服务端的专用上传目录里,开新会话或切换目录时清理。
46
46
  - 语音输入:用浏览器自带的语音识别(Chrome / Edge / Safari,需要 HTTPS)。
@@ -57,7 +57,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
57
57
  - **设置都在网页上**:工作目录、会话闲置过期时间、局域网访问和端口、各 agent 的默认模型和思考程度、可选模型、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、通知设备、登录设备管理、用户管理、服务端升级。
58
58
  - **多用户**(信任型):管理员在 设置 → 用户 里给别人建账号。每个人只看得到自己的对话、授权和通知,设置只有管理员能改,见下面「安全」。
59
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 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
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.27",
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
@@ -575,6 +575,7 @@ async function boot() {
575
575
  S.bots = data.bots;
576
576
  S.workspaceRoot = data.workspaceRoot;
577
577
  S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
578
+ S.quotaEnabled = data.quotaEnabled !== false;
578
579
  S.convs = new Map(data.conversations.map((c) => [c.key, c]));
579
580
  S.pending = new Map(data.pending.map((i) => [i.id, i]));
580
581
  const enabled = usableAgents().map((a) => a.id);
@@ -749,14 +750,20 @@ function stopMarquee() {
749
750
  // - 有会话跑完一轮:10 秒后拉;若那时服务端缓存还没过期,就推迟到过期之后,保证拿到新数据;
750
751
  // - 切回前台:距上次超过一个刷新间隔就拉;
751
752
  // - 点一下额度区域:立刻拉(可能拿到 60 秒内的缓存)。
753
+ // 设置 → 通用里关了「查询额度」(S.quotaEnabled):以上全都不做,侧栏也不显示额度。
752
754
  // 在桌面端里(desktopQuota)以上全都不做:桌面端统一去查(同一个账号的多台服务器只问一台),
753
755
  // 查好推过来;点一下额度区域是请桌面端去刷新。
754
756
 
755
757
  const quotaState = { data: null, at: 0, loading: false, timer: 0, idle: 0 };
756
758
  const QUOTA_SERVER_CACHE = 60_000;
757
- const quotaInterval = () => (S.quotaRefreshMin ?? 10) * 60_000;
759
+ const quotaInterval = () => (S.quotaEnabled ? (S.quotaRefreshMin ?? 10) * 60_000 : 0);
758
760
 
759
761
  async function loadQuota() {
762
+ if (!S.quotaEnabled) {
763
+ quotaState.data = null;
764
+ renderQuota();
765
+ return;
766
+ }
760
767
  if (desktopQuota) {
761
768
  ui.quota?.classList.add("loading");
762
769
  desktop.refreshQuota();
@@ -803,7 +810,7 @@ function scheduleIdleQuota() {
803
810
  /** 一轮跑完:10 秒后拉;服务端缓存那时还没过期就推迟到过期之后 */
804
811
  function scheduleQuota() {
805
812
  // 桌面端里:它从页面报的运行数看出跑完了一轮,自己会去查
806
- if (desktopQuota) return;
813
+ if (desktopQuota || !S.quotaEnabled) return;
807
814
  const wait = Math.max(10_000, quotaState.at + QUOTA_SERVER_CACHE + 1000 - Date.now());
808
815
  clearTimeout(quotaState.timer);
809
816
  quotaState.timer = setTimeout(() => void loadQuota(), wait);
@@ -831,7 +838,7 @@ function quotaLevel(p) {
831
838
 
832
839
  function renderQuota() {
833
840
  if (!ui.quota) return;
834
- const list = quotaState.data ?? [];
841
+ const list = S.quotaEnabled ? (quotaState.data ?? []) : [];
835
842
  if (list.length === 0) return fill(ui.quota);
836
843
  const rows = list.map((q) =>
837
844
  h(
@@ -2039,6 +2046,9 @@ function renderItem(item) {
2039
2046
  !item.text && origin ? h("span", { class: "origin" }, origin) : null,
2040
2047
  renderUserFiles(item.files),
2041
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,
2042
2052
  );
2043
2053
  }
2044
2054
  case "ai":
@@ -2294,10 +2304,10 @@ function renderInteraction(item) {
2294
2304
  : h(
2295
2305
  "div",
2296
2306
  { class: "ix-buttons" },
2297
- h("button", { class: "btn accent", onclick: () => ixOp(ix.id, "decide", { decision: "allow-once" }) }, "允许一次"),
2298
2307
  spec.allowSession
2299
- ? 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" }) }, "本会话全部允许")
2300
2309
  : null,
2310
+ h("button", { class: `btn${spec.allowSession ? "" : " accent"}`, onclick: () => ixOp(ix.id, "decide", { decision: "allow-once" }) }, "允许一次"),
2301
2311
  // 会落盘、对所有人生效的「总是允许」只给管理员
2302
2312
  spec.alwaysLabel && (!spec.alwaysGlobal || isAdmin())
2303
2313
  ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
@@ -3832,9 +3842,14 @@ async function saveConfig(cfg, patch) {
3832
3842
  S.agents = boot2.agents;
3833
3843
  S.bots = boot2.bots;
3834
3844
  S.workspaceRoot = boot2.workspaceRoot;
3835
- 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;
3836
3848
  S.quotaRefreshMin = boot2.quotaRefreshMin ?? 10;
3837
- scheduleIdleQuota();
3849
+ S.quotaEnabled = quotaOn;
3850
+ // 刚打开就马上查一次;关掉就清掉侧栏的额度
3851
+ if (turnedOn || !quotaOn) void loadQuota();
3852
+ else scheduleIdleQuota();
3838
3853
  }
3839
3854
  renderTopbar();
3840
3855
  renderSidebar();
@@ -3908,6 +3923,7 @@ function generalTab(cfg, panel) {
3908
3923
  const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
3909
3924
  const ttl = h("input", { type: "number", min: 0, value: Math.round(cfg.sessionTtlMs / 3600000) });
3910
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 });
3911
3927
  panel.append(
3912
3928
  h(
3913
3929
  "div",
@@ -3916,6 +3932,8 @@ function generalTab(cfg, panel) {
3916
3932
  field("工作目录(workspace)", root, "所有会话的默认目录,/cd 只能在它之内"),
3917
3933
  field("授权等待超时(分钟)", approval, "超时按拒绝处理"),
3918
3934
  field("会话闲置多久自动开新会话(小时)", ttl, "0 表示永不过期"),
3935
+ h("label", { class: "check" }, quotaOn, "查询账号额度"),
3936
+ h("div", { class: "muted" }, "关掉后侧栏、/status、桌面端都不再去问 agent 的额度(Claude 每次要临时起一个 CLI 进程才查得到)"),
3919
3937
  field(
3920
3938
  "额度自动刷新间隔(分钟)",
3921
3939
  quotaMin,
@@ -3935,7 +3953,7 @@ function generalTab(cfg, panel) {
3935
3953
  workspaceRoot: root.value,
3936
3954
  approvalTimeoutMs: Number(approval.value) * 60000,
3937
3955
  sessionTtlMs: Number(ttl.value) * 3600000,
3938
- web: { quotaRefreshMin: Number(quotaMin.value) },
3956
+ web: { quotaRefreshMin: Number(quotaMin.value), quotaEnabled: quotaOn.checked },
3939
3957
  }),
3940
3958
  },
3941
3959
  "保存",
@@ -5179,6 +5197,16 @@ function curateSection(mem, reload) {
5179
5197
  ],
5180
5198
  (v) => save({ when: v }),
5181
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
+ );
5182
5210
  const timing =
5183
5211
  c.when === "idle"
5184
5212
  ? field("停下来多久算聊完", select(c.idleMinutes, [10, 30, 60, 120, 240].map((m) => [m, m < 60 ? `${m} 分钟` : `${m / 60} 小时`]), (v) => save({ idleMinutes: Number(v) })))
@@ -5256,6 +5284,7 @@ function curateSection(mem, reload) {
5256
5284
  }[c.when],
5257
5285
  ),
5258
5286
  timing,
5287
+ c.when === "off" ? null : field("整理哪些对话", scope, "按整理时对话所在的模式算;不在范围里的对话直接跳过,之后改回来也不会补整理"),
5259
5288
  c.when === "off" ? null : field("用哪个 agent", agent),
5260
5289
  c.when === "off" ? null : model,
5261
5290
  h("div", { class: "row", style: { alignItems: "center", gap: "10px" } }, nowBtn, h("span", { class: "muted" }, `${mem.pending ? `有 ${mem.pending} 段对话等着整理。` : ""}${lastText}`)),
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
  }