im-agent 0.1.1 → 0.2.0
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/account.ts +100 -0
- package/src/agents/claude/driver.ts +47 -2
- package/src/agents/claude/permissions.ts +35 -2
- package/src/agents/claude/probe.ts +54 -0
- package/src/agents/claude/session.ts +19 -9
- package/src/agents/codex/driver.ts +4 -0
- package/src/channels/feishu/bot.ts +60 -0
- package/src/channels/feishu/cards.ts +50 -0
- package/src/channels/feishu/envelope.ts +6 -1
- package/src/channels/feishu/index.ts +11 -1
- package/src/channels/feishu/slash-sync.ts +192 -0
- package/src/channels/web/api.ts +54 -0
- package/src/channels/web/server.ts +12 -0
- package/src/core/command-catalog.ts +112 -0
- package/src/core/commands.ts +21 -16
- package/src/core/config.ts +5 -0
- package/src/core/hub.ts +55 -0
- package/src/core/store.ts +2 -0
- package/src/core/types.ts +9 -0
- package/web/app.js +541 -15
- package/web/style.css +147 -0
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
|
|
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`
|
|
52
52
|
|
|
53
53
|
## 安全
|
|
54
54
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// 账号额度:Claude 订阅的 5 小时 / 7 天用量窗口,和 Codex 那边的 account.ts 对齐。
|
|
2
|
+
//
|
|
3
|
+
// 数据来自 SDK 的 usage 控制请求 —— 也就是 Claude Code 里 `/usage` 背后的那份结构化数据,
|
|
4
|
+
// 通过探针 query 读取(见 probe.ts)。
|
|
5
|
+
//
|
|
6
|
+
// 注意:SDK 把它标成了 EXPERIMENTAL,方法名以后会改。改了的话这里只会读失败,
|
|
7
|
+
// /status 少几行而已,不影响别的。
|
|
8
|
+
import { cachedProbe, withProbe } from "./probe.js";
|
|
9
|
+
|
|
10
|
+
type Window = { utilization: number | null; resets_at: string | null } | null | undefined;
|
|
11
|
+
|
|
12
|
+
type LimitRow = {
|
|
13
|
+
kind: string;
|
|
14
|
+
group: string;
|
|
15
|
+
percent: number;
|
|
16
|
+
resets_at: string | null;
|
|
17
|
+
severity?: string;
|
|
18
|
+
scope?: { model?: { display_name: string } | null; surface?: { display_name: string } | null } | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ClaudeUsage = {
|
|
22
|
+
subscriptionType: string | null;
|
|
23
|
+
rows: { label: string; percent: number; resetsAt: string | null; severity?: string }[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function labelOf(row: LimitRow): string {
|
|
27
|
+
const scope = row.scope?.model?.display_name || row.scope?.surface?.display_name;
|
|
28
|
+
const base = row.kind === "session" ? "5 小时" : row.group === "weekly" ? "7 天" : row.kind;
|
|
29
|
+
return scope ? `${base}(${scope})` : base;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalize(res: {
|
|
33
|
+
subscription_type: string | null;
|
|
34
|
+
rate_limits_available: boolean;
|
|
35
|
+
rate_limits: Record<string, unknown> | null;
|
|
36
|
+
}): ClaudeUsage | null {
|
|
37
|
+
if (!res.rate_limits_available || !res.rate_limits) return null;
|
|
38
|
+
const rl = res.rate_limits;
|
|
39
|
+
const limits = rl.limits as LimitRow[] | null | undefined;
|
|
40
|
+
let rows: ClaudeUsage["rows"];
|
|
41
|
+
if (Array.isArray(limits)) {
|
|
42
|
+
// 新版服务端直接给出要显示哪些行、什么顺序,照单全收
|
|
43
|
+
rows = limits.map((r) => ({
|
|
44
|
+
label: labelOf(r),
|
|
45
|
+
percent: r.percent,
|
|
46
|
+
resetsAt: r.resets_at,
|
|
47
|
+
...(r.severity ? { severity: r.severity } : {}),
|
|
48
|
+
}));
|
|
49
|
+
} else {
|
|
50
|
+
// 老格式兜底:只认两个主窗口
|
|
51
|
+
rows = [];
|
|
52
|
+
for (const [key, label] of [
|
|
53
|
+
["five_hour", "5 小时"],
|
|
54
|
+
["seven_day", "7 天"],
|
|
55
|
+
] as const) {
|
|
56
|
+
const w = rl[key] as Window;
|
|
57
|
+
if (w && w.utilization != null) rows.push({ label, percent: w.utilization, resetsAt: w.resets_at });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { subscriptionType: res.subscription_type, rows };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fetchUsage(): Promise<ClaudeUsage | null> {
|
|
64
|
+
const res = await withProbe((q) =>
|
|
65
|
+
q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true }),
|
|
66
|
+
);
|
|
67
|
+
return normalize(res as Parameters<typeof normalize>[0]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 同 Codex:缓存 60 秒,挡住连敲几次 /status,又不至于数字明显过期。
|
|
72
|
+
* 读不到额度不该让整条 /status 失败 —— 它只是其中几行,所以失败时给 null。
|
|
73
|
+
*/
|
|
74
|
+
export const readUsage = cachedProbe(60_000, "额度", fetchUsage, null);
|
|
75
|
+
|
|
76
|
+
/** 距离重置还有多久,和 Codex 那边同一个说法 */
|
|
77
|
+
function untilReset(resetsAt: string | null, now = Date.now()): string {
|
|
78
|
+
if (!resetsAt) return "";
|
|
79
|
+
const ms = Date.parse(resetsAt);
|
|
80
|
+
if (Number.isNaN(ms)) return "";
|
|
81
|
+
const seconds = Math.floor((ms - now) / 1000);
|
|
82
|
+
if (seconds <= 0) return "即将重置";
|
|
83
|
+
const days = Math.floor(seconds / 86400);
|
|
84
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
85
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
86
|
+
if (days > 0) return `${days} 天 ${hours} 小时后重置`;
|
|
87
|
+
if (hours > 0) return `${hours} 小时 ${minutes} 分后重置`;
|
|
88
|
+
return `${minutes} 分钟后重置`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 渲染成 /status 里的几行;没有可显示的就返回空数组(API key / Bedrock 之类没有订阅额度) */
|
|
92
|
+
export function describeUsage(usage: ClaudeUsage | null, now = Date.now()): string[] {
|
|
93
|
+
if (!usage || usage.rows.length === 0) return [];
|
|
94
|
+
const rows = usage.rows.map((r) => {
|
|
95
|
+
const reset = untilReset(r.resetsAt, now);
|
|
96
|
+
return ` ${r.label} 已用 ${Math.round(r.percent)}%${reset ? ` ${reset}` : ""}`;
|
|
97
|
+
});
|
|
98
|
+
if (usage.rows.some((r) => r.percent >= 100)) rows.push(" ⚠️ 已达上限,等窗口重置后才能继续");
|
|
99
|
+
return [`额度${usage.subscriptionType ? `(${usage.subscriptionType})` : ""}`, ...rows];
|
|
100
|
+
}
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
getSessionMessages,
|
|
8
8
|
listSessions,
|
|
9
9
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import type { AgentCommand } from "../../core/command-catalog.js";
|
|
10
12
|
import { config } from "../../core/config.js";
|
|
11
13
|
import type {
|
|
12
14
|
AgentDriver,
|
|
@@ -19,8 +21,43 @@ import type {
|
|
|
19
21
|
SessionOptions,
|
|
20
22
|
SessionSummary,
|
|
21
23
|
} from "../../core/types.js";
|
|
24
|
+
import { describeUsage, readUsage } from "./account.js";
|
|
25
|
+
import { cachedProbe, withProbe } from "./probe.js";
|
|
22
26
|
import { diagnose } from "./errors.js";
|
|
23
|
-
import { ClaudeSession, describeToolUse, summarize } from "./session.js";
|
|
27
|
+
import { ClaudeSession, describeToolUse, summarize, toModelInfo } from "./session.js";
|
|
28
|
+
|
|
29
|
+
/** 模型列表几乎不变,缓存 10 分钟;读失败抛给设置页,让它退回手填 */
|
|
30
|
+
const readCatalog = cachedProbe(10 * 60_000, "模型列表", () => withProbe(async (q) => toModelInfo(await q.supportedModels())));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 斜杠命令(skills、/compact 这些)。项目目录里可能有自己的命令,所以按 cwd 分开缓存;
|
|
34
|
+
* 装了新 skill 最多 5 分钟后出现。读不到就只显示 im-agent 自己的命令。
|
|
35
|
+
*/
|
|
36
|
+
const commandReaders = new Map<string, () => Promise<AgentCommand[]>>();
|
|
37
|
+
function readCommands(cwd: string): Promise<AgentCommand[]> {
|
|
38
|
+
let reader = commandReaders.get(cwd);
|
|
39
|
+
if (!reader) {
|
|
40
|
+
// 目录数量一般很少;万一很多,丢掉最早的,别无限涨
|
|
41
|
+
if (commandReaders.size >= 50) commandReaders.delete(commandReaders.keys().next().value!);
|
|
42
|
+
reader = cachedProbe(
|
|
43
|
+
5 * 60_000,
|
|
44
|
+
"Claude 命令列表",
|
|
45
|
+
() =>
|
|
46
|
+
withProbe(
|
|
47
|
+
async (q) =>
|
|
48
|
+
(await q.supportedCommands()).map((c) => ({
|
|
49
|
+
name: c.name,
|
|
50
|
+
description: c.description,
|
|
51
|
+
...(c.argumentHint ? { args: c.argumentHint } : {}),
|
|
52
|
+
})),
|
|
53
|
+
cwd,
|
|
54
|
+
),
|
|
55
|
+
[] as AgentCommand[],
|
|
56
|
+
);
|
|
57
|
+
commandReaders.set(cwd, reader);
|
|
58
|
+
}
|
|
59
|
+
return reader();
|
|
60
|
+
}
|
|
24
61
|
|
|
25
62
|
/** sessionId 会被拼进路径,先验形,防 /resume ../../x 越界 */
|
|
26
63
|
const SESSION_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
@@ -131,6 +168,14 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
131
168
|
return (session as ClaudeSession).listModels();
|
|
132
169
|
}
|
|
133
170
|
|
|
171
|
+
catalog(): Promise<ModelInfo[]> {
|
|
172
|
+
return readCatalog();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
commands(cwd?: string): Promise<AgentCommand[]> {
|
|
176
|
+
return readCommands(cwd ?? os.homedir());
|
|
177
|
+
}
|
|
178
|
+
|
|
134
179
|
async defaults(): Promise<RuntimeInfo> {
|
|
135
180
|
const c = config().agents.claude;
|
|
136
181
|
return {
|
|
@@ -140,7 +185,7 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
140
185
|
}
|
|
141
186
|
|
|
142
187
|
async statusLines(): Promise<string[]> {
|
|
143
|
-
return
|
|
188
|
+
return describeUsage(await readUsage());
|
|
144
189
|
}
|
|
145
190
|
|
|
146
191
|
diagnose(err: unknown) {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Codex 把「总是允许」做进了协议(acceptForSession / execpolicy 补丁),Claude 没有,
|
|
4
4
|
// 只能自己记一份名单。特征刻意保守:Bash 只认命令的第一个词,文件类只认所在目录。
|
|
5
5
|
// 宁可多问几次,也不要因为特征太宽而放行了本不该放行的东西。
|
|
6
|
+
import os from "node:os";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
|
|
8
9
|
import type { QuestionSpec } from "../../core/types.js";
|
|
@@ -143,7 +144,39 @@ export function signatureOf(toolName: string, input: Record<string, unknown>): s
|
|
|
143
144
|
|
|
144
145
|
export function isPreApproved(toolName: string, signature: string): boolean {
|
|
145
146
|
// 防绕过靠的是 signatureOf:复合命令拿到的是 BashExact 签名,命中不了 Bash:<首词>
|
|
146
|
-
|
|
147
|
+
if (!isRememberableSignature(toolName, signature)) return false;
|
|
148
|
+
const list = load();
|
|
149
|
+
if (list.includes(signature)) return true;
|
|
150
|
+
return coveredBySubdirectoryRule(toolName, signature, list);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const DIR_TOOLS = new Set(["Edit", "Write", "NotebookEdit"]);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 按目录记的批准连带子目录:批准过 /home/me/project,改 project/src/core 下的文件不用再问。
|
|
157
|
+
* 以前只认完全相同的目录,项目里每进一个子目录都要再点一次,点到后来就是闭眼点 —— 那才不安全。
|
|
158
|
+
*
|
|
159
|
+
* 两道收口:
|
|
160
|
+
* - 太宽的批准不往下延伸:/、家目录、家目录的上级。批准过「在家目录下新建文件」
|
|
161
|
+
* 不等于能改 ~/.ssh、~/.bashrc;
|
|
162
|
+
* - 不延伸进隐藏目录(.git/hooks、.github/workflows、.husky…):往那里写东西等于
|
|
163
|
+
* 让它下次自动执行。真要改,单独批一次那个目录。
|
|
164
|
+
*/
|
|
165
|
+
function coveredBySubdirectoryRule(toolName: string, signature: string, list: string[]): boolean {
|
|
166
|
+
if (!DIR_TOOLS.has(toolName)) return false;
|
|
167
|
+
const target = signature.slice(toolName.length + 1);
|
|
168
|
+
const home = os.homedir();
|
|
169
|
+
for (const entry of list) {
|
|
170
|
+
if (!entry.startsWith(`${toolName}:`)) continue;
|
|
171
|
+
const approved = entry.slice(toolName.length + 1);
|
|
172
|
+
if (!path.isAbsolute(approved)) continue;
|
|
173
|
+
if (approved === path.parse(approved).root || approved === home || home.startsWith(`${approved}${path.sep}`)) continue;
|
|
174
|
+
const rel = path.relative(approved, target);
|
|
175
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
|
|
176
|
+
if (rel.split(path.sep).some((part) => part.startsWith("."))) continue;
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
147
180
|
}
|
|
148
181
|
|
|
149
182
|
export function rememberAlways(signature: string): void {
|
|
@@ -168,7 +201,7 @@ export function alwaysLabelOf(toolName: string, signature: string): string {
|
|
|
168
201
|
if (toolName === "Bash") {
|
|
169
202
|
return signature.startsWith("BashExact:") ? "总是允许这条完全相同的命令" : `总是允许 ${signature.slice(5)} 开头的单条命令`;
|
|
170
203
|
}
|
|
171
|
-
if (signature.includes(":")) return
|
|
204
|
+
if (signature.includes(":")) return `总是允许在该目录及子目录下${toolName === "Write" ? "新建" : "修改"}`;
|
|
172
205
|
return `总是允许 ${toolName}`;
|
|
173
206
|
}
|
|
174
207
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// 「探针」query:拉一个从不发消息的 CLI 进程,只用来问控制请求(额度、模型列表),问完就关。
|
|
2
|
+
//
|
|
3
|
+
// 这些控制方法挂在 Query 上,正常要借活会话才能问;但 /status、设置页不该依赖
|
|
4
|
+
// 「当前恰好有活会话」。不发消息就不耗 token,代价只是起一次进程(两三秒)。
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { query, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
7
|
+
import { agentEnvironment } from "../../core/agent-env.js";
|
|
8
|
+
|
|
9
|
+
/** 永远不产出消息的输入流:query 必须是流式输入模式才能发控制请求 */
|
|
10
|
+
async function* silence(): AsyncGenerator<SDKUserMessage> {
|
|
11
|
+
await new Promise<never>(() => {});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** cwd 会影响项目级的东西(.claude/commands、项目 skills),不关心就用家目录 */
|
|
15
|
+
export async function withProbe<T>(fn: (q: Query) => Promise<T>, cwd = os.homedir()): Promise<T> {
|
|
16
|
+
const q = query({ prompt: silence(), options: { cwd, env: agentEnvironment() } });
|
|
17
|
+
try {
|
|
18
|
+
return await fn(q);
|
|
19
|
+
} finally {
|
|
20
|
+
q.close();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 带缓存、并发合并的读取:同一时刻多个调用方共用一次探针,结果缓存 ttl 毫秒。
|
|
26
|
+
* 给了 fallback 就是「读不到也无所谓」:失败记日志、返回并缓存 fallback,免得每次都再等几秒;
|
|
27
|
+
* 没给就把错误抛给调用方,也不缓存,下次重试。
|
|
28
|
+
*/
|
|
29
|
+
export function cachedProbe<T>(
|
|
30
|
+
ttl: number,
|
|
31
|
+
what: string,
|
|
32
|
+
fetch: () => Promise<T>,
|
|
33
|
+
...fallback: [] | [T]
|
|
34
|
+
): () => Promise<T> {
|
|
35
|
+
let cache: { at: number; value: T } | null = null;
|
|
36
|
+
let inflight: Promise<T> | null = null;
|
|
37
|
+
return () => {
|
|
38
|
+
if (cache && Date.now() - cache.at < ttl) return Promise.resolve(cache.value);
|
|
39
|
+
inflight ??= fetch()
|
|
40
|
+
.catch((err) => {
|
|
41
|
+
console.error(`[${what}] 读取失败:`, err instanceof Error ? err.message : err);
|
|
42
|
+
if (fallback.length === 0) throw err;
|
|
43
|
+
return fallback[0] as T;
|
|
44
|
+
})
|
|
45
|
+
.then((value) => {
|
|
46
|
+
cache = { at: Date.now(), value };
|
|
47
|
+
return value;
|
|
48
|
+
})
|
|
49
|
+
.finally(() => {
|
|
50
|
+
inflight = null;
|
|
51
|
+
});
|
|
52
|
+
return inflight;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -33,6 +33,15 @@ import {
|
|
|
33
33
|
signatureOf,
|
|
34
34
|
} from "./permissions.js";
|
|
35
35
|
|
|
36
|
+
export function toModelInfo(models: Awaited<ReturnType<Query["supportedModels"]>>): ModelInfo[] {
|
|
37
|
+
return models.map((m) => ({
|
|
38
|
+
id: m.value,
|
|
39
|
+
label: m.displayName || m.value,
|
|
40
|
+
...(m.description ? { description: m.description } : {}),
|
|
41
|
+
...(m.supportedEffortLevels?.length ? { efforts: [...m.supportedEffortLevels] } : {}),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
class AsyncQueue<T> {
|
|
37
46
|
private items: T[] = [];
|
|
38
47
|
private waiting: ((result: IteratorResult<T>) => void)[] = [];
|
|
@@ -152,8 +161,9 @@ export class ClaudeSession implements AgentSession {
|
|
|
152
161
|
options: {
|
|
153
162
|
cwd: this.opts.cwd,
|
|
154
163
|
env: agentEnvironment(),
|
|
155
|
-
//
|
|
156
|
-
|
|
164
|
+
// 只能是 default 或 acceptEdits:bypassPermissions 会让 canUseTool 变成死代码。
|
|
165
|
+
// acceptEdits 只放行工作目录(含附件目录)内的改文件,其它照样走 canUseTool
|
|
166
|
+
permissionMode: this.opts.autoEdit ? "acceptEdits" : "default",
|
|
157
167
|
// 逐字输出给 Web 用;飞书渠道直接忽略这些片段
|
|
158
168
|
includePartialMessages: true,
|
|
159
169
|
...(this.opts.model ? { model: this.opts.model } : {}),
|
|
@@ -342,13 +352,13 @@ export class ClaudeSession implements AgentSession {
|
|
|
342
352
|
|
|
343
353
|
async listModels(): Promise<ModelInfo[]> {
|
|
344
354
|
this.start();
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
355
|
+
return toModelInfo(await this.q!.supportedModels());
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async setAutoEdit(on: boolean): Promise<void> {
|
|
359
|
+
this.opts.autoEdit = on;
|
|
360
|
+
// 还没起 CLI 就不用管:下次拉起时按 opts 来
|
|
361
|
+
if (this.q) await this.q.setPermissionMode(on ? "acceptEdits" : "default");
|
|
352
362
|
}
|
|
353
363
|
|
|
354
364
|
async setModel(model?: string, effort?: string): Promise<void> {
|
|
@@ -155,6 +155,10 @@ export class CodexDriver implements AgentDriver {
|
|
|
155
155
|
}));
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
catalog(): Promise<ModelInfo[]> {
|
|
159
|
+
return this.listModels();
|
|
160
|
+
}
|
|
161
|
+
|
|
158
162
|
/** config.toml 里的默认值(config/read),不需要为此建 thread。 */
|
|
159
163
|
async defaults(): Promise<RuntimeInfo> {
|
|
160
164
|
const c = config().agents.codex;
|
|
@@ -17,9 +17,12 @@ import { getConversation, updateConversation } from "../../core/store.js";
|
|
|
17
17
|
import { adoptFile, KIND_LABEL } from "../../core/uploads.js";
|
|
18
18
|
import type { SavedAttachment } from "../../core/types.js";
|
|
19
19
|
import { FeishuClient } from "./client.js";
|
|
20
|
+
import { SlashSync } from "./slash-sync.js";
|
|
21
|
+
import type { CommandEntry } from "../../core/command-catalog.js";
|
|
20
22
|
import {
|
|
21
23
|
agentPickerCard,
|
|
22
24
|
approvalCard,
|
|
25
|
+
commandListCard,
|
|
23
26
|
modelPickerCard,
|
|
24
27
|
progressCard,
|
|
25
28
|
questionCard,
|
|
@@ -56,8 +59,27 @@ export class FeishuBot {
|
|
|
56
59
|
private stopped = false;
|
|
57
60
|
status: BotStatus = { state: "stopped", since: Date.now() };
|
|
58
61
|
|
|
62
|
+
/** 飞书输入框里「/」弹出的指令面板 */
|
|
63
|
+
readonly slash: SlashSync;
|
|
64
|
+
private slashTimer: NodeJS.Timeout | null = null;
|
|
65
|
+
|
|
59
66
|
constructor(readonly cfg: FeishuBotConfig) {
|
|
60
67
|
this.client = new FeishuClient(cfg.appId, cfg.appSecret, cfg.domain, `飞书:${cfg.name}`);
|
|
68
|
+
this.slash = new SlashSync(this.client.lark, () => this.slashCommands(), `[飞书:${cfg.name}]`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 这个机器人该注册的命令:默认 agent 的命令表在前,其它可用 agent 的原生命令接在后面。
|
|
73
|
+
* 飞书的指令面板是整个应用一份,不分会话,没法跟着每个会话当前的 agent 变。
|
|
74
|
+
*/
|
|
75
|
+
private async slashCommands(): Promise<CommandEntry[]> {
|
|
76
|
+
const agents = [this.cfg.defaultAgent, ...this.cfg.agents.filter((a) => a !== this.cfg.defaultAgent)];
|
|
77
|
+
const out: CommandEntry[] = [];
|
|
78
|
+
for (const agent of agents) {
|
|
79
|
+
if (!hub.driver(agent)) continue;
|
|
80
|
+
out.push(...(await hub.commandCatalog(agent, { allowedAgents: this.cfg.agents })));
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
get id(): string {
|
|
@@ -120,10 +142,17 @@ export class FeishuBot {
|
|
|
120
142
|
|
|
121
143
|
this.connect();
|
|
122
144
|
this.startWatchdog();
|
|
145
|
+
// 等 agent 起来再同步(Claude 的原生命令要起进程读);之后每 6 小时对一次账,新装的 skill 会跟上
|
|
146
|
+
this.slash.request(15_000);
|
|
147
|
+
this.slashTimer = setInterval(() => this.slash.request(0), 6 * 3600_000);
|
|
148
|
+
this.slashTimer.unref?.();
|
|
123
149
|
}
|
|
124
150
|
|
|
125
151
|
stop(): void {
|
|
126
152
|
this.stopped = true;
|
|
153
|
+
this.slash.stop();
|
|
154
|
+
if (this.slashTimer) clearInterval(this.slashTimer);
|
|
155
|
+
this.slashTimer = null;
|
|
127
156
|
for (const off of this.offs.splice(0)) off();
|
|
128
157
|
if (this.watchdog) clearInterval(this.watchdog);
|
|
129
158
|
this.watchdog = null;
|
|
@@ -486,6 +515,12 @@ export class FeishuBot {
|
|
|
486
515
|
case "agents":
|
|
487
516
|
await this.client.sendCard(chatId, agentPickerCard({ ...reply, sessionKey: key, ctx }));
|
|
488
517
|
return;
|
|
518
|
+
case "commands":
|
|
519
|
+
await this.client.sendCard(
|
|
520
|
+
chatId,
|
|
521
|
+
commandListCard({ agentLabel: hub.label(reply.agent), commands: reply.commands, sessionKey: key, ctx }),
|
|
522
|
+
);
|
|
523
|
+
return;
|
|
489
524
|
case "none":
|
|
490
525
|
return;
|
|
491
526
|
}
|
|
@@ -595,9 +630,34 @@ export class FeishuBot {
|
|
|
595
630
|
case "agent":
|
|
596
631
|
this.background(envelope.k, () => hub.setAgent(envelope.k, envelope.g, this.allowedAgents()));
|
|
597
632
|
return { toast: { type: "success", content: `正在切换到 ${hub.label(envelope.g)}` } };
|
|
633
|
+
case "cmd":
|
|
634
|
+
return this.runCardCommand(envelope.k, `/${envelope.c}`);
|
|
598
635
|
}
|
|
599
636
|
}
|
|
600
637
|
|
|
638
|
+
/**
|
|
639
|
+
* 命令表上点「执行」:等同于用户自己发了这条命令。
|
|
640
|
+
* im-agent 的命令就地执行并回卡片;agent 原生命令(skills 等)当一条消息发给 agent。
|
|
641
|
+
*/
|
|
642
|
+
private runCardCommand(key: string, text: string): CardResponse {
|
|
643
|
+
if (!this.mine(key) || !getConversation(key)) {
|
|
644
|
+
return { toast: { type: "error", content: "这条会话已不存在,请重新发一次「/」。" } };
|
|
645
|
+
}
|
|
646
|
+
const command = parseCommand(text);
|
|
647
|
+
if (command.kind === "chat") {
|
|
648
|
+
const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书" };
|
|
649
|
+
const gen = hub.generation(key);
|
|
650
|
+
this.enqueue(key, async () => hub.send(key, { text, attachments: [] }, origin, gen));
|
|
651
|
+
return { toast: { type: "success", content: `已发送 ${text}` } };
|
|
652
|
+
}
|
|
653
|
+
const chatId = this.chatIdOf(key);
|
|
654
|
+
void hub
|
|
655
|
+
.command(key, command, { allowedAgents: this.allowedAgents(), by: "飞书命令" })
|
|
656
|
+
.then((reply) => this.renderReply(key, reply))
|
|
657
|
+
.catch((err) => this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`));
|
|
658
|
+
return { toast: { type: "info", content: `正在执行 ${text}` } };
|
|
659
|
+
}
|
|
660
|
+
|
|
601
661
|
/** 卡片回调里耗时的操作:先应答,做完在会话里回一句。 */
|
|
602
662
|
private background(key: string, task: () => Promise<string>): void {
|
|
603
663
|
if (!this.mine(key) || !getConversation(key)) return;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// 3. 没有 `tag: "note"`,小字提示只能并进 markdown
|
|
7
7
|
import { createEnvelope, type CardCtx, type EnvelopeAction } from "./envelope.js";
|
|
8
8
|
import { answerTextOf, SETTLEMENT_LABEL, type Interaction } from "../../core/interactions.js";
|
|
9
|
+
import { usageOf, type CommandEntry } from "../../core/command-catalog.js";
|
|
9
10
|
import type { ModelInfo, SessionSummary } from "../../core/types.js";
|
|
10
11
|
|
|
11
12
|
function button(
|
|
@@ -226,6 +227,55 @@ export function modelPickerCard(params: {
|
|
|
226
227
|
return card(elements, { title: "切换模型", template: "blue" });
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
/** 卡片太大会被飞书拒收(JSON 上限约 30KB),列表截到这么多条 */
|
|
231
|
+
const COMMAND_CARD_MAX = 30;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 只发「/」时回的命令表。顺序就是设置里排好的顺序;不带参数的命令给一个「执行」按钮,
|
|
235
|
+
* 要参数的只展示用法,让用户自己打。
|
|
236
|
+
*/
|
|
237
|
+
export function commandListCard(params: {
|
|
238
|
+
agentLabel: string;
|
|
239
|
+
commands: CommandEntry[];
|
|
240
|
+
sessionKey: string;
|
|
241
|
+
ctx: CardCtx;
|
|
242
|
+
}) {
|
|
243
|
+
const shown = params.commands.slice(0, COMMAND_CARD_MAX);
|
|
244
|
+
const rest = params.commands.length - shown.length;
|
|
245
|
+
const oneLine = (s: string) => {
|
|
246
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
247
|
+
return t.length > 60 ? `${t.slice(0, 60)}…` : t;
|
|
248
|
+
};
|
|
249
|
+
const elements: unknown[] = [
|
|
250
|
+
{
|
|
251
|
+
tag: "markdown",
|
|
252
|
+
content: `当前 agent:**${params.agentLabel}**。点「执行」直接运行;带 \`<参数>\` 的请手动输入。\n顺序和显示哪些可以在 Web 设置 → 命令里调整。`,
|
|
253
|
+
},
|
|
254
|
+
{ tag: "hr" },
|
|
255
|
+
];
|
|
256
|
+
for (const c of shown) {
|
|
257
|
+
const tag = c.source === "agent" ? ` <font color='grey'>· ${params.agentLabel}</font>` : "";
|
|
258
|
+
const text = `**${usageOf(c)}**${tag}\n<font color='grey'>${oneLine(c.description) || "(无说明)"}</font>`;
|
|
259
|
+
elements.push({
|
|
260
|
+
tag: "column_set",
|
|
261
|
+
horizontal_spacing: "8px",
|
|
262
|
+
columns: [
|
|
263
|
+
{ tag: "column", width: "weighted", weight: 1, vertical_align: "center", elements: [{ tag: "markdown", content: text }] },
|
|
264
|
+
{
|
|
265
|
+
tag: "column",
|
|
266
|
+
width: "auto",
|
|
267
|
+
vertical_align: "center",
|
|
268
|
+
elements: c.runnable ? [button("执行", { a: "cmd", c: c.name, k: params.sessionKey }, params.ctx)] : [],
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (rest > 0) {
|
|
274
|
+
elements.push({ tag: "markdown", content: `<font color='grey'>还有 ${rest} 个没列出,可以在设置里把常用的排到前面、隐藏用不到的。</font>` });
|
|
275
|
+
}
|
|
276
|
+
return card(elements, { title: "可用命令", template: "blue" });
|
|
277
|
+
}
|
|
278
|
+
|
|
229
279
|
export function agentPickerCard(params: {
|
|
230
280
|
agents: { id: string; label: string }[];
|
|
231
281
|
current: string;
|
|
@@ -21,7 +21,9 @@ export type EnvelopeAction =
|
|
|
21
21
|
| { a: "model"; m: string; k: string }
|
|
22
22
|
| { a: "effort"; f: string; k: string }
|
|
23
23
|
| { a: "agent"; g: string; k: string }
|
|
24
|
-
| { a: "resume"; s: string; k: string; c?: string }
|
|
24
|
+
| { a: "resume"; s: string; k: string; c?: string }
|
|
25
|
+
/** 命令列表卡上的「执行」:c 是命令名,不带斜杠 */
|
|
26
|
+
| { a: "cmd"; c: string; k: string };
|
|
25
27
|
|
|
26
28
|
export type CardEnvelope = EnvelopeAction & {
|
|
27
29
|
oc: typeof ENVELOPE_VERSION;
|
|
@@ -98,6 +100,9 @@ function validAction(v: Record<string, unknown>): boolean {
|
|
|
98
100
|
return str(v.g) && str(v.k);
|
|
99
101
|
case "resume":
|
|
100
102
|
return str(v.s) && str(v.k) && (v.c === undefined || str(v.c));
|
|
103
|
+
case "cmd":
|
|
104
|
+
// 只放行命令名该有的字符(skill 名会带冒号),别让参数、换行之类混进来
|
|
105
|
+
return str(v.c) && /^[\w:.-]+$/.test(v.c) && str(v.k);
|
|
101
106
|
default:
|
|
102
107
|
return false;
|
|
103
108
|
}
|
|
@@ -35,7 +35,17 @@ function reconcile(list: FeishuBotConfig[]): void {
|
|
|
35
35
|
|
|
36
36
|
export function startFeishu(): void {
|
|
37
37
|
reconcile(config().feishu.bots);
|
|
38
|
-
onConfigChange((next) =>
|
|
38
|
+
onConfigChange((next, prev) => {
|
|
39
|
+
reconcile(next.feishu.bots);
|
|
40
|
+
// 命令顺序 / 隐藏改了,或者机器人能用的 agent 变了:重新同步飞书指令面板
|
|
41
|
+
const commandsChanged = JSON.stringify(next.commands) !== JSON.stringify(prev.commands);
|
|
42
|
+
for (const bot of bots.values()) {
|
|
43
|
+
const before = prev.feishu.bots.find((b) => b.id === bot.id);
|
|
44
|
+
const agentsChanged =
|
|
45
|
+
!before || JSON.stringify([before.agents, before.defaultAgent]) !== JSON.stringify([bot.cfg.agents, bot.cfg.defaultAgent]);
|
|
46
|
+
if (commandsChanged || agentsChanged) bot.slash.request();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
39
49
|
// 配对被批准(Web 上点的,或终端里另一个进程批的):通知等着的人
|
|
40
50
|
watchPairing((channelId, _userId, chatId) => {
|
|
41
51
|
if (chatId) bots.get(channelId)?.notifyApproved(chatId);
|