lightclawbot 1.2.3 → 1.2.6-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/channel.js +160 -39
- package/dist/src/config.js +4 -0
- package/dist/src/gateway.js +17 -5
- package/dist/src/history/index.js +1 -1
- package/dist/src/history/message-parser.js +20 -0
- package/dist/src/history/session-reader.js +74 -4
- package/dist/src/history/session-store.js +43 -0
- package/dist/src/history/text-processing.js +7 -0
- package/dist/src/history/usage-aggregator.js +53 -0
- package/dist/src/inbound.js +37 -20
- package/dist/src/socket/chat.js +257 -0
- package/dist/src/socket/handlers.js +271 -29
- package/dist/src/streaming/stream-reply-sink.js +58 -6
- package/dist/src/usage/index.js +4 -0
- package/dist/src/usage/normalize.js +47 -0
- package/dist/src/usage/types.js +1 -0
- package/dist/src/utils/common.js +160 -5
- package/node_modules/ws/lib/receiver.js +54 -0
- package/node_modules/ws/lib/sender.js +6 -1
- package/node_modules/ws/lib/websocket-server.js +8 -0
- package/node_modules/ws/lib/websocket.js +14 -0
- package/node_modules/ws/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LightClaw — Usage 轮次聚合、一次问答只展示一次消耗。
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* 把每一轮中除"最后一条" assistant 之外的所有 assistant 消息的 usage 字段删掉。
|
|
6
|
+
* - **就地修改** 传入的 messages 数组中的 assistant 消息(不创建新数组);
|
|
7
|
+
* - 同轮内中间 assistant 消息:删掉 usage 字段;
|
|
8
|
+
* - 同轮内最后一条 assistant 消息:保持原 usage 不变;
|
|
9
|
+
* - 整轮内没有 usage 的消息不做任何修改。
|
|
10
|
+
*
|
|
11
|
+
* 调用时机:在 4 个公共读取入口(readSessionHistory / Tail / ByIds / WithCron)
|
|
12
|
+
* 的最后一步统一调用,确保聚合基于完整对话流。
|
|
13
|
+
*
|
|
14
|
+
* @param messages 已 normalize 完成的历史消息数组(按时间正序)
|
|
15
|
+
*/
|
|
16
|
+
export function aggregateUsageByTurn(messages) {
|
|
17
|
+
// 当前轮内最后一条 assistant 的下标(轮次结束时它的 usage 保留)
|
|
18
|
+
let lastAssistantIdx = -1;
|
|
19
|
+
// 当前轮内除"最后一条"之外的 assistant 下标(这些节点的 usage 要被清空)
|
|
20
|
+
let intermediateAssistantIdxs = [];
|
|
21
|
+
/** 把当前轮的中间 assistant 节点 usage 全部清空,重置状态准备进入下一轮。 */
|
|
22
|
+
const flushTurn = () => {
|
|
23
|
+
for (const idx of intermediateAssistantIdxs) {
|
|
24
|
+
if (messages[idx]?.usage) {
|
|
25
|
+
delete messages[idx].usage;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
lastAssistantIdx = -1;
|
|
29
|
+
intermediateAssistantIdxs = [];
|
|
30
|
+
};
|
|
31
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
32
|
+
const msg = messages[i];
|
|
33
|
+
// cron 提醒类消息(带 cronInfo)属于独立的提醒上下文,不参与主会话轮次聚合,
|
|
34
|
+
// 保留它们各自的 usage 原样展示。
|
|
35
|
+
if (msg.cronInfo)
|
|
36
|
+
continue;
|
|
37
|
+
if (msg.role === "user") {
|
|
38
|
+
// 新一轮开始前,先把上一轮的中间 assistant usage 清空
|
|
39
|
+
flushTurn();
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (msg.role !== "assistant")
|
|
43
|
+
continue; // tool / system 节点不参与
|
|
44
|
+
// 把上一个"最后一条"挪到中间列表(它的 usage 不再代表轮末,要删)
|
|
45
|
+
if (lastAssistantIdx >= 0) {
|
|
46
|
+
intermediateAssistantIdxs.push(lastAssistantIdx);
|
|
47
|
+
}
|
|
48
|
+
// 当前节点暂定为"最后一条"
|
|
49
|
+
lastAssistantIdx = i;
|
|
50
|
+
}
|
|
51
|
+
// 处理最后一轮(可能没有后续 user 消息触发 flushTurn)
|
|
52
|
+
flushTurn();
|
|
53
|
+
}
|
package/dist/src/inbound.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - /stop 及自然语言 abort 由 tryFastAbortFromMessage 统一处理并递归 kill subagent。
|
|
9
9
|
*/
|
|
10
10
|
import { emitSignal } from './utils/common.js';
|
|
11
|
-
import { CHANNEL_KEY, MEDIA_MAX_BYTES, resolveEffectiveApiKey, setSessionApiKey, DEFAULT_AGENT_ID, LOCALFILE_SCHEME } from './config.js';
|
|
11
|
+
import { CHANNEL_KEY, MEDIA_MAX_BYTES, resolveEffectiveApiKey, setSessionApiKey, DEFAULT_AGENT_ID, LOCALFILE_SCHEME, } from './config.js';
|
|
12
12
|
import { getLightclawRuntime } from './runtime.js';
|
|
13
13
|
import { createChannelReplyPipeline } from 'openclaw/plugin-sdk/channel-reply-pipeline';
|
|
14
14
|
import { generateMsgId } from './dedup.js';
|
|
@@ -45,23 +45,29 @@ export function createInboundHandler(account, emitter, log) {
|
|
|
45
45
|
peer: { kind: 'direct', id: msg.senderId },
|
|
46
46
|
});
|
|
47
47
|
// route: {"agentId":"main","channel":"lightclawbot","accountId":"default","sessionKey":"agent:main:lightclawbot:direct:100013456706","mainSessionKey":"agent:main:main","lastRoutePolicy":"session","matchedBy":"default"}
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
48
|
+
// 框架生成的基础 sessionKey 形如:
|
|
49
|
+
// agent:<agentId>:<channel>:direct:<userId>
|
|
50
|
+
// 当前端带上 chatId(多会话场景)时,需要在末尾追加 `:<chatId>`,
|
|
51
|
+
// 形成:agent:<agentId>:<channel>:direct:<userId>:<chatId>,
|
|
52
|
+
const chatIdSuffix = msg?.chatId?.trim();
|
|
53
|
+
const appendChatId = (key) => (chatIdSuffix ? `${key}:${chatIdSuffix}` : key);
|
|
54
|
+
// 用 buildAgentSessionKey 基于 resolvedAgentId 重建 sessionKey 和 agentId,
|
|
55
|
+
// 实现 Agent 间隔离(sessionKey 含 agentId,不同 Agent 的会话历史完全独立)。
|
|
56
|
+
// resolvedAgentId 由 DEFAULT_AGENT_ID 兜底,必定有值,故无需再判空。
|
|
57
|
+
const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({
|
|
58
|
+
agentId: resolvedAgentId,
|
|
59
|
+
channel: CHANNEL_KEY,
|
|
60
|
+
accountId: baseRoute.accountId,
|
|
61
|
+
peer: { kind: 'direct', id: msg.senderId },
|
|
62
|
+
dmScope: 'per-channel-peer',
|
|
63
|
+
});
|
|
64
|
+
const route = {
|
|
65
|
+
...baseRoute,
|
|
66
|
+
agentId: resolvedAgentId,
|
|
67
|
+
sessionKey: appendChatId(baseSessionKey),
|
|
68
|
+
mainSessionKey: `agent:${resolvedAgentId}:main`,
|
|
69
|
+
};
|
|
70
|
+
log?.info(`[CHANNEL_KEY]: route= ${JSON.stringify(route)}, chatId=${chatIdSuffix ?? '-'}`);
|
|
65
71
|
// ---- 步骤 3:权限检查 ----
|
|
66
72
|
// 根据 allowFrom 白名单和 dmPolicy 策略决定是否允许此用户发送命令
|
|
67
73
|
const commandAuthorized = checkAuth(account.allowFrom, account.dmPolicy, msg.senderId);
|
|
@@ -77,8 +83,16 @@ export function createInboundHandler(account, emitter, log) {
|
|
|
77
83
|
const targetId = msg.senderId;
|
|
78
84
|
// 2. 提前发 typing_start(作为"已读回执",减少 COS 下载/LLM TTFT 期间的无反馈感)
|
|
79
85
|
const replyMsgId = generateMsgId();
|
|
80
|
-
const signalCtx = {
|
|
86
|
+
const signalCtx = {
|
|
87
|
+
emitter,
|
|
88
|
+
targetId,
|
|
89
|
+
replyMsgId,
|
|
90
|
+
originalMsgId: msg.messageId,
|
|
91
|
+
agentId: resolvedAgentId,
|
|
92
|
+
chatId: msg.chatId ?? '',
|
|
93
|
+
};
|
|
81
94
|
emitSignal(signalCtx, 'typing_start');
|
|
95
|
+
// 注:SignalContext.chatId 是上下文字段,由 emitSignal 内部规整到 extra.chatId 输出
|
|
82
96
|
// 3. 处理文件附件(files[] → 本地存储 + 公网 URL)
|
|
83
97
|
const localMediaPaths = [];
|
|
84
98
|
/** 本地文件 MIME 类型列表,与 localMediaPaths 一一对应 */
|
|
@@ -232,6 +246,9 @@ export function createInboundHandler(account, emitter, log) {
|
|
|
232
246
|
log,
|
|
233
247
|
effectiveApiKey,
|
|
234
248
|
typingAlreadyStarted: true,
|
|
249
|
+
// 透传 sessionKey + agentId 给 sink,markComplete 时用来从 transcript 兜底读 usage
|
|
250
|
+
sessionKey: route?.sessionKey,
|
|
251
|
+
agentId: resolvedAgentId,
|
|
235
252
|
}, { ...prefixOptions, onModelSelected }, signalCtx);
|
|
236
253
|
// 8. 分发给 AI 引擎(真流式)。abort 由 openclaw fast-abort 处理,sink 层
|
|
237
254
|
// 把英文回执汉化;此处仅兜底自身抛错。
|
|
@@ -251,7 +268,7 @@ export function createInboundHandler(account, emitter, log) {
|
|
|
251
268
|
log?.error(`[${CHANNEL_KEY}] Dispatch error: ${errMsg}`);
|
|
252
269
|
// 已推过可见内容时不再追加错误文案,避免打断阅读。
|
|
253
270
|
if (!hasEmittedContent()) {
|
|
254
|
-
emitter.sendReply(targetId, '抱歉,处理您的消息时出现了问题,请稍后重试', msg.messageId);
|
|
271
|
+
emitter.sendReply(targetId, '抱歉,处理您的消息时出现了问题,请稍后重试', msg.messageId, msg.chatId);
|
|
255
272
|
}
|
|
256
273
|
}
|
|
257
274
|
};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LightClaw — Chat(会话元信息)处理器
|
|
3
|
+
*
|
|
4
|
+
* 收敛 EVENT_CHAT_REQUEST 各子类型的处理逻辑,与 handlers.ts 中的
|
|
5
|
+
* 消息 / 历史 / Agents 等事件处理保持平级解耦,避免 handlers.ts 膨胀。
|
|
6
|
+
*
|
|
7
|
+
* 涉及的持久化文件:
|
|
8
|
+
* `~/.openclaw/agents/<agentId>/chats/<userId>/chats.json`
|
|
9
|
+
* └─ 以 ChatMeta[] 形式存储该用户在该 Agent 下的会话元信息列表
|
|
10
|
+
* (不包含消息正文,正文以 jsonl 形式另外存储)
|
|
11
|
+
*
|
|
12
|
+
* 四个处理器(对应 data.type):
|
|
13
|
+
* - list: 读取 chats.json 返回 { chats };文件不存在 / 解析失败
|
|
14
|
+
* 时返回空数组,不抛错,避免阻塞前端。
|
|
15
|
+
* - create: 新建一条会话元数据,chatId = randomUUID(),插入列表最前。
|
|
16
|
+
* 文件或目录不存在时由 writeChatsFile 递归创建。
|
|
17
|
+
* - update: 根据 chatId 修改 title(并置 titleLocked=true + 刷新 updatedAt);
|
|
18
|
+
* 文件或条目不存在时回 error,不隐式创建。
|
|
19
|
+
* - delete: 根据 chatId 从列表剔除,原子写回;文件或条目不存在时回 error。
|
|
20
|
+
*
|
|
21
|
+
* 全部响应均通过 EVENT_CHAT_RESPONSE + ReliableEmitter 发出(带 ACK + 重试)。
|
|
22
|
+
* 错误处理统一走 createChatErrorSender 生成的回调,保证日志 / 响应结构一致。
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import * as fs from 'node:fs';
|
|
26
|
+
import { CHANNEL_KEY, EVENT_CHAT_RESPONSE } from '../config.js';
|
|
27
|
+
import { generateMsgId } from '../dedup.js';
|
|
28
|
+
import { readChatsFile, readChatsFileOrInitDefault, resolveChatsFilePath, writeChatsFile } from '../utils/common.js';
|
|
29
|
+
/**
|
|
30
|
+
* 生成统一的错误响应发送器。
|
|
31
|
+
*
|
|
32
|
+
* 背景:list / create / update / delete 四个 handler 的错误处理结构完全一致:
|
|
33
|
+
* 1. 打同一格式日志:`[CHANNEL_KEY] Chat <type> error: <msg>`
|
|
34
|
+
* 2. 通过 EVENT_CHAT_RESPONSE 回同一组基础字段(msgId/from/to/type/agentId/error)
|
|
35
|
+
* 仅差异项:
|
|
36
|
+
* - `type` 不同
|
|
37
|
+
* - `chatId` 是否需要回显(update/delete 需要)
|
|
38
|
+
* - list 出错时前端期望一个兜底的空列表 `chats: []`,避免 UI 处理 undefined
|
|
39
|
+
*
|
|
40
|
+
* 因此将其封装为模块级工厂,handler 开头调用一次即可,避免重复闭包。
|
|
41
|
+
*
|
|
42
|
+
* @param ctx 共享上下文(见 ChatErrorSenderContext)
|
|
43
|
+
* @param extra 额外字段(如 `chatId`、兜底 `chats: []`),会与 error 一并合并到响应体
|
|
44
|
+
* @returns ChatErrorSender — 调用时传入错误信息即可
|
|
45
|
+
*/
|
|
46
|
+
function createChatErrorSender(ctx, extra = {}) {
|
|
47
|
+
const { responseMsgId, botClientId, userId, agentId, type, reliableEmitter, log } = ctx;
|
|
48
|
+
return (error) => {
|
|
49
|
+
log?.error(`[${CHANNEL_KEY}] Chat ${type} error: ${error}`);
|
|
50
|
+
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
|
|
51
|
+
msgId: responseMsgId,
|
|
52
|
+
from: botClientId,
|
|
53
|
+
to: userId,
|
|
54
|
+
type,
|
|
55
|
+
agentId,
|
|
56
|
+
timestamp: Date.now(),
|
|
57
|
+
...extra,
|
|
58
|
+
error,
|
|
59
|
+
}, responseMsgId);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 处理 EVENT_CHAT_REQUEST(type=list):返回该用户在指定 Agent 下的会话列表。
|
|
64
|
+
*
|
|
65
|
+
* 行为:
|
|
66
|
+
* 1. 解析当前用户 + Agent 的 chats.json 绝对路径
|
|
67
|
+
* 2. 调用 readChatsFileOrInitDefault 读取:文件不存在时**主动落盘一条 chatId=''
|
|
68
|
+
* 的默认会话兜底条目**,保证前端拉列表永远拿到至少一项;格式异常时返回 []
|
|
69
|
+
* 3. 通过 EVENT_CHAT_RESPONSE 回 { chats };若意外抛异常则回 { chats: [], error }
|
|
70
|
+
*
|
|
71
|
+
* 为什么 list 路径要主动兜底?
|
|
72
|
+
* - 用户首次连接拉列表 → 拿到默认对话 → UI 展示一致,不会出现
|
|
73
|
+
* “先空态、首条消息后又凭空多出一项”的诡异闪烁;
|
|
74
|
+
* - 兜底是幂等的:若 chats.json 已存在则等价于纯读取,无副作用;
|
|
75
|
+
* - 与 ensureSessionInHistory 的被动兜底形成双保险,覆盖“前端跳过 list
|
|
76
|
+
* 直接发消息”的极端场景。
|
|
77
|
+
*
|
|
78
|
+
* 为什么 create/update/delete/history:request 仍走纯只读 readChatsFile?
|
|
79
|
+
* - 它们要么是用户主动操作(隐式创建语义已在 create 中处理),
|
|
80
|
+
* 要么是只读查询(不应有写盘副作用),由 list 路径统一负责兜底即可。
|
|
81
|
+
*/
|
|
82
|
+
export function handleChatList(params) {
|
|
83
|
+
const { userId, agentId, botClientId, reliableEmitter, log } = params;
|
|
84
|
+
// 当前用户在当前 Agent 下的会话记录保存路径,即 chats.json 的绝对路径
|
|
85
|
+
const chatsPath = resolveChatsFilePath(agentId, userId);
|
|
86
|
+
// 响应 msgId:同时做 ReliableEmitter 的 ack key,成功 / 失败分支共用同一枚
|
|
87
|
+
const responseMsgId = generateMsgId();
|
|
88
|
+
// list 出错时,用一个空数组兑底,避免 UI 处理 undefined
|
|
89
|
+
const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'list', reliableEmitter, log }, { chats: [] });
|
|
90
|
+
try {
|
|
91
|
+
// 主动兜底读取:文件不存在则落盘一条 chatId='' 的默认会话;存在则等价于纯只读
|
|
92
|
+
const chats = readChatsFileOrInitDefault(chatsPath);
|
|
93
|
+
log?.info(`[${CHANNEL_KEY}] Chat list: userId=${userId} agentId=${agentId} count=${chats.length}`);
|
|
94
|
+
// 正常分支:统一经 ReliableEmitter 发送,依赖其 ACK + 重试保障送达
|
|
95
|
+
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
|
|
96
|
+
msgId: responseMsgId,
|
|
97
|
+
from: botClientId,
|
|
98
|
+
to: userId,
|
|
99
|
+
type: 'list',
|
|
100
|
+
agentId,
|
|
101
|
+
chats,
|
|
102
|
+
timestamp: Date.now(),
|
|
103
|
+
}, responseMsgId);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
// 兼容 Error / 非 Error 投掷(如字符串 throw),统一拼接为可读消息
|
|
107
|
+
sendError(err instanceof Error ? err.message : String(err));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 处理 EVENT_CHAT_REQUEST(type=create):新建一条会话元数据。
|
|
112
|
+
*
|
|
113
|
+
* 行为:
|
|
114
|
+
* 1. 解析 `~/.openclaw/agents/<agentId>/chats/<userId>/chats.json`
|
|
115
|
+
* 2. 文件不存在 → 以空列表起步(writeChatsFile 会递归建目录 + 原子落盘)
|
|
116
|
+
* 3. 文件存在 → 读取并在头部插入新会话(新建的默认置顶于列表最前)
|
|
117
|
+
* 4. 新会话默认 `title = '新会话'`,`chatId = randomUUID()`,createdAt = updatedAt = now
|
|
118
|
+
* 5. 通过 EVENT_CHAT_RESPONSE 返回 { chat: 新建项 }
|
|
119
|
+
*/
|
|
120
|
+
export function handleChatCreate(params) {
|
|
121
|
+
const { userId, agentId, botClientId, reliableEmitter, log } = params;
|
|
122
|
+
const chatsPath = resolveChatsFilePath(agentId, userId);
|
|
123
|
+
// 响应 msgId:成功 / 失败分支共用,侜助前端根据 msgId 反查原请求
|
|
124
|
+
const responseMsgId = generateMsgId();
|
|
125
|
+
const sendError = createChatErrorSender({
|
|
126
|
+
responseMsgId,
|
|
127
|
+
botClientId,
|
|
128
|
+
userId,
|
|
129
|
+
agentId,
|
|
130
|
+
type: 'create',
|
|
131
|
+
reliableEmitter,
|
|
132
|
+
log,
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
// 读取已有列表:文件不存在则返回 [] —— create 是隐式创建语义
|
|
136
|
+
const existing = readChatsFile(chatsPath);
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
const newChat = {
|
|
139
|
+
chatId: randomUUID(),
|
|
140
|
+
title: '新会话',
|
|
141
|
+
// 新建时 titleLocked=false:首条消息后可被自动标题生成策略改写
|
|
142
|
+
titleLocked: false,
|
|
143
|
+
createdAt: now,
|
|
144
|
+
updatedAt: now,
|
|
145
|
+
pinned: false,
|
|
146
|
+
// sessionIdHistory:同一 chat 历史上使用过的 sessionId(支持会话分支 / 重置)
|
|
147
|
+
sessionIdHistory: [],
|
|
148
|
+
};
|
|
149
|
+
// 新会话插入到最前,writeChatsFile 会在目录不存在时逐级创建(tmp → rename 原子落盘)
|
|
150
|
+
const nextChats = [newChat, ...existing];
|
|
151
|
+
writeChatsFile(chatsPath, nextChats);
|
|
152
|
+
log?.info(`[${CHANNEL_KEY}] Chat create: userId=${userId} agentId=${agentId} chatId=${newChat.chatId}`);
|
|
153
|
+
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
|
|
154
|
+
msgId: responseMsgId,
|
|
155
|
+
from: botClientId,
|
|
156
|
+
to: userId,
|
|
157
|
+
type: 'create',
|
|
158
|
+
agentId,
|
|
159
|
+
chats: [newChat],
|
|
160
|
+
timestamp: Date.now(),
|
|
161
|
+
}, responseMsgId);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
sendError(err instanceof Error ? err.message : String(err));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export function handleChatUpdate(params) {
|
|
168
|
+
const { userId, agentId, chatId, title, botClientId, reliableEmitter, log } = params;
|
|
169
|
+
const chatsPath = resolveChatsFilePath(agentId, userId);
|
|
170
|
+
const responseMsgId = generateMsgId();
|
|
171
|
+
// update 错误响应需要回显 chatId,便于前端关联请求
|
|
172
|
+
const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'update', reliableEmitter, log }, { chatId });
|
|
173
|
+
// title 可能为纯空白字符,trim 后再判断,避免“空标题”落盘
|
|
174
|
+
const nextTitle = title?.trim();
|
|
175
|
+
if (!nextTitle) {
|
|
176
|
+
sendError('Missing title');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// 文件必须存在,update 不负责创建(语义上:无源数据则无从更新)
|
|
180
|
+
if (!fs.existsSync(chatsPath)) {
|
|
181
|
+
sendError(`Chats file not found: ${chatsPath}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const chats = readChatsFile(chatsPath);
|
|
186
|
+
// O(n) 线性查找;chats.json 一般单用户单 Agent 规模有限,无需 Map 索引
|
|
187
|
+
const idx = chats.findIndex((c) => c.chatId === chatId);
|
|
188
|
+
if (idx < 0) {
|
|
189
|
+
sendError(`Chat not found: chatId=${chatId}`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// 更新:title + updatedAt + titleLocked
|
|
193
|
+
// titleLocked=true:用户主动改名后锁定,防止被自动标题生成策略覆盖
|
|
194
|
+
const updated = {
|
|
195
|
+
...chats[idx],
|
|
196
|
+
title: nextTitle,
|
|
197
|
+
titleLocked: true,
|
|
198
|
+
updatedAt: Date.now(),
|
|
199
|
+
};
|
|
200
|
+
// 浅拷贝后原位替换,保留其他项的顺序(列表排序给前端决定,后端不托管)
|
|
201
|
+
const nextChats = [...chats];
|
|
202
|
+
nextChats[idx] = updated;
|
|
203
|
+
// 原子落盘:writeChatsFile 内部使用 tmp + rename 保证不损坏原文件
|
|
204
|
+
writeChatsFile(chatsPath, nextChats);
|
|
205
|
+
log?.info(`[${CHANNEL_KEY}] Chat update: userId=${userId} agentId=${agentId} chatId=${chatId} title="${nextTitle}"`);
|
|
206
|
+
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
|
|
207
|
+
msgId: responseMsgId,
|
|
208
|
+
from: botClientId,
|
|
209
|
+
to: userId,
|
|
210
|
+
type: 'update',
|
|
211
|
+
agentId,
|
|
212
|
+
chats: [updated],
|
|
213
|
+
timestamp: Date.now(),
|
|
214
|
+
}, responseMsgId);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
sendError(err instanceof Error ? err.message : String(err));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export function handleChatDelete(params) {
|
|
221
|
+
const { userId, agentId, chatId, botClientId, reliableEmitter, log } = params;
|
|
222
|
+
const chatsPath = resolveChatsFilePath(agentId, userId);
|
|
223
|
+
const responseMsgId = generateMsgId();
|
|
224
|
+
// delete 错误响应需要回显 chatId,便于前端关联请求
|
|
225
|
+
const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'delete', reliableEmitter, log }, { chatId });
|
|
226
|
+
// 文件必须存在,delete 不负责创建(无文件 == 无可删的项,返回明确错误)
|
|
227
|
+
if (!fs.existsSync(chatsPath)) {
|
|
228
|
+
sendError(`Chats file not found: ${chatsPath}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const chats = readChatsFile(chatsPath);
|
|
233
|
+
// 先确认条目存在再写盘,避免“无效删除”仍触发一次磁盘 IO
|
|
234
|
+
const idx = chats.findIndex((c) => c.chatId === chatId);
|
|
235
|
+
if (idx < 0) {
|
|
236
|
+
sendError(`Chat not found: chatId=${chatId}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// 剔除目标项,保留其余顺序(不重排,不影响前端缓存的列表索引)
|
|
240
|
+
const nextChats = chats.filter((c) => c.chatId !== chatId);
|
|
241
|
+
// 原子落盘:writeChatsFile 内部使用 tmp + rename 保证不损坏原文件
|
|
242
|
+
writeChatsFile(chatsPath, nextChats);
|
|
243
|
+
log?.info(`[${CHANNEL_KEY}] Chat delete: userId=${userId} agentId=${agentId} chatId=${chatId} remaining=${nextChats.length}`);
|
|
244
|
+
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
|
|
245
|
+
msgId: responseMsgId,
|
|
246
|
+
from: botClientId,
|
|
247
|
+
to: userId,
|
|
248
|
+
type: 'delete',
|
|
249
|
+
agentId,
|
|
250
|
+
chats: chats.filter((c) => c.chatId === chatId),
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
}, responseMsgId);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
sendError(err instanceof Error ? err.message : String(err));
|
|
256
|
+
}
|
|
257
|
+
}
|