openclaw-linso 1.0.7 → 1.0.8
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 +0 -4
- package/dist/src/monitor.js +342 -45
- package/package.json +1 -1
- package/src/channel.ts +7 -11
- package/src/monitor.ts +398 -54
package/dist/src/channel.js
CHANGED
|
@@ -73,16 +73,12 @@ export const linsoPlugin = {
|
|
|
73
73
|
additionalProperties: false,
|
|
74
74
|
properties: {
|
|
75
75
|
enabled: { type: "boolean" },
|
|
76
|
-
relayUrl: { type: "string" },
|
|
77
|
-
pluginSecret: { type: "string" },
|
|
78
76
|
appToken: { type: "string" },
|
|
79
77
|
agentId: { type: "string" },
|
|
80
78
|
dmPolicy: { type: "string", enum: ["open", "pairing"] },
|
|
81
79
|
},
|
|
82
80
|
},
|
|
83
81
|
uiHints: {
|
|
84
|
-
relayUrl: { label: "Relay Server 地址", placeholder: "wss://relay.yourdomain.com" },
|
|
85
|
-
pluginSecret: { label: "Plugin 密钥(Plugin↔Relay 鉴权)", sensitive: true },
|
|
86
82
|
appToken: { label: "App Token(iOS↔Relay 鉴权)", sensitive: true },
|
|
87
83
|
agentId: { label: "Agent ID(默认 main)", placeholder: "main" },
|
|
88
84
|
dmPolicy: { label: "连接策略" },
|
package/dist/src/monitor.js
CHANGED
|
@@ -10,6 +10,23 @@ import { getLinsoRuntime } from "./runtime.js";
|
|
|
10
10
|
import { connectToRelay, disconnectFromRelay } from "./relay-client.js";
|
|
11
11
|
import { sendToClient } from "./store.js";
|
|
12
12
|
import { resolveLinsoAccount } from "./types.js";
|
|
13
|
+
// ── 每设备会话状态 ───────────────────────────────────────────────────────────
|
|
14
|
+
/** 会话计数器:/new /reset 时自增,派生新 sessionKey */
|
|
15
|
+
const deviceSessionCounters = new Map();
|
|
16
|
+
/** stop 标志:/stop 时设置,deliver 回调里检查 */
|
|
17
|
+
const deviceStopFlags = new Map();
|
|
18
|
+
/** 模型覆盖:/model <name> 时设置,undefined = 使用全局默认 */
|
|
19
|
+
const deviceModelOverrides = new Map();
|
|
20
|
+
/** 当用户没指定 provider 时自动补全的默认 provider */
|
|
21
|
+
const DEFAULT_MODEL_PROVIDER = "liao-claude";
|
|
22
|
+
function getDevicePeerId(deviceId) {
|
|
23
|
+
const n = deviceSessionCounters.get(deviceId) ?? 0;
|
|
24
|
+
return n === 0 ? deviceId : `${deviceId}#${n}`;
|
|
25
|
+
}
|
|
26
|
+
function bumpDeviceSession(deviceId) {
|
|
27
|
+
deviceSessionCounters.set(deviceId, (deviceSessionCounters.get(deviceId) ?? 0) + 1);
|
|
28
|
+
}
|
|
29
|
+
// ── monitorLinsoProvider ─────────────────────────────────────────────────────
|
|
13
30
|
export async function monitorLinsoProvider(opts = {}) {
|
|
14
31
|
const cfg = opts.config;
|
|
15
32
|
if (!cfg)
|
|
@@ -26,7 +43,6 @@ export async function monitorLinsoProvider(opts = {}) {
|
|
|
26
43
|
return;
|
|
27
44
|
}
|
|
28
45
|
log(`[Linso] 连接 Relay Server: ${account.relayUrl}`);
|
|
29
|
-
// Plugin 主动连接 Relay(outbound,和飞书连 open.feishu.cn 完全一致)
|
|
30
46
|
connectToRelay(account.relayUrl, {
|
|
31
47
|
appToken: account.appToken,
|
|
32
48
|
log,
|
|
@@ -47,12 +63,32 @@ export async function monitorLinsoProvider(opts = {}) {
|
|
|
47
63
|
const imageUrls = Array.isArray(msg.images)
|
|
48
64
|
? msg.images.filter((u) => typeof u === "string")
|
|
49
65
|
: [];
|
|
50
|
-
// 拦截 slash 命令,直接回复,不走 Agent
|
|
51
66
|
if (text.startsWith("/")) {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
const result = handleSlashCommand(text, deviceId, cfg);
|
|
68
|
+
if (result.kind === "sync") {
|
|
69
|
+
const runId = `linso-${Date.now()}`;
|
|
70
|
+
sendToClient(deviceId, { type: "run_start", runId });
|
|
71
|
+
sendToClient(deviceId, { type: "final", runId, text: result.text });
|
|
72
|
+
sendToClient(deviceId, { type: "done", runId });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (result.kind === "async") {
|
|
76
|
+
const runId = `linso-${Date.now()}`;
|
|
77
|
+
sendToClient(deviceId, { type: "run_start", runId });
|
|
78
|
+
result.handler().then((text) => {
|
|
79
|
+
sendToClient(deviceId, { type: "final", runId, text });
|
|
80
|
+
sendToClient(deviceId, { type: "done", runId });
|
|
81
|
+
}).catch((err) => {
|
|
82
|
+
sendToClient(deviceId, { type: "final", runId, text: `❌ 获取状态失败: ${String(err)}` });
|
|
83
|
+
sendToClient(deviceId, { type: "done", runId });
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// kind === "agent":把处理后的文字转发给 Agent
|
|
88
|
+
void handleIncomingMessage(deviceId, result.text, [], cfg, log).catch((err) => {
|
|
89
|
+
log(`[Linso] 处理命令出错: ${String(err)}`);
|
|
90
|
+
sendToClient(deviceId, { type: "error", message: String(err) });
|
|
91
|
+
});
|
|
56
92
|
return;
|
|
57
93
|
}
|
|
58
94
|
void handleIncomingMessage(deviceId, text, imageUrls, cfg, log).catch((err) => {
|
|
@@ -62,7 +98,6 @@ export async function monitorLinsoProvider(opts = {}) {
|
|
|
62
98
|
}
|
|
63
99
|
},
|
|
64
100
|
});
|
|
65
|
-
// 等待 abort 信号(Gateway 重启/重载时触发)
|
|
66
101
|
if (opts.abortSignal) {
|
|
67
102
|
await new Promise((resolve) => {
|
|
68
103
|
opts.abortSignal.addEventListener("abort", () => {
|
|
@@ -73,10 +108,171 @@ export async function monitorLinsoProvider(opts = {}) {
|
|
|
73
108
|
});
|
|
74
109
|
}
|
|
75
110
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
111
|
+
// ── slash 命令处理 ────────────────────────────────────────────────────────────
|
|
112
|
+
function handleSlashCommand(raw, deviceId, cfg) {
|
|
113
|
+
const parts = raw.trim().split(/\s+/);
|
|
114
|
+
const cmd = parts[0].toLowerCase();
|
|
115
|
+
const args = parts.slice(1);
|
|
116
|
+
switch (cmd) {
|
|
117
|
+
// ── Session 管理 ──────────────────────────────────────────────────────────
|
|
118
|
+
case "/new":
|
|
119
|
+
case "/reset": {
|
|
120
|
+
bumpDeviceSession(deviceId);
|
|
121
|
+
return {
|
|
122
|
+
kind: "sync",
|
|
123
|
+
text: [
|
|
124
|
+
"✅ 已开启新会话",
|
|
125
|
+
"之前的对话历史已清除,可以重新开始了 🦞",
|
|
126
|
+
].join("\n"),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
case "/compact": {
|
|
130
|
+
// 转发给 Agent,让它自己 compact
|
|
131
|
+
const focus = args.length > 0 ? `重点关注:${args.join(" ")}` : "";
|
|
132
|
+
return {
|
|
133
|
+
kind: "agent",
|
|
134
|
+
text: [
|
|
135
|
+
"[系统指令] 请将当前对话历史压缩为简洁摘要,保留关键决策和上下文,删除冗余细节。",
|
|
136
|
+
focus,
|
|
137
|
+
].filter(Boolean).join("\n"),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
case "/stop": {
|
|
141
|
+
deviceStopFlags.set(deviceId, true);
|
|
142
|
+
return {
|
|
143
|
+
kind: "sync",
|
|
144
|
+
text: "🛑 已发送停止信号,当前任务将在下一个输出节点终止。",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// ── 模型切换 ──────────────────────────────────────────────────────────────
|
|
148
|
+
case "/model": {
|
|
149
|
+
if (args.length === 0) {
|
|
150
|
+
const current = deviceModelOverrides.get(deviceId);
|
|
151
|
+
return {
|
|
152
|
+
kind: "sync",
|
|
153
|
+
text: current
|
|
154
|
+
? `🤖 当前模型:${current}\n\n用法:/model <name> 切换模型(自动补全 ${DEFAULT_MODEL_PROVIDER}/)\n /model <p/m> 指定 provider\n /model off 恢复默认`
|
|
155
|
+
: `🤖 当前模型:默认(${DEFAULT_MODEL_PROVIDER}/claude-sonnet-4-6)\n\n示例:/model claude-opus-4-5\n /model liao-claude/claude-opus-4-5`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const name = args[0].toLowerCase();
|
|
159
|
+
if (name === "off" || name === "default" || name === "reset") {
|
|
160
|
+
deviceModelOverrides.delete(deviceId);
|
|
161
|
+
return { kind: "sync", text: "✅ 已恢复默认模型" };
|
|
162
|
+
}
|
|
163
|
+
// 没有 / 时自动补全默认 provider
|
|
164
|
+
const modelInput = args[0].includes("/") ? args[0] : `${DEFAULT_MODEL_PROVIDER}/${args[0]}`;
|
|
165
|
+
deviceModelOverrides.set(deviceId, modelInput);
|
|
166
|
+
return { kind: "sync", text: `✅ 模型已切换为:${modelInput}` };
|
|
167
|
+
}
|
|
168
|
+
// ── 状态信息 ──────────────────────────────────────────────────────────────
|
|
169
|
+
case "/context": {
|
|
170
|
+
const counter = deviceSessionCounters.get(deviceId) ?? 0;
|
|
171
|
+
const model = deviceModelOverrides.get(deviceId) ?? `${DEFAULT_MODEL_PROVIDER}/claude-sonnet-4-6(默认)`;
|
|
172
|
+
return {
|
|
173
|
+
kind: "sync",
|
|
174
|
+
text: [
|
|
175
|
+
"📊 当前上下文:",
|
|
176
|
+
`• 设备 ID:${deviceId}`,
|
|
177
|
+
`• 会话版本:#${counter}`,
|
|
178
|
+
`• 模型:${model}`,
|
|
179
|
+
`• 时间:${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`,
|
|
180
|
+
"",
|
|
181
|
+
"(上下文 token 用量需在 Agent 侧查询)",
|
|
182
|
+
].join("\n"),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
case "/status": {
|
|
186
|
+
return {
|
|
187
|
+
kind: "async",
|
|
188
|
+
handler: () => buildStatusCard(deviceId, cfg),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// ── Skill ─────────────────────────────────────────────────────────────────
|
|
192
|
+
case "/skill": {
|
|
193
|
+
if (args.length === 0) {
|
|
194
|
+
return {
|
|
195
|
+
kind: "sync",
|
|
196
|
+
text: "⚠️ 用法:/skill <name> [input]\n示例:/skill weather 上海明天天气",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const skillName = args[0];
|
|
200
|
+
const input = args.slice(1).join(" ");
|
|
201
|
+
return {
|
|
202
|
+
kind: "agent",
|
|
203
|
+
text: input
|
|
204
|
+
? `[Skill: ${skillName}] ${input}`
|
|
205
|
+
: `请使用 skill "${skillName}" 执行任务。`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// ── 帮助 ──────────────────────────────────────────────────────────────────
|
|
209
|
+
case "/commands": {
|
|
210
|
+
return {
|
|
211
|
+
kind: "sync",
|
|
212
|
+
text: FULL_COMMAND_LIST,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
case "/help": {
|
|
216
|
+
return {
|
|
217
|
+
kind: "sync",
|
|
218
|
+
text: [
|
|
219
|
+
"📋 常用命令(/commands 查看完整列表):",
|
|
220
|
+
"",
|
|
221
|
+
"Session",
|
|
222
|
+
" /new 开启新会话(清除历史)",
|
|
223
|
+
" /reset 同 /new",
|
|
224
|
+
" /compact [focus] 压缩对话历史",
|
|
225
|
+
" /stop 停止当前任务",
|
|
226
|
+
"",
|
|
227
|
+
"模型",
|
|
228
|
+
" /model 查看当前模型",
|
|
229
|
+
" /model <name> 切换模型",
|
|
230
|
+
" /model off 恢复默认",
|
|
231
|
+
"",
|
|
232
|
+
"状态",
|
|
233
|
+
" /status 连接状态",
|
|
234
|
+
" /context 会话上下文信息",
|
|
235
|
+
"",
|
|
236
|
+
"Skills",
|
|
237
|
+
" /skill <name> [input]",
|
|
238
|
+
"",
|
|
239
|
+
"直接发消息即可和 AI 对话 🦞",
|
|
240
|
+
].join("\n"),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
default:
|
|
244
|
+
return {
|
|
245
|
+
kind: "sync",
|
|
246
|
+
text: `未知命令:${raw}\n输入 /help 查看常用命令,/commands 查看完整列表`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const FULL_COMMAND_LIST = [
|
|
251
|
+
"📋 完整命令列表:",
|
|
252
|
+
"",
|
|
253
|
+
"── Session ──────────────────────",
|
|
254
|
+
" /new 开启新会话(清除历史)",
|
|
255
|
+
" /reset 同 /new",
|
|
256
|
+
" /compact [focus] 压缩对话历史,可指定关注点",
|
|
257
|
+
" /stop 停止当前正在运行的任务",
|
|
258
|
+
"",
|
|
259
|
+
"── 模型 ─────────────────────────",
|
|
260
|
+
" /model 查看当前模型",
|
|
261
|
+
" /model <name> 切换到指定模型",
|
|
262
|
+
" /model off 恢复全局默认模型",
|
|
263
|
+
"",
|
|
264
|
+
"── 状态信息 ─────────────────────",
|
|
265
|
+
" /status Relay & Plugin 连接状态",
|
|
266
|
+
" /context 当前会话上下文信息(含模型)",
|
|
267
|
+
"",
|
|
268
|
+
"── Skills ───────────────────────",
|
|
269
|
+
" /skill <name> [input] 调用指定 skill",
|
|
270
|
+
"",
|
|
271
|
+
"── 帮助 ─────────────────────────",
|
|
272
|
+
" /help 常用命令速览",
|
|
273
|
+
" /commands 本列表",
|
|
274
|
+
].join("\n");
|
|
275
|
+
// ── 图片下载 ──────────────────────────────────────────────────────────────────
|
|
80
276
|
async function downloadImageToTemp(url, index) {
|
|
81
277
|
return new Promise((resolve) => {
|
|
82
278
|
try {
|
|
@@ -91,10 +287,7 @@ async function downloadImageToTemp(url, index) {
|
|
|
91
287
|
return;
|
|
92
288
|
}
|
|
93
289
|
res.pipe(file);
|
|
94
|
-
file.on("finish", () => {
|
|
95
|
-
file.close();
|
|
96
|
-
resolve(tmpPath);
|
|
97
|
-
});
|
|
290
|
+
file.on("finish", () => { file.close(); resolve(tmpPath); });
|
|
98
291
|
});
|
|
99
292
|
req.on("error", () => { file.close(); resolve(null); });
|
|
100
293
|
req.setTimeout(15000, () => { req.destroy(); file.close(); resolve(null); });
|
|
@@ -104,12 +297,12 @@ async function downloadImageToTemp(url, index) {
|
|
|
104
297
|
}
|
|
105
298
|
});
|
|
106
299
|
}
|
|
107
|
-
|
|
108
|
-
* 把 iOS 消息路由到 Agent,流式回复(核心逻辑不变)
|
|
109
|
-
*/
|
|
300
|
+
// ── Agent 路由 ────────────────────────────────────────────────────────────────
|
|
110
301
|
async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
|
|
111
302
|
const core = getLinsoRuntime();
|
|
112
|
-
//
|
|
303
|
+
// 清除 stop 标志(新消息开始)
|
|
304
|
+
deviceStopFlags.delete(deviceId);
|
|
305
|
+
// 下载图片到本地临时文件
|
|
113
306
|
const localPaths = [];
|
|
114
307
|
if (imageUrls.length > 0) {
|
|
115
308
|
log(`[Linso] 下载 ${imageUrls.length} 张图片...`);
|
|
@@ -120,14 +313,20 @@ async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
|
|
|
120
313
|
}
|
|
121
314
|
log(`[Linso] 下载完成: ${localPaths.length}/${imageUrls.length}`);
|
|
122
315
|
}
|
|
316
|
+
// 使用带 session counter 的 peerId,实现会话隔离
|
|
317
|
+
const peerId = getDevicePeerId(deviceId);
|
|
123
318
|
const route = core.channel.routing.resolveAgentRoute({
|
|
124
319
|
cfg,
|
|
125
320
|
channel: "linso",
|
|
126
321
|
accountId: "default",
|
|
127
|
-
peer: { kind: "direct", id:
|
|
322
|
+
peer: { kind: "direct", id: peerId },
|
|
128
323
|
});
|
|
129
|
-
|
|
130
|
-
|
|
324
|
+
// 模型覆盖:patchAgentModel 修改 cfg.agents.list 里对应 agent 的 model 字段
|
|
325
|
+
// 经实测 resolveDefaultModelForAgent 会读取此字段,传 provider/model 格式即可生效
|
|
326
|
+
const modelOverride = deviceModelOverrides.get(deviceId);
|
|
327
|
+
const effectiveCfg = modelOverride ? patchAgentModel(cfg, route.agentId, modelOverride) : cfg;
|
|
328
|
+
log(`[Linso] 路由: ${deviceId} → session=${route.sessionKey} agent=${route.agentId}${modelOverride ? ` model=${modelOverride}` : ""}`);
|
|
329
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(effectiveCfg);
|
|
131
330
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
132
331
|
channel: "Linso",
|
|
133
332
|
from: deviceId,
|
|
@@ -162,6 +361,11 @@ async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
|
|
|
162
361
|
const runId = `linso-${Date.now()}`;
|
|
163
362
|
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
|
164
363
|
deliver: async (payload, info) => {
|
|
364
|
+
// 检查 stop 标志
|
|
365
|
+
if (deviceStopFlags.get(deviceId)) {
|
|
366
|
+
log(`[Linso] stop 标志命中,跳过输出: deviceId=${deviceId}`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
165
369
|
if (!payload.text || payload.isReasoning)
|
|
166
370
|
return;
|
|
167
371
|
sendToClient(deviceId, {
|
|
@@ -177,7 +381,7 @@ async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
|
|
|
177
381
|
onSettled: () => markDispatchIdle(),
|
|
178
382
|
run: () => core.channel.reply.dispatchReplyFromConfig({
|
|
179
383
|
ctx: inboundCtx,
|
|
180
|
-
cfg,
|
|
384
|
+
cfg: effectiveCfg,
|
|
181
385
|
dispatcher,
|
|
182
386
|
replyOptions,
|
|
183
387
|
}),
|
|
@@ -185,27 +389,120 @@ async function handleIncomingMessage(deviceId, text, imageUrls, cfg, log) {
|
|
|
185
389
|
sendToClient(deviceId, { type: "done", runId });
|
|
186
390
|
log(`[Linso] 回复完成: deviceId=${deviceId}`);
|
|
187
391
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
392
|
+
// ── 工具函数 ──────────────────────────────────────────────────────────────────
|
|
393
|
+
/**
|
|
394
|
+
* 读取 sessions.json 并格式化状态卡片。
|
|
395
|
+
*/
|
|
396
|
+
async function buildStatusCard(deviceId, cfg) {
|
|
397
|
+
const core = getLinsoRuntime();
|
|
398
|
+
const peerId = getDevicePeerId(deviceId);
|
|
399
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
400
|
+
cfg,
|
|
401
|
+
channel: "linso",
|
|
402
|
+
accountId: "default",
|
|
403
|
+
peer: { kind: "direct", id: peerId },
|
|
404
|
+
});
|
|
405
|
+
const sessionKey = route.sessionKey;
|
|
406
|
+
const agentId = route.agentId ?? "main";
|
|
407
|
+
// 读取 sessions.json
|
|
408
|
+
const store = loadSessionsStore(agentId);
|
|
409
|
+
const entry = store[sessionKey] ?? null;
|
|
410
|
+
const version = core.version ?? "unknown";
|
|
411
|
+
const now = Date.now();
|
|
412
|
+
// 模型
|
|
413
|
+
const modelOverride = deviceModelOverrides.get(deviceId);
|
|
414
|
+
const modelProvider = modelOverride
|
|
415
|
+
? (modelOverride.includes("/") ? modelOverride.split("/")[0] : "")
|
|
416
|
+
: (entry?.modelProvider ?? "");
|
|
417
|
+
const modelName = modelOverride
|
|
418
|
+
? (modelOverride.includes("/") ? modelOverride.split("/")[1] : modelOverride)
|
|
419
|
+
: (entry?.model ?? "unknown");
|
|
420
|
+
const modelDisplay = modelProvider ? `${modelProvider}/${modelName}` : modelName;
|
|
421
|
+
// Tokens
|
|
422
|
+
const tokIn = entry?.inputTokens ?? 0;
|
|
423
|
+
const tokOut = entry?.outputTokens ?? 0;
|
|
424
|
+
const cacheRead = entry?.cacheRead ?? 0;
|
|
425
|
+
const cacheWrite = entry?.cacheWrite ?? 0;
|
|
426
|
+
const totalTokens = entry?.totalTokens ?? 0;
|
|
427
|
+
const contextWindow = entry?.contextTokens ?? 200000;
|
|
428
|
+
const compactions = entry?.compactionCount ?? 0;
|
|
429
|
+
// Cache hit %
|
|
430
|
+
const cacheHitPct = cacheWrite > 0 ? Math.round((cacheRead / cacheWrite) * 100) : 0;
|
|
431
|
+
const cacheReadK = Math.round(cacheRead / 1000);
|
|
432
|
+
const cacheWriteK = Math.round(cacheWrite / 1000);
|
|
433
|
+
// Context %
|
|
434
|
+
const ctxPct = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 100) : 0;
|
|
435
|
+
const totalK = Math.round(totalTokens / 1000);
|
|
436
|
+
const ctxK = Math.round(contextWindow / 1000);
|
|
437
|
+
// Updated
|
|
438
|
+
const updatedAt = entry?.updatedAt;
|
|
439
|
+
const updatedStr = updatedAt ? relativeTime(now - updatedAt) : "never";
|
|
440
|
+
const sessionCounter = deviceSessionCounters.get(deviceId) ?? 0;
|
|
441
|
+
const sessionDisplay = sessionCounter > 0 ? `${sessionKey}#${sessionCounter}` : sessionKey;
|
|
442
|
+
const lines = [
|
|
443
|
+
`🦞 OpenClaw ${version}`,
|
|
444
|
+
`🧠 Model: ${modelDisplay}`,
|
|
445
|
+
`🧮 Tokens: ${tokIn} in / ${tokOut} out`,
|
|
446
|
+
`🗄️ Cache: ${cacheHitPct}% hit · ${cacheReadK}k cached, ${cacheWriteK}k new`,
|
|
447
|
+
`📚 Context: ${totalK}k/${ctxK}k (${ctxPct}%) · 🧹 Compactions: ${compactions}`,
|
|
448
|
+
`🧵 Session: ${sessionDisplay} • updated ${updatedStr}`,
|
|
449
|
+
`⚙️ Runtime: direct · Think: low`,
|
|
450
|
+
];
|
|
451
|
+
return lines.join("\n");
|
|
452
|
+
}
|
|
453
|
+
function relativeTime(ms) {
|
|
454
|
+
const s = Math.floor(ms / 1000);
|
|
455
|
+
if (s < 10)
|
|
456
|
+
return "just now";
|
|
457
|
+
if (s < 60)
|
|
458
|
+
return `${s}s ago`;
|
|
459
|
+
const m = Math.floor(s / 60);
|
|
460
|
+
if (m < 60)
|
|
461
|
+
return `${m}m ago`;
|
|
462
|
+
const h = Math.floor(m / 60);
|
|
463
|
+
if (h < 24)
|
|
464
|
+
return `${h}h ago`;
|
|
465
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* 解析 sessions.json 路径
|
|
469
|
+
*/
|
|
470
|
+
function resolveSessionsJsonPath(agentId) {
|
|
471
|
+
return path.join(os.homedir(), ".openclaw", "agents", agentId, "sessions", "sessions.json");
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* 读取 sessions.json store
|
|
475
|
+
*/
|
|
476
|
+
function loadSessionsStore(agentId) {
|
|
477
|
+
try {
|
|
478
|
+
const raw = fs.readFileSync(resolveSessionsJsonPath(agentId), "utf-8");
|
|
479
|
+
return JSON.parse(raw);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return {};
|
|
210
483
|
}
|
|
211
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* 写回 sessions.json(用于 /status 的 buildStatusCard,只读不写)
|
|
487
|
+
*/
|
|
488
|
+
function saveSessionsStore(agentId, store) {
|
|
489
|
+
fs.writeFileSync(resolveSessionsJsonPath(agentId), JSON.stringify(store, null, 2), "utf-8");
|
|
490
|
+
}
|
|
491
|
+
// saveSessionsStore 保留给将来可能的用途,此处避免 unused 警告
|
|
492
|
+
void saveSessionsStore;
|
|
493
|
+
/**
|
|
494
|
+
* 覆盖 cfg.agents.defaults.model,这是 resolveDefaultModelForAgent 实际读取的字段。
|
|
495
|
+
* agents.list 为空时,defaults 是唯一生效的模型配置。
|
|
496
|
+
*/
|
|
497
|
+
function patchAgentModel(cfg, _agentId, modelOverride) {
|
|
498
|
+
return {
|
|
499
|
+
...cfg,
|
|
500
|
+
agents: {
|
|
501
|
+
...(cfg.agents ?? {}),
|
|
502
|
+
defaults: {
|
|
503
|
+
...(cfg.agents?.defaults ?? {}),
|
|
504
|
+
model: modelOverride,
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -98,20 +98,16 @@ export const linsoPlugin: ChannelPlugin<ResolvedLinsoAccount> = {
|
|
|
98
98
|
type: "object",
|
|
99
99
|
additionalProperties: false,
|
|
100
100
|
properties: {
|
|
101
|
-
enabled:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
agentId: { type: "string" },
|
|
106
|
-
dmPolicy: { type: "string", enum: ["open", "pairing"] },
|
|
101
|
+
enabled: { type: "boolean" },
|
|
102
|
+
appToken: { type: "string" },
|
|
103
|
+
agentId: { type: "string" },
|
|
104
|
+
dmPolicy: { type: "string", enum: ["open", "pairing"] },
|
|
107
105
|
},
|
|
108
106
|
},
|
|
109
107
|
uiHints: {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
agentId: { label: "Agent ID(默认 main)", placeholder: "main" },
|
|
114
|
-
dmPolicy: { label: "连接策略" },
|
|
108
|
+
appToken: { label: "App Token(iOS↔Relay 鉴权)", sensitive: true },
|
|
109
|
+
agentId: { label: "Agent ID(默认 main)", placeholder: "main" },
|
|
110
|
+
dmPolicy: { label: "连接策略" },
|
|
115
111
|
},
|
|
116
112
|
},
|
|
117
113
|
|
package/src/monitor.ts
CHANGED
|
@@ -19,6 +19,38 @@ export type MonitorLinsoOpts = {
|
|
|
19
19
|
abortSignal?: AbortSignal;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
// ── 每设备会话状态 ───────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** 会话计数器:/new /reset 时自增,派生新 sessionKey */
|
|
25
|
+
const deviceSessionCounters = new Map<string, number>();
|
|
26
|
+
|
|
27
|
+
/** stop 标志:/stop 时设置,deliver 回调里检查 */
|
|
28
|
+
const deviceStopFlags = new Map<string, boolean>();
|
|
29
|
+
|
|
30
|
+
/** 模型覆盖:/model <name> 时设置,undefined = 使用全局默认 */
|
|
31
|
+
const deviceModelOverrides = new Map<string, string>();
|
|
32
|
+
|
|
33
|
+
/** 当用户没指定 provider 时自动补全的默认 provider */
|
|
34
|
+
const DEFAULT_MODEL_PROVIDER = "liao-claude";
|
|
35
|
+
|
|
36
|
+
function getDevicePeerId(deviceId: string): string {
|
|
37
|
+
const n = deviceSessionCounters.get(deviceId) ?? 0;
|
|
38
|
+
return n === 0 ? deviceId : `${deviceId}#${n}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function bumpDeviceSession(deviceId: string): void {
|
|
42
|
+
deviceSessionCounters.set(deviceId, (deviceSessionCounters.get(deviceId) ?? 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── slash 命令分发结果类型 ────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
type SlashResult =
|
|
48
|
+
| { kind: "sync"; text: string } // 直接回复,不走 Agent
|
|
49
|
+
| { kind: "agent"; text: string } // 转发给 Agent 处理
|
|
50
|
+
| { kind: "async"; handler: () => Promise<string> }; // 异步处理后同步回复
|
|
51
|
+
|
|
52
|
+
// ── monitorLinsoProvider ─────────────────────────────────────────────────────
|
|
53
|
+
|
|
22
54
|
export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise<void> {
|
|
23
55
|
const cfg = opts.config;
|
|
24
56
|
if (!cfg) throw new Error("Config is required for Linso monitor");
|
|
@@ -39,7 +71,6 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
|
|
|
39
71
|
|
|
40
72
|
log(`[Linso] 连接 Relay Server: ${account.relayUrl}`);
|
|
41
73
|
|
|
42
|
-
// Plugin 主动连接 Relay(outbound,和飞书连 open.feishu.cn 完全一致)
|
|
43
74
|
connectToRelay(account.relayUrl, {
|
|
44
75
|
appToken: account.appToken,
|
|
45
76
|
log,
|
|
@@ -64,12 +95,35 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
|
|
|
64
95
|
? (msg.images as unknown[]).filter((u): u is string => typeof u === "string")
|
|
65
96
|
: [];
|
|
66
97
|
|
|
67
|
-
// 拦截 slash 命令,直接回复,不走 Agent
|
|
68
98
|
if (text.startsWith("/")) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
99
|
+
const result = handleSlashCommand(text, deviceId, cfg);
|
|
100
|
+
|
|
101
|
+
if (result.kind === "sync") {
|
|
102
|
+
const runId = `linso-${Date.now()}`;
|
|
103
|
+
sendToClient(deviceId, { type: "run_start", runId });
|
|
104
|
+
sendToClient(deviceId, { type: "final", runId, text: result.text });
|
|
105
|
+
sendToClient(deviceId, { type: "done", runId });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (result.kind === "async") {
|
|
110
|
+
const runId = `linso-${Date.now()}`;
|
|
111
|
+
sendToClient(deviceId, { type: "run_start", runId });
|
|
112
|
+
result.handler().then((text) => {
|
|
113
|
+
sendToClient(deviceId, { type: "final", runId, text });
|
|
114
|
+
sendToClient(deviceId, { type: "done", runId });
|
|
115
|
+
}).catch((err) => {
|
|
116
|
+
sendToClient(deviceId, { type: "final", runId, text: `❌ 获取状态失败: ${String(err)}` });
|
|
117
|
+
sendToClient(deviceId, { type: "done", runId });
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// kind === "agent":把处理后的文字转发给 Agent
|
|
123
|
+
void handleIncomingMessage(deviceId, result.text, [], cfg, log).catch((err) => {
|
|
124
|
+
log(`[Linso] 处理命令出错: ${String(err)}`);
|
|
125
|
+
sendToClient(deviceId, { type: "error", message: String(err) });
|
|
126
|
+
});
|
|
73
127
|
return;
|
|
74
128
|
}
|
|
75
129
|
|
|
@@ -81,7 +135,6 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
|
|
|
81
135
|
},
|
|
82
136
|
});
|
|
83
137
|
|
|
84
|
-
// 等待 abort 信号(Gateway 重启/重载时触发)
|
|
85
138
|
if (opts.abortSignal) {
|
|
86
139
|
await new Promise<void>((resolve) => {
|
|
87
140
|
opts.abortSignal!.addEventListener("abort", () => {
|
|
@@ -93,10 +146,191 @@ export async function monitorLinsoProvider(opts: MonitorLinsoOpts = {}): Promise
|
|
|
93
146
|
}
|
|
94
147
|
}
|
|
95
148
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
149
|
+
// ── slash 命令处理 ────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function handleSlashCommand(raw: string, deviceId: string, cfg: OpenClawConfig): SlashResult {
|
|
152
|
+
const parts = raw.trim().split(/\s+/);
|
|
153
|
+
const cmd = parts[0].toLowerCase();
|
|
154
|
+
const args = parts.slice(1);
|
|
155
|
+
|
|
156
|
+
switch (cmd) {
|
|
157
|
+
|
|
158
|
+
// ── Session 管理 ──────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
case "/new":
|
|
161
|
+
case "/reset": {
|
|
162
|
+
bumpDeviceSession(deviceId);
|
|
163
|
+
return {
|
|
164
|
+
kind: "sync",
|
|
165
|
+
text: [
|
|
166
|
+
"✅ 已开启新会话",
|
|
167
|
+
"之前的对话历史已清除,可以重新开始了 🦞",
|
|
168
|
+
].join("\n"),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case "/compact": {
|
|
173
|
+
// 转发给 Agent,让它自己 compact
|
|
174
|
+
const focus = args.length > 0 ? `重点关注:${args.join(" ")}` : "";
|
|
175
|
+
return {
|
|
176
|
+
kind: "agent",
|
|
177
|
+
text: [
|
|
178
|
+
"[系统指令] 请将当前对话历史压缩为简洁摘要,保留关键决策和上下文,删除冗余细节。",
|
|
179
|
+
focus,
|
|
180
|
+
].filter(Boolean).join("\n"),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case "/stop": {
|
|
185
|
+
deviceStopFlags.set(deviceId, true);
|
|
186
|
+
return {
|
|
187
|
+
kind: "sync",
|
|
188
|
+
text: "🛑 已发送停止信号,当前任务将在下一个输出节点终止。",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── 模型切换 ──────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
case "/model": {
|
|
195
|
+
if (args.length === 0) {
|
|
196
|
+
const current = deviceModelOverrides.get(deviceId);
|
|
197
|
+
return {
|
|
198
|
+
kind: "sync",
|
|
199
|
+
text: current
|
|
200
|
+
? `🤖 当前模型:${current}\n\n用法:/model <name> 切换模型(自动补全 ${DEFAULT_MODEL_PROVIDER}/)\n /model <p/m> 指定 provider\n /model off 恢复默认`
|
|
201
|
+
: `🤖 当前模型:默认(${DEFAULT_MODEL_PROVIDER}/claude-sonnet-4-6)\n\n示例:/model claude-opus-4-5\n /model liao-claude/claude-opus-4-5`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const name = args[0].toLowerCase();
|
|
205
|
+
if (name === "off" || name === "default" || name === "reset") {
|
|
206
|
+
deviceModelOverrides.delete(deviceId);
|
|
207
|
+
return { kind: "sync", text: "✅ 已恢复默认模型" };
|
|
208
|
+
}
|
|
209
|
+
// 没有 / 时自动补全默认 provider
|
|
210
|
+
const modelInput = args[0].includes("/") ? args[0] : `${DEFAULT_MODEL_PROVIDER}/${args[0]}`;
|
|
211
|
+
deviceModelOverrides.set(deviceId, modelInput);
|
|
212
|
+
return { kind: "sync", text: `✅ 模型已切换为:${modelInput}` };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 状态信息 ──────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
case "/context": {
|
|
218
|
+
const counter = deviceSessionCounters.get(deviceId) ?? 0;
|
|
219
|
+
const model = deviceModelOverrides.get(deviceId) ?? `${DEFAULT_MODEL_PROVIDER}/claude-sonnet-4-6(默认)`;
|
|
220
|
+
return {
|
|
221
|
+
kind: "sync",
|
|
222
|
+
text: [
|
|
223
|
+
"📊 当前上下文:",
|
|
224
|
+
`• 设备 ID:${deviceId}`,
|
|
225
|
+
`• 会话版本:#${counter}`,
|
|
226
|
+
`• 模型:${model}`,
|
|
227
|
+
`• 时间:${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`,
|
|
228
|
+
"",
|
|
229
|
+
"(上下文 token 用量需在 Agent 侧查询)",
|
|
230
|
+
].join("\n"),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
case "/status": {
|
|
235
|
+
return {
|
|
236
|
+
kind: "async",
|
|
237
|
+
handler: () => buildStatusCard(deviceId, cfg),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Skill ─────────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
case "/skill": {
|
|
244
|
+
if (args.length === 0) {
|
|
245
|
+
return {
|
|
246
|
+
kind: "sync",
|
|
247
|
+
text: "⚠️ 用法:/skill <name> [input]\n示例:/skill weather 上海明天天气",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const skillName = args[0];
|
|
251
|
+
const input = args.slice(1).join(" ");
|
|
252
|
+
return {
|
|
253
|
+
kind: "agent",
|
|
254
|
+
text: input
|
|
255
|
+
? `[Skill: ${skillName}] ${input}`
|
|
256
|
+
: `请使用 skill "${skillName}" 执行任务。`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── 帮助 ──────────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
case "/commands": {
|
|
263
|
+
return {
|
|
264
|
+
kind: "sync",
|
|
265
|
+
text: FULL_COMMAND_LIST,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
case "/help": {
|
|
270
|
+
return {
|
|
271
|
+
kind: "sync",
|
|
272
|
+
text: [
|
|
273
|
+
"📋 常用命令(/commands 查看完整列表):",
|
|
274
|
+
"",
|
|
275
|
+
"Session",
|
|
276
|
+
" /new 开启新会话(清除历史)",
|
|
277
|
+
" /reset 同 /new",
|
|
278
|
+
" /compact [focus] 压缩对话历史",
|
|
279
|
+
" /stop 停止当前任务",
|
|
280
|
+
"",
|
|
281
|
+
"模型",
|
|
282
|
+
" /model 查看当前模型",
|
|
283
|
+
" /model <name> 切换模型",
|
|
284
|
+
" /model off 恢复默认",
|
|
285
|
+
"",
|
|
286
|
+
"状态",
|
|
287
|
+
" /status 连接状态",
|
|
288
|
+
" /context 会话上下文信息",
|
|
289
|
+
"",
|
|
290
|
+
"Skills",
|
|
291
|
+
" /skill <name> [input]",
|
|
292
|
+
"",
|
|
293
|
+
"直接发消息即可和 AI 对话 🦞",
|
|
294
|
+
].join("\n"),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
default:
|
|
299
|
+
return {
|
|
300
|
+
kind: "sync",
|
|
301
|
+
text: `未知命令:${raw}\n输入 /help 查看常用命令,/commands 查看完整列表`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const FULL_COMMAND_LIST = [
|
|
307
|
+
"📋 完整命令列表:",
|
|
308
|
+
"",
|
|
309
|
+
"── Session ──────────────────────",
|
|
310
|
+
" /new 开启新会话(清除历史)",
|
|
311
|
+
" /reset 同 /new",
|
|
312
|
+
" /compact [focus] 压缩对话历史,可指定关注点",
|
|
313
|
+
" /stop 停止当前正在运行的任务",
|
|
314
|
+
"",
|
|
315
|
+
"── 模型 ─────────────────────────",
|
|
316
|
+
" /model 查看当前模型",
|
|
317
|
+
" /model <name> 切换到指定模型",
|
|
318
|
+
" /model off 恢复全局默认模型",
|
|
319
|
+
"",
|
|
320
|
+
"── 状态信息 ─────────────────────",
|
|
321
|
+
" /status Relay & Plugin 连接状态",
|
|
322
|
+
" /context 当前会话上下文信息(含模型)",
|
|
323
|
+
"",
|
|
324
|
+
"── Skills ───────────────────────",
|
|
325
|
+
" /skill <name> [input] 调用指定 skill",
|
|
326
|
+
"",
|
|
327
|
+
"── 帮助 ─────────────────────────",
|
|
328
|
+
" /help 常用命令速览",
|
|
329
|
+
" /commands 本列表",
|
|
330
|
+
].join("\n");
|
|
331
|
+
|
|
332
|
+
// ── 图片下载 ──────────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
100
334
|
async function downloadImageToTemp(url: string, index: number): Promise<string | null> {
|
|
101
335
|
return new Promise((resolve) => {
|
|
102
336
|
try {
|
|
@@ -106,16 +340,9 @@ async function downloadImageToTemp(url: string, index: number): Promise<string |
|
|
|
106
340
|
const client = url.startsWith("https") ? https : http;
|
|
107
341
|
|
|
108
342
|
const req = client.get(url, (res) => {
|
|
109
|
-
if (res.statusCode !== 200) {
|
|
110
|
-
file.close();
|
|
111
|
-
resolve(null);
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
343
|
+
if (res.statusCode !== 200) { file.close(); resolve(null); return; }
|
|
114
344
|
res.pipe(file);
|
|
115
|
-
file.on("finish", () => {
|
|
116
|
-
file.close();
|
|
117
|
-
resolve(tmpPath);
|
|
118
|
-
});
|
|
345
|
+
file.on("finish", () => { file.close(); resolve(tmpPath); });
|
|
119
346
|
});
|
|
120
347
|
req.on("error", () => { file.close(); resolve(null); });
|
|
121
348
|
req.setTimeout(15000, () => { req.destroy(); file.close(); resolve(null); });
|
|
@@ -125,9 +352,8 @@ async function downloadImageToTemp(url: string, index: number): Promise<string |
|
|
|
125
352
|
});
|
|
126
353
|
}
|
|
127
354
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
*/
|
|
355
|
+
// ── Agent 路由 ────────────────────────────────────────────────────────────────
|
|
356
|
+
|
|
131
357
|
async function handleIncomingMessage(
|
|
132
358
|
deviceId: string,
|
|
133
359
|
text: string,
|
|
@@ -137,27 +363,36 @@ async function handleIncomingMessage(
|
|
|
137
363
|
): Promise<void> {
|
|
138
364
|
const core = getLinsoRuntime();
|
|
139
365
|
|
|
140
|
-
//
|
|
366
|
+
// 清除 stop 标志(新消息开始)
|
|
367
|
+
deviceStopFlags.delete(deviceId);
|
|
368
|
+
|
|
369
|
+
// 下载图片到本地临时文件
|
|
141
370
|
const localPaths: string[] = [];
|
|
142
371
|
if (imageUrls.length > 0) {
|
|
143
372
|
log(`[Linso] 下载 ${imageUrls.length} 张图片...`);
|
|
144
373
|
const results = await Promise.all(imageUrls.map((url, i) => downloadImageToTemp(url, i)));
|
|
145
|
-
for (const p of results) {
|
|
146
|
-
if (p) localPaths.push(p);
|
|
147
|
-
}
|
|
374
|
+
for (const p of results) { if (p) localPaths.push(p); }
|
|
148
375
|
log(`[Linso] 下载完成: ${localPaths.length}/${imageUrls.length}`);
|
|
149
376
|
}
|
|
150
377
|
|
|
378
|
+
// 使用带 session counter 的 peerId,实现会话隔离
|
|
379
|
+
const peerId = getDevicePeerId(deviceId);
|
|
380
|
+
|
|
151
381
|
const route = core.channel.routing.resolveAgentRoute({
|
|
152
382
|
cfg,
|
|
153
383
|
channel: "linso",
|
|
154
384
|
accountId: "default",
|
|
155
|
-
peer: { kind: "direct", id:
|
|
385
|
+
peer: { kind: "direct", id: peerId },
|
|
156
386
|
});
|
|
157
387
|
|
|
158
|
-
|
|
388
|
+
// 模型覆盖:patchAgentModel 修改 cfg.agents.list 里对应 agent 的 model 字段
|
|
389
|
+
// 经实测 resolveDefaultModelForAgent 会读取此字段,传 provider/model 格式即可生效
|
|
390
|
+
const modelOverride = deviceModelOverrides.get(deviceId);
|
|
391
|
+
const effectiveCfg = modelOverride ? patchAgentModel(cfg, route.agentId, modelOverride) : cfg;
|
|
392
|
+
|
|
393
|
+
log(`[Linso] 路由: ${deviceId} → session=${route.sessionKey} agent=${route.agentId}${modelOverride ? ` model=${modelOverride}` : ""}`);
|
|
159
394
|
|
|
160
|
-
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(
|
|
395
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(effectiveCfg);
|
|
161
396
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
162
397
|
channel: "Linso",
|
|
163
398
|
from: deviceId,
|
|
@@ -196,6 +431,11 @@ async function handleIncomingMessage(
|
|
|
196
431
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
197
432
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
198
433
|
deliver: async (payload, info) => {
|
|
434
|
+
// 检查 stop 标志
|
|
435
|
+
if (deviceStopFlags.get(deviceId)) {
|
|
436
|
+
log(`[Linso] stop 标志命中,跳过输出: deviceId=${deviceId}`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
199
439
|
if (!payload.text || payload.isReasoning) return;
|
|
200
440
|
sendToClient(deviceId, {
|
|
201
441
|
type: info.kind === "final" ? "final" : "delta",
|
|
@@ -213,7 +453,7 @@ async function handleIncomingMessage(
|
|
|
213
453
|
run: () =>
|
|
214
454
|
core.channel.reply.dispatchReplyFromConfig({
|
|
215
455
|
ctx: inboundCtx,
|
|
216
|
-
cfg,
|
|
456
|
+
cfg: effectiveCfg,
|
|
217
457
|
dispatcher,
|
|
218
458
|
replyOptions,
|
|
219
459
|
}),
|
|
@@ -223,29 +463,133 @@ async function handleIncomingMessage(
|
|
|
223
463
|
log(`[Linso] 回复完成: deviceId=${deviceId}`);
|
|
224
464
|
}
|
|
225
465
|
|
|
226
|
-
|
|
227
|
-
const cmd = text.toLowerCase().split(/\s+/)[0];
|
|
228
|
-
switch (cmd) {
|
|
229
|
-
case "/help":
|
|
230
|
-
return [
|
|
231
|
-
"📋 可用命令:",
|
|
232
|
-
"/help — 显示帮助",
|
|
233
|
-
"/status — 查看连接状态",
|
|
234
|
-
"/clear — 提示清除会话(实际需在 App 操作)",
|
|
235
|
-
"",
|
|
236
|
-
"直接发消息即可和 AI 对话 🦞",
|
|
237
|
-
].join("\n");
|
|
238
|
-
|
|
239
|
-
case "/status":
|
|
240
|
-
return [
|
|
241
|
-
"✅ 连接状态:",
|
|
242
|
-
"• Relay Server:已连接",
|
|
243
|
-
"• Plugin:已就绪",
|
|
244
|
-
"• AI Agent:可用",
|
|
245
|
-
`• 时间:${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`,
|
|
246
|
-
].join("\n");
|
|
466
|
+
// ── 工具函数 ──────────────────────────────────────────────────────────────────
|
|
247
467
|
|
|
248
|
-
|
|
249
|
-
|
|
468
|
+
/**
|
|
469
|
+
* 读取 sessions.json 并格式化状态卡片。
|
|
470
|
+
*/
|
|
471
|
+
async function buildStatusCard(deviceId: string, cfg: OpenClawConfig): Promise<string> {
|
|
472
|
+
const core = getLinsoRuntime();
|
|
473
|
+
const peerId = getDevicePeerId(deviceId);
|
|
474
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
475
|
+
cfg,
|
|
476
|
+
channel: "linso",
|
|
477
|
+
accountId: "default",
|
|
478
|
+
peer: { kind: "direct", id: peerId },
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const sessionKey = route.sessionKey;
|
|
482
|
+
const agentId = route.agentId ?? "main";
|
|
483
|
+
|
|
484
|
+
// 读取 sessions.json
|
|
485
|
+
const store = loadSessionsStore(agentId);
|
|
486
|
+
const entry = store[sessionKey] ?? null;
|
|
487
|
+
|
|
488
|
+
const version = core.version ?? "unknown";
|
|
489
|
+
const now = Date.now();
|
|
490
|
+
|
|
491
|
+
// 模型
|
|
492
|
+
const modelOverride = deviceModelOverrides.get(deviceId);
|
|
493
|
+
const modelProvider = modelOverride
|
|
494
|
+
? (modelOverride.includes("/") ? modelOverride.split("/")[0] : "")
|
|
495
|
+
: (entry?.modelProvider as string ?? "");
|
|
496
|
+
const modelName = modelOverride
|
|
497
|
+
? (modelOverride.includes("/") ? modelOverride.split("/")[1] : modelOverride)
|
|
498
|
+
: (entry?.model as string ?? "unknown");
|
|
499
|
+
const modelDisplay = modelProvider ? `${modelProvider}/${modelName}` : modelName;
|
|
500
|
+
|
|
501
|
+
// Tokens
|
|
502
|
+
const tokIn = (entry?.inputTokens as number) ?? 0;
|
|
503
|
+
const tokOut = (entry?.outputTokens as number) ?? 0;
|
|
504
|
+
const cacheRead = (entry?.cacheRead as number) ?? 0;
|
|
505
|
+
const cacheWrite = (entry?.cacheWrite as number) ?? 0;
|
|
506
|
+
const totalTokens = (entry?.totalTokens as number) ?? 0;
|
|
507
|
+
const contextWindow = (entry?.contextTokens as number) ?? 200000;
|
|
508
|
+
const compactions = (entry?.compactionCount as number) ?? 0;
|
|
509
|
+
|
|
510
|
+
// Cache hit %
|
|
511
|
+
const cacheHitPct = cacheWrite > 0 ? Math.round((cacheRead / cacheWrite) * 100) : 0;
|
|
512
|
+
const cacheReadK = Math.round(cacheRead / 1000);
|
|
513
|
+
const cacheWriteK = Math.round(cacheWrite / 1000);
|
|
514
|
+
|
|
515
|
+
// Context %
|
|
516
|
+
const ctxPct = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 100) : 0;
|
|
517
|
+
const totalK = Math.round(totalTokens / 1000);
|
|
518
|
+
const ctxK = Math.round(contextWindow / 1000);
|
|
519
|
+
|
|
520
|
+
// Updated
|
|
521
|
+
const updatedAt = entry?.updatedAt as number | undefined;
|
|
522
|
+
const updatedStr = updatedAt ? relativeTime(now - updatedAt) : "never";
|
|
523
|
+
|
|
524
|
+
const sessionCounter = deviceSessionCounters.get(deviceId) ?? 0;
|
|
525
|
+
const sessionDisplay = sessionCounter > 0 ? `${sessionKey}#${sessionCounter}` : sessionKey;
|
|
526
|
+
|
|
527
|
+
const lines = [
|
|
528
|
+
`🦞 OpenClaw ${version}`,
|
|
529
|
+
`🧠 Model: ${modelDisplay}`,
|
|
530
|
+
`🧮 Tokens: ${tokIn} in / ${tokOut} out`,
|
|
531
|
+
`🗄️ Cache: ${cacheHitPct}% hit · ${cacheReadK}k cached, ${cacheWriteK}k new`,
|
|
532
|
+
`📚 Context: ${totalK}k/${ctxK}k (${ctxPct}%) · 🧹 Compactions: ${compactions}`,
|
|
533
|
+
`🧵 Session: ${sessionDisplay} • updated ${updatedStr}`,
|
|
534
|
+
`⚙️ Runtime: direct · Think: low`,
|
|
535
|
+
];
|
|
536
|
+
|
|
537
|
+
return lines.join("\n");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function relativeTime(ms: number): string {
|
|
541
|
+
const s = Math.floor(ms / 1000);
|
|
542
|
+
if (s < 10) return "just now";
|
|
543
|
+
if (s < 60) return `${s}s ago`;
|
|
544
|
+
const m = Math.floor(s / 60);
|
|
545
|
+
if (m < 60) return `${m}m ago`;
|
|
546
|
+
const h = Math.floor(m / 60);
|
|
547
|
+
if (h < 24) return `${h}h ago`;
|
|
548
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 解析 sessions.json 路径
|
|
553
|
+
*/
|
|
554
|
+
function resolveSessionsJsonPath(agentId: string): string {
|
|
555
|
+
return path.join(os.homedir(), ".openclaw", "agents", agentId, "sessions", "sessions.json");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 读取 sessions.json store
|
|
560
|
+
*/
|
|
561
|
+
function loadSessionsStore(agentId: string): Record<string, Record<string, unknown>> {
|
|
562
|
+
try {
|
|
563
|
+
const raw = fs.readFileSync(resolveSessionsJsonPath(agentId), "utf-8");
|
|
564
|
+
return JSON.parse(raw) as Record<string, Record<string, unknown>>;
|
|
565
|
+
} catch {
|
|
566
|
+
return {};
|
|
250
567
|
}
|
|
251
568
|
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* 写回 sessions.json(用于 /status 的 buildStatusCard,只读不写)
|
|
572
|
+
*/
|
|
573
|
+
function saveSessionsStore(agentId: string, store: Record<string, Record<string, unknown>>): void {
|
|
574
|
+
fs.writeFileSync(resolveSessionsJsonPath(agentId), JSON.stringify(store, null, 2), "utf-8");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// saveSessionsStore 保留给将来可能的用途,此处避免 unused 警告
|
|
578
|
+
void saveSessionsStore;
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* 覆盖 cfg.agents.defaults.model,这是 resolveDefaultModelForAgent 实际读取的字段。
|
|
582
|
+
* agents.list 为空时,defaults 是唯一生效的模型配置。
|
|
583
|
+
*/
|
|
584
|
+
function patchAgentModel(cfg: OpenClawConfig, _agentId: string, modelOverride: string): OpenClawConfig {
|
|
585
|
+
return {
|
|
586
|
+
...cfg,
|
|
587
|
+
agents: {
|
|
588
|
+
...(cfg.agents ?? {}),
|
|
589
|
+
defaults: {
|
|
590
|
+
...(cfg.agents?.defaults ?? {}),
|
|
591
|
+
model: modelOverride,
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
} as unknown as OpenClawConfig;
|
|
595
|
+
}
|