im-agent 0.2.0 → 0.2.2

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
@@ -48,7 +48,7 @@ im-agent service install
48
48
  | `im-agent service install \| uninstall \| restart \| status` | 开机自启 |
49
49
  | `im-agent doctor` | 体检 |
50
50
 
51
- 聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(im-agent 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/approve <id> allow-once|allow-always|deny` `/help`
51
+ 聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(im-agent 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/approve <id> allow-once|allow-always|allow-session|deny` `/help`
52
52
 
53
53
  ## 安全
54
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "im-agent",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "把飞书(以及以后的 Telegram 等)和 Web 页面桥接到本机 Claude Code / Codex:聊天驱动、卡片审批、会话与模型切换",
6
6
  "keywords": [
@@ -27,6 +27,7 @@ import {
27
27
  alwaysLabelOf,
28
28
  describeToolCall,
29
29
  isPreApproved,
30
+ isRememberable,
30
31
  isRememberableSignature,
31
32
  parseQuestions,
32
33
  rememberAlways,
@@ -134,6 +135,11 @@ export class ClaudeSession implements AgentSession {
134
135
  */
135
136
  private turnBusy = false;
136
137
  private pending: UserInput[] = [];
138
+ /**
139
+ * 用户点过「本会话全部允许」。只在内存里:这个对象一换(/new、切目录、切 agent、
140
+ * 恢复历史、进程重启),就重新逐条询问。
141
+ */
142
+ private allowAll = false;
137
143
 
138
144
  constructor(
139
145
  private readonly opts: SessionOptions,
@@ -265,6 +271,10 @@ export class ClaudeSession implements AgentSession {
265
271
  if (answered) return answered;
266
272
  }
267
273
 
274
+ // 提问、批准计划这类不是「安全确认」,而是要用户真的做决定,全部允许也照样问
275
+ const sessionable = isRememberable(toolName);
276
+ if (sessionable && this.allowAll) return { behavior: "allow", updatedInput: input };
277
+
268
278
  const signature = signatureOf(toolName, input);
269
279
  if (isPreApproved(toolName, signature)) return { behavior: "allow", updatedInput: input };
270
280
 
@@ -273,9 +283,14 @@ export class ClaudeSession implements AgentSession {
273
283
  title: `需要授权:${toolName}`,
274
284
  detail: describeToolCall(toolName, input),
275
285
  ...(isRememberableSignature(toolName, signature) ? { alwaysLabel: alwaysLabelOf(toolName, signature) } : {}),
286
+ ...(sessionable ? { allowSession: true } : {}),
276
287
  });
277
288
  if (outcome.settlement === "allow-always" && isRememberableSignature(toolName, signature)) rememberAlways(signature);
278
- if (outcome.settlement === "allow-once" || outcome.settlement === "allow-always") {
289
+ if (outcome.settlement === "allow-session") {
290
+ if (!this.allowAll) this.events.onStep("warning", "🔓 本会话内后续操作不再询问(/new 或切换会话后恢复逐条确认)");
291
+ this.allowAll = true;
292
+ }
293
+ if (outcome.settlement === "allow-once" || outcome.settlement === "allow-always" || outcome.settlement === "allow-session") {
279
294
  return { behavior: "allow", updatedInput: input };
280
295
  }
281
296
  const why =
@@ -10,6 +10,8 @@ import type {
10
10
  AgentEvents,
11
11
  AgentSession,
12
12
  InteractionHost,
13
+ InteractionOutcome,
14
+ InteractionSpec,
13
15
  QuestionSpec,
14
16
  RuntimeInfo,
15
17
  SessionOptions,
@@ -113,7 +115,7 @@ function describePermissions(params: PermissionsApprovalParams): string {
113
115
  }
114
116
 
115
117
  function allowed(s: Settlement): boolean {
116
- return s === "allow-once" || s === "allow-always";
118
+ return s === "allow-once" || s === "allow-always" || s === "allow-session";
117
119
  }
118
120
 
119
121
  type ParsedQuestion = QuestionSpec & { secret: boolean };
@@ -176,6 +178,8 @@ export class CodexSession implements AgentSession {
176
178
  private opening: Promise<string> | null = null;
177
179
  private detached = false;
178
180
  private runtime: RuntimeInfo = {};
181
+ /** 用户点过「本会话全部允许」。只在内存里,会话对象一换就重新逐条询问。 */
182
+ private allowAll = false;
179
183
  private replyText = "";
180
184
  private lastTokens: number | undefined;
181
185
  /** 分叉已经真正完成(拿到并接管了新 thread)。失败了不算,重试还要接着分叉 */
@@ -473,6 +477,16 @@ export class CodexSession implements AgentSession {
473
477
 
474
478
  // ---------- 反向请求:审批与提问 ----------
475
479
 
480
+ /** 审批都走这里:带上「本会话全部允许」按钮,点了就记住。 */
481
+ private async ask(spec: Extract<InteractionSpec, { kind: "approval" }>): Promise<InteractionOutcome> {
482
+ const outcome = await this.host({ ...spec, allowSession: true });
483
+ if (outcome.settlement === "allow-session") {
484
+ if (!this.allowAll) this.events.onStep("warning", "🔓 本会话内后续操作不再询问(/new 或切换会话后恢复逐条确认)");
485
+ this.allowAll = true;
486
+ }
487
+ return outcome;
488
+ }
489
+
476
490
  /**
477
491
  * 每一条都**必须**返回点什么 —— 漏回一次,Codex 那一轮就永远挂在等审批上。
478
492
  * 未知方法也要按它自己的协议结构明确拒绝。
@@ -481,8 +495,9 @@ export class CodexSession implements AgentSession {
481
495
  switch (method) {
482
496
  case "item/commandExecution/requestApproval": {
483
497
  const req = params as CommandExecutionApprovalParams;
498
+ if (this.allowAll) return { decision: "acceptForSession" };
484
499
  const amendment = req.proposedExecpolicyAmendment ?? null;
485
- const outcome = await this.host({
500
+ const outcome = await this.ask({
486
501
  kind: "approval",
487
502
  title: req.kind === "writeStdin" ? "需要授权:向运行中的命令输入" : "需要授权:执行命令",
488
503
  detail: describeCommand(req),
@@ -491,6 +506,7 @@ export class CodexSession implements AgentSession {
491
506
  });
492
507
  const s = outcome.settlement;
493
508
  if (s === "allow-once") return { decision: "accept" };
509
+ if (s === "allow-session") return { decision: "acceptForSession" };
494
510
  if (s === "allow-always") {
495
511
  return {
496
512
  decision: amendment
@@ -504,7 +520,8 @@ export class CodexSession implements AgentSession {
504
520
 
505
521
  case "item/fileChange/requestApproval": {
506
522
  const req = params as FileChangeApprovalParams;
507
- const outcome = await this.host({
523
+ if (this.allowAll) return { decision: "acceptForSession" };
524
+ const outcome = await this.ask({
508
525
  kind: "approval",
509
526
  title: "需要授权:改动文件",
510
527
  detail: describeFileChange(req),
@@ -512,14 +529,15 @@ export class CodexSession implements AgentSession {
512
529
  });
513
530
  const s = outcome.settlement;
514
531
  if (s === "allow-once") return { decision: "accept" };
515
- if (s === "allow-always") return { decision: "acceptForSession" };
532
+ if (s === "allow-always" || s === "allow-session") return { decision: "acceptForSession" };
516
533
  if (s === "cancelled") return { decision: "cancel" };
517
534
  return { decision: "decline" };
518
535
  }
519
536
 
520
537
  case "item/permissions/requestApproval": {
521
538
  const req = params as PermissionsApprovalParams;
522
- const outcome = await this.host({
539
+ if (this.allowAll) return { permissions: req.permissions ?? {}, scope: "session" };
540
+ const outcome = await this.ask({
523
541
  kind: "approval",
524
542
  title: "需要授权:额外权限",
525
543
  detail: describePermissions(req),
@@ -528,7 +546,7 @@ export class CodexSession implements AgentSession {
528
546
  if (!allowed(outcome.settlement)) return { permissions: {}, scope: "turn" };
529
547
  return {
530
548
  permissions: req.permissions ?? {},
531
- scope: outcome.settlement === "allow-always" ? "session" : "turn",
549
+ scope: outcome.settlement === "allow-once" ? "turn" : "session",
532
550
  };
533
551
  }
534
552
 
@@ -62,6 +62,7 @@ export function approvalCard(interaction: Interaction, ctx: CardCtx) {
62
62
  const buttons = [
63
63
  button("允许一次", { a: "approval", r: id, d: "allow-once" }, ctx, "primary"),
64
64
  ...(spec.alwaysLabel ? [button(spec.alwaysLabel, { a: "approval", r: id, d: "allow-always" }, ctx)] : []),
65
+ ...(spec.allowSession ? [button("本会话全部允许", { a: "approval", r: id, d: "allow-session" }, ctx)] : []),
65
66
  button("拒绝", { a: "approval", r: id, d: "deny" }, ctx, "danger"),
66
67
  ];
67
68
  return card(
@@ -127,7 +128,7 @@ export function settledCard(interaction: Interaction) {
127
128
  const settlement = settled?.settlement ?? "cancelled";
128
129
  const by = settled?.by ?? "";
129
130
  if (interaction.spec.kind === "approval") {
130
- const ok = settlement === "allow-once" || settlement === "allow-always";
131
+ const ok = settlement === "allow-once" || settlement === "allow-always" || settlement === "allow-session";
131
132
  const label =
132
133
  settlement === "allow-always"
133
134
  ? `已允许(${interaction.spec.alwaysLabel ?? "后续同类自动放行"})`
@@ -86,7 +86,7 @@ const idx = (v: unknown): v is number => typeof v === "number" && Number.isInteg
86
86
  function validAction(v: Record<string, unknown>): boolean {
87
87
  switch (v.a) {
88
88
  case "approval":
89
- return str(v.r) && (v.d === "allow-once" || v.d === "allow-always" || v.d === "deny");
89
+ return str(v.r) && (v.d === "allow-once" || v.d === "allow-always" || v.d === "allow-session" || v.d === "deny");
90
90
  case "ask":
91
91
  return str(v.r) && idx(v.q) && idx(v.o) && (v.v === 0 || v.v === 1);
92
92
  case "ask-submit":
@@ -255,7 +255,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
255
255
  capabilities: d?.capabilities ?? {},
256
256
  };
257
257
  }),
258
- bots: config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents })),
258
+ bots: config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })),
259
259
  workspaceRoot: config().workspaceRoot,
260
260
  conversations: hub.list(),
261
261
  pending: interactions.listPending(),
@@ -23,7 +23,7 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
23
23
  { name: "cd", args: "<相对目录>", description: "切换工作目录(限 workspace 内)" },
24
24
  { name: "pwd", description: "显示当前工作目录" },
25
25
  { name: "autoedit", args: "[on|off]", description: "本对话自动允许改工作目录内的文件(Bash 仍询问)" },
26
- { name: "approve", args: "<请求ID> allow-once|allow-always|deny", description: "审批兜底(卡片失效时用)" },
26
+ { name: "approve", args: "<请求ID> allow-once|allow-always|allow-session|deny", description: "审批兜底(卡片失效时用)" },
27
27
  { name: "help", description: "显示帮助" },
28
28
  ];
29
29
 
@@ -24,7 +24,7 @@ export type ParsedCommand =
24
24
  | { kind: "approve"; requestId: string; decision: ApprovalDecision }
25
25
  | { kind: "chat"; text: string };
26
26
 
27
- const DECISIONS: ApprovalDecision[] = ["allow-once", "allow-always", "deny"];
27
+ const DECISIONS: ApprovalDecision[] = ["allow-once", "allow-always", "allow-session", "deny"];
28
28
 
29
29
  export function parseCommand(raw: string): ParsedCommand {
30
30
  const text = raw.trim();
package/src/core/hub.ts CHANGED
@@ -950,6 +950,9 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
950
950
  return text(await this.stop(key));
951
951
  case "approve": {
952
952
  const settled = interactions.decide(cmd.requestId, cmd.decision as ApprovalDecision, ctx.by);
953
+ if (!settled && interactions.isPending(cmd.requestId)) {
954
+ return text(`请求 ${cmd.requestId} 不支持 ${cmd.decision}。`);
955
+ }
953
956
  if (!settled) return text(`请求 ${cmd.requestId} 不存在或已处理。`);
954
957
  return text(`${SETTLEMENT_LABEL[cmd.decision]}:${cmd.requestId}`);
955
958
  }
@@ -89,7 +89,18 @@ class InteractionCenter extends EventEmitter<InteractionEvents> {
89
89
  decide(id: string, decision: Settlement, by: string): Interaction | undefined {
90
90
  const entry = this.pending.get(id);
91
91
  if (!entry || entry.spec.kind !== "approval") return undefined;
92
- return this.settle(id, decision, by);
92
+ // 卡上没有这个按钮(比如批准计划)就不认:不能靠手打 /approve 绕过去
93
+ if (decision === "allow-session" && !entry.spec.allowSession) return undefined;
94
+ const settled = this.settle(id, decision, by);
95
+ if (decision === "allow-session") {
96
+ // 并行的工具调用会同时挂着好几张卡:同一个会话进程里能一并放行的,一起放掉
97
+ for (const other of [...this.pending.values()]) {
98
+ if (other.convKey !== entry.convKey || other.owner !== entry.owner) continue;
99
+ if (other.spec.kind !== "approval" || !other.spec.allowSession) continue;
100
+ this.settle(other.id, "allow-session", `${by}(随「本会话全部允许」)`);
101
+ }
102
+ }
103
+ return settled;
93
104
  }
94
105
 
95
106
  /** 把某个选项设成选中 / 未选中。全单选且都答完时自动收束。 */
@@ -251,6 +262,7 @@ export function answerTextOf(entry: Interaction): Record<string, string> {
251
262
  export const SETTLEMENT_LABEL: Record<Settlement, string> = {
252
263
  "allow-once": "已允许(本次)",
253
264
  "allow-always": "已允许",
265
+ "allow-session": "已允许(本会话全部放行)",
254
266
  deny: "已拒绝",
255
267
  timeout: "已超时,按拒绝处理",
256
268
  cancelled: "已取消",
package/src/core/types.ts CHANGED
@@ -20,7 +20,11 @@ export type UserInput = { text: string; attachments: SavedAttachment[] };
20
20
 
21
21
  // ---------- 交互(审批 / 提问) ----------
22
22
 
23
- export type ApprovalDecision = "allow-once" | "allow-always" | "deny";
23
+ /**
24
+ * allow-session:本会话内后续的审批全部自动放行(不落盘)。会话重建
25
+ * (/new、切目录、切 agent、恢复历史、进程重启)后失效。
26
+ */
27
+ export type ApprovalDecision = "allow-once" | "allow-always" | "allow-session" | "deny";
24
28
 
25
29
  /** 一个卡点的收束方式。timeout / cancelled 和 deny 一样都是「没放行」,但显示不同。 */
26
30
  export type Settlement = ApprovalDecision | "timeout" | "cancelled" | "answered" | "skipped";
@@ -44,6 +48,8 @@ export type InteractionSpec =
44
48
  detail: string;
45
49
  /** 「总是允许」按钮的文字;不给就没有这个按钮 */
46
50
  alwaysLabel?: string;
51
+ /** 给了就显示「本会话全部允许」按钮。只有 agent 能真正兑现时才给 */
52
+ allowSession?: boolean;
47
53
  }
48
54
  | { kind: "question"; title: string; questions: QuestionSpec[] };
49
55
 
package/web/app.js CHANGED
@@ -41,6 +41,8 @@ const ICON = {
41
41
  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>',
42
42
  copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
43
43
  x: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
44
+ feishu:
45
+ '<svg viewBox="2 4 44 40"><path fill="#00D6B9" d="M11.6 7H27.6C30.9 9.9 32.8 14.7 33.6 17C30.9 18.3 29 20.6 27 24C22.6 16.4 15.6 11 11.6 7Z"/><path fill="#133C9A" d="M17 29C21 29 25 26.9 28 23.4C36 14 41.4 16.8 44 18C38.5 21 40.5 29.6 33 36C27.5 34.8 21.8 32.2 17 29Z"/><path fill="#3370FF" d="M4 16.2C7.2 20.8 12.4 26 17.4 29.4C23 33.2 28.6 35.7 34 35.2C28.9 39.8 23.8 41 19 41C12.5 41 6.9 37.8 4 35.4Z"/></svg>',
44
46
  };
45
47
 
46
48
  function icon(name) {
@@ -472,21 +474,57 @@ function agentLabel(id) {
472
474
  return S.agents.find((a) => a.id === id)?.label ?? id;
473
475
  }
474
476
 
477
+ function agentShort(id) {
478
+ return id === "claude" ? "Claude" : id === "codex" ? "Codex" : id;
479
+ }
480
+
475
481
  function renderSidebar() {
476
482
  if (!ui.convList) return;
477
483
  const groups = new Map();
478
484
  const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
479
- const list = [...S.convs.values()]
480
- .filter((c) => !c.hidden)
481
- .filter((c) => !S.search || convTitle(c).toLowerCase().includes(S.search))
482
- .sort((a, b) => b.activeAt - a.activeAt);
485
+ const visible = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt);
486
+ const nodes = [];
487
+
488
+ // 飞书:每个机器人只占一行,点开它最近活跃的那个聊天
489
+ const cur = S.current ? S.convs.get(S.current) : null;
490
+ const botRows = [];
491
+ for (const bot of S.bots) {
492
+ const convs = visible.filter((c) => c.channel === "feishu" && c.channelId === bot.id);
493
+ if (bot.enabled === false && convs.length === 0) continue;
494
+ if (S.search && !bot.name.toLowerCase().includes(S.search)) continue;
495
+ const latest = convs[0];
496
+ const ask = convs.some((c) => askKeys.has(c.key));
497
+ const busy = convs.some((c) => c.busy);
498
+ botRows.push(
499
+ h(
500
+ "button",
501
+ {
502
+ class: `conv-item${cur?.channel === "feishu" && cur.channelId === bot.id ? " active" : ""}`,
503
+ title: latest ? `${bot.name}\n${agentLabel(latest.agent)} · ${latest.cwd}` : `${bot.name}\n还没有会话`,
504
+ onclick: () => {
505
+ if (!latest) {
506
+ toast("这个机器人还没有会话,先在飞书里给它发条消息");
507
+ return;
508
+ }
509
+ closeSide();
510
+ if (latest.key !== S.current) void openConversation(latest.key);
511
+ },
512
+ },
513
+ ask ? h("span", { class: "dot ask", title: "等你处理" }) : busy ? h("span", { class: "dot busy" }) : null,
514
+ h("span", { class: "brand-icon", html: ICON.feishu }),
515
+ h("span", { class: "title" }, bot.name),
516
+ latest && agentShort(latest.agent).toLowerCase() !== bot.name.toLowerCase() ? h("span", { class: "badge" }, agentShort(latest.agent)) : null,
517
+ ),
518
+ );
519
+ }
520
+ if (botRows.length) nodes.push(h("div", { class: "conv-group" }, "飞书"), ...botRows);
521
+
522
+ const list = visible.filter((c) => c.channel !== "feishu").filter((c) => !S.search || convTitle(c).toLowerCase().includes(S.search));
483
523
  for (const c of list) {
484
- const group =
485
- c.channel === "web" ? "Web" : c.channel === "feishu" ? `飞书 · ${S.bots.find((b) => b.id === c.channelId)?.name ?? c.channelId}` : c.channel;
524
+ const group = c.channel === "web" ? "Web" : c.channel;
486
525
  if (!groups.has(group)) groups.set(group, []);
487
526
  groups.get(group).push(c);
488
527
  }
489
- const nodes = [];
490
528
  for (const [group, convs] of groups) {
491
529
  nodes.push(h("div", { class: "conv-group" }, group));
492
530
  for (const c of convs) {
@@ -503,7 +541,7 @@ function renderSidebar() {
503
541
  },
504
542
  askKeys.has(c.key) ? h("span", { class: "dot ask", title: "等你处理" }) : c.busy ? h("span", { class: "dot busy" }) : null,
505
543
  h("span", { class: "title" }, convTitle(c)),
506
- h("span", { class: "badge" }, c.agent === "claude" ? "Claude" : c.agent === "codex" ? "Codex" : c.agent),
544
+ h("span", { class: "badge" }, agentShort(c.agent)),
507
545
  ),
508
546
  );
509
547
  }
@@ -957,6 +995,7 @@ async function ixOp(id, op, extra = {}) {
957
995
  const SETTLE_LABEL = {
958
996
  "allow-once": "已允许(本次)",
959
997
  "allow-always": "已允许(总是)",
998
+ "allow-session": "已允许(本会话全部放行)",
960
999
  deny: "已拒绝",
961
1000
  timeout: "已超时,按拒绝处理",
962
1001
  cancelled: "已取消",
@@ -984,6 +1023,9 @@ function renderInteraction(item) {
984
1023
  spec.alwaysLabel
985
1024
  ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
986
1025
  : null,
1026
+ spec.allowSession
1027
+ ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
1028
+ : null,
987
1029
  h("button", { class: "btn danger", onclick: () => ixOp(ix.id, "decide", { decision: "deny" }) }, "拒绝"),
988
1030
  ),
989
1031
  );
@@ -1734,7 +1776,7 @@ function modelPicker(agent, model, effort, defaultLabel) {
1734
1776
  const asText = (reason) => {
1735
1777
  const m = h("input", { value: getModel(), placeholder: `留空${defaultLabel}` });
1736
1778
  const e = h("input", { value: getEffort(), placeholder: "low / medium / high …,留空默认" });
1737
- modelSlot.replaceChildren(m, reason ? h("small", {}, reason) : null);
1779
+ fill(modelSlot, m, reason ? h("small", {}, reason) : null);
1738
1780
  effortSlot.replaceChildren(e);
1739
1781
  getModel = () => m.value.trim();
1740
1782
  getEffort = () => e.value.trim();
@@ -1761,7 +1803,9 @@ function modelPicker(agent, model, effort, defaultLabel) {
1761
1803
  const picked = modelSel.value ? models.find((m) => m.id === modelSel.value) : fallback;
1762
1804
  const efforts = picked?.efforts ?? [];
1763
1805
  const keep = effortSel.value || effort;
1764
- effortSel.replaceChildren(
1806
+ // 子项里有数组:replaceChildren 不展开数组,会把它变成文本节点(select 里直接被忽略)
1807
+ fill(
1808
+ effortSel,
1765
1809
  option("", "默认", keep),
1766
1810
  withCurrent(efforts, keep).map((v) => option(v, `${v}(当前值,该模型可能不支持)`, keep)),
1767
1811
  efforts.map((v) => option(v, v, keep)),
Binary file
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#3b82f6"/>
5
+ <stop offset="1" stop-color="#06b6d4"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="512" height="512" fill="#fff"/>
9
+ <g transform="translate(256 262) scale(1.2) translate(-256 -262)">
10
+ <path d="M196 140H316A64 64 0 0 1 380 204V276A64 64 0 0 1 316 340H236L180 380C173 385 164 380 166 372L173 336A64 64 0 0 1 132 276V204A64 64 0 0 1 196 140Z" fill="none" stroke="url(#g)" stroke-width="26" stroke-linejoin="round"/>
11
+ <path d="M256 178C262 222 274 234 318 240C274 246 262 258 256 302C250 258 238 246 194 240C238 234 250 222 256 178Z" fill="url(#g)"/>
12
+ </g>
13
+ </svg>
package/web/icon.svg CHANGED
@@ -1,6 +1,12 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
- <rect width="512" height="512" rx="112" fill="#10a37f"/>
3
- <path d="M136 168c0-26.5 21.5-48 48-48h144c26.5 0 48 21.5 48 48v112c0 26.5-21.5 48-48 48h-72l-64 56v-56h-8c-26.5 0-48-21.5-48-48z" fill="#fff"/>
4
- <circle cx="208" cy="224" r="20" fill="#10a37f"/>
5
- <circle cx="304" cy="224" r="20" fill="#10a37f"/>
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#3b82f6"/>
5
+ <stop offset="1" stop-color="#06b6d4"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <g transform="translate(256 262) scale(1.4) translate(-256 -262)">
9
+ <path d="M196 140H316A64 64 0 0 1 380 204V276A64 64 0 0 1 316 340H236L180 380C173 385 164 380 166 372L173 336A64 64 0 0 1 132 276V204A64 64 0 0 1 196 140Z" fill="none" stroke="url(#g)" stroke-width="26" stroke-linejoin="round"/>
10
+ <path d="M256 178C262 222 274 234 318 240C274 246 262 258 256 302C250 258 238 246 194 240C238 234 250 222 256 178Z" fill="url(#g)"/>
11
+ </g>
6
12
  </svg>
package/web/index.html CHANGED
@@ -11,7 +11,7 @@
11
11
  <title>IM Agent</title>
12
12
  <link rel="manifest" href="/manifest.webmanifest" />
13
13
  <link rel="icon" href="/icon.svg" type="image/svg+xml" />
14
- <link rel="apple-touch-icon" href="/icon.svg" />
14
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
15
15
  <link rel="stylesheet" href="/vendor/hljs-light.css" media="(prefers-color-scheme: light)" />
16
16
  <link rel="stylesheet" href="/vendor/hljs-dark.css" media="(prefers-color-scheme: dark)" />
17
17
  <link rel="stylesheet" href="/style.css" />
@@ -8,6 +8,8 @@
8
8
  "background_color": "#ffffff",
9
9
  "theme_color": "#ffffff",
10
10
  "icons": [
11
- { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
11
+ { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
12
+ { "src": "/icon-maskable.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" },
13
+ { "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
12
14
  ]
13
15
  }
package/web/style.css CHANGED
@@ -8,8 +8,9 @@
8
8
  --fg-soft: #5d5d5d;
9
9
  --fg-faint: #8f8f8f;
10
10
  --border: #e5e5e5;
11
- --accent: #10a37f;
11
+ --accent: #2563eb;
12
12
  --accent-fg: #ffffff;
13
+ --link: #2563eb;
13
14
  --danger: #d93f3f;
14
15
  --warn-bg: #fff7e6;
15
16
  --warn-border: #f5c26b;
@@ -38,6 +39,7 @@
38
39
  --fg-soft: #b4b4b4;
39
40
  --fg-faint: #8a8a8a;
40
41
  --border: #3a3a3a;
42
+ --link: #60a5fa;
41
43
  --warn-bg: #3a2f1a;
42
44
  --warn-border: #8a6a2a;
43
45
  --ask-bg: #2c2640;
@@ -652,7 +654,7 @@ select {
652
654
  }
653
655
 
654
656
  .md a {
655
- color: var(--accent);
657
+ color: var(--link);
656
658
  }
657
659
 
658
660
  .md blockquote {
@@ -1412,3 +1414,15 @@ button.list-item:hover {
1412
1414
  .cmd-row input[type="checkbox"] {
1413
1415
  width: auto;
1414
1416
  }
1417
+
1418
+ .brand-icon {
1419
+ display: inline-grid;
1420
+ width: 18px;
1421
+ height: 18px;
1422
+ flex-shrink: 0;
1423
+ }
1424
+
1425
+ .brand-icon svg {
1426
+ width: 100%;
1427
+ height: 100%;
1428
+ }