agent-loft 0.2.19 → 0.2.20
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 +2 -1
- package/package.json +1 -1
- package/src/channels/feishu/attach.ts +48 -0
- package/src/channels/feishu/bot.ts +308 -66
- package/src/channels/feishu/cards.ts +97 -0
- package/src/channels/feishu/client.ts +22 -0
- package/src/channels/feishu/envelope.ts +9 -1
- package/src/channels/web/api.ts +24 -0
- package/src/core/command-catalog.ts +13 -1
- package/src/core/commands.ts +2 -1
- package/src/core/hub.ts +17 -5
- package/web/app.js +55 -1
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
|
|
|
37
37
|
- 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类,飞书机器人各占一行并带飞书图标;分叉出来的对话挂在主对话下面,用虚线连起来;分组可折叠,宽度可拖动,双击会话重命名,名字太长时悬停滚动显示。
|
|
38
38
|
- 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,悬停看重置时间);空闲时按设置的间隔自动刷新(默认 10 分钟),跑完一轮后也会刷新。
|
|
39
39
|
- **跨渠道同步**:飞书里的会话在 Web 上也能打开,接着聊。同一个授权请求同时出现在飞书卡片和网页上,哪边先点算哪边,另一边自动变成「已处理」。
|
|
40
|
+
- **在飞书里接着 Web 会话**:飞书里发 `/web` 列出最近的 Web 会话,点「接上」后这个飞书聊天说的话、发的命令都进那条会话,它的回复、授权和提问也发到飞书,Web 上说的话同步过来;发 `/detach` 回到这个聊天自己的会话。也可以在 Web 侧栏右键会话 →「在飞书继续…」,选一个机器人聊过的飞书聊天直接接上(只有一个聊天时不用选)。Web 会话被删除时飞书那边自动退回来。
|
|
40
41
|
- **设置都在网页上**:工作目录、各 agent 的默认模型和思考程度、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、登录设备管理。
|
|
41
42
|
|
|
42
43
|
## 安装
|
|
@@ -85,7 +86,7 @@ agent-loft service install
|
|
|
85
86
|
| `agent-loft service install \| uninstall \| restart \| status` | 开机自启 |
|
|
86
87
|
| `agent-loft doctor` | 体检 |
|
|
87
88
|
|
|
88
|
-
聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 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`
|
|
89
|
+
聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 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`;只在飞书里有的:`/web` `/detach`
|
|
89
90
|
|
|
90
91
|
自定义命令:设置 → 命令 → 「添加命令」,填命令名、说明和一段提示词,之后发 `/命令名 参数` 就会把这段提示词发给 agent(提示词里的 `$ARGUMENTS` 换成参数,没写就把参数接在后面)。按 agent 分开存,可以编辑、删除,也会同步到飞书指令面板。
|
|
91
92
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// 飞书聊天「挂到」Web 会话上:挂上之后,这个飞书聊天说的话、发的命令都送进那条 Web 会话,
|
|
2
|
+
// 那条会话的回复、授权、提问也画到这个飞书聊天里。发 /detach 回到聊天自己的会话。
|
|
3
|
+
//
|
|
4
|
+
// 只是一层路由,会话本身(agent 进程、历史、审批)一点不动。按飞书会话键记:
|
|
5
|
+
// feishu:<机器人id>:<chatId>[:<话题id>] → web:<…>
|
|
6
|
+
// 一条 Web 会话可以同时被好几个飞书聊天挂着。要落盘:重启之后飞书那边还接着它聊。
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
|
|
9
|
+
|
|
10
|
+
type Shape = Record<string, { target: string; at: number }>;
|
|
11
|
+
|
|
12
|
+
const storePath = path.join(dataDir, "feishu-attach.json");
|
|
13
|
+
let cache: Shape | null = null;
|
|
14
|
+
|
|
15
|
+
function load(): Shape {
|
|
16
|
+
cache ??= readJson<Shape>(storePath, {});
|
|
17
|
+
return cache;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function save(): void {
|
|
21
|
+
writeJsonAtomic(storePath, cache ?? {});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 这个飞书会话挂着的 Web 会话键 */
|
|
25
|
+
export function attachedTarget(feishuKey: string): string | undefined {
|
|
26
|
+
return load()[feishuKey]?.target;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function attach(feishuKey: string, target: string): void {
|
|
30
|
+
load()[feishuKey] = { target, at: Date.now() };
|
|
31
|
+
save();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 返回原来挂着的目标(没挂着返回 undefined) */
|
|
35
|
+
export function detach(feishuKey: string): string | undefined {
|
|
36
|
+
const prev = load()[feishuKey]?.target;
|
|
37
|
+
if (prev === undefined) return undefined;
|
|
38
|
+
delete load()[feishuKey];
|
|
39
|
+
save();
|
|
40
|
+
return prev;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 挂在 target 上的所有飞书会话键(可选只要某个前缀的,也就是某个机器人的) */
|
|
44
|
+
export function watchersOf(target: string, prefix = ""): string[] {
|
|
45
|
+
return Object.entries(load())
|
|
46
|
+
.filter(([k, v]) => v.target === target && k.startsWith(prefix))
|
|
47
|
+
.map(([k]) => k);
|
|
48
|
+
}
|
|
@@ -13,15 +13,17 @@ import { interactions, type Interaction } from "../../core/interactions.js";
|
|
|
13
13
|
import { parseCommand } from "../../core/commands.js";
|
|
14
14
|
import { Dedup } from "../../core/dedup.js";
|
|
15
15
|
import { isPaired, requestPairing } from "../../core/pairing.js";
|
|
16
|
-
import { getConversation, updateConversation } from "../../core/store.js";
|
|
16
|
+
import { getConversation, listConversations, 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
20
|
import { SlashSync } from "./slash-sync.js";
|
|
21
21
|
import type { CommandEntry } from "../../core/command-catalog.js";
|
|
22
|
+
import { attach, attachedTarget, detach, watchersOf } from "./attach.js";
|
|
22
23
|
import {
|
|
23
24
|
agentPickerCard,
|
|
24
25
|
approvalCard,
|
|
26
|
+
attachedCard,
|
|
25
27
|
commandListCard,
|
|
26
28
|
modelPickerCard,
|
|
27
29
|
progressCard,
|
|
@@ -29,6 +31,8 @@ import {
|
|
|
29
31
|
replyCard,
|
|
30
32
|
sessionListCard,
|
|
31
33
|
settledCard,
|
|
34
|
+
webListCard,
|
|
35
|
+
type WebConvEntry,
|
|
32
36
|
} from "./cards.js";
|
|
33
37
|
import { decodeEnvelope, DECODE_FAILURE_TEXT, type CardCtx, type CardEnvelope } from "./envelope.js";
|
|
34
38
|
import { parseMessage, unsupportedTypeLabel, type AttachmentRef } from "./message.js";
|
|
@@ -41,15 +45,27 @@ type CardResponse = {
|
|
|
41
45
|
|
|
42
46
|
export type BotStatus = { state: string; since: number; error?: string };
|
|
43
47
|
|
|
48
|
+
/** 只在飞书里认的命令:挂到 Web 会话上 / 退回来。整条消息只有命令才算 */
|
|
49
|
+
function feishuCommandOf(text: string): "web" | "detach" | null {
|
|
50
|
+
const t = text.trim().replace(/^//, "/").toLowerCase();
|
|
51
|
+
if (t === "/web") return "web";
|
|
52
|
+
if (t === "/detach") return "detach";
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 一条会话画到某个飞书聊天里:流式卡片、操作人都按这一对记 */
|
|
57
|
+
const pairKey = (key: string, chatId: string) => `${key}\n${chatId}`;
|
|
58
|
+
|
|
44
59
|
export class FeishuBot {
|
|
45
60
|
readonly client: FeishuClient;
|
|
46
61
|
private ws: Lark.WSClient | null = null;
|
|
47
62
|
private dedup = new Dedup();
|
|
63
|
+
/** (会话键, 聊天) → 这一轮的输出编排。一条 Web 会话可能同时画在好几个飞书聊天里 */
|
|
48
64
|
private streams = new Map<string, TurnStream>();
|
|
49
65
|
private typing = new Map<string, { messageId: string; reactionId: string }>();
|
|
50
|
-
/** 交互 id →
|
|
51
|
-
private cards = new Map<string, string>();
|
|
52
|
-
/**
|
|
66
|
+
/** 交互 id → 发出去的飞书卡片(可能发在好几个聊天里) */
|
|
67
|
+
private cards = new Map<string, { chatId: string; messageId: string }[]>();
|
|
68
|
+
/** (会话键, 聊天) → 最后一个在这个聊天里说话的人。卡片默认只让他点 */
|
|
53
69
|
private operators = new Map<string, string>();
|
|
54
70
|
/** 附件要先下载才能送,紧跟着的文字不能抢在前面 —— 每条会话一条串行链 */
|
|
55
71
|
private chains = new Map<string, Promise<void>>();
|
|
@@ -77,7 +93,7 @@ export class FeishuBot {
|
|
|
77
93
|
const out: CommandEntry[] = [];
|
|
78
94
|
for (const agent of agents) {
|
|
79
95
|
if (!hub.driver(agent)) continue;
|
|
80
|
-
out.push(...(await hub.commandCatalog(agent, { allowedAgents: this.cfg.agents })));
|
|
96
|
+
out.push(...(await hub.commandCatalog(agent, { allowedAgents: this.cfg.agents, channel: "feishu" })));
|
|
81
97
|
}
|
|
82
98
|
return out;
|
|
83
99
|
}
|
|
@@ -112,6 +128,159 @@ export class FeishuBot {
|
|
|
112
128
|
return this.cfg.agents.filter((a) => enabled.has(a));
|
|
113
129
|
}
|
|
114
130
|
|
|
131
|
+
/** 某条会话能切到哪些 agent:自己的会话按机器人的勾选;接着的 Web 会话和 Web 上一样,启用的都行 */
|
|
132
|
+
private allowedFor(key: string): string[] {
|
|
133
|
+
return this.mine(key) ? this.allowedAgents() : hub.enabledAgents().map((d) => d.id);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------- 接着 Web 会话 ----------
|
|
137
|
+
|
|
138
|
+
/** 飞书会话键挂着的 Web 会话(还在、没被删的);没挂着返回 undefined */
|
|
139
|
+
attachedWeb(feishuKey: string): string | undefined {
|
|
140
|
+
const target = attachedTarget(feishuKey);
|
|
141
|
+
if (!target) return undefined;
|
|
142
|
+
const conv = getConversation(target);
|
|
143
|
+
return conv && !conv.hidden ? target : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 这条会话的事件该画到哪些飞书聊天里:自己的那个聊天,加上挂着它的聊天 */
|
|
147
|
+
chatsOf(key: string): string[] {
|
|
148
|
+
const out = new Set<string>();
|
|
149
|
+
if (this.mine(key)) out.add(this.chatIdOf(key));
|
|
150
|
+
for (const k of watchersOf(key, this.prefix)) out.add(this.chatIdOf(k));
|
|
151
|
+
return [...out];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 这个聊天能不能操作这条会话(卡片按钮里带的会话键) */
|
|
155
|
+
private controls(key: string, chatId: string | undefined): boolean {
|
|
156
|
+
if (!chatId) return false;
|
|
157
|
+
if (this.mine(key)) return this.chatIdOf(key) === chatId;
|
|
158
|
+
return watchersOf(key, this.prefix).some((k) => this.chatIdOf(k) === chatId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private webEntry(key: string): WebConvEntry | undefined {
|
|
162
|
+
const info = hub.info(key);
|
|
163
|
+
if (!info) return undefined;
|
|
164
|
+
return {
|
|
165
|
+
key,
|
|
166
|
+
title: info.title || "(未命名)",
|
|
167
|
+
agentLabel: hub.label(info.agent),
|
|
168
|
+
cwd: info.cwd,
|
|
169
|
+
busy: info.busy,
|
|
170
|
+
activeAt: info.activeAt,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 这个机器人聊过的飞书聊天 → 最近活跃时间 */
|
|
175
|
+
private chatActivity(): Map<string, number> {
|
|
176
|
+
const latest = new Map<string, number>();
|
|
177
|
+
for (const c of listConversations()) {
|
|
178
|
+
if (c.channel !== "feishu" || c.channelId !== this.cfg.id) continue;
|
|
179
|
+
const chatId = this.chatIdOf(c.key);
|
|
180
|
+
latest.set(chatId, Math.max(latest.get(chatId) ?? 0, c.activeAt));
|
|
181
|
+
}
|
|
182
|
+
return latest;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
hasChat(chatId: string): boolean {
|
|
186
|
+
return this.chatActivity().has(chatId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 这个机器人聊过的飞书聊天(Web 上选「在哪继续」用),按最近活跃排 */
|
|
190
|
+
async knownChats(): Promise<{ chatId: string; label: string; activeAt: number; attached?: string }[]> {
|
|
191
|
+
const chats = [...this.chatActivity()].sort((a, b) => b[1] - a[1]).slice(0, 20);
|
|
192
|
+
return Promise.all(
|
|
193
|
+
chats.map(async ([chatId, activeAt]) => {
|
|
194
|
+
const info = await this.client.chatInfo(chatId);
|
|
195
|
+
const label =
|
|
196
|
+
info?.mode === "p2p" ? "私聊" : info?.name ? info.name : `聊天 ${chatId.slice(-6)}`;
|
|
197
|
+
const attached = this.attachedWeb(this.keyOf(chatId));
|
|
198
|
+
return { chatId, label, activeAt, ...(attached ? { attached } : {}) };
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 把飞书会话 feishuKey 挂到 Web 会话 webKey 上,并在聊天里发一张「已接上」卡(带最近几句)。
|
|
205
|
+
* 返回给人看的结果;挂不上时抛错。
|
|
206
|
+
*/
|
|
207
|
+
async attachTo(feishuKey: string, webKey: string, operatorOpenId?: string): Promise<string> {
|
|
208
|
+
const conv = getConversation(webKey);
|
|
209
|
+
if (!conv || conv.channel !== "web" || conv.hidden) throw new Error("这条 Web 会话已不存在");
|
|
210
|
+
const chatId = this.chatIdOf(feishuKey);
|
|
211
|
+
// 本聊天自己的会话也得有:之后 /detach 回来、侧栏里显示机器人都要它
|
|
212
|
+
const allowed = this.allowedAgents();
|
|
213
|
+
if (allowed.length > 0) {
|
|
214
|
+
hub.ensure({
|
|
215
|
+
key: feishuKey,
|
|
216
|
+
channel: "feishu",
|
|
217
|
+
channelId: this.cfg.id,
|
|
218
|
+
defaultAgent: allowed.includes(this.cfg.defaultAgent) ? this.cfg.defaultAgent : allowed[0]!,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const already = attachedTarget(feishuKey) === webKey;
|
|
222
|
+
attach(feishuKey, webKey);
|
|
223
|
+
if (operatorOpenId) this.operators.set(pairKey(webKey, chatId), operatorOpenId.trim().toLowerCase());
|
|
224
|
+
const entry = this.webEntry(webKey)!;
|
|
225
|
+
console.log(`${this.tag} ${feishuKey} 接上 Web 会话 ${webKey}`);
|
|
226
|
+
// 最近几句:读历史可能要一会儿,读不到也不影响接上
|
|
227
|
+
const history = await hub.history(webKey).catch(() => []);
|
|
228
|
+
const recent = history
|
|
229
|
+
.filter((m) => m.text.trim())
|
|
230
|
+
.slice(-4)
|
|
231
|
+
.map((m) => ({ role: m.role, text: m.text }));
|
|
232
|
+
await this.client.sendCard(
|
|
233
|
+
chatId,
|
|
234
|
+
attachedCard({ conv: entry, recent, sessionKey: feishuKey, ctx: this.cardCtx(feishuKey, chatId) }),
|
|
235
|
+
);
|
|
236
|
+
// 已经挂着的授权 / 提问:这个聊天也要能点
|
|
237
|
+
for (const i of interactions.listPending(webKey)) {
|
|
238
|
+
if (!this.cards.get(i.id)?.some((c) => c.chatId === chatId)) await this.showInteractionIn(i, chatId);
|
|
239
|
+
}
|
|
240
|
+
return already ? `已经接着「${entry.title}」了` : `已在飞书接上「${entry.title}」`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 不再接着 Web 会话。返回给人看的结果 */
|
|
244
|
+
detachFrom(feishuKey: string): string {
|
|
245
|
+
const prev = detach(feishuKey);
|
|
246
|
+
if (!prev) return "这个聊天没有接着 Web 会话,本来就在用自己的会话。";
|
|
247
|
+
console.log(`${this.tag} ${feishuKey} 不再接着 Web 会话 ${prev}`);
|
|
248
|
+
const title = getConversation(prev)?.title;
|
|
249
|
+
return `已回到这个聊天自己的会话${title ? `,不再接着「${title}」` : ""}。之后发 \`/web\` 可以再接上。`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 发 /web:列最近的 Web 会话 */
|
|
253
|
+
private async sendWebList(feishuKey: string, chatId: string): Promise<void> {
|
|
254
|
+
const current = this.attachedWeb(feishuKey);
|
|
255
|
+
const convs = hub
|
|
256
|
+
.list()
|
|
257
|
+
.filter((c) => c.channel === "web" && !c.hidden)
|
|
258
|
+
.slice(0, 10)
|
|
259
|
+
.map((c) => this.webEntry(c.key)!)
|
|
260
|
+
.filter(Boolean);
|
|
261
|
+
await this.client.sendCard(
|
|
262
|
+
chatId,
|
|
263
|
+
webListCard({
|
|
264
|
+
convs,
|
|
265
|
+
...(current ? { current } : {}),
|
|
266
|
+
sessionKey: feishuKey,
|
|
267
|
+
ctx: this.cardCtx(feishuKey, chatId),
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 挂着的 Web 会话被删了 / 没了:自动退回来,告诉一声 */
|
|
273
|
+
private dropStale(feishuKey: string): boolean {
|
|
274
|
+
const target = attachedTarget(feishuKey);
|
|
275
|
+
if (!target || this.attachedWeb(feishuKey)) return false;
|
|
276
|
+
detach(feishuKey);
|
|
277
|
+
void this.client.sendCard(
|
|
278
|
+
this.chatIdOf(feishuKey),
|
|
279
|
+
replyCard("之前接着的 Web 会话已经被删除,已回到这个聊天自己的会话。"),
|
|
280
|
+
);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
115
284
|
isAuthorized(openId: string | undefined): boolean {
|
|
116
285
|
if (!openId) return false;
|
|
117
286
|
const id = openId.trim().toLowerCase();
|
|
@@ -123,14 +292,20 @@ export class FeishuBot {
|
|
|
123
292
|
start(): void {
|
|
124
293
|
this.stopped = false;
|
|
125
294
|
const onEvent = (env: EventEnvelope) => {
|
|
126
|
-
|
|
295
|
+
// 流式片段飞书不画(回复走整段),先挡掉,免得每个片段都去翻一遍挂载表
|
|
296
|
+
if (env.ev.type === "delta") return;
|
|
297
|
+
if (env.ev.type === "meta") {
|
|
298
|
+
for (const k of watchersOf(env.key, this.prefix)) this.dropStale(k);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const chatId of this.chatsOf(env.key)) this.render(env, chatId);
|
|
127
302
|
};
|
|
128
303
|
hub.on("event", onEvent);
|
|
129
304
|
this.offs.push(() => hub.off("event", onEvent));
|
|
130
305
|
|
|
131
|
-
const onCreated = (i: Interaction) =>
|
|
132
|
-
const onUpdated = (i: Interaction) => this.
|
|
133
|
-
const onSettled = (i: Interaction) => this.
|
|
306
|
+
const onCreated = (i: Interaction) => void this.showInteraction(i);
|
|
307
|
+
const onUpdated = (i: Interaction) => this.cards.has(i.id) && void this.refreshInteraction(i);
|
|
308
|
+
const onSettled = (i: Interaction) => this.cards.has(i.id) && void this.refreshInteraction(i);
|
|
134
309
|
interactions.on("created", onCreated);
|
|
135
310
|
interactions.on("updated", onUpdated);
|
|
136
311
|
interactions.on("settled", onSettled);
|
|
@@ -250,10 +425,10 @@ export class FeishuBot {
|
|
|
250
425
|
|
|
251
426
|
// ---------- 渲染 hub 事件 ----------
|
|
252
427
|
|
|
253
|
-
private stream(key: string): TurnStream {
|
|
254
|
-
|
|
428
|
+
private stream(key: string, chatId: string): TurnStream {
|
|
429
|
+
const id = pairKey(key, chatId);
|
|
430
|
+
let stream = this.streams.get(id);
|
|
255
431
|
if (stream) return stream;
|
|
256
|
-
const chatId = this.chatIdOf(key);
|
|
257
432
|
stream = new TurnStream({
|
|
258
433
|
sendCard: (card) => this.client.sendCard(chatId, card),
|
|
259
434
|
updateCard: (messageId, card) => this.client.updateCard(messageId, card),
|
|
@@ -261,35 +436,38 @@ export class FeishuBot {
|
|
|
261
436
|
sendReply: (text) => this.client.sendCard(chatId, replyCard(text)),
|
|
262
437
|
onError: (err) => console.error(`${this.tag} 卡片更新失败:`, err),
|
|
263
438
|
});
|
|
264
|
-
this.streams.set(
|
|
439
|
+
this.streams.set(id, stream);
|
|
265
440
|
return stream;
|
|
266
441
|
}
|
|
267
442
|
|
|
268
|
-
private render(env: EventEnvelope): void {
|
|
443
|
+
private render(env: EventEnvelope, chatId: string): void {
|
|
269
444
|
const { key, ev } = env;
|
|
270
|
-
const chatId = this.chatIdOf(key);
|
|
271
445
|
switch (ev.type) {
|
|
272
|
-
case "user":
|
|
273
|
-
//
|
|
274
|
-
|
|
446
|
+
case "user": {
|
|
447
|
+
// 别处(Web、别的飞书聊天)说的话,同步一句过来,不然这边看着没头没尾
|
|
448
|
+
const o = ev.origin;
|
|
449
|
+
const here = o.channel === "feishu" && o.channelId === this.cfg.id && (!o.chatId || o.chatId === chatId);
|
|
450
|
+
if (!here) {
|
|
275
451
|
const files = ev.attachments.length ? `\n\n📎 ${ev.attachments.join("、")}` : "";
|
|
276
|
-
|
|
452
|
+
const icon = o.channel === "web" ? "💻" : "💬";
|
|
453
|
+
void this.client.sendCard(chatId, replyCard(`${icon} **${o.name ?? "Web"}**:${ev.text}${files}`));
|
|
277
454
|
}
|
|
278
455
|
return;
|
|
456
|
+
}
|
|
279
457
|
case "step":
|
|
280
|
-
this.stream(key).addStep(ev.text);
|
|
458
|
+
this.stream(key, chatId).addStep(ev.text);
|
|
281
459
|
return;
|
|
282
460
|
case "text":
|
|
283
|
-
this.stream(key).addText(ev.text);
|
|
461
|
+
this.stream(key, chatId).addText(ev.text);
|
|
284
462
|
return;
|
|
285
463
|
case "result": {
|
|
286
|
-
const stream = this.stream(key);
|
|
464
|
+
const stream = this.stream(key, chatId);
|
|
287
465
|
stream.finish(ev.text, ev.footer);
|
|
288
466
|
void stream.drain().then(() => this.clearTyping(key));
|
|
289
467
|
return;
|
|
290
468
|
}
|
|
291
469
|
case "error": {
|
|
292
|
-
const stream = this.stream(key);
|
|
470
|
+
const stream = this.stream(key, chatId);
|
|
293
471
|
stream.finish("", "出错");
|
|
294
472
|
void stream.drain().then(() => {
|
|
295
473
|
this.clearTyping(key);
|
|
@@ -305,38 +483,45 @@ export class FeishuBot {
|
|
|
305
483
|
}
|
|
306
484
|
}
|
|
307
485
|
|
|
308
|
-
private cardCtx(key: string): CardCtx {
|
|
309
|
-
const operator = this.operators.get(key);
|
|
486
|
+
private cardCtx(key: string, chatId: string): CardCtx {
|
|
487
|
+
const operator = this.operators.get(pairKey(key, chatId));
|
|
310
488
|
return {
|
|
311
489
|
...(operator ? { operatorOpenId: operator } : {}),
|
|
312
|
-
chatId
|
|
490
|
+
chatId,
|
|
313
491
|
expiresAt: Date.now() + config().approvalTimeoutMs,
|
|
314
492
|
};
|
|
315
493
|
}
|
|
316
494
|
|
|
317
|
-
private interactionCard(i: Interaction, ctx?: CardCtx) {
|
|
495
|
+
private interactionCard(i: Interaction, chatId: string, ctx?: CardCtx) {
|
|
318
496
|
if (i.settled) return settledCard(i);
|
|
319
|
-
const c = ctx ?? this.cardCtx(i.convKey);
|
|
497
|
+
const c = ctx ?? this.cardCtx(i.convKey, chatId);
|
|
320
498
|
if (i.spec.kind === "approval") return approvalCard(i, c);
|
|
321
499
|
return questionCard(i, c, hub.label(getConversation(i.convKey)?.agent ?? ""));
|
|
322
500
|
}
|
|
323
501
|
|
|
324
502
|
private async showInteraction(i: Interaction): Promise<void> {
|
|
325
|
-
const
|
|
503
|
+
for (const chatId of this.chatsOf(i.convKey)) await this.showInteractionIn(i, chatId);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private async showInteractionIn(i: Interaction, chatId: string): Promise<void> {
|
|
507
|
+
const messageId = await this.client.sendCard(chatId, this.interactionCard(i, chatId));
|
|
326
508
|
if (!messageId) return;
|
|
327
|
-
this.cards.
|
|
509
|
+
const list = this.cards.get(i.id) ?? [];
|
|
510
|
+
list.push({ chatId, messageId });
|
|
511
|
+
this.cards.set(i.id, list);
|
|
328
512
|
// 发卡的空当里可能已经在 Web 上处理掉了
|
|
329
513
|
const latest = interactions.get(i.id);
|
|
330
514
|
if (latest?.settled) await this.refreshInteraction(latest);
|
|
331
515
|
}
|
|
332
516
|
|
|
333
517
|
private async refreshInteraction(i: Interaction): Promise<void> {
|
|
334
|
-
const
|
|
335
|
-
if (!
|
|
518
|
+
const list = this.cards.get(i.id);
|
|
519
|
+
if (!list) return;
|
|
336
520
|
if (i.settled) this.cards.delete(i.id);
|
|
337
|
-
await this.client.updateCard(messageId, this.interactionCard(i));
|
|
521
|
+
await Promise.all(list.map((c) => this.client.updateCard(c.messageId, this.interactionCard(i, c.chatId))));
|
|
338
522
|
}
|
|
339
523
|
|
|
524
|
+
|
|
340
525
|
// ---------- 正在输入 ----------
|
|
341
526
|
|
|
342
527
|
private clearTyping(key: string): void {
|
|
@@ -400,24 +585,36 @@ export class FeishuBot {
|
|
|
400
585
|
return;
|
|
401
586
|
}
|
|
402
587
|
|
|
403
|
-
const
|
|
588
|
+
const ownKey = this.keyOf(chatId, message.thread_id);
|
|
404
589
|
const conv = hub.ensure({
|
|
405
|
-
key,
|
|
590
|
+
key: ownKey,
|
|
406
591
|
channel: "feishu",
|
|
407
592
|
channelId: this.cfg.id,
|
|
408
593
|
defaultAgent: allowed.includes(this.cfg.defaultAgent) ? this.cfg.defaultAgent : allowed[0]!,
|
|
409
594
|
});
|
|
410
595
|
// 配置改过(这个机器人不再能用当前 agent):退回默认
|
|
411
596
|
if (!allowed.includes(conv.agent)) {
|
|
412
|
-
updateConversation(
|
|
597
|
+
updateConversation(ownKey, { agent: allowed.includes(this.cfg.defaultAgent) ? this.cfg.defaultAgent : allowed[0]! });
|
|
413
598
|
}
|
|
414
|
-
|
|
599
|
+
const operator = openId!.trim().toLowerCase();
|
|
600
|
+
this.operators.set(pairKey(ownKey, chatId), operator);
|
|
601
|
+
|
|
602
|
+
const feishuCmd = attachments.length > 0 ? null : feishuCommandOf(text);
|
|
603
|
+
if (feishuCmd) {
|
|
604
|
+
await this.runFeishuCommand(feishuCmd, ownKey, chatId);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 挂着 Web 会话:说的话、命令都送进那条会话
|
|
609
|
+
this.dropStale(ownKey);
|
|
610
|
+
const key = this.attachedWeb(ownKey) ?? ownKey;
|
|
611
|
+
this.operators.set(pairKey(key, chatId), operator);
|
|
415
612
|
|
|
416
613
|
const command = attachments.length > 0 ? ({ kind: "chat", text } as const) : parseCommand(text);
|
|
417
614
|
if (command.kind !== "chat") {
|
|
418
615
|
try {
|
|
419
|
-
const reply = await hub.command(key, command, { allowedAgents:
|
|
420
|
-
await this.renderReply(key, reply);
|
|
616
|
+
const reply = await hub.command(key, command, { allowedAgents: this.allowedFor(key), by: "飞书命令", channel: "feishu" });
|
|
617
|
+
await this.renderReply(key, chatId, reply);
|
|
421
618
|
} catch (err) {
|
|
422
619
|
await this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`);
|
|
423
620
|
}
|
|
@@ -425,7 +622,7 @@ export class FeishuBot {
|
|
|
425
622
|
}
|
|
426
623
|
|
|
427
624
|
void this.markTyping(key, message.message_id);
|
|
428
|
-
const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书" };
|
|
625
|
+
const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书", chatId };
|
|
429
626
|
// 收到消息这一刻的代次:下载附件期间点了 /stop、或者 /cd /new 换了会话,这条就作废
|
|
430
627
|
const gen = hub.generation(key);
|
|
431
628
|
this.enqueue(key, async () => {
|
|
@@ -476,9 +673,18 @@ export class FeishuBot {
|
|
|
476
673
|
return { saved, failed };
|
|
477
674
|
}
|
|
478
675
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
676
|
+
/** /web、/detach:只认飞书会话自己的键,不管现在挂没挂着 */
|
|
677
|
+
private async runFeishuCommand(cmd: "web" | "detach", ownKey: string, chatId: string): Promise<void> {
|
|
678
|
+
try {
|
|
679
|
+
if (cmd === "web") await this.sendWebList(ownKey, chatId);
|
|
680
|
+
else await this.client.sendCard(chatId, replyCard(this.detachFrom(ownKey)));
|
|
681
|
+
} catch (err) {
|
|
682
|
+
await this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private async renderReply(key: string, chatId: string, reply: CommandReply): Promise<void> {
|
|
687
|
+
const ctx = this.cardCtx(key, chatId);
|
|
482
688
|
switch (reply.type) {
|
|
483
689
|
case "text":
|
|
484
690
|
await this.client.sendCard(chatId, replyCard(reply.text));
|
|
@@ -602,7 +808,7 @@ export class FeishuBot {
|
|
|
602
808
|
}
|
|
603
809
|
return {
|
|
604
810
|
toast: { type: "info", content: "已勾选" },
|
|
605
|
-
card: { type: "raw", data: this.interactionCard(state.interaction) },
|
|
811
|
+
card: { type: "raw", data: this.interactionCard(state.interaction, chatId ?? "") },
|
|
606
812
|
};
|
|
607
813
|
}
|
|
608
814
|
case "ask-submit": {
|
|
@@ -619,19 +825,39 @@ export class FeishuBot {
|
|
|
619
825
|
return { toast: { type: "info", content: "已跳过" }, card: { type: "raw", data: settledCard(result) } };
|
|
620
826
|
}
|
|
621
827
|
case "resume":
|
|
622
|
-
this.background(envelope.k, () => hub.resume(envelope.k, envelope.s, envelope.c));
|
|
623
|
-
return { toast: { type: "info", content: "正在恢复该会话…" } };
|
|
828
|
+
return this.background(envelope.k, chatId, "正在恢复该会话…", () => hub.resume(envelope.k, envelope.s, envelope.c));
|
|
624
829
|
case "model":
|
|
625
|
-
this.background(envelope.k, () => hub.setModel(envelope.k, envelope.m));
|
|
626
|
-
return { toast: { type: "success", content: `正在切换到 ${envelope.m}` } };
|
|
830
|
+
return this.background(envelope.k, chatId, `正在切换到 ${envelope.m}`, () => hub.setModel(envelope.k, envelope.m));
|
|
627
831
|
case "effort":
|
|
628
|
-
this.background(envelope.k, () => hub.setEffort(envelope.k, envelope.f));
|
|
629
|
-
return { toast: { type: "success", content: `思考程度设为 ${envelope.f}` } };
|
|
832
|
+
return this.background(envelope.k, chatId, `思考程度设为 ${envelope.f}`, () => hub.setEffort(envelope.k, envelope.f));
|
|
630
833
|
case "agent":
|
|
631
|
-
this.background(envelope.k,
|
|
632
|
-
|
|
834
|
+
return this.background(envelope.k, chatId, `正在切换到 ${hub.label(envelope.g)}`, () =>
|
|
835
|
+
hub.setAgent(envelope.k, envelope.g, this.allowedFor(envelope.k)),
|
|
836
|
+
);
|
|
633
837
|
case "cmd":
|
|
634
|
-
return this.runCardCommand(envelope.k, `/${envelope.c}`);
|
|
838
|
+
return this.runCardCommand(envelope.k, chatId, operatorOpenId, `/${envelope.c}`);
|
|
839
|
+
case "attach": {
|
|
840
|
+
// k 必须是这个聊天自己的会话:卡片被转发到别处点,挂的也只能是那个聊天
|
|
841
|
+
if (!this.mine(envelope.k) || this.chatIdOf(envelope.k) !== chatId) {
|
|
842
|
+
return { toast: { type: "error", content: DECODE_FAILURE_TEXT.wrong_conversation } };
|
|
843
|
+
}
|
|
844
|
+
const conv = getConversation(envelope.w);
|
|
845
|
+
if (!conv || conv.channel !== "web" || conv.hidden) {
|
|
846
|
+
return { toast: { type: "error", content: "这条 Web 会话已不存在,请重新发 /web。" } };
|
|
847
|
+
}
|
|
848
|
+
void this.attachTo(envelope.k, envelope.w, operatorOpenId).catch((err) =>
|
|
849
|
+
this.client.sendText(chatId!, `出错了:${err instanceof Error ? err.message : String(err)}`),
|
|
850
|
+
);
|
|
851
|
+
return { toast: { type: "success", content: `正在接上「${conv.title ?? "Web 会话"}」` } };
|
|
852
|
+
}
|
|
853
|
+
case "detach": {
|
|
854
|
+
if (!this.mine(envelope.k) || this.chatIdOf(envelope.k) !== chatId) {
|
|
855
|
+
return { toast: { type: "error", content: DECODE_FAILURE_TEXT.wrong_conversation } };
|
|
856
|
+
}
|
|
857
|
+
const text = this.detachFrom(envelope.k);
|
|
858
|
+
void this.client.sendCard(chatId!, replyCard(text));
|
|
859
|
+
return { toast: { type: "success", content: "已回到本聊天" } };
|
|
860
|
+
}
|
|
635
861
|
}
|
|
636
862
|
}
|
|
637
863
|
|
|
@@ -639,31 +865,47 @@ export class FeishuBot {
|
|
|
639
865
|
* 命令表上点「执行」:等同于用户自己发了这条命令。
|
|
640
866
|
* agent-loft 的命令就地执行并回卡片;agent 原生命令(skills 等)当一条消息发给 agent。
|
|
641
867
|
*/
|
|
642
|
-
private runCardCommand(key: string, text: string): CardResponse {
|
|
643
|
-
if (!this.
|
|
868
|
+
private runCardCommand(key: string, chatId: string | undefined, operatorOpenId: string | undefined, text: string): CardResponse {
|
|
869
|
+
if (!this.controls(key, chatId) || !getConversation(key)) {
|
|
644
870
|
return { toast: { type: "error", content: "这条会话已不存在,请重新发一次「/」。" } };
|
|
645
871
|
}
|
|
872
|
+
const chat = chatId!;
|
|
873
|
+
// /web、/detach 作用在这个聊天自己的会话上:命令表可能是接着 Web 会话时发的,k 是 Web 会话
|
|
874
|
+
const feishuCmd = feishuCommandOf(text);
|
|
875
|
+
if (feishuCmd) {
|
|
876
|
+
const ownKey = this.mine(key) ? key : this.keyOf(chat);
|
|
877
|
+
void this.runFeishuCommand(feishuCmd, ownKey, chat);
|
|
878
|
+
return { toast: { type: "info", content: `正在执行 ${text}` } };
|
|
879
|
+
}
|
|
880
|
+
if (operatorOpenId) this.operators.set(pairKey(key, chat), operatorOpenId.trim().toLowerCase());
|
|
646
881
|
const command = parseCommand(text);
|
|
647
882
|
if (command.kind === "chat") {
|
|
648
|
-
const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书" };
|
|
883
|
+
const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书", chatId: chat };
|
|
649
884
|
const gen = hub.generation(key);
|
|
650
885
|
this.enqueue(key, async () => hub.send(key, { text, attachments: [] }, origin, gen));
|
|
651
886
|
return { toast: { type: "success", content: `已发送 ${text}` } };
|
|
652
887
|
}
|
|
653
|
-
const chatId = this.chatIdOf(key);
|
|
654
888
|
void hub
|
|
655
|
-
.command(key, command, { allowedAgents: this.
|
|
656
|
-
.then((reply) => this.renderReply(key, reply))
|
|
657
|
-
.catch((err) => this.client.sendText(
|
|
889
|
+
.command(key, command, { allowedAgents: this.allowedFor(key), by: "飞书命令", channel: "feishu" })
|
|
890
|
+
.then((reply) => this.renderReply(key, chat, reply))
|
|
891
|
+
.catch((err) => this.client.sendText(chat, `出错了:${err instanceof Error ? err.message : String(err)}`));
|
|
658
892
|
return { toast: { type: "info", content: `正在执行 ${text}` } };
|
|
659
893
|
}
|
|
660
894
|
|
|
661
|
-
/**
|
|
662
|
-
private background(
|
|
663
|
-
|
|
664
|
-
|
|
895
|
+
/** 卡片回调里耗时的操作:先应答,做完在点的那个聊天里回一句。 */
|
|
896
|
+
private background(
|
|
897
|
+
key: string,
|
|
898
|
+
chatId: string | undefined,
|
|
899
|
+
toast: string,
|
|
900
|
+
task: () => Promise<string>,
|
|
901
|
+
): CardResponse {
|
|
902
|
+
if (!this.controls(key, chatId) || !getConversation(key)) {
|
|
903
|
+
return { toast: { type: "error", content: "这条会话已不存在,或者这个聊天已经不接着它了。" } };
|
|
904
|
+
}
|
|
905
|
+
const chat = chatId!;
|
|
665
906
|
void task()
|
|
666
|
-
.then((text) => this.client.sendCard(
|
|
667
|
-
.catch((err) => this.client.sendText(
|
|
907
|
+
.then((text) => this.client.sendCard(chat, replyCard(text)))
|
|
908
|
+
.catch((err) => this.client.sendText(chat, `出错了:${err instanceof Error ? err.message : String(err)}`));
|
|
909
|
+
return { toast: { type: "info", content: toast } };
|
|
668
910
|
}
|
|
669
911
|
}
|
|
@@ -363,3 +363,100 @@ export function relativeTime(ts: number, now = Date.now()): string {
|
|
|
363
363
|
if (days < 30) return `${days} 天前`;
|
|
364
364
|
return new Date(ts).toLocaleDateString("zh-CN");
|
|
365
365
|
}
|
|
366
|
+
|
|
367
|
+
// ---------- 接着 Web 会话 ----------
|
|
368
|
+
|
|
369
|
+
export type WebConvEntry = {
|
|
370
|
+
key: string;
|
|
371
|
+
title: string;
|
|
372
|
+
agentLabel: string;
|
|
373
|
+
cwd: string;
|
|
374
|
+
busy: boolean;
|
|
375
|
+
activeAt: number;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
/** /web:最近的 Web 会话,点「接上」后这个聊天就接着它聊 */
|
|
379
|
+
export function webListCard(params: {
|
|
380
|
+
convs: WebConvEntry[];
|
|
381
|
+
/** 当前挂着的 Web 会话 */
|
|
382
|
+
current?: string;
|
|
383
|
+
/** 这个飞书聊天自己的会话键 */
|
|
384
|
+
sessionKey: string;
|
|
385
|
+
ctx: CardCtx;
|
|
386
|
+
}) {
|
|
387
|
+
const elements: unknown[] = [
|
|
388
|
+
{
|
|
389
|
+
tag: "markdown",
|
|
390
|
+
content: params.current
|
|
391
|
+
? "这个聊天正接着下面标 ✅ 的 Web 会话。点别的「接上」换一条,或者回到这个聊天自己的会话。"
|
|
392
|
+
: "点「接上」后,在这里说的话都发进那条 Web 会话,它的回复、授权和提问也会发到这里。发 `/detach` 回到这个聊天自己的会话。",
|
|
393
|
+
},
|
|
394
|
+
];
|
|
395
|
+
if (params.current) {
|
|
396
|
+
elements.push(buttonRow([button("回到本聊天", { a: "detach", k: params.sessionKey }, params.ctx)]));
|
|
397
|
+
}
|
|
398
|
+
if (params.convs.length === 0) {
|
|
399
|
+
elements.push({ tag: "hr" }, { tag: "markdown", content: "还没有 Web 会话。" });
|
|
400
|
+
}
|
|
401
|
+
params.convs.forEach((c, index) => {
|
|
402
|
+
const isCurrent = c.key === params.current;
|
|
403
|
+
elements.push({ tag: "hr" });
|
|
404
|
+
elements.push({
|
|
405
|
+
tag: "column_set",
|
|
406
|
+
horizontal_spacing: "8px",
|
|
407
|
+
columns: [
|
|
408
|
+
{
|
|
409
|
+
tag: "column",
|
|
410
|
+
width: "weighted",
|
|
411
|
+
weight: 1,
|
|
412
|
+
vertical_align: "center",
|
|
413
|
+
elements: [
|
|
414
|
+
{
|
|
415
|
+
tag: "markdown",
|
|
416
|
+
content:
|
|
417
|
+
`**${index + 1}. ${clip(c.title, 60)}**${isCurrent ? " ✅ 当前" : ""}${c.busy ? " ⏳ 回复中" : ""}\n` +
|
|
418
|
+
`<font color='grey'>${c.agentLabel} · \`${c.cwd}\` · ${relativeTime(c.activeAt)}</font>`,
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
tag: "column",
|
|
424
|
+
width: "auto",
|
|
425
|
+
vertical_align: "center",
|
|
426
|
+
elements: isCurrent ? [] : [button("接上", { a: "attach", w: c.key, k: params.sessionKey }, params.ctx)],
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
return card(elements, { title: "Web 会话", template: "blue" });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** 接上之后发一张:接的是哪条、最近聊到哪,以及回来的按钮 */
|
|
435
|
+
export function attachedCard(params: {
|
|
436
|
+
conv: WebConvEntry;
|
|
437
|
+
recent: { role: "user" | "assistant"; text: string }[];
|
|
438
|
+
sessionKey: string;
|
|
439
|
+
ctx: CardCtx;
|
|
440
|
+
}) {
|
|
441
|
+
const { conv } = params;
|
|
442
|
+
const elements: unknown[] = [
|
|
443
|
+
{
|
|
444
|
+
tag: "markdown",
|
|
445
|
+
content: `**${clip(conv.title, 80)}**\n<font color='grey'>${conv.agentLabel} · \`${conv.cwd}\`</font>`,
|
|
446
|
+
},
|
|
447
|
+
];
|
|
448
|
+
if (params.recent.length > 0) {
|
|
449
|
+
const lines = params.recent.map((m) => {
|
|
450
|
+
const who = m.role === "user" ? "**你**" : `**${conv.agentLabel}**`;
|
|
451
|
+
return `${who}:${clip(m.text.trim(), 400)}`;
|
|
452
|
+
});
|
|
453
|
+
elements.push({ tag: "hr" }, { tag: "markdown", content: clip(`最近的对话:\n\n${lines.join("\n\n")}`, 3000) });
|
|
454
|
+
}
|
|
455
|
+
elements.push({ tag: "hr" });
|
|
456
|
+
elements.push({
|
|
457
|
+
tag: "markdown",
|
|
458
|
+
content: `${conv.busy ? "它正在回复,后面的进度会接着发到这里。\n" : ""}直接在这里说话就会发进这条会话;Web 上说的话也会同步过来。发 \`/detach\` 回到这个聊天自己的会话。`,
|
|
459
|
+
});
|
|
460
|
+
elements.push(buttonRow([button("回到本聊天", { a: "detach", k: params.sessionKey }, params.ctx)]));
|
|
461
|
+
return card(elements, { title: "已接上 Web 会话", template: "turquoise" });
|
|
462
|
+
}
|
|
@@ -112,6 +112,28 @@ export class FeishuClient {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* 群名 / 是不是私聊,Web 上选「在哪个飞书聊天继续」时显示用。
|
|
117
|
+
* 要 im:chat 读权限,没有就返回 undefined,调用方退回显示 chat_id。成功的结果缓存起来。
|
|
118
|
+
*/
|
|
119
|
+
private chatInfoCache = new Map<string, { name?: string; mode?: string }>();
|
|
120
|
+
async chatInfo(chatId: string): Promise<{ name?: string; mode?: string } | undefined> {
|
|
121
|
+
const cached = this.chatInfoCache.get(chatId);
|
|
122
|
+
if (cached) return cached;
|
|
123
|
+
try {
|
|
124
|
+
const res = await this.lark.im.v1.chat.get({ path: { chat_id: chatId } });
|
|
125
|
+
if (res.code !== 0 || !res.data) return undefined;
|
|
126
|
+
const info = {
|
|
127
|
+
...(res.data.name ? { name: res.data.name } : {}),
|
|
128
|
+
...(res.data.chat_mode ? { mode: res.data.chat_mode } : {}),
|
|
129
|
+
};
|
|
130
|
+
rememberBounded(this.chatInfoCache, chatId, info, 200);
|
|
131
|
+
return info;
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
115
137
|
/** 飞书没有给机器人开放 typing 指示器,表情回复是最接近的替代。 */
|
|
116
138
|
async addTypingReaction(messageId: string): Promise<string | undefined> {
|
|
117
139
|
try {
|
|
@@ -23,7 +23,11 @@ export type EnvelopeAction =
|
|
|
23
23
|
| { a: "agent"; g: string; k: string }
|
|
24
24
|
| { a: "resume"; s: string; k: string; c?: string }
|
|
25
25
|
/** 命令列表卡上的「执行」:c 是命令名,不带斜杠 */
|
|
26
|
-
| { a: "cmd"; c: string; k: string }
|
|
26
|
+
| { a: "cmd"; c: string; k: string }
|
|
27
|
+
/** 把飞书会话 k 挂到 Web 会话 w 上 */
|
|
28
|
+
| { a: "attach"; w: string; k: string }
|
|
29
|
+
/** 飞书会话 k 不再挂着 Web 会话 */
|
|
30
|
+
| { a: "detach"; k: string };
|
|
27
31
|
|
|
28
32
|
export type CardEnvelope = EnvelopeAction & {
|
|
29
33
|
oc: typeof ENVELOPE_VERSION;
|
|
@@ -103,6 +107,10 @@ function validAction(v: Record<string, unknown>): boolean {
|
|
|
103
107
|
case "cmd":
|
|
104
108
|
// 只放行命令名该有的字符(skill 名会带冒号),别让参数、换行之类混进来
|
|
105
109
|
return str(v.c) && /^[\w:.-]+$/.test(v.c) && str(v.k);
|
|
110
|
+
case "attach":
|
|
111
|
+
return str(v.w) && v.w.startsWith("web:") && str(v.k);
|
|
112
|
+
case "detach":
|
|
113
|
+
return str(v.k);
|
|
106
114
|
default:
|
|
107
115
|
return false;
|
|
108
116
|
}
|
package/src/channels/web/api.ts
CHANGED
|
@@ -518,6 +518,20 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
518
518
|
sendJson(res, 200, { info: hub.info(newKey) });
|
|
519
519
|
return;
|
|
520
520
|
}
|
|
521
|
+
if (sub === "feishu" && method === "POST") {
|
|
522
|
+
// 在飞书继续:把选中的飞书聊天挂到这条 Web 会话上
|
|
523
|
+
if (conv.channel !== "web" || conv.hidden) throw new HttpError(400, "只有 Web 会话能到飞书继续");
|
|
524
|
+
const body = await readJsonBody<{ botId?: string; chatId?: string }>(req);
|
|
525
|
+
const bot = feishuBots().find((b) => b.id === body.botId);
|
|
526
|
+
if (!bot) throw new HttpError(404, "这个飞书机器人没有在运行");
|
|
527
|
+
const chatId = String(body.chatId ?? "");
|
|
528
|
+
// 只能挂到这个机器人聊过的聊天上,不能拿它往任意 chat_id 发消息
|
|
529
|
+
if (!bot.hasChat(chatId)) {
|
|
530
|
+
throw new HttpError(404, "这个机器人没在那个聊天里说过话");
|
|
531
|
+
}
|
|
532
|
+
sendJson(res, 200, { text: await bot.attachTo(bot.keyOf(chatId), key) });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
521
535
|
if (sub === "sessions" && method === "GET") {
|
|
522
536
|
sendJson(res, 200, await hub.sessions(key, url.searchParams.get("all") === "1"));
|
|
523
537
|
return;
|
|
@@ -652,6 +666,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
652
666
|
const commands = await hub.commandCatalog(m[1]!, {
|
|
653
667
|
...(conv ? { cwd: conv.cwd } : {}),
|
|
654
668
|
allowedAgents: hub.enabledAgents().map((d) => d.id),
|
|
669
|
+
// 带 key 是输入框补全:只列 Web 能用的;不带是设置页排序,全列
|
|
670
|
+
...(conv ? { channel: "web" } : {}),
|
|
655
671
|
});
|
|
656
672
|
sendJson(res, 200, { commands });
|
|
657
673
|
return;
|
|
@@ -712,6 +728,14 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
712
728
|
sendJson(res, 200, { ok: true });
|
|
713
729
|
return;
|
|
714
730
|
}
|
|
731
|
+
if (p === "/api/feishu/targets" && method === "GET") {
|
|
732
|
+
// 「在飞书继续」能选的地方:每个运行中的机器人,以及它聊过的聊天
|
|
733
|
+
const bots = await Promise.all(
|
|
734
|
+
feishuBots().map(async (b) => ({ id: b.id, name: b.cfg.name, state: b.status.state, chats: await b.knownChats() })),
|
|
735
|
+
);
|
|
736
|
+
sendJson(res, 200, { bots });
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
715
739
|
if (p === "/api/feishu/status" && method === "GET") {
|
|
716
740
|
sendJson(res, 200, feishuBots().map((b) => ({ id: b.id, name: b.cfg.name, ...b.status })));
|
|
717
741
|
return;
|
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
// 顺序和隐藏按 agent 分开存在配置里(设置 → 命令),用户自己排。
|
|
8
8
|
|
|
9
9
|
/** agent-loft 内置命令。name 不带斜杠;args 里尖括号是必填、方括号是可选 */
|
|
10
|
-
export type BuiltinSpec = {
|
|
10
|
+
export type BuiltinSpec = {
|
|
11
|
+
name: string;
|
|
12
|
+
args?: string;
|
|
13
|
+
description: string;
|
|
14
|
+
multiAgentOnly?: boolean;
|
|
15
|
+
/** 只在飞书里有意义(挂到 Web 会话上 / 退回来),Web 的补全里不列 */
|
|
16
|
+
feishuOnly?: boolean;
|
|
17
|
+
};
|
|
11
18
|
|
|
12
19
|
export const BUILTIN_COMMANDS: BuiltinSpec[] = [
|
|
13
20
|
{ name: "new", args: "[模型]", description: "开一条新会话" },
|
|
@@ -24,6 +31,8 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
|
|
|
24
31
|
{ name: "cd", args: "<相对目录>", description: "切换工作目录(限 workspace 内)" },
|
|
25
32
|
{ name: "pwd", description: "显示当前工作目录" },
|
|
26
33
|
{ name: "autoedit", args: "[on|off]", description: "本对话自动允许改工作目录内的文件(Bash 仍询问)" },
|
|
34
|
+
{ name: "web", description: "接上一条 Web 会话,在飞书里查看和回复", feishuOnly: true },
|
|
35
|
+
{ name: "detach", description: "不再接着 Web 会话,回到这个聊天自己的会话", feishuOnly: true },
|
|
27
36
|
{ name: "approve", args: "<请求ID> allow-once|allow-always|allow-session|deny", description: "审批兜底(卡片失效时用)" },
|
|
28
37
|
{ name: "help", description: "显示帮助" },
|
|
29
38
|
];
|
|
@@ -129,11 +138,14 @@ export function buildCatalog(params: {
|
|
|
129
138
|
allowedAgents: string[];
|
|
130
139
|
native: AgentCommand[];
|
|
131
140
|
prefs: CommandPrefs | undefined;
|
|
141
|
+
/** 给哪个渠道看:不是飞书就不列飞书专用的命令。不给就全列(设置页排序用) */
|
|
142
|
+
channel?: string;
|
|
132
143
|
}): CommandEntry[] {
|
|
133
144
|
const multi = params.allowedAgents.length > 1;
|
|
134
145
|
const builtin: CommandEntry[] = BUILTIN_COMMANDS.filter(
|
|
135
146
|
(c) =>
|
|
136
147
|
(!c.multiAgentOnly || multi) &&
|
|
148
|
+
(!c.feishuOnly || params.channel === undefined || params.channel === "feishu") &&
|
|
137
149
|
// /claude /codex 只在这个渠道能用对应 agent 时才有意义
|
|
138
150
|
(!["claude", "codex"].includes(c.name) || params.allowedAgents.includes(c.name)),
|
|
139
151
|
).map((c) => ({
|
package/src/core/commands.ts
CHANGED
|
@@ -84,7 +84,7 @@ export function parseCommand(raw: string): ParsedCommand {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export function helpText(agents: string[]): string {
|
|
87
|
+
export function helpText(agents: string[], channel?: string): string {
|
|
88
88
|
const multi = agents.length > 1;
|
|
89
89
|
return [
|
|
90
90
|
"直接说话即可,会自动接着上次的会话。闲置太久会自动开新会话。",
|
|
@@ -94,6 +94,7 @@ export function helpText(agents: string[]): string {
|
|
|
94
94
|
"**命令**(需整条消息只有命令;只发一个 `/` 可以列出全部命令,包括 agent 自己的)",
|
|
95
95
|
"",
|
|
96
96
|
...BUILTIN_COMMANDS.filter((c) => !c.multiAgentOnly || multi)
|
|
97
|
+
.filter((c) => !c.feishuOnly || channel === "feishu")
|
|
97
98
|
.filter((c) => !["claude", "codex"].includes(c.name))
|
|
98
99
|
.map((c) =>
|
|
99
100
|
c.name === "agent"
|
package/src/core/hub.ts
CHANGED
|
@@ -50,6 +50,8 @@ export type Origin = {
|
|
|
50
50
|
channelId: string;
|
|
51
51
|
/** 显示用的人名 / 设备名 */
|
|
52
52
|
name?: string;
|
|
53
|
+
/** 飞书:从哪个聊天说的(一条会话可能同时挂在好几个飞书聊天上,回显时要分清) */
|
|
54
|
+
chatId?: string;
|
|
53
55
|
};
|
|
54
56
|
|
|
55
57
|
export type ConvEvent =
|
|
@@ -1113,14 +1115,24 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
1113
1115
|
}
|
|
1114
1116
|
|
|
1115
1117
|
/** 执行一条已解析的命令。chat 不在这里处理。 */
|
|
1116
|
-
|
|
1118
|
+
/** ctx.channel:命令是从哪个渠道发的(飞书聊天挂在 Web 会话上时,和会话自己的渠道不一样) */
|
|
1119
|
+
async command(
|
|
1120
|
+
key: string,
|
|
1121
|
+
cmd: ParsedCommand,
|
|
1122
|
+
ctx: { allowedAgents: string[]; by: string; channel?: string },
|
|
1123
|
+
): Promise<CommandReply> {
|
|
1117
1124
|
const text = (t: string): CommandReply => ({ type: "text", text: t });
|
|
1125
|
+
const channel = ctx.channel ?? getConversation(key)?.channel;
|
|
1118
1126
|
switch (cmd.kind) {
|
|
1119
1127
|
case "help":
|
|
1120
|
-
return text(helpText(ctx.allowedAgents));
|
|
1128
|
+
return text(helpText(ctx.allowedAgents, channel));
|
|
1121
1129
|
case "commands": {
|
|
1122
1130
|
const conv = getConversation(key)!;
|
|
1123
|
-
const all = await this.commandCatalog(conv.agent, {
|
|
1131
|
+
const all = await this.commandCatalog(conv.agent, {
|
|
1132
|
+
cwd: conv.cwd,
|
|
1133
|
+
allowedAgents: ctx.allowedAgents,
|
|
1134
|
+
...(channel ? { channel } : {}),
|
|
1135
|
+
});
|
|
1124
1136
|
return { type: "commands", agent: conv.agent, commands: all.filter((c) => !c.hidden) };
|
|
1125
1137
|
}
|
|
1126
1138
|
case "pwd":
|
|
@@ -1180,7 +1192,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
1180
1192
|
*/
|
|
1181
1193
|
async commandCatalog(
|
|
1182
1194
|
agent: string,
|
|
1183
|
-
opts: { cwd?: string; allowedAgents: string[] },
|
|
1195
|
+
opts: { cwd?: string; allowedAgents: string[]; channel?: string },
|
|
1184
1196
|
): Promise<CommandEntry[]> {
|
|
1185
1197
|
const driver = this.enabledAgents().find((d) => d.id === agent);
|
|
1186
1198
|
let native: AgentCommand[] = [];
|
|
@@ -1191,7 +1203,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
1191
1203
|
});
|
|
1192
1204
|
}
|
|
1193
1205
|
const prefs = (config().commands as Record<string, CommandPrefs | undefined>)[agent];
|
|
1194
|
-
return buildCatalog({ allowedAgents: opts.allowedAgents, native, prefs });
|
|
1206
|
+
return buildCatalog({ allowedAgents: opts.allowedAgents, native, prefs, ...(opts.channel ? { channel: opts.channel } : {}) });
|
|
1195
1207
|
}
|
|
1196
1208
|
|
|
1197
1209
|
async shutdown(): Promise<void> {
|
package/web/app.js
CHANGED
|
@@ -1428,10 +1428,64 @@ function openConvContextMenu(key, at) {
|
|
|
1428
1428
|
},
|
|
1429
1429
|
},
|
|
1430
1430
|
{ label: "重命名", onclick: () => renameConv(key) },
|
|
1431
|
+
...(c.channel === "web" && S.bots.length > 0 ? [{ label: "在飞书继续…", onclick: () => continueInFeishu(key) }] : []),
|
|
1431
1432
|
...(c.channel === "web" ? [{ label: "删除", danger: true, onclick: () => deleteConv(key) }] : []),
|
|
1432
1433
|
]);
|
|
1433
1434
|
}
|
|
1434
1435
|
|
|
1436
|
+
/**
|
|
1437
|
+
* 在飞书继续:选一个机器人聊过的飞书聊天,把它接到这条 Web 会话上。
|
|
1438
|
+
* 之后在那个聊天里说话就进这条会话,回复、授权、提问也发过去;飞书里发 /detach 退回来。
|
|
1439
|
+
* 只有一个聊天可选时直接接上,不弹窗。
|
|
1440
|
+
*/
|
|
1441
|
+
async function continueInFeishu(key) {
|
|
1442
|
+
let data;
|
|
1443
|
+
try {
|
|
1444
|
+
data = await api("/api/feishu/targets");
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
return toast(err.message);
|
|
1447
|
+
}
|
|
1448
|
+
const targets = data.bots.flatMap((b) => b.chats.map((c) => ({ bot: b, chat: c })));
|
|
1449
|
+
if (data.bots.length === 0) return toast("没有运行中的飞书机器人");
|
|
1450
|
+
if (targets.length === 0) return toast("机器人还没有聊天:先在飞书里给它发一条消息,再回来推过去");
|
|
1451
|
+
const go = async (t, close) => {
|
|
1452
|
+
try {
|
|
1453
|
+
const r = await api(`/api/conversations/${encodeURIComponent(key)}/feishu`, {
|
|
1454
|
+
method: "POST",
|
|
1455
|
+
body: { botId: t.bot.id, chatId: t.chat.chatId },
|
|
1456
|
+
});
|
|
1457
|
+
close?.();
|
|
1458
|
+
toast(r.text);
|
|
1459
|
+
} catch (err) {
|
|
1460
|
+
toast(err.message);
|
|
1461
|
+
}
|
|
1462
|
+
};
|
|
1463
|
+
if (targets.length === 1) return go(targets[0]);
|
|
1464
|
+
const multiBot = data.bots.filter((b) => b.chats.length > 0).length > 1;
|
|
1465
|
+
const body = h("div", { class: "modal-body" });
|
|
1466
|
+
const close = modal({ title: "在飞书继续", body });
|
|
1467
|
+
fill(
|
|
1468
|
+
body,
|
|
1469
|
+
h("div", { class: "muted" }, "选一个飞书聊天,之后在那里说话就会发进这条会话,回复和授权也会发过去。在飞书里发 /detach 回到那个聊天自己的会话。"),
|
|
1470
|
+
...targets.map((t) => {
|
|
1471
|
+
const here = t.chat.attached === key;
|
|
1472
|
+
const other = t.chat.attached && !here ? S.convs.get(t.chat.attached) : null;
|
|
1473
|
+
const note = here ? "已接着这条" : other ? `正接着「${convTitle(other)}」,选它会换过来` : "";
|
|
1474
|
+
return h(
|
|
1475
|
+
"button",
|
|
1476
|
+
{ class: `list-item${here ? " on" : ""}`, onclick: () => go(t, close) },
|
|
1477
|
+
h("span", { class: "brand-icon", html: ICON.feishu }),
|
|
1478
|
+
h(
|
|
1479
|
+
"div",
|
|
1480
|
+
{ class: "grow" },
|
|
1481
|
+
h("div", { class: "t" }, multiBot ? `${t.bot.name} · ${t.chat.label}` : t.chat.label),
|
|
1482
|
+
h("div", { class: "s" }, [`${relTime(t.chat.activeAt)}活跃`, t.bot.state !== "connected" ? "机器人未连上" : "", note].filter(Boolean).join(" · ")),
|
|
1483
|
+
),
|
|
1484
|
+
);
|
|
1485
|
+
}),
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1435
1489
|
/** 飞书机器人的历史会话:这个机器人的所有聊天,按活跃时间排,点一下打开 */
|
|
1436
1490
|
function openBotConversations(botId) {
|
|
1437
1491
|
const bot = S.bots.find((b) => b.id === botId);
|
|
@@ -3519,7 +3573,7 @@ function modelPicker(agent, model, effort, defaultLabel) {
|
|
|
3519
3573
|
}
|
|
3520
3574
|
|
|
3521
3575
|
/** agent-loft 内置命令名(和服务端 BUILTIN_COMMANDS 一致),自定义命令不能用 */
|
|
3522
|
-
const BUILTIN_NAMES = ["new", "status", "stop", "model", "effort", "sessions", "resume", "fork", "agent", "claude", "codex", "cd", "pwd", "autoedit", "approve", "help"];
|
|
3576
|
+
const BUILTIN_NAMES = ["new", "status", "stop", "model", "effort", "sessions", "resume", "fork", "agent", "claude", "codex", "cd", "pwd", "autoedit", "web", "detach", "approve", "help"];
|
|
3523
3577
|
|
|
3524
3578
|
/**
|
|
3525
3579
|
* 添加 / 编辑一条自定义命令。onSave 返回 true 才关窗;失败(比如服务端校验没过)留着让用户改。
|