agent-loft 0.2.18 → 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/channels/web/server.ts +22 -8
- 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 +99 -38
- package/web/style.css +12 -0
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;
|
|
@@ -13,6 +13,7 @@ import { parseCommand } from "../../core/commands.js";
|
|
|
13
13
|
import { hub, type EventEnvelope } from "../../core/hub.js";
|
|
14
14
|
import { interactions, type Interaction } from "../../core/interactions.js";
|
|
15
15
|
import { getConversation } from "../../core/store.js";
|
|
16
|
+
import { RUNNING_VERSION } from "../../core/upgrade.js";
|
|
16
17
|
import type { Settlement } from "../../core/types.js";
|
|
17
18
|
import { authed, handleApi, takeUploads } from "./api.js";
|
|
18
19
|
import { authEvents, ensureSetupToken, validateSession } from "./auth.js";
|
|
@@ -60,18 +61,33 @@ const MIME: Record<string, string> = {
|
|
|
60
61
|
".webmanifest": "application/manifest+json",
|
|
61
62
|
};
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
/**
|
|
65
|
+
* 静态文件一律 no-cache + Last-Modified:每次都向服务端确认一下,没变就回 304。
|
|
66
|
+
* 升级后 app.js、style.css、vendor 都会变,但地址不变,带 max-age 的话刷新页面还会拿到旧的。
|
|
67
|
+
*/
|
|
68
|
+
function serveFile(req: http.IncomingMessage, res: http.ServerResponse, file: string): void {
|
|
64
69
|
fs.stat(file, (err, stat) => {
|
|
65
70
|
if (err || !stat.isFile()) {
|
|
66
71
|
res.writeHead(404, SECURITY_HEADERS);
|
|
67
72
|
res.end("not found");
|
|
68
73
|
return;
|
|
69
74
|
}
|
|
70
|
-
|
|
75
|
+
const lastModified = new Date(Math.floor(stat.mtimeMs / 1000) * 1000);
|
|
76
|
+
const since = Date.parse(req.headers["if-modified-since"] ?? "");
|
|
77
|
+
const headers = {
|
|
71
78
|
...SECURITY_HEADERS,
|
|
79
|
+
"Cache-Control": "no-cache",
|
|
80
|
+
"Last-Modified": lastModified.toUTCString(),
|
|
81
|
+
};
|
|
82
|
+
if (!Number.isNaN(since) && lastModified.getTime() <= since) {
|
|
83
|
+
res.writeHead(304, headers);
|
|
84
|
+
res.end();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
res.writeHead(200, {
|
|
88
|
+
...headers,
|
|
72
89
|
"Content-Type": MIME[path.extname(file)] ?? "application/octet-stream",
|
|
73
90
|
"Content-Length": stat.size,
|
|
74
|
-
"Cache-Control": cache,
|
|
75
91
|
});
|
|
76
92
|
fs.createReadStream(file).pipe(res);
|
|
77
93
|
});
|
|
@@ -81,7 +97,7 @@ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: U
|
|
|
81
97
|
const vendor = VENDOR[url.pathname];
|
|
82
98
|
if (vendor) {
|
|
83
99
|
try {
|
|
84
|
-
serveFile(res, vendor()
|
|
100
|
+
serveFile(req, res, vendor());
|
|
85
101
|
} catch {
|
|
86
102
|
res.writeHead(404);
|
|
87
103
|
res.end();
|
|
@@ -108,9 +124,7 @@ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: U
|
|
|
108
124
|
res.end();
|
|
109
125
|
return;
|
|
110
126
|
}
|
|
111
|
-
|
|
112
|
-
serveFile(res, file, rel === "/index.html" || rel === "/sw.js" ? "no-cache" : "public, max-age=300");
|
|
113
|
-
void req;
|
|
127
|
+
serveFile(req, res, file);
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
// ---------- WebSocket ----------
|
|
@@ -397,7 +411,7 @@ export async function startWeb(): Promise<void> {
|
|
|
397
411
|
});
|
|
398
412
|
ws.on("close", () => clients.delete(client));
|
|
399
413
|
ws.on("error", () => clients.delete(client));
|
|
400
|
-
send(client, { t: "hello", seq: hub.currentSeq(), epoch: EPOCH });
|
|
414
|
+
send(client, { t: "hello", seq: hub.currentSeq(), epoch: EPOCH, version: RUNNING_VERSION });
|
|
401
415
|
});
|
|
402
416
|
} catch (err) {
|
|
403
417
|
console.error("[web] WebSocket 握手出错:", err);
|
|
@@ -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
|
@@ -38,7 +38,6 @@ const ICON = {
|
|
|
38
38
|
stop: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1.5"/></svg>',
|
|
39
39
|
clip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.6-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>',
|
|
40
40
|
mic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
|
|
41
|
-
more: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>',
|
|
42
41
|
down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg>',
|
|
43
42
|
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>',
|
|
44
43
|
fork: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10"/><path d="M18 9c0 5-6 4-11.2 8.6"/></svg>',
|
|
@@ -269,6 +268,12 @@ const desktop = window.agentLoftDesktop ?? null;
|
|
|
269
268
|
const desktopQuota = typeof desktop?.onQuota === "function";
|
|
270
269
|
// 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 style.css 的 .in-desktop)
|
|
271
270
|
if (desktop) document.documentElement.classList.add("in-desktop");
|
|
271
|
+
/**
|
|
272
|
+
* 新版桌面端顶栏的「更多」里能打开每台服务器的设置,额度也在它的菜单栏里:侧栏底部的额度和设置就不显示了。
|
|
273
|
+
* 老版本桌面端没有这个入口,照旧显示,免得设置打不开。
|
|
274
|
+
*/
|
|
275
|
+
const desktopSettings = typeof desktop?.onOpenSettings === "function";
|
|
276
|
+
if (desktopSettings) document.documentElement.classList.add("desktop-settings");
|
|
272
277
|
|
|
273
278
|
// ---------- WebSocket ----------
|
|
274
279
|
|
|
@@ -402,6 +407,7 @@ function markGone(ix) {
|
|
|
402
407
|
function onWsMessage(msg) {
|
|
403
408
|
switch (msg.t) {
|
|
404
409
|
case "hello": {
|
|
410
|
+
if (checkServerVersion(msg.version)) return;
|
|
405
411
|
const restarted = WS.epoch && WS.epoch !== msg.epoch;
|
|
406
412
|
WS.epoch = msg.epoch;
|
|
407
413
|
WS.hellos += 1;
|
|
@@ -464,14 +470,31 @@ function onWsMessage(msg) {
|
|
|
464
470
|
}
|
|
465
471
|
}
|
|
466
472
|
|
|
467
|
-
function setConnBanner(text) {
|
|
473
|
+
function setConnBanner(text, onclick) {
|
|
468
474
|
ui.banner?.remove();
|
|
469
475
|
ui.banner = null;
|
|
470
476
|
if (!text || !ui.main) return;
|
|
471
|
-
ui.banner = h("div", { class: "conn-banner" }, text);
|
|
477
|
+
ui.banner = onclick ? h("button", { class: "conn-banner clickable", onclick }, text) : h("div", { class: "conn-banner" }, text);
|
|
472
478
|
ui.main.append(ui.banner);
|
|
473
479
|
}
|
|
474
480
|
|
|
481
|
+
/** 页面加载时连上的服务端版本。服务端升级重启后版本变了,页面上跑的还是旧前端,得刷新 */
|
|
482
|
+
let loadedServerVersion = null;
|
|
483
|
+
|
|
484
|
+
/** 服务端换了版本:刷新页面换上新前端。返回 true 表示已经在刷新了,后面不用再处理 */
|
|
485
|
+
function checkServerVersion(version) {
|
|
486
|
+
if (!version) return false;
|
|
487
|
+
if (!loadedServerVersion) loadedServerVersion = version;
|
|
488
|
+
if (version === loadedServerVersion) return false;
|
|
489
|
+
// 输入框里有没发出去的字:别直接刷掉,提示一下让用户自己点
|
|
490
|
+
if (ui.input?.value.trim()) {
|
|
491
|
+
setConnBanner(`服务端已升级到 ${version},点这里刷新页面`, () => location.reload());
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
location.reload();
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
|
|
475
498
|
// ---------- 登录 ----------
|
|
476
499
|
|
|
477
500
|
async function showAuth() {
|
|
@@ -549,6 +572,12 @@ async function boot() {
|
|
|
549
572
|
desktop?.onOpen((key) => {
|
|
550
573
|
if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
|
|
551
574
|
});
|
|
575
|
+
// 桌面端「更多」里点了这台服务器的设置;已经开着就不再叠一层
|
|
576
|
+
if (desktopSettings) {
|
|
577
|
+
desktop.onOpenSettings(() => {
|
|
578
|
+
if (!document.querySelector(".overlay .settings")) void openSettings();
|
|
579
|
+
});
|
|
580
|
+
}
|
|
552
581
|
if ("serviceWorker" in navigator) {
|
|
553
582
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
|
554
583
|
clearAppBadge();
|
|
@@ -579,6 +608,13 @@ function renderLayout() {
|
|
|
579
608
|
lastDown = { key, at: now };
|
|
580
609
|
});
|
|
581
610
|
ui.convList.addEventListener("contextmenu", (e) => {
|
|
611
|
+
// 飞书机器人那一行:右键看这个机器人的所有聊天
|
|
612
|
+
const bot = e.target.closest?.("[data-bot]")?.dataset.bot;
|
|
613
|
+
if (bot) {
|
|
614
|
+
e.preventDefault();
|
|
615
|
+
showMenu({ x: e.clientX, y: e.clientY }, [{ label: "历史会话…", onclick: () => openBotConversations(bot) }]);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
582
618
|
const key = e.target.closest?.("[data-key]")?.dataset.key;
|
|
583
619
|
if (!key || !S.convs.has(key)) return;
|
|
584
620
|
e.preventDefault();
|
|
@@ -1025,6 +1061,7 @@ function renderSidebar() {
|
|
|
1025
1061
|
"button",
|
|
1026
1062
|
{
|
|
1027
1063
|
class: `conv-item${cur?.channel === "feishu" && cur.channelId === bot.id ? " active" : ""}`,
|
|
1064
|
+
"data-bot": bot.id,
|
|
1028
1065
|
title: latest ? `飞书 · ${bot.name}\n${agentLabel(latest.agent)} · ${latest.cwd}` : `飞书 · ${bot.name}\n还没有会话`,
|
|
1029
1066
|
onclick: () => (latest ? open(latest.key) : toast("这个机器人还没有会话,先在飞书里给它发条消息")),
|
|
1030
1067
|
},
|
|
@@ -1117,13 +1154,9 @@ function renderSidebar() {
|
|
|
1117
1154
|
|
|
1118
1155
|
function renderTopbar() {
|
|
1119
1156
|
const c = S.current ? S.convs.get(S.current) : null;
|
|
1120
|
-
const more = h("button", { class: "icon-btn", title: "更多", onclick: (e) => openMoreMenu(e.currentTarget) }, icon("more"));
|
|
1121
1157
|
fill(ui.topbar,
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
h("div", { class: "top-title" }, c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null),
|
|
1125
|
-
c ? more : null,
|
|
1126
|
-
].filter(Boolean),
|
|
1158
|
+
h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
|
|
1159
|
+
h("div", { class: "top-title" }, c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null),
|
|
1127
1160
|
);
|
|
1128
1161
|
renderContext();
|
|
1129
1162
|
}
|
|
@@ -1380,24 +1413,6 @@ function closeMenu() {
|
|
|
1380
1413
|
removeEventListener("blur", closeMenu);
|
|
1381
1414
|
}
|
|
1382
1415
|
|
|
1383
|
-
function openMoreMenu(anchor) {
|
|
1384
|
-
const c = S.convs.get(S.current);
|
|
1385
|
-
if (!c) return;
|
|
1386
|
-
showMenu(anchor, [
|
|
1387
|
-
// 飞书机器人在侧栏只占一行(点开最近活跃的那个聊天),它的其他聊天从这里找
|
|
1388
|
-
...(c.channel === "feishu" ? [{ label: "历史会话…", onclick: () => openBotConversations(c.channelId) }] : []),
|
|
1389
|
-
{ label: "开新会话(清空上下文)", onclick: () => doAction("new") },
|
|
1390
|
-
c.canFork
|
|
1391
|
-
? { label: "分叉成新对话", onclick: () => forkConversation() }
|
|
1392
|
-
: { label: "分叉当前会话", onclick: () => doAction("fork") },
|
|
1393
|
-
{ label: "切换工作目录…", onclick: () => changeCwd() },
|
|
1394
|
-
{ label: "查看状态", onclick: () => showStatus() },
|
|
1395
|
-
"sep",
|
|
1396
|
-
{ label: "重命名", onclick: () => renameConv(S.current) },
|
|
1397
|
-
...(c.channel === "web" ? [{ label: "从列表删除", danger: true, onclick: () => deleteConv() }] : []),
|
|
1398
|
-
]);
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
1416
|
/** 侧栏会话的右键菜单:分叉 / 重命名 / 删除 */
|
|
1402
1417
|
function openConvContextMenu(key, at) {
|
|
1403
1418
|
const c = S.convs.get(key);
|
|
@@ -1413,10 +1428,64 @@ function openConvContextMenu(key, at) {
|
|
|
1413
1428
|
},
|
|
1414
1429
|
},
|
|
1415
1430
|
{ label: "重命名", onclick: () => renameConv(key) },
|
|
1431
|
+
...(c.channel === "web" && S.bots.length > 0 ? [{ label: "在飞书继续…", onclick: () => continueInFeishu(key) }] : []),
|
|
1416
1432
|
...(c.channel === "web" ? [{ label: "删除", danger: true, onclick: () => deleteConv(key) }] : []),
|
|
1417
1433
|
]);
|
|
1418
1434
|
}
|
|
1419
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
|
+
|
|
1420
1489
|
/** 飞书机器人的历史会话:这个机器人的所有聊天,按活跃时间排,点一下打开 */
|
|
1421
1490
|
function openBotConversations(botId) {
|
|
1422
1491
|
const bot = S.bots.find((b) => b.id === botId);
|
|
@@ -2041,7 +2110,8 @@ function renderComposer() {
|
|
|
2041
2110
|
},
|
|
2042
2111
|
});
|
|
2043
2112
|
ui.sendBtn = h("button", { class: "send-btn", title: "发送", onclick: () => (S.busy && !hasDraft() ? stopTurn() : sendMessage()) });
|
|
2044
|
-
|
|
2113
|
+
// 桌面端(Electron)里不放语音输入
|
|
2114
|
+
ui.micBtn = SpeechRec && !desktop
|
|
2045
2115
|
? h("button", { class: "icon-btn mic-btn", title: "语音输入", onclick: toggleVoice }, icon("mic"))
|
|
2046
2116
|
: null;
|
|
2047
2117
|
ui.ctxBar = h("div", { class: "ctx-bar" });
|
|
@@ -2945,15 +3015,6 @@ async function changeCwd() {
|
|
|
2945
3015
|
await load(startRel);
|
|
2946
3016
|
}
|
|
2947
3017
|
|
|
2948
|
-
async function showStatus() {
|
|
2949
|
-
try {
|
|
2950
|
-
const data = await api(`/api/conversations/${encodeURIComponent(S.current)}/status`);
|
|
2951
|
-
modal({ title: "状态", body: h("div", { class: "modal-body" }, renderMarkdown("```\n" + data.text + "\n```")) });
|
|
2952
|
-
} catch (err) {
|
|
2953
|
-
toast(err.message);
|
|
2954
|
-
}
|
|
2955
|
-
}
|
|
2956
|
-
|
|
2957
3018
|
/** 重命名:小弹窗,回车保存、Esc 取消,打开时全选原名 */
|
|
2958
3019
|
function renameConv(key = S.current) {
|
|
2959
3020
|
const c = S.convs.get(key);
|
|
@@ -3512,7 +3573,7 @@ function modelPicker(agent, model, effort, defaultLabel) {
|
|
|
3512
3573
|
}
|
|
3513
3574
|
|
|
3514
3575
|
/** agent-loft 内置命令名(和服务端 BUILTIN_COMMANDS 一致),自定义命令不能用 */
|
|
3515
|
-
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"];
|
|
3516
3577
|
|
|
3517
3578
|
/**
|
|
3518
3579
|
* 添加 / 编辑一条自定义命令。onSave 返回 true 才关窗;失败(比如服务端校验没过)留着让用户改。
|
package/web/style.css
CHANGED
|
@@ -550,6 +550,11 @@ button.conv-group:hover {
|
|
|
550
550
|
margin-left: auto;
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
/* 桌面端里:额度在菜单栏、设置在顶栏「更多」里,侧栏底部整条不要 */
|
|
554
|
+
.desktop-settings .side-bottom {
|
|
555
|
+
display: none;
|
|
556
|
+
}
|
|
557
|
+
|
|
553
558
|
/* ---- 侧栏底部的额度 ---- */
|
|
554
559
|
.quota {
|
|
555
560
|
position: relative;
|
|
@@ -2139,6 +2144,13 @@ button.ctx-item:hover {
|
|
|
2139
2144
|
z-index: 5;
|
|
2140
2145
|
}
|
|
2141
2146
|
|
|
2147
|
+
.conn-banner.clickable {
|
|
2148
|
+
border: 0;
|
|
2149
|
+
font: inherit;
|
|
2150
|
+
font-size: 12px;
|
|
2151
|
+
cursor: pointer;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2142
2154
|
/* ---------- 弹层 ---------- */
|
|
2143
2155
|
|
|
2144
2156
|
.overlay {
|