agent-loft 0.2.24 → 0.2.26
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 +24 -2
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +41 -0
- package/src/agents/claude/session.ts +68 -7
- package/src/agents/codex/driver.ts +55 -3
- package/src/agents/codex/session.ts +134 -7
- package/src/channels/feishu/bot.ts +91 -23
- package/src/channels/web/api.ts +209 -3
- package/src/cli/user.ts +3 -0
- package/src/core/chat-history.ts +250 -0
- package/src/core/chat-mode.ts +62 -0
- package/src/core/command-catalog.ts +3 -0
- package/src/core/commands.ts +4 -0
- package/src/core/feishu-links.ts +119 -0
- package/src/core/hub.ts +138 -1
- package/src/core/memory-curator.ts +447 -0
- package/src/core/memory-mcp.ts +37 -0
- package/src/core/memory.ts +311 -0
- package/src/core/personal.ts +44 -0
- package/src/core/scheduled.ts +113 -0
- package/src/core/store.ts +9 -2
- package/src/core/types.ts +34 -0
- package/src/core/uploads.ts +56 -3
- package/src/main.ts +12 -0
- package/src/mcp/memory-server.ts +168 -0
- package/web/app.js +669 -25
- package/web/style.css +160 -0
|
@@ -14,7 +14,8 @@ import { parseCommand } from "../../core/commands.js";
|
|
|
14
14
|
import { Dedup } from "../../core/dedup.js";
|
|
15
15
|
import { isPaired, requestPairing } from "../../core/pairing.js";
|
|
16
16
|
import { getConversation, listConversations, sessionOwner, updateConversation } from "../../core/store.js";
|
|
17
|
-
import { adminVisible, hasMembers } from "../../core/users.js";
|
|
17
|
+
import { adminVisible, getUser, hasMembers, ownedBy, type User } from "../../core/users.js";
|
|
18
|
+
import { linkedUser, redeemLinkCode } from "../../core/feishu-links.js";
|
|
18
19
|
import { adoptFile, KIND_LABEL } from "../../core/uploads.js";
|
|
19
20
|
import type { SavedAttachment } from "../../core/types.js";
|
|
20
21
|
import { FeishuClient } from "./client.js";
|
|
@@ -46,11 +47,16 @@ type CardResponse = {
|
|
|
46
47
|
|
|
47
48
|
export type BotStatus = { state: string; since: number; error?: string };
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
type FeishuCommand = { kind: "web" } | { kind: "detach" } | { kind: "bind"; code: string };
|
|
51
|
+
|
|
52
|
+
/** 只在飞书里认的命令:挂到 Web 会话上 / 退回来 / 绑定账号。整条消息只有命令才算 */
|
|
53
|
+
function feishuCommandOf(text: string): FeishuCommand | null {
|
|
54
|
+
const t = text.trim().replace(/^//, "/");
|
|
55
|
+
const lower = t.toLowerCase();
|
|
56
|
+
if (lower === "/web") return { kind: "web" };
|
|
57
|
+
if (lower === "/detach") return { kind: "detach" };
|
|
58
|
+
const bind = /^\/bind\s+([A-Za-z0-9]{4,16})$/i.exec(t);
|
|
59
|
+
if (bind) return { kind: "bind", code: bind[1]! };
|
|
54
60
|
return null;
|
|
55
61
|
}
|
|
56
62
|
|
|
@@ -134,6 +140,37 @@ export class FeishuBot {
|
|
|
134
140
|
return this.mine(key) ? this.allowedAgents() : hub.enabledAgents().map((d) => d.id);
|
|
135
141
|
}
|
|
136
142
|
|
|
143
|
+
// ---------- 这个聊天能碰哪些东西 ----------
|
|
144
|
+
//
|
|
145
|
+
// 飞书聊天自己的会话有主人(绑定了账号的私聊,见 core/feishu-links.ts):按主人算,和他在 Web 上一样;
|
|
146
|
+
// 没主人(群聊、没绑定的私聊):照旧是管理员的地盘。
|
|
147
|
+
|
|
148
|
+
/** 飞书会话键的主人(绑定了账号的私聊才有) */
|
|
149
|
+
private viewerOf(feishuKey: string): User | undefined {
|
|
150
|
+
const owner = getConversation(feishuKey)?.owner;
|
|
151
|
+
const user = getUser(owner);
|
|
152
|
+
return user && !user.disabled ? user : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** feishuKey 这个聊天能不能碰主人是 owner 的东西(Web 会话、agent 会话) */
|
|
156
|
+
private canSee(feishuKey: string, owner: string | undefined): boolean {
|
|
157
|
+
const viewer = this.viewerOf(feishuKey);
|
|
158
|
+
return viewer ? ownedBy(viewer, owner) : adminVisible(owner);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 命令里要用的可见范围:/sessions 列哪些、/resume 能恢复哪些、/approve 能处理哪些 */
|
|
162
|
+
private commandScope(feishuKey: string) {
|
|
163
|
+
const viewer = this.viewerOf(feishuKey);
|
|
164
|
+
return {
|
|
165
|
+
...(viewer
|
|
166
|
+
? { sessionVisible: (id: string) => ownedBy(viewer, sessionOwner(id)) }
|
|
167
|
+
: hasMembers()
|
|
168
|
+
? { sessionVisible: (id: string) => adminVisible(sessionOwner(id)) }
|
|
169
|
+
: {}),
|
|
170
|
+
convVisible: (k: string) => this.canSee(feishuKey, getConversation(k)?.owner),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
137
174
|
// ---------- 接着 Web 会话 ----------
|
|
138
175
|
|
|
139
176
|
/** 飞书会话键挂着的 Web 会话(还在、没被删的);没挂着返回 undefined */
|
|
@@ -141,13 +178,14 @@ export class FeishuBot {
|
|
|
141
178
|
const target = attachedTarget(feishuKey);
|
|
142
179
|
if (!target) return undefined;
|
|
143
180
|
const conv = getConversation(target);
|
|
144
|
-
//
|
|
145
|
-
return conv && !conv.hidden &&
|
|
181
|
+
// 这个聊天不再能碰它了(主人被降成了普通成员、私聊换了主人):当它没了(下条消息时自动退回来)
|
|
182
|
+
return conv && !conv.hidden && this.canSee(feishuKey, conv.owner) ? target : undefined;
|
|
146
183
|
}
|
|
147
184
|
|
|
148
|
-
/** 挂在 Web 会话 key
|
|
185
|
+
/** 挂在 Web 会话 key 上的本机器人聊天(只算还能碰它的) */
|
|
149
186
|
private watchers(key: string): string[] {
|
|
150
|
-
|
|
187
|
+
const owner = getConversation(key)?.owner;
|
|
188
|
+
return watchersOf(key, this.prefix).filter((k) => this.canSee(k, owner));
|
|
151
189
|
}
|
|
152
190
|
|
|
153
191
|
/** 这条会话的事件该画到哪些飞书聊天里:自己的那个聊天,加上挂着它的聊天 */
|
|
@@ -213,8 +251,8 @@ export class FeishuBot {
|
|
|
213
251
|
*/
|
|
214
252
|
async attachTo(feishuKey: string, webKey: string, operatorOpenId?: string): Promise<string> {
|
|
215
253
|
const conv = getConversation(webKey);
|
|
216
|
-
//
|
|
217
|
-
if (!conv || conv.channel !== "web" || conv.hidden || !
|
|
254
|
+
// 只能接上这个聊天的主人自己的 Web 会话(没主人的聊天:管理员能看的)
|
|
255
|
+
if (!conv || conv.channel !== "web" || conv.hidden || !this.canSee(feishuKey, conv.owner)) throw new Error("这条 Web 会话已不存在");
|
|
218
256
|
const chatId = this.chatIdOf(feishuKey);
|
|
219
257
|
// 本聊天自己的会话也得有:之后 /detach 回来、侧栏里显示机器人都要它
|
|
220
258
|
const allowed = this.allowedAgents();
|
|
@@ -261,7 +299,7 @@ export class FeishuBot {
|
|
|
261
299
|
private async sendWebList(feishuKey: string, chatId: string): Promise<void> {
|
|
262
300
|
const current = this.attachedWeb(feishuKey);
|
|
263
301
|
const convs = hub
|
|
264
|
-
.list((c) => c.channel === "web" && !c.hidden &&
|
|
302
|
+
.list((c) => c.channel === "web" && !c.hidden && this.canSee(feishuKey, c.owner))
|
|
265
303
|
.slice(0, 10)
|
|
266
304
|
.map((c) => this.webEntry(c.key)!)
|
|
267
305
|
.filter(Boolean);
|
|
@@ -606,9 +644,17 @@ export class FeishuBot {
|
|
|
606
644
|
const operator = openId!.trim().toLowerCase();
|
|
607
645
|
this.operators.set(pairKey(ownKey, chatId), operator);
|
|
608
646
|
|
|
647
|
+
// 私聊的主人跟着发消息的人的绑定走:绑定了就是他的个人会话(用他的记忆),解绑了就回到管理员的地盘。
|
|
648
|
+
// 群聊不动:群里不止一个人
|
|
649
|
+
const p2p = message.chat_type === "p2p";
|
|
650
|
+
if (p2p && hub.reassignOwner(ownKey, linkedUser(this.cfg.id, operator)?.id)) {
|
|
651
|
+
const owner = this.viewerOf(ownKey);
|
|
652
|
+
console.log(`${this.tag} 私聊 ${ownKey} 的主人变为 ${owner?.name ?? "(无,归管理员)"}`);
|
|
653
|
+
}
|
|
654
|
+
|
|
609
655
|
const feishuCmd = attachments.length > 0 ? null : feishuCommandOf(text);
|
|
610
656
|
if (feishuCmd) {
|
|
611
|
-
await this.runFeishuCommand(feishuCmd, ownKey, chatId);
|
|
657
|
+
await this.runFeishuCommand(feishuCmd, ownKey, chatId, { openId: operator, p2p });
|
|
612
658
|
return;
|
|
613
659
|
}
|
|
614
660
|
|
|
@@ -624,9 +670,8 @@ export class FeishuBot {
|
|
|
624
670
|
allowedAgents: this.allowedFor(key),
|
|
625
671
|
by: "飞书命令",
|
|
626
672
|
channel: "feishu",
|
|
627
|
-
//
|
|
628
|
-
...(
|
|
629
|
-
convVisible: (k) => adminVisible(getConversation(k)?.owner),
|
|
673
|
+
// 别人的 agent 会话、挂着的请求,这个聊天里都碰不到
|
|
674
|
+
...this.commandScope(ownKey),
|
|
630
675
|
});
|
|
631
676
|
await this.renderReply(key, chatId, reply);
|
|
632
677
|
} catch (err) {
|
|
@@ -687,16 +732,39 @@ export class FeishuBot {
|
|
|
687
732
|
return { saved, failed };
|
|
688
733
|
}
|
|
689
734
|
|
|
690
|
-
/** /web、/detach:只认飞书会话自己的键,不管现在挂没挂着 */
|
|
691
|
-
private async runFeishuCommand(
|
|
735
|
+
/** /web、/detach、/bind:只认飞书会话自己的键,不管现在挂没挂着 */
|
|
736
|
+
private async runFeishuCommand(
|
|
737
|
+
cmd: FeishuCommand,
|
|
738
|
+
ownKey: string,
|
|
739
|
+
chatId: string,
|
|
740
|
+
sender?: { openId: string; p2p: boolean },
|
|
741
|
+
): Promise<void> {
|
|
692
742
|
try {
|
|
693
|
-
if (cmd === "web") await this.sendWebList(ownKey, chatId);
|
|
694
|
-
else await this.client.sendCard(chatId, replyCard(this.detachFrom(ownKey)));
|
|
743
|
+
if (cmd.kind === "web") await this.sendWebList(ownKey, chatId);
|
|
744
|
+
else if (cmd.kind === "detach") await this.client.sendCard(chatId, replyCard(this.detachFrom(ownKey)));
|
|
745
|
+
else await this.client.sendCard(chatId, replyCard(this.bind(cmd.code, ownKey, sender)));
|
|
695
746
|
} catch (err) {
|
|
696
747
|
await this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`);
|
|
697
748
|
}
|
|
698
749
|
}
|
|
699
750
|
|
|
751
|
+
/** /bind 绑定码:把发消息的人的飞书账号绑到生成这个码的 agent-loft 用户上 */
|
|
752
|
+
private bind(code: string, ownKey: string, sender: { openId: string; p2p: boolean } | undefined): string {
|
|
753
|
+
if (!sender) return "请直接在聊天里发 `/bind 绑定码`。";
|
|
754
|
+
// 群里发的码别人也看得见,而且群聊本来就不归个人:只在私聊里绑
|
|
755
|
+
if (!sender.p2p) return "请在和我的私聊里发 `/bind 绑定码`(群里别人也看得到绑定码)。绑定码已经露出来了的话,到 Web 上重新生成一个。";
|
|
756
|
+
const user = redeemLinkCode(code, this.cfg.id, sender.openId);
|
|
757
|
+
hub.reassignOwner(ownKey, user.id);
|
|
758
|
+
console.log(`${this.tag} ${sender.openId} 绑定到用户 ${user.name}`);
|
|
759
|
+
return [
|
|
760
|
+
`已绑定到 agent-loft 用户 **${user.name}**。`,
|
|
761
|
+
"",
|
|
762
|
+
"从现在起这个私聊是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话。群聊不受影响。",
|
|
763
|
+
"",
|
|
764
|
+
"在 Web 的「设置 → 我的账号」里可以随时解除绑定。",
|
|
765
|
+
].join("\n");
|
|
766
|
+
}
|
|
767
|
+
|
|
700
768
|
private async renderReply(key: string, chatId: string, reply: CommandReply): Promise<void> {
|
|
701
769
|
const ctx = this.cardCtx(key, chatId);
|
|
702
770
|
switch (reply.type) {
|
|
@@ -885,9 +953,9 @@ export class FeishuBot {
|
|
|
885
953
|
}
|
|
886
954
|
const chat = chatId!;
|
|
887
955
|
// /web、/detach 作用在这个聊天自己的会话上:命令表可能是接着 Web 会话时发的,k 是 Web 会话
|
|
956
|
+
const ownKey = this.mine(key) ? key : this.keyOf(chat);
|
|
888
957
|
const feishuCmd = feishuCommandOf(text);
|
|
889
958
|
if (feishuCmd) {
|
|
890
|
-
const ownKey = this.mine(key) ? key : this.keyOf(chat);
|
|
891
959
|
void this.runFeishuCommand(feishuCmd, ownKey, chat);
|
|
892
960
|
return { toast: { type: "info", content: `正在执行 ${text}` } };
|
|
893
961
|
}
|
|
@@ -900,7 +968,7 @@ export class FeishuBot {
|
|
|
900
968
|
return { toast: { type: "success", content: `已发送 ${text}` } };
|
|
901
969
|
}
|
|
902
970
|
void hub
|
|
903
|
-
.command(key, command, { allowedAgents: this.allowedFor(key), by: "飞书命令", channel: "feishu" })
|
|
971
|
+
.command(key, command, { allowedAgents: this.allowedFor(key), by: "飞书命令", channel: "feishu", ...this.commandScope(ownKey) })
|
|
904
972
|
.then((reply) => this.renderReply(key, chat, reply))
|
|
905
973
|
.catch((err) => this.client.sendText(chat, `出错了:${err instanceof Error ? err.message : String(err)}`));
|
|
906
974
|
return { toast: { type: "info", content: `正在执行 ${text}` } };
|
package/src/channels/web/api.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { cleanCustomCommands } from "../../core/command-catalog.js";
|
|
|
8
8
|
import { hub } from "../../core/hub.js";
|
|
9
9
|
import { interactions } from "../../core/interactions.js";
|
|
10
10
|
import { approve, listApproved, listPending, revoke } from "../../core/pairing.js";
|
|
11
|
-
import { getConversation, updateConversation } from "../../core/store.js";
|
|
11
|
+
import { getConversation, listConversations, updateConversation } from "../../core/store.js";
|
|
12
|
+
import { addScheduled, clearScheduled, removeScheduled } from "../../core/scheduled.js";
|
|
12
13
|
import { resolveUploadFile, saveBuffer, sniff, uploadKeyOf } from "../../core/uploads.js";
|
|
13
14
|
import {
|
|
14
15
|
createUser,
|
|
@@ -23,6 +24,20 @@ import {
|
|
|
23
24
|
type User,
|
|
24
25
|
} from "../../core/users.js";
|
|
25
26
|
import type { SavedAttachment } from "../../core/types.js";
|
|
27
|
+
import {
|
|
28
|
+
addMemoryItem,
|
|
29
|
+
clearMemory,
|
|
30
|
+
MEMORY_LIMITS,
|
|
31
|
+
readMemory,
|
|
32
|
+
removeMemoryItem,
|
|
33
|
+
setMemoryOptions,
|
|
34
|
+
updateMemoryItem,
|
|
35
|
+
type CurateSettings,
|
|
36
|
+
} from "../../core/memory.js";
|
|
37
|
+
import { createLinkCode, listLinks, unlink, unlinkUser } from "../../core/feishu-links.js";
|
|
38
|
+
import { purgeUserData } from "../../core/personal.js";
|
|
39
|
+
import { purgeHistory, readMeta, readRecords, writeMeta } from "../../core/chat-history.js";
|
|
40
|
+
import { curateNow, pendingCount } from "../../core/memory-curator.js";
|
|
26
41
|
import { listAllowlist, setAllowlist } from "../../agents/claude/permissions.js";
|
|
27
42
|
import { agentAvailability, agentHealth, invalidateHealth } from "../../agents/health.js";
|
|
28
43
|
import { installCodex, upgrade, useInstalledCodex, versionInfo } from "../../core/upgrade.js";
|
|
@@ -329,6 +344,46 @@ function userOp<T>(fn: () => T): T {
|
|
|
329
344
|
}
|
|
330
345
|
}
|
|
331
346
|
|
|
347
|
+
// ---------- 记忆、飞书绑定 ----------
|
|
348
|
+
|
|
349
|
+
/** 飞书 open_id 只给人看个尾巴:认得出是哪个就够了 */
|
|
350
|
+
function maskOpenId(openId: string): string {
|
|
351
|
+
return openId.length > 10 ? `${openId.slice(0, 3)}…${openId.slice(-6)}` : openId;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function feishuLinksOf(userId: string) {
|
|
355
|
+
const bots = config().feishu.bots;
|
|
356
|
+
return listLinks(userId).map((l) => ({
|
|
357
|
+
botId: l.botId,
|
|
358
|
+
botName: bots.find((b) => b.id === l.botId)?.name ?? "(已删除的机器人)",
|
|
359
|
+
openId: l.openId,
|
|
360
|
+
openIdShort: maskOpenId(l.openId),
|
|
361
|
+
boundAt: l.boundAt,
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 解除绑定后,那个人和机器人的私聊立刻不再是他的:交还给管理员的地盘,旧会话不带过去
|
|
367
|
+
* (见 hub.reassignOwner)。不等他下次发消息 —— 万一他再也不发,那条对话就一直挂在他名下。
|
|
368
|
+
*/
|
|
369
|
+
function releaseFeishuChats(userId: string, botId?: string): void {
|
|
370
|
+
for (const c of listConversations()) {
|
|
371
|
+
if (c.channel !== "feishu" || c.owner !== userId || (botId && c.channelId !== botId)) continue;
|
|
372
|
+
hub.reassignOwner(c.key, undefined);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function memoryOp<T>(userId: string, fn: () => T, affectsAgents = true): T {
|
|
377
|
+
try {
|
|
378
|
+
const result = fn();
|
|
379
|
+
// 用户在设置里改了记忆:他名下空闲的会话下次说话时换上新的(只改整理时机、用哪个模型的不用)
|
|
380
|
+
if (affectsAgents) hub.memoryChanged(userId);
|
|
381
|
+
return result;
|
|
382
|
+
} catch (err) {
|
|
383
|
+
throw new HttpError(400, err instanceof Error ? err.message : String(err));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
332
387
|
// ---------- 路由 ----------
|
|
333
388
|
|
|
334
389
|
function conversationOr404(key: string) {
|
|
@@ -481,7 +536,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
481
536
|
return;
|
|
482
537
|
}
|
|
483
538
|
if (p === "/api/conversations" && method === "POST") {
|
|
484
|
-
const body = await readJsonBody<{ agent?: string; cwd?: string }>(req);
|
|
539
|
+
const body = await readJsonBody<{ agent?: string; cwd?: string; mode?: string }>(req);
|
|
485
540
|
const enabled = hub.enabledAgents().map((d) => d.id);
|
|
486
541
|
const agent = body.agent && enabled.includes(body.agent) ? body.agent : enabled[0];
|
|
487
542
|
if (!agent) throw new HttpError(400, "没有可用的 agent");
|
|
@@ -494,6 +549,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
494
549
|
const key = `web:${crypto.randomBytes(6).toString("hex")}`;
|
|
495
550
|
hub.ensure({ key, channel: "web", channelId: "web", defaultAgent: agent, owner: user.id });
|
|
496
551
|
if (cwd && cwd !== config().workspaceRoot) updateConversation(key, { cwd, cwdExplicit: true });
|
|
552
|
+
// 新对话页上选的模式
|
|
553
|
+
if (body.mode === "chat") updateConversation(key, { mode: "chat" });
|
|
497
554
|
sendJson(res, 200, hub.info(key));
|
|
498
555
|
return;
|
|
499
556
|
}
|
|
@@ -521,10 +578,35 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
521
578
|
// 只是从列表里藏起来:agent 侧的会话记录还在,随时能从「历史会话」恢复
|
|
522
579
|
await hub.drop(key, "会话已删除");
|
|
523
580
|
updateConversation(key, { hidden: true });
|
|
581
|
+
clearScheduled(key);
|
|
524
582
|
hub.emitEvent(key, { type: "meta" });
|
|
525
583
|
sendJson(res, 200, { ok: true });
|
|
526
584
|
return;
|
|
527
585
|
}
|
|
586
|
+
// ----- 定时发送:列表随会话信息(info.scheduled)下发,这里只管增删 -----
|
|
587
|
+
if (sub === "scheduled" && method === "POST") {
|
|
588
|
+
const body = await readJsonBody<{ text?: string; at?: number }>(req);
|
|
589
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
590
|
+
const at = Number(body.at);
|
|
591
|
+
if (!text.trim()) throw new HttpError(400, "定时发送的内容不能为空");
|
|
592
|
+
if (!Number.isFinite(at) || at < Date.now() - 60_000) throw new HttpError(400, "发送时间要在现在之后");
|
|
593
|
+
if (at > Date.now() + 30 * 86400_000) throw new HttpError(400, "最多只能排到 30 天以后");
|
|
594
|
+
try {
|
|
595
|
+
addScheduled(key, text, at);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
throw new HttpError(400, err instanceof Error ? err.message : String(err));
|
|
598
|
+
}
|
|
599
|
+
hub.emitEvent(key, { type: "meta" });
|
|
600
|
+
sendJson(res, 200, hub.info(key));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (sub === "scheduled" && method === "DELETE") {
|
|
604
|
+
const id = url.searchParams.get("id") ?? "";
|
|
605
|
+
if (!removeScheduled(key, id)) throw new HttpError(404, "这条定时消息已经发出或取消了");
|
|
606
|
+
hub.emitEvent(key, { type: "meta" });
|
|
607
|
+
sendJson(res, 200, hub.info(key));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
528
610
|
if (sub === "history" && method === "GET") {
|
|
529
611
|
// 读历史是异步的。要保证「历史快照 + 进行中这一轮的事件 + 之后的补发」首尾相接、
|
|
530
612
|
// 不重不漏,只在两种情况下接受这次读到的快照:
|
|
@@ -651,6 +733,10 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
651
733
|
case "cd":
|
|
652
734
|
text = await hub.cd(key, value);
|
|
653
735
|
break;
|
|
736
|
+
case "mode":
|
|
737
|
+
if (value !== "chat" && value !== "work") throw new HttpError(400, "模式只能是 chat 或 work");
|
|
738
|
+
text = await hub.setMode(key, value);
|
|
739
|
+
break;
|
|
654
740
|
default:
|
|
655
741
|
throw new HttpError(400, "未知操作");
|
|
656
742
|
}
|
|
@@ -775,15 +861,131 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
775
861
|
return;
|
|
776
862
|
}
|
|
777
863
|
|
|
864
|
+
// ----- 我的记忆:每个人只能看、改自己的 -----
|
|
865
|
+
if (p === "/api/account/memory" && method === "GET") {
|
|
866
|
+
const meta = readMeta(user.id);
|
|
867
|
+
sendJson(res, 200, {
|
|
868
|
+
...readMemory(user.id),
|
|
869
|
+
limits: MEMORY_LIMITS,
|
|
870
|
+
// 等着整理的对话有几段、上次整理的结果(设置页显示)
|
|
871
|
+
pending: pendingCount(user.id),
|
|
872
|
+
...(meta.lastCuration ? { lastCuration: meta.lastCuration } : {}),
|
|
873
|
+
});
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (p === "/api/account/memory/curate" && method === "POST") {
|
|
877
|
+
// 「现在整理」:聊完了的对话合起来整理一次。要等模型回来,可能要几十秒
|
|
878
|
+
const log = await curateNow(user.id);
|
|
879
|
+
sendJson(res, 200, log);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (p === "/api/account/memory" && method === "PUT") {
|
|
883
|
+
const body = await readJsonBody<{ enabled?: boolean; about?: string; curate?: Partial<CurateSettings>; chatHistory?: boolean }>(req);
|
|
884
|
+
sendJson(
|
|
885
|
+
res,
|
|
886
|
+
200,
|
|
887
|
+
memoryOp(user.id, () =>
|
|
888
|
+
setMemoryOptions(user.id, {
|
|
889
|
+
...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}),
|
|
890
|
+
...(typeof body.about === "string" ? { about: body.about } : {}),
|
|
891
|
+
...(body.curate && typeof body.curate === "object" ? { curate: body.curate } : {}),
|
|
892
|
+
...(typeof body.chatHistory === "boolean" ? { chatHistory: body.chatHistory } : {}),
|
|
893
|
+
}),
|
|
894
|
+
typeof body.enabled === "boolean" || typeof body.about === "string" || typeof body.chatHistory === "boolean",
|
|
895
|
+
),
|
|
896
|
+
);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (p === "/api/account/history" && method === "GET") {
|
|
900
|
+
// 只给数量:索引里有多少句、来自多少段对话
|
|
901
|
+
const records = readRecords(user.id);
|
|
902
|
+
sendJson(res, 200, { count: records.length, conversations: new Set(records.map((r) => r.k)).size });
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (p === "/api/account/history" && method === "DELETE") {
|
|
906
|
+
// 清掉聊天记录索引。不再回填:清了就是不想让以前的聊天被搜到,只记以后的
|
|
907
|
+
purgeHistory(user.id);
|
|
908
|
+
writeMeta(user.id, { backfilled: true, extracted: {} });
|
|
909
|
+
sendJson(res, 200, { ok: true });
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (p === "/api/account/memory" && method === "DELETE") {
|
|
913
|
+
memoryOp(user.id, () => clearMemory(user.id));
|
|
914
|
+
sendJson(res, 200, readMemory(user.id));
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (p === "/api/account/memory/items" && method === "POST") {
|
|
918
|
+
const body = await readJsonBody<{ text?: string }>(req);
|
|
919
|
+
sendJson(res, 200, memoryOp(user.id, () => addMemoryItem(user.id, String(body.text ?? ""), "user")));
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
{
|
|
923
|
+
const m = /^\/api\/account\/memory\/items\/(m_[a-f0-9]+)$/.exec(p);
|
|
924
|
+
if (m && method === "PATCH") {
|
|
925
|
+
const body = await readJsonBody<{ text?: string }>(req);
|
|
926
|
+
sendJson(res, 200, memoryOp(user.id, () => updateMemoryItem(user.id, m[1]!, String(body.text ?? ""))));
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (m && method === "DELETE") {
|
|
930
|
+
sendJson(res, 200, { ok: !!memoryOp(user.id, () => removeMemoryItem(user.id, m[1]!)) });
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ----- 我的飞书账号绑定 -----
|
|
936
|
+
if (p === "/api/account/feishu" && method === "GET") {
|
|
937
|
+
sendJson(res, 200, {
|
|
938
|
+
links: feishuLinksOf(user.id),
|
|
939
|
+
// 能私聊的机器人(发 /bind 用)。机器人的配置细节照旧只给管理员
|
|
940
|
+
bots: config().feishu.bots.filter((b) => b.enabled).map((b) => ({ id: b.id, name: b.name })),
|
|
941
|
+
});
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (p === "/api/account/feishu/code" && method === "POST") {
|
|
945
|
+
sendJson(res, 200, createLinkCode(user.id));
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (p === "/api/account/feishu" && method === "DELETE") {
|
|
949
|
+
const body = await readJsonBody<{ botId?: string; openId?: string }>(req);
|
|
950
|
+
const botId = String(body.botId ?? "");
|
|
951
|
+
const ok = unlink(botId, String(body.openId ?? ""), user.id);
|
|
952
|
+
if (ok) releaseFeishuChats(user.id, botId);
|
|
953
|
+
sendJson(res, 200, { ok, links: feishuLinksOf(user.id) });
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
778
957
|
// ----- 用户管理(管理员) -----
|
|
779
958
|
if (p === "/api/users" && method === "GET") {
|
|
780
959
|
const devices = listWebSessions();
|
|
781
960
|
sendJson(res, 200, {
|
|
782
|
-
users: listUsers().map((u) => ({
|
|
961
|
+
users: listUsers().map((u) => ({
|
|
962
|
+
...u,
|
|
963
|
+
devices: devices.filter((d) => d.userId === u.id).length,
|
|
964
|
+
// 只给数量:管理员不能看成员的记忆和绑定的是哪个飞书账号
|
|
965
|
+
feishuLinks: listLinks(u.id).length,
|
|
966
|
+
})),
|
|
783
967
|
me: user.id,
|
|
784
968
|
});
|
|
785
969
|
return;
|
|
786
970
|
}
|
|
971
|
+
{
|
|
972
|
+
// 管理员能做的只有「清掉」:清空某人的记忆、解除某人的飞书绑定。看不到内容
|
|
973
|
+
const m = /^\/api\/users\/([A-Za-z0-9_]+)\/(memory|feishu)$/.exec(p);
|
|
974
|
+
if (m && method === "DELETE") {
|
|
975
|
+
const id = m[1]!;
|
|
976
|
+
if (!getUser(id)) throw new HttpError(404, "用户不存在");
|
|
977
|
+
if (m[2] === "memory") {
|
|
978
|
+
clearMemory(id);
|
|
979
|
+
hub.memoryChanged(id);
|
|
980
|
+
sendJson(res, 200, { ok: true });
|
|
981
|
+
} else {
|
|
982
|
+
const n = unlinkUser(id);
|
|
983
|
+
releaseFeishuChats(id);
|
|
984
|
+
sendJson(res, 200, { ok: true, removed: n });
|
|
985
|
+
}
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
787
989
|
if (p === "/api/users" && method === "POST") {
|
|
788
990
|
const body = await readJsonBody<{ name?: string; password?: string; role?: Role }>(req);
|
|
789
991
|
sendJson(
|
|
@@ -823,6 +1025,10 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
823
1025
|
const removed = userOp(() => removeUser(id));
|
|
824
1026
|
removeSubscriptionsOf((owner) => (owner ?? legacy) === id);
|
|
825
1027
|
revokeUserSessions(id);
|
|
1028
|
+
// 记忆和飞书绑定一起删:不像会话那样转给管理员(见 core/personal.ts)
|
|
1029
|
+
releaseFeishuChats(id);
|
|
1030
|
+
purgeUserData(id);
|
|
1031
|
+
hub.memoryChanged(id);
|
|
826
1032
|
sendJson(res, 200, removed);
|
|
827
1033
|
return;
|
|
828
1034
|
}
|
package/src/cli/user.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { loadConfig } from "../core/config.js";
|
|
|
11
11
|
import { createUser, findUserByName, legacyOwnerId, listUsers, removeUser } from "../core/users.js";
|
|
12
12
|
import { patchUser, revokeUserSessions } from "../channels/web/auth.js";
|
|
13
13
|
import { removeSubscriptionsOf } from "../channels/web/push.js";
|
|
14
|
+
import { purgeUserData } from "../core/personal.js";
|
|
14
15
|
|
|
15
16
|
loadConfig();
|
|
16
17
|
|
|
@@ -90,6 +91,8 @@ switch (sub) {
|
|
|
90
91
|
const legacy = legacyOwnerId();
|
|
91
92
|
removeUser(user.id);
|
|
92
93
|
removeSubscriptionsOf((owner) => (owner ?? legacy) === user.id);
|
|
94
|
+
// 记忆和飞书绑定一起删(飞书私聊下次有人说话时自动交还给管理员)
|
|
95
|
+
purgeUserData(user.id);
|
|
93
96
|
revokeUserSessions(user.id);
|
|
94
97
|
console.log(`已删除「${user.name}」。他的会话还在,归管理员。`);
|
|
95
98
|
break;
|