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 +1 -1
- package/package.json +1 -1
- package/src/agents/claude/session.ts +16 -1
- package/src/agents/codex/session.ts +24 -6
- package/src/channels/feishu/cards.ts +2 -1
- package/src/channels/feishu/envelope.ts +1 -1
- package/src/channels/web/api.ts +1 -1
- package/src/core/command-catalog.ts +1 -1
- package/src/core/commands.ts +1 -1
- package/src/core/hub.ts +3 -0
- package/src/core/interactions.ts +13 -1
- package/src/core/types.ts +7 -1
- package/web/app.js +54 -10
- package/web/apple-touch-icon.png +0 -0
- package/web/icon-maskable.svg +13 -0
- package/web/icon.svg +10 -4
- package/web/index.html +1 -1
- package/web/manifest.webmanifest +3 -1
- package/web/style.css +16 -2
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
|
@@ -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-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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":
|
package/src/channels/web/api.ts
CHANGED
|
@@ -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
|
|
package/src/core/commands.ts
CHANGED
|
@@ -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
|
}
|
package/src/core/interactions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
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" />
|
package/web/manifest.webmanifest
CHANGED
|
@@ -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
|
|
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: #
|
|
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(--
|
|
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
|
+
}
|