dsh-connect 0.2.0 → 0.5.2
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 +197 -0
- package/lib/binding.d.ts +51 -0
- package/lib/binding.d.ts.map +1 -1
- package/lib/binding.js +71 -3
- package/lib/binding.js.map +1 -1
- package/lib/commands.d.ts +18 -1
- package/lib/commands.d.ts.map +1 -1
- package/lib/commands.js +66 -25
- package/lib/commands.js.map +1 -1
- package/lib/i18n.d.ts +233 -0
- package/lib/i18n.d.ts.map +1 -0
- package/lib/i18n.js +469 -0
- package/lib/i18n.js.map +1 -0
- package/lib/index.d.ts +39 -3
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +14 -2
- package/lib/index.js.map +1 -1
- package/lib/interaction.d.ts +96 -0
- package/lib/interaction.d.ts.map +1 -0
- package/lib/interaction.js +389 -0
- package/lib/interaction.js.map +1 -0
- package/lib/runner.d.ts +201 -1
- package/lib/runner.d.ts.map +1 -1
- package/lib/runner.js +1506 -170
- package/lib/runner.js.map +1 -1
- package/lib/service.d.ts +19 -0
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +128 -2
- package/lib/service.js.map +1 -1
- package/lib/types.d.ts +38 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +58 -49
- package/LICENSE +0 -21
package/lib/runner.js
CHANGED
|
@@ -5,32 +5,183 @@
|
|
|
5
5
|
* @module dsh-connect/runner
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import { existsSync, statSync } from "node:fs";
|
|
9
|
-
import {
|
|
8
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { copyFile, readFile } from "node:fs/promises";
|
|
10
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
10
11
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
11
|
-
import { installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
12
12
|
import { createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
|
|
13
13
|
import { createAsyncQueue } from "./stream.js";
|
|
14
|
-
import {
|
|
14
|
+
import { helpText, parseCommand } from "./commands.js";
|
|
15
|
+
import { messages } from "./i18n.js";
|
|
15
16
|
export function resolveConnectConfig(config) {
|
|
16
17
|
return {
|
|
17
18
|
agentPreset: config.agentPreset,
|
|
18
19
|
workDir: config.workDir,
|
|
19
20
|
workspaces: config.workspaces ?? [],
|
|
21
|
+
visionModel: config.visionModel,
|
|
22
|
+
language: config.language ?? "zh",
|
|
20
23
|
allowUsers: config.allowUsers ?? [],
|
|
21
24
|
allowChats: config.allowChats ?? [],
|
|
22
25
|
stateDir: config.stateDir,
|
|
26
|
+
autoMirror: config.autoMirror ?? true, // Enabled by default
|
|
27
|
+
streamHeartbeatMs: config.streamHeartbeatMs ?? 60_000,
|
|
28
|
+
notifyLevel: config.notifyLevel ?? "important",
|
|
29
|
+
progressTimeoutMs: config.progressTimeoutMs ?? 5 * 60_000, // Proactive progress notice after 5 min of silence
|
|
23
30
|
};
|
|
24
31
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Assemble one `assistant/chunk` event into the streaming queue with visible
|
|
34
|
+
* structure. DSH streams one block per LLM content block and block boundaries
|
|
35
|
+
* carry no newline of their own, so without separators every block (and the
|
|
36
|
+
* reasoning phase vs the final answer) would be glued together in the chat
|
|
37
|
+
* card. This inserts a blank line between blocks and before the answer, keeps
|
|
38
|
+
* the thinking hint once per turn, expands reasoning soft breaks (Feishu's
|
|
39
|
+
* streaming card collapses single `\n`), and falls back to whole-block text
|
|
40
|
+
* for blocks that arrived only via `block-end`. Every push refreshes
|
|
41
|
+
* `lastPushAt` so the liveness heartbeat knows the card is still moving.
|
|
42
|
+
* @param state - the turn's mutable assembly state.
|
|
43
|
+
* @param thinkingHint - localized hint text pushed at the first reasoning delta.
|
|
44
|
+
* @param chunk - the assistant chunk event's `chunk` payload.
|
|
45
|
+
*/
|
|
46
|
+
/** Every line-break run becomes a paragraph break (Feishu's streaming card collapses single `\n`). */
|
|
47
|
+
function expandLineBreaks(text) {
|
|
48
|
+
return text.replace(/\r\n?/g, "\n").replace(/\n+/g, "\n\n");
|
|
49
|
+
}
|
|
50
|
+
export function applyStreamChunk(state, thinkingHint, chunk, level = "full") {
|
|
51
|
+
if (chunk.type === "text-delta" || chunk.type === "reasoning-delta") {
|
|
52
|
+
const isReasoning = chunk.type === "reasoning-delta";
|
|
53
|
+
// Reasoning content is only streamed in `full` mode; `important` shows
|
|
54
|
+
// just the thinking hint and `result` shows nothing until the answer.
|
|
55
|
+
if (isReasoning && level !== "full") {
|
|
56
|
+
if (level === "important" && !state.hintPushed) {
|
|
57
|
+
state.hintPushed = true;
|
|
58
|
+
state.chunks.push(thinkingHint);
|
|
59
|
+
state.pushedAny = true;
|
|
60
|
+
state.lastPushAt = Date.now();
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (state.pushedAny) {
|
|
65
|
+
const newBlock = chunk.index !== undefined && state.lastIndex !== undefined && chunk.index !== state.lastIndex;
|
|
66
|
+
const reasoningToAnswer = !isReasoning && state.reasoning;
|
|
67
|
+
if (newBlock || reasoningToAnswer)
|
|
68
|
+
state.chunks.push("\n\n");
|
|
69
|
+
}
|
|
70
|
+
if (isReasoning) {
|
|
71
|
+
if (!state.hintPushed) {
|
|
72
|
+
state.hintPushed = true;
|
|
73
|
+
state.chunks.push(thinkingHint);
|
|
74
|
+
}
|
|
75
|
+
const text = chunk.text ?? "";
|
|
76
|
+
if (text !== "") {
|
|
77
|
+
state.chunks.push(expandLineBreaks(text));
|
|
78
|
+
}
|
|
79
|
+
state.reasoning = true;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const text = chunk.text ?? "";
|
|
83
|
+
state.chunks.push(text);
|
|
84
|
+
state.lastText += text;
|
|
85
|
+
state.reasoning = false;
|
|
86
|
+
}
|
|
87
|
+
state.lastIndex = chunk.index;
|
|
88
|
+
state.pushedAny = true;
|
|
89
|
+
state.lastPushAt = Date.now();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (chunk.type === "block-end") {
|
|
93
|
+
// Short blocks may arrive whole here with no preceding delta chunks.
|
|
94
|
+
// Fall back to the block text when this index never streamed deltas, so
|
|
95
|
+
// no content is silently dropped (guarded by index against duplication;
|
|
96
|
+
// each step restarts block indices, and a tool call resets `lastIndex`,
|
|
97
|
+
// so a same-index block-end after streamed deltas is reliably detected).
|
|
98
|
+
const index = chunk.index;
|
|
99
|
+
const blockType = chunk.block?.type;
|
|
100
|
+
const text = chunk.block?.text;
|
|
101
|
+
if (blockType === "reasoning" && level !== "full") {
|
|
102
|
+
// Reasoning block text is never streamed outside `full` mode.
|
|
103
|
+
if (level === "important" && !state.hintPushed) {
|
|
104
|
+
state.hintPushed = true;
|
|
105
|
+
state.chunks.push(thinkingHint);
|
|
106
|
+
state.pushedAny = true;
|
|
107
|
+
state.lastPushAt = Date.now();
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (typeof text === "string" && text.length > 0 && (index === undefined || index !== state.lastIndex)) {
|
|
112
|
+
if (state.pushedAny)
|
|
113
|
+
state.chunks.push("\n\n");
|
|
114
|
+
if (blockType === "reasoning" && !state.hintPushed) {
|
|
115
|
+
state.hintPushed = true;
|
|
116
|
+
state.chunks.push(thinkingHint);
|
|
117
|
+
}
|
|
118
|
+
state.chunks.push(blockType === "reasoning" ? expandLineBreaks(text) : text);
|
|
119
|
+
if (blockType !== "reasoning")
|
|
120
|
+
state.lastText += text;
|
|
121
|
+
state.lastIndex = index;
|
|
122
|
+
state.pushedAny = true;
|
|
123
|
+
state.reasoning = blockType === "reasoning";
|
|
124
|
+
state.lastPushAt = Date.now();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Push a localized tool-call status line into the streaming queue so long
|
|
130
|
+
* tool-execution stretches stay visibly alive (the Web GUI shows tool calls;
|
|
131
|
+
* the chat card should too). Ends the reasoning phase and drops the block
|
|
132
|
+
* index so the next content block gets its own separator instead of stacking
|
|
133
|
+
* one on top of the tool line's own blank lines.
|
|
134
|
+
* @param state - the turn's mutable assembly state.
|
|
135
|
+
* @param label - the localized status line, e.g. "🔧 调用工具 `pwsh` — …".
|
|
136
|
+
*/
|
|
137
|
+
export function applyToolCall(state, label) {
|
|
138
|
+
state.chunks.push(`\n\n${label}\n\n`);
|
|
139
|
+
state.reasoning = false;
|
|
140
|
+
state.lastIndex = undefined;
|
|
141
|
+
state.pushedAny = true;
|
|
142
|
+
state.lastPushAt = Date.now();
|
|
143
|
+
}
|
|
144
|
+
/** Pull a short human summary out of a tool call's `arguments` JSON (whitespace folded to one line). */
|
|
145
|
+
export function toolCallSummary(argumentsJson) {
|
|
146
|
+
if (typeof argumentsJson !== "string")
|
|
147
|
+
return undefined;
|
|
148
|
+
const fold = (value) => value.replace(/\s+/g, " ").trim();
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(argumentsJson);
|
|
151
|
+
const description = parsed?.description;
|
|
152
|
+
if (typeof description === "string" && description !== "") {
|
|
153
|
+
const folded = fold(description);
|
|
154
|
+
return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
|
|
155
|
+
}
|
|
156
|
+
for (const key of ["file_path", "path"]) {
|
|
157
|
+
const value = parsed?.[key];
|
|
158
|
+
if (typeof value === "string" && value !== "") {
|
|
159
|
+
const folded = fold(value);
|
|
160
|
+
return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Not JSON — no summary.
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
/** Pull the first question's text out of an `ask_user_question` arguments JSON (whitespace folded to one line). */
|
|
170
|
+
export function questionTextOf(argumentsJson) {
|
|
171
|
+
if (typeof argumentsJson !== "string")
|
|
172
|
+
return "";
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(argumentsJson);
|
|
175
|
+
const first = Array.isArray(parsed?.questions) ? parsed.questions[0] : undefined;
|
|
176
|
+
const text = typeof first?.question === "string" ? first.question : "";
|
|
177
|
+
const folded = text.replace(/\s+/g, " ").trim();
|
|
178
|
+
return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Not JSON — no question text.
|
|
182
|
+
}
|
|
183
|
+
return "";
|
|
184
|
+
}
|
|
34
185
|
function mapReason(kind) {
|
|
35
186
|
switch (kind) {
|
|
36
187
|
case "completed":
|
|
@@ -50,16 +201,44 @@ export function summarizeTurn(events, firstSeq) {
|
|
|
50
201
|
let reason = "unknown";
|
|
51
202
|
let code;
|
|
52
203
|
let message;
|
|
204
|
+
let model;
|
|
205
|
+
let inputTokens = 0;
|
|
206
|
+
let outputTokens = 0;
|
|
207
|
+
let cacheReadTokens = 0;
|
|
208
|
+
let contextSize;
|
|
209
|
+
let contextWindow;
|
|
210
|
+
let steps = 0;
|
|
211
|
+
let turnStartTime;
|
|
212
|
+
let turnEndTime;
|
|
53
213
|
for (const event of events) {
|
|
54
214
|
if (event.seq < firstSeq)
|
|
55
215
|
continue;
|
|
56
216
|
if (event.type === "turn/start") {
|
|
57
217
|
started = true;
|
|
218
|
+
turnStartTime = typeof event.time === "number" ? event.time : undefined;
|
|
58
219
|
continue;
|
|
59
220
|
}
|
|
60
221
|
if (!started)
|
|
61
222
|
continue;
|
|
223
|
+
if (event.type === "step/start")
|
|
224
|
+
steps += 1;
|
|
225
|
+
if (event.type === "request/context") {
|
|
226
|
+
const provider = typeof event.data.provider === "string" ? event.data.provider : undefined;
|
|
227
|
+
const m = typeof event.data.model === "string" ? event.data.model : undefined;
|
|
228
|
+
if (provider !== undefined && m !== undefined)
|
|
229
|
+
model = `${provider}/${m}`;
|
|
230
|
+
if (typeof event.data.contextWindow === "number")
|
|
231
|
+
contextWindow = event.data.contextWindow;
|
|
232
|
+
}
|
|
62
233
|
if (event.type === "assistant/message") {
|
|
234
|
+
const usage = event.data.usage;
|
|
235
|
+
if (usage !== undefined) {
|
|
236
|
+
inputTokens += typeof usage.inputTokens === "number" ? usage.inputTokens : 0;
|
|
237
|
+
outputTokens += typeof usage.outputTokens === "number" ? usage.outputTokens : 0;
|
|
238
|
+
cacheReadTokens += typeof usage.cacheReadTokens === "number" ? usage.cacheReadTokens : 0;
|
|
239
|
+
if (typeof usage.inputTokens === "number")
|
|
240
|
+
contextSize = usage.inputTokens;
|
|
241
|
+
}
|
|
63
242
|
const joined = event.data.message.content
|
|
64
243
|
.filter((block) => block.type === "text")
|
|
65
244
|
.map((block) => block.text)
|
|
@@ -68,6 +247,7 @@ export function summarizeTurn(events, firstSeq) {
|
|
|
68
247
|
text = joined;
|
|
69
248
|
}
|
|
70
249
|
if (event.type === "turn/end") {
|
|
250
|
+
turnEndTime = typeof event.time === "number" ? event.time : undefined;
|
|
71
251
|
reason = mapReason(event.data.reason.kind);
|
|
72
252
|
if (event.data.reason.kind === "error") {
|
|
73
253
|
code = event.data.reason.error.code;
|
|
@@ -75,11 +255,31 @@ export function summarizeTurn(events, firstSeq) {
|
|
|
75
255
|
}
|
|
76
256
|
}
|
|
77
257
|
}
|
|
78
|
-
|
|
258
|
+
const elapsedMs = turnStartTime !== undefined && turnEndTime !== undefined && turnEndTime >= turnStartTime
|
|
259
|
+
? turnEndTime - turnStartTime
|
|
260
|
+
: undefined;
|
|
261
|
+
return {
|
|
262
|
+
reason,
|
|
263
|
+
text,
|
|
264
|
+
...(code === undefined ? {} : { code }),
|
|
265
|
+
...(message === undefined ? {} : { message }),
|
|
266
|
+
...(model === undefined ? {} : { model }),
|
|
267
|
+
...(inputTokens > 0 ? { inputTokens } : {}),
|
|
268
|
+
...(outputTokens > 0 ? { outputTokens } : {}),
|
|
269
|
+
...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
|
|
270
|
+
...(contextSize === undefined ? {} : { contextSize }),
|
|
271
|
+
...(contextWindow === undefined ? {} : { contextWindow }),
|
|
272
|
+
...(steps > 0 ? { steps } : {}),
|
|
273
|
+
...(elapsedMs === undefined ? {} : { elapsedMs }),
|
|
274
|
+
};
|
|
79
275
|
}
|
|
80
276
|
function truncate(text, max = 500) {
|
|
81
277
|
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
82
278
|
}
|
|
279
|
+
/** Thousands-separated token count for display. */
|
|
280
|
+
function fmtTokens(n) {
|
|
281
|
+
return n.toLocaleString("en-US");
|
|
282
|
+
}
|
|
83
283
|
function textOf(message) {
|
|
84
284
|
if (message === undefined)
|
|
85
285
|
return "";
|
|
@@ -88,15 +288,58 @@ function textOf(message) {
|
|
|
88
288
|
.map((b) => b.text ?? "")
|
|
89
289
|
.join("");
|
|
90
290
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
291
|
+
/** Lightweight fold of `schedule/change` events (create / delete / dispatch). */
|
|
292
|
+
function foldReminders(events) {
|
|
293
|
+
const active = new Map();
|
|
294
|
+
for (const e of events) {
|
|
295
|
+
// `schedule/change` is a plugin-extended event type outside dsh-session's map.
|
|
296
|
+
const evt = e;
|
|
297
|
+
if (evt.type !== "schedule/change")
|
|
298
|
+
continue;
|
|
299
|
+
const d = evt.data;
|
|
300
|
+
if (d.version !== 1)
|
|
301
|
+
continue;
|
|
302
|
+
if (d.operation === "create" && d.schedule !== undefined) {
|
|
303
|
+
active.set(d.schedule.id, {
|
|
304
|
+
id: d.schedule.id,
|
|
305
|
+
kind: d.schedule.kind,
|
|
306
|
+
prompt: d.schedule.prompt,
|
|
307
|
+
scheduledAt: d.schedule.scheduledAt,
|
|
308
|
+
everySeconds: d.schedule.everySeconds,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
else if (d.operation === "delete" && d.id !== undefined) {
|
|
312
|
+
active.delete(d.id);
|
|
313
|
+
}
|
|
314
|
+
else if (d.operation === "dispatch" && d.id !== undefined) {
|
|
315
|
+
const rec = active.get(d.id);
|
|
316
|
+
if (rec === undefined)
|
|
317
|
+
continue;
|
|
318
|
+
if (rec.kind === "every" && rec.everySeconds !== undefined && d.acceptedAt !== undefined) {
|
|
319
|
+
const next = Date.parse(d.acceptedAt) + rec.everySeconds * 1000;
|
|
320
|
+
active.set(d.id, { ...rec, scheduledAt: new Date(next).toISOString() });
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
active.delete(d.id);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return [...active.values()];
|
|
328
|
+
}
|
|
329
|
+
/** Sniff an image's media type from its magic bytes (defaults to PNG). */
|
|
330
|
+
function detectImageMediaType(buf) {
|
|
331
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
|
|
332
|
+
return "image/png";
|
|
333
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
|
|
334
|
+
return "image/jpeg";
|
|
335
|
+
if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP")
|
|
336
|
+
return "image/webp";
|
|
337
|
+
if (buf.length >= 6 && buf.toString("ascii", 0, 3) === "GIF")
|
|
338
|
+
return "image/gif";
|
|
339
|
+
return "image/png";
|
|
340
|
+
}
|
|
341
|
+
/** How often the proactive progress watchdog re-checks whether a status card is due (ms). */
|
|
342
|
+
const PROGRESS_WATCHDOG_CHECK_MS = 15_000;
|
|
100
343
|
export class AgentRunner {
|
|
101
344
|
ctx;
|
|
102
345
|
config;
|
|
@@ -105,6 +348,7 @@ export class AgentRunner {
|
|
|
105
348
|
chatType;
|
|
106
349
|
adapter;
|
|
107
350
|
bindings;
|
|
351
|
+
adapters;
|
|
108
352
|
queue = [];
|
|
109
353
|
running = false;
|
|
110
354
|
agent;
|
|
@@ -113,7 +357,11 @@ export class AgentRunner {
|
|
|
113
357
|
workDir;
|
|
114
358
|
streamEnabled = true;
|
|
115
359
|
summaryEnabled = true;
|
|
116
|
-
|
|
360
|
+
language;
|
|
361
|
+
notifyLevel;
|
|
362
|
+
progressTimeoutMs;
|
|
363
|
+
t;
|
|
364
|
+
constructor(ctx, config, channel, chatKey, chatType, adapter, bindings, adapters) {
|
|
117
365
|
this.ctx = ctx;
|
|
118
366
|
this.config = config;
|
|
119
367
|
this.channel = channel;
|
|
@@ -121,7 +369,62 @@ export class AgentRunner {
|
|
|
121
369
|
this.chatType = chatType;
|
|
122
370
|
this.adapter = adapter;
|
|
123
371
|
this.bindings = bindings;
|
|
372
|
+
this.adapters = adapters;
|
|
124
373
|
this.workDir = config.workDir ?? this.resolveDefaultWorkDir();
|
|
374
|
+
// A per-chat language override (set via the settings menu) wins over config.
|
|
375
|
+
const stored = this.bindings.get(channel, chatKey);
|
|
376
|
+
this.language = stored?.language ?? config.language ?? "zh";
|
|
377
|
+
this.notifyLevel = stored?.notifyLevel ?? config.notifyLevel;
|
|
378
|
+
this.progressTimeoutMs = stored?.progressTimeoutMs ?? config.progressTimeoutMs;
|
|
379
|
+
this.t = messages(this.language);
|
|
380
|
+
}
|
|
381
|
+
menuTitle(menuId) {
|
|
382
|
+
switch (menuId) {
|
|
383
|
+
case "root":
|
|
384
|
+
return this.t.menuRoot;
|
|
385
|
+
case "workspace":
|
|
386
|
+
return this.t.menuWorkspace;
|
|
387
|
+
case "chat":
|
|
388
|
+
return this.t.menuChat;
|
|
389
|
+
case "settings":
|
|
390
|
+
return this.t.menuSettings;
|
|
391
|
+
case "model":
|
|
392
|
+
return this.t.menuModel;
|
|
393
|
+
case "reasoning":
|
|
394
|
+
return this.t.menuReasoning;
|
|
395
|
+
case "notify":
|
|
396
|
+
return this.t.menuNotify;
|
|
397
|
+
case "language":
|
|
398
|
+
return this.t.menuSettingsLanguage;
|
|
399
|
+
case "progress":
|
|
400
|
+
return this.t.progressMenuTitle;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
rootMenuSections() {
|
|
404
|
+
return [
|
|
405
|
+
{ title: this.t.rootSectionWorkspace, ids: ["workspace"], columnsPerRow: 1 },
|
|
406
|
+
{ title: this.t.rootSectionChat, ids: ["chat", "history"], columnsPerRow: 1 },
|
|
407
|
+
{ title: this.t.rootSectionTask, ids: ["task", "goals", "schedule"], columnsPerRow: 2 },
|
|
408
|
+
{ title: this.t.rootSectionSystem, ids: ["status", "plugins", "compact", "settings"], columnsPerRow: 2 },
|
|
409
|
+
];
|
|
410
|
+
}
|
|
411
|
+
reasonLabel(reason) {
|
|
412
|
+
switch (reason) {
|
|
413
|
+
case "completed":
|
|
414
|
+
return this.t.reasonCompleted;
|
|
415
|
+
case "aborted":
|
|
416
|
+
return this.t.reasonAborted;
|
|
417
|
+
case "blocked":
|
|
418
|
+
return this.t.reasonBlocked;
|
|
419
|
+
case "error":
|
|
420
|
+
return this.t.reasonError;
|
|
421
|
+
case "max-tokens":
|
|
422
|
+
return this.t.reasonMaxTokens;
|
|
423
|
+
case "interrupted":
|
|
424
|
+
return this.t.reasonInterrupted;
|
|
425
|
+
default:
|
|
426
|
+
return this.t.reasonUnknown;
|
|
427
|
+
}
|
|
125
428
|
}
|
|
126
429
|
/** Prefer the user's first DSH workspace over the process cwd as default. */
|
|
127
430
|
resolveDefaultWorkDir() {
|
|
@@ -149,14 +452,37 @@ export class AgentRunner {
|
|
|
149
452
|
return;
|
|
150
453
|
if (event.type === "assistant/chunk") {
|
|
151
454
|
const chunk = event.data.chunk;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
turn.
|
|
455
|
+
// First reasoning delta = the milestone the proactive progress notice reports.
|
|
456
|
+
if (chunk.type === "reasoning-delta" && turn.milestone === undefined) {
|
|
457
|
+
turn.milestone = this.t.progressThinking;
|
|
155
458
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
459
|
+
applyStreamChunk(turn, this.t.thinkingHint, chunk, this.notifyLevel);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (event.type === "tool/call") {
|
|
463
|
+
const name = event.data.name;
|
|
464
|
+
if (typeof name !== "string" || name === "")
|
|
465
|
+
return;
|
|
466
|
+
const summary = toolCallSummary(event.data.arguments);
|
|
467
|
+
// The proactive progress notice reports the latest milestone even when
|
|
468
|
+
// `result` mode hides the streaming details.
|
|
469
|
+
if (name === "ask_user_question") {
|
|
470
|
+
const question = questionTextOf(event.data.arguments);
|
|
471
|
+
turn.milestone = this.t.questionToolCall(question);
|
|
159
472
|
}
|
|
473
|
+
else {
|
|
474
|
+
turn.milestone = this.t.toolCalling(name, undefined);
|
|
475
|
+
}
|
|
476
|
+
// `result` mode shows nothing until the final answer; `important` shows
|
|
477
|
+
// the tool name only, `full` also carries the one-line summary.
|
|
478
|
+
if (this.notifyLevel === "result")
|
|
479
|
+
return;
|
|
480
|
+
const label = name === "ask_user_question"
|
|
481
|
+
? this.t.questionToolCall(questionTextOf(event.data.arguments))
|
|
482
|
+
: this.notifyLevel === "full"
|
|
483
|
+
? this.t.toolCalling(name, summary)
|
|
484
|
+
: this.t.toolCalling(name, undefined);
|
|
485
|
+
applyToolCall(turn, label);
|
|
160
486
|
}
|
|
161
487
|
}
|
|
162
488
|
target(msg) {
|
|
@@ -183,18 +509,51 @@ export class AgentRunner {
|
|
|
183
509
|
}
|
|
184
510
|
}
|
|
185
511
|
async runTurn(msg) {
|
|
512
|
+
// Check session lock - only allow if this channel owns the lock or no lock exists
|
|
513
|
+
const currentChannel = this.channel === "feishu" ? "feishu" : "web";
|
|
514
|
+
// Check timeout before attempting to acquire
|
|
515
|
+
this.checkAndReleaseTimeoutLock();
|
|
516
|
+
if (!this.acquireLock(currentChannel)) {
|
|
517
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
518
|
+
const lockedBy = binding?.lockOwner ?? "unknown";
|
|
519
|
+
// If from Web channel, queue the message instead of rejecting
|
|
520
|
+
if (currentChannel === "web") {
|
|
521
|
+
const position = this.queueMessage(msg);
|
|
522
|
+
await this.adapter
|
|
523
|
+
.sendText(this.target(msg), this.t.messageQueued(position))
|
|
524
|
+
.catch(() => undefined);
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
// Feishu gets immediate feedback
|
|
528
|
+
await this.adapter
|
|
529
|
+
.sendText(this.target(msg), this.t.sessionLockedBy(lockedBy))
|
|
530
|
+
.catch(() => undefined);
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
186
534
|
try {
|
|
535
|
+
// Acknowledge receipt before the (possibly slow) agent spin-up so the
|
|
536
|
+
// user knows processing started.
|
|
537
|
+
await this.adapter
|
|
538
|
+
.sendText(this.target(msg), this.t.processingStarted(truncate(msg.text, 60)))
|
|
539
|
+
.catch(() => undefined);
|
|
187
540
|
const agent = await this.ensureAgent(msg);
|
|
188
541
|
const outcome = await this.driveAgent(agent, msg);
|
|
189
542
|
this.touchBinding(msg);
|
|
543
|
+
// Task-end stats: model, tokens, elapsed, and a compaction suggestion.
|
|
544
|
+
await this.sendTurnStats(msg, outcome);
|
|
545
|
+
// Release lock after task completion
|
|
546
|
+
this.releaseLock();
|
|
190
547
|
if (outcome.reason !== "completed" && this.summaryEnabled) {
|
|
191
548
|
await this.sendSummary(msg, outcome);
|
|
192
549
|
}
|
|
193
550
|
}
|
|
194
551
|
catch (error) {
|
|
552
|
+
// Release lock even on error
|
|
553
|
+
this.releaseLock();
|
|
195
554
|
const detail = error instanceof Error ? error.message : String(error);
|
|
196
555
|
await this.adapter
|
|
197
|
-
.sendText(this.target(msg),
|
|
556
|
+
.sendText(this.target(msg), this.t.processingFailed(truncate(detail)))
|
|
198
557
|
.catch(() => undefined);
|
|
199
558
|
}
|
|
200
559
|
}
|
|
@@ -204,6 +563,10 @@ export class AgentRunner {
|
|
|
204
563
|
const live = binding === undefined ? undefined : this.agents.get(SessionId(binding.sessionId));
|
|
205
564
|
if (live !== undefined) {
|
|
206
565
|
this.agent = live;
|
|
566
|
+
// The live agent may have been recreated outside the runner (e.g. by
|
|
567
|
+
// the Web GUI's api-proxy) since we last saw it — make sure we are
|
|
568
|
+
// listening to the current instance so streaming can't silently die.
|
|
569
|
+
this.watchAgent(live);
|
|
207
570
|
return live;
|
|
208
571
|
}
|
|
209
572
|
const selection = this.defaultSelection();
|
|
@@ -218,6 +581,10 @@ export class AgentRunner {
|
|
|
218
581
|
this.handle = handle;
|
|
219
582
|
this.agent = handle.agent;
|
|
220
583
|
this.watchAgent(handle.agent);
|
|
584
|
+
// Ensure Web mirror exists for resumed sessions
|
|
585
|
+
this.autoCreateWebMirror(binding.sessionId, msg.senderKey);
|
|
586
|
+
// Ensure the session shows up under its workspace in the Web GUI
|
|
587
|
+
void this.attachToWorkspace(binding.sessionId);
|
|
221
588
|
return handle.agent;
|
|
222
589
|
}
|
|
223
590
|
catch (error) {
|
|
@@ -238,8 +605,29 @@ export class AgentRunner {
|
|
|
238
605
|
this.agent = handle.agent;
|
|
239
606
|
this.watchAgent(handle.agent);
|
|
240
607
|
this.recordSession(String(sessionId), truncate(msg.text, 40), msg.senderKey);
|
|
608
|
+
// Auto-create Web mirror for new sessions (if enabled)
|
|
609
|
+
this.autoCreateWebMirror(String(sessionId), msg.senderKey);
|
|
610
|
+
// Ensure the new session shows up under its workspace in the Web GUI
|
|
611
|
+
void this.attachToWorkspace(String(sessionId));
|
|
241
612
|
return handle.agent;
|
|
242
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Attach a session to the DSH workspace matching its work directory so the
|
|
616
|
+
* Web GUI groups it under the same workspace the Feishu side uses.
|
|
617
|
+
* Best-effort: without a workspace registry this is a no-op.
|
|
618
|
+
*/
|
|
619
|
+
async attachToWorkspace(sessionId) {
|
|
620
|
+
const registry = this.ctx.get("workspaceRegistry");
|
|
621
|
+
if (registry?.resolveByPath === undefined)
|
|
622
|
+
return;
|
|
623
|
+
try {
|
|
624
|
+
const ws = await registry.resolveByPath(this.workDir);
|
|
625
|
+
await ws?.attachSession?.(sessionId);
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
this.log(`connect: attachToWorkspace(${sessionId}) skipped: ${String(error)}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
243
631
|
// Service lookups must go through ctx.get(): property access (ctx.agents) is
|
|
244
632
|
// fiber-scoped and rejected from the async Feishu callback with "without inject".
|
|
245
633
|
get agents() {
|
|
@@ -252,37 +640,58 @@ export class AgentRunner {
|
|
|
252
640
|
const service = this.ctx.get("agentDefaultModel");
|
|
253
641
|
return service?.currentSelection?.() ?? { provider: "", model: "" };
|
|
254
642
|
}
|
|
255
|
-
|
|
643
|
+
/**
|
|
644
|
+
* Compose the preset-only agent setup.
|
|
645
|
+
*
|
|
646
|
+
* Deliberately does NOT install a static model selection on the agent. The
|
|
647
|
+
* DSH Web GUI switches models through the host api-proxy's session-scoped
|
|
648
|
+
* selection (`selectionFor` + `installModelSelection`), which is installed
|
|
649
|
+
* lazily when a session's model directory is loaded and wins the
|
|
650
|
+
* `agent/request` waterfall. A static selection captured here at create /
|
|
651
|
+
* resume time would take precedence over that and silently pin every request
|
|
652
|
+
* to the default model captured at composition — so a model the user picks
|
|
653
|
+
* in the Web GUI would appear to succeed (the seat and `session.models`
|
|
654
|
+
* report it) while the actual LLM requests keep using the old default.
|
|
655
|
+
* The default model still seeds the agent through `agentOptions` in
|
|
656
|
+
* `ensureAgent`, which `buildRequest` uses as its fallback route.
|
|
657
|
+
*/
|
|
658
|
+
async composeSetup(_selection) {
|
|
256
659
|
const presets = this.ctx.get("agentPresets");
|
|
257
660
|
if (presets === undefined) {
|
|
258
661
|
return {
|
|
259
|
-
setup: (
|
|
260
|
-
installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
261
|
-
},
|
|
662
|
+
setup: () => undefined,
|
|
262
663
|
};
|
|
263
664
|
}
|
|
264
665
|
const resolved = await presets.resolve(this.config.agentPreset);
|
|
265
666
|
return {
|
|
266
667
|
agentPreset: resolved.id,
|
|
267
668
|
setup: async (agentCtx) => {
|
|
268
|
-
installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
269
669
|
await presets.mount(agentCtx, resolved.id);
|
|
270
670
|
},
|
|
271
671
|
};
|
|
272
672
|
}
|
|
673
|
+
/** Agents this runner has already attached a `session/event` listener to. */
|
|
674
|
+
watchedAgents = new WeakSet();
|
|
273
675
|
watchAgent(agent) {
|
|
274
|
-
// Scope-filtered: receives only this agent's session events.
|
|
676
|
+
// Scope-filtered: receives only this agent's session events. Deduplicated
|
|
677
|
+
// by object so a re-resumed agent (possibly recreated outside the runner,
|
|
678
|
+
// e.g. by the Web GUI's api-proxy) is re-watched without double-firing on
|
|
679
|
+
// a still-live agent.
|
|
680
|
+
if (this.watchedAgents.has(agent))
|
|
681
|
+
return;
|
|
682
|
+
this.watchedAgents.add(agent);
|
|
275
683
|
agent.ctx.on("session/event", (session, event) => {
|
|
276
684
|
this.onSessionEvent(session, event);
|
|
277
685
|
});
|
|
278
686
|
}
|
|
279
687
|
async driveAgent(agent, msg) {
|
|
280
688
|
const firstSeq = agent.session.seq;
|
|
689
|
+
const userMessage = async () => {
|
|
690
|
+
const content = await this.buildUserContent(msg);
|
|
691
|
+
return createUserMessage({ content, source: { kind: "user" } });
|
|
692
|
+
};
|
|
281
693
|
if (!this.streamEnabled) {
|
|
282
|
-
agent.followup(
|
|
283
|
-
content: [{ type: "text", text: msg.text }],
|
|
284
|
-
source: { kind: "user" },
|
|
285
|
-
}));
|
|
694
|
+
agent.followup(await userMessage());
|
|
286
695
|
await agent.whenIdle();
|
|
287
696
|
await this.sessions.flush(agent.session);
|
|
288
697
|
const outcome = summarizeTurn(agent.session.events, firstSeq);
|
|
@@ -292,21 +701,288 @@ export class AgentRunner {
|
|
|
292
701
|
return outcome;
|
|
293
702
|
}
|
|
294
703
|
const chunks = createAsyncQueue();
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
704
|
+
const now = Date.now();
|
|
705
|
+
this.turn = {
|
|
706
|
+
firstSeq, chunks, lastText: "",
|
|
707
|
+
reasoning: false, hintPushed: false, lastIndex: undefined, pushedAny: false,
|
|
708
|
+
startedAt: now, lastPushAt: now,
|
|
709
|
+
};
|
|
710
|
+
// Liveness heartbeat: while the agent is working, keep the streaming card
|
|
711
|
+
// visibly alive even through long reasoning or tool-execution stretches
|
|
712
|
+
// (a card that sits on "Thinking…" for minutes looks frozen). Configurable
|
|
713
|
+
// via `streamHeartbeatMs`; 0 disables it.
|
|
714
|
+
const heartbeatMs = this.config.streamHeartbeatMs;
|
|
715
|
+
const heartbeat = heartbeatMs > 0 && this.notifyLevel !== "result"
|
|
716
|
+
? setInterval(() => {
|
|
717
|
+
const turn = this.turn;
|
|
718
|
+
if (turn === undefined)
|
|
719
|
+
return;
|
|
720
|
+
const elapsed = Date.now() - turn.lastPushAt;
|
|
721
|
+
if (elapsed < heartbeatMs)
|
|
722
|
+
return;
|
|
723
|
+
turn.lastPushAt = Date.now();
|
|
724
|
+
const minutes = Math.max(1, Math.round((turn.lastPushAt - turn.startedAt) / 60_000));
|
|
725
|
+
turn.chunks.push(`\n\n${this.t.processingHeartbeat(minutes)}\n\n`);
|
|
726
|
+
}, heartbeatMs)
|
|
727
|
+
: undefined;
|
|
728
|
+
// Proactive progress watchdog: when no standalone card/text has been sent
|
|
729
|
+
// for `progressTimeoutMs` (default 5 min, user-configurable), send a status
|
|
730
|
+
// card reporting the latest milestone so a long turn never looks frozen.
|
|
731
|
+
// Streaming-card heartbeats deliberately do NOT reset this — the user asked
|
|
732
|
+
// for an explicit, separate nudge.
|
|
733
|
+
const progressTimeoutMs = this.progressTimeoutMs;
|
|
734
|
+
let lastStandaloneNoticeAt = now;
|
|
735
|
+
const progressWatchdog = progressTimeoutMs > 0
|
|
736
|
+
? setInterval(() => {
|
|
737
|
+
const turn = this.turn;
|
|
738
|
+
if (turn === undefined)
|
|
739
|
+
return;
|
|
740
|
+
const elapsed = Date.now() - lastStandaloneNoticeAt;
|
|
741
|
+
if (elapsed < progressTimeoutMs)
|
|
742
|
+
return;
|
|
743
|
+
lastStandaloneNoticeAt = Date.now();
|
|
744
|
+
const minutes = Math.max(1, Math.round((Date.now() - turn.startedAt) / 60_000));
|
|
745
|
+
const status = turn.milestone ?? this.t.progressThinking;
|
|
746
|
+
void this.adapter
|
|
747
|
+
.sendText(this.target(msg), this.t.progressReminder(minutes, status))
|
|
748
|
+
.catch(() => undefined);
|
|
749
|
+
}, PROGRESS_WATCHDOG_CHECK_MS)
|
|
750
|
+
: undefined;
|
|
751
|
+
try {
|
|
752
|
+
const streamPromise = this.adapter.streamText(this.target(msg), chunks);
|
|
753
|
+
agent.followup(await userMessage());
|
|
754
|
+
await agent.whenIdle();
|
|
755
|
+
await this.sessions.flush(agent.session);
|
|
756
|
+
chunks.end();
|
|
757
|
+
await streamPromise;
|
|
758
|
+
}
|
|
759
|
+
finally {
|
|
760
|
+
if (heartbeat !== undefined)
|
|
761
|
+
clearInterval(heartbeat);
|
|
762
|
+
if (progressWatchdog !== undefined)
|
|
763
|
+
clearInterval(progressWatchdog);
|
|
764
|
+
}
|
|
305
765
|
const outcome = summarizeTurn(agent.session.events, firstSeq);
|
|
306
766
|
const text = outcome.text !== "" ? outcome.text : this.turn.lastText;
|
|
307
767
|
this.turn = undefined;
|
|
308
768
|
return { ...outcome, text };
|
|
309
769
|
}
|
|
770
|
+
/**
|
|
771
|
+
* Build the user content for one inbound message. When the message carries
|
|
772
|
+
* images, either pass them directly to the main model (if it supports
|
|
773
|
+
* vision) or run a vision sub-task with a capable model and inject its
|
|
774
|
+
* description as text — so a text-only main model never stalls on images.
|
|
775
|
+
*/
|
|
776
|
+
async buildUserContent(msg) {
|
|
777
|
+
const content = [{ type: "text", text: msg.text }];
|
|
778
|
+
const rawImages = msg.images;
|
|
779
|
+
if (rawImages === undefined || rawImages.length === 0) {
|
|
780
|
+
if (msg.imageError !== undefined) {
|
|
781
|
+
content.push({ type: "text", text: this.t.imageDownloadFailed(msg.imageError) });
|
|
782
|
+
}
|
|
783
|
+
return content;
|
|
784
|
+
}
|
|
785
|
+
// Copy images into the agent's work directory so its tools can access them.
|
|
786
|
+
const paths = await this.stageImages(rawImages);
|
|
787
|
+
const locationText = paths.map((p) => `- ${p}`).join("\n");
|
|
788
|
+
const mainVision = await this.modelSupportsVision(this.agent?.options.provider, this.agent?.options.model);
|
|
789
|
+
if (mainVision) {
|
|
790
|
+
const imageBlocks = await this.imageBlocks(paths);
|
|
791
|
+
if (imageBlocks.length > 0) {
|
|
792
|
+
this.log(`connect: main model supports vision — passing ${imageBlocks.length} image block(s) directly`);
|
|
793
|
+
return [{ type: "text", text: msg.text }, ...imageBlocks];
|
|
794
|
+
}
|
|
795
|
+
this.log("connect: main model supports vision but image blocks failed to attach — falling back to paths");
|
|
796
|
+
return content;
|
|
797
|
+
}
|
|
798
|
+
const description = await this.describeImages(paths);
|
|
799
|
+
const note = this.t.imagesStaged(locationText, description);
|
|
800
|
+
content.push({ type: "text", text: note });
|
|
801
|
+
// Non-image attachments (files / audio / video): stage them into the work
|
|
802
|
+
// directory and hand the agent the paths — it can open them with its tools.
|
|
803
|
+
const rawFiles = msg.files;
|
|
804
|
+
if (rawFiles !== undefined && rawFiles.length > 0) {
|
|
805
|
+
const stagedFiles = await this.stageFiles(rawFiles);
|
|
806
|
+
content.push({
|
|
807
|
+
type: "text",
|
|
808
|
+
text: this.t.filesStaged(stagedFiles.length, stagedFiles.map((p) => `- ${p}`).join("\n")),
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
if (msg.fileError !== undefined) {
|
|
812
|
+
content.push({ type: "text", text: this.t.fileDownloadFailed(msg.fileError) });
|
|
813
|
+
}
|
|
814
|
+
return content;
|
|
815
|
+
}
|
|
816
|
+
/** Copy images into `<workDir>/.dsh-connect-images` so the agent's tools can reach them. */
|
|
817
|
+
async stageImages(paths) {
|
|
818
|
+
const dir = join(this.workDir, ".dsh-connect-images");
|
|
819
|
+
try {
|
|
820
|
+
mkdirSync(dir, { recursive: true });
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
return [...paths];
|
|
824
|
+
}
|
|
825
|
+
const staged = [];
|
|
826
|
+
for (const p of paths) {
|
|
827
|
+
const target = join(dir, basename(p));
|
|
828
|
+
try {
|
|
829
|
+
await copyFile(p, target);
|
|
830
|
+
staged.push(target);
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
staged.push(p);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return staged;
|
|
837
|
+
}
|
|
838
|
+
/** Copy non-image attachments into `<workDir>/.dsh-connect-files` so the agent's tools can reach them. */
|
|
839
|
+
async stageFiles(paths) {
|
|
840
|
+
const dir = join(this.workDir, ".dsh-connect-files");
|
|
841
|
+
try {
|
|
842
|
+
mkdirSync(dir, { recursive: true });
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return [...paths];
|
|
846
|
+
}
|
|
847
|
+
const staged = [];
|
|
848
|
+
for (const p of paths) {
|
|
849
|
+
const target = join(dir, basename(p));
|
|
850
|
+
try {
|
|
851
|
+
await copyFile(p, target);
|
|
852
|
+
staged.push(target);
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
staged.push(p);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return staged;
|
|
859
|
+
}
|
|
860
|
+
async imageBlocks(paths) {
|
|
861
|
+
const attachments = this.ctx.get("attachments");
|
|
862
|
+
if (attachments?.saveImage === undefined)
|
|
863
|
+
return [];
|
|
864
|
+
const blocks = [];
|
|
865
|
+
for (const path of paths) {
|
|
866
|
+
try {
|
|
867
|
+
const buf = await readFile(path);
|
|
868
|
+
const mediaType = detectImageMediaType(buf);
|
|
869
|
+
const ref = await attachments.saveImage({ data: new Uint8Array(buf), mediaType });
|
|
870
|
+
blocks.push({ type: "image", attachment: ref });
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
// Skip unreadable / unsupported images.
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return blocks;
|
|
877
|
+
}
|
|
878
|
+
async describeImages(paths) {
|
|
879
|
+
const vision = await this.findVisionModel();
|
|
880
|
+
if (vision === null) {
|
|
881
|
+
this.log("connect: describeImages skipped — no vision model available");
|
|
882
|
+
return "";
|
|
883
|
+
}
|
|
884
|
+
const attachments = this.ctx.get("attachments");
|
|
885
|
+
const llm = this.ctx.get("llm");
|
|
886
|
+
if (attachments?.saveImage === undefined || llm?.stream === undefined) {
|
|
887
|
+
this.log(`connect: describeImages skipped — attachments.saveImage=${attachments?.saveImage !== undefined}, llm.stream=${llm?.stream !== undefined}`);
|
|
888
|
+
return "";
|
|
889
|
+
}
|
|
890
|
+
const refs = [];
|
|
891
|
+
for (const path of paths) {
|
|
892
|
+
try {
|
|
893
|
+
const buf = await readFile(path);
|
|
894
|
+
refs.push(await attachments.saveImage({ data: new Uint8Array(buf), mediaType: detectImageMediaType(buf) }));
|
|
895
|
+
}
|
|
896
|
+
catch (error) {
|
|
897
|
+
this.log(`connect: saveImage failed for ${path}: ${String(error)}`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (refs.length === 0) {
|
|
901
|
+
this.log("connect: describeImages skipped — no image could be attached");
|
|
902
|
+
return "";
|
|
903
|
+
}
|
|
904
|
+
this.log(`connect: describing ${refs.length} image(s) with ${vision.provider}/${vision.model}`);
|
|
905
|
+
const messages = [
|
|
906
|
+
{
|
|
907
|
+
role: "user",
|
|
908
|
+
content: [
|
|
909
|
+
{ type: "text", text: this.t.visionPrompt },
|
|
910
|
+
...refs.map((ref) => ({ type: "image", attachment: ref })),
|
|
911
|
+
],
|
|
912
|
+
},
|
|
913
|
+
];
|
|
914
|
+
try {
|
|
915
|
+
let text = "";
|
|
916
|
+
const stream = llm.stream({ provider: vision.provider, model: vision.model, messages, maxTokens: 2048 });
|
|
917
|
+
for await (const chunk of stream) {
|
|
918
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string")
|
|
919
|
+
text += chunk.text;
|
|
920
|
+
}
|
|
921
|
+
this.log(`connect: vision description produced ${text.length} char(s)`);
|
|
922
|
+
return text.trim();
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
this.log(`connect: vision description call failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
926
|
+
return "";
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async modelSupportsVision(provider, model) {
|
|
930
|
+
if (provider === undefined || model === undefined)
|
|
931
|
+
return false;
|
|
932
|
+
const llm = this.ctx.get("llm");
|
|
933
|
+
try {
|
|
934
|
+
const info = await llm?.resolveModelInfo?.(provider, model);
|
|
935
|
+
const supports = info?.inputModalities?.includes("image") ?? false;
|
|
936
|
+
this.log(`connect: main model vision check ${provider}/${model} -> ${supports} (inputModalities=${JSON.stringify(info?.inputModalities)})`);
|
|
937
|
+
return supports;
|
|
938
|
+
}
|
|
939
|
+
catch (error) {
|
|
940
|
+
this.log(`connect: main model vision check ${provider}/${model} failed: ${String(error)}`);
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
async findVisionModel() {
|
|
945
|
+
if (this.config.visionModel !== undefined) {
|
|
946
|
+
this.log(`connect: vision model configured: ${this.config.visionModel.provider}/${this.config.visionModel.model}`);
|
|
947
|
+
return this.config.visionModel;
|
|
948
|
+
}
|
|
949
|
+
const llm = this.ctx.get("llm");
|
|
950
|
+
if (llm?.listProviders === undefined || llm.resolveModelInfo === undefined) {
|
|
951
|
+
this.log("connect: vision auto-detection unavailable — llm service lacks listProviders/resolveModelInfo");
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
const providers = llm.listProviders() ?? [];
|
|
955
|
+
this.log(`connect: vision auto-detection scanning ${providers.length} provider(s): ${providers.map((p) => p.id).join(", ") || "(none)"}`);
|
|
956
|
+
for (const p of providers) {
|
|
957
|
+
let models = [];
|
|
958
|
+
try {
|
|
959
|
+
models = (await llm.listModels?.(p.id)) ?? [];
|
|
960
|
+
}
|
|
961
|
+
catch (error) {
|
|
962
|
+
this.log(`connect: listModels(${p.id}) failed: ${String(error)}`);
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
this.log(`connect: provider ${p.id} advertises ${models.length} model(s)`);
|
|
966
|
+
for (const m of models) {
|
|
967
|
+
try {
|
|
968
|
+
const info = await llm.resolveModelInfo(p.id, m.id);
|
|
969
|
+
this.log(`connect: ${p.id}/${m.id} inputModalities=${JSON.stringify(info.inputModalities)}`);
|
|
970
|
+
if (info.inputModalities?.includes("image")) {
|
|
971
|
+
this.log(`connect: vision model selected: ${p.id}/${m.id}`);
|
|
972
|
+
return { provider: p.id, model: m.id };
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
catch (error) {
|
|
976
|
+
this.log(`connect: resolveModelInfo(${p.id}/${m.id}) failed: ${String(error)}`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
this.log("connect: no image-capable model found via auto-detection");
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
log(message) {
|
|
984
|
+
this.ctx.get("logger")?.info?.(message);
|
|
985
|
+
}
|
|
310
986
|
readTodos() {
|
|
311
987
|
const agent = this.agent;
|
|
312
988
|
if (agent === undefined)
|
|
@@ -326,13 +1002,42 @@ export class AgentRunner {
|
|
|
326
1002
|
this.bindings.put({ ...binding, ownerKey: msg.senderKey, lastActiveAt: Date.now() });
|
|
327
1003
|
}
|
|
328
1004
|
async sendSummary(msg, outcome) {
|
|
329
|
-
const lines = [
|
|
1005
|
+
const lines = [this.t.taskEnded(this.reasonLabel(outcome.reason))];
|
|
330
1006
|
if (outcome.code !== undefined)
|
|
331
|
-
lines.push(
|
|
1007
|
+
lines.push(this.t.errorCode(outcome.code));
|
|
332
1008
|
if (outcome.message !== undefined)
|
|
333
|
-
lines.push(
|
|
1009
|
+
lines.push(this.t.reasonMessage(truncate(outcome.message)));
|
|
334
1010
|
if (outcome.text !== "")
|
|
335
|
-
lines.push(
|
|
1011
|
+
lines.push(this.t.produced(truncate(outcome.text, 300)));
|
|
1012
|
+
const card = { markdown: lines.join("\n") };
|
|
1013
|
+
await this.adapter.sendCard(this.target(msg), card).catch(() => undefined);
|
|
1014
|
+
}
|
|
1015
|
+
/** Context-window usage at or above this percentage suggests compaction. */
|
|
1016
|
+
static COMPACT_THRESHOLD_PCT = 75;
|
|
1017
|
+
/**
|
|
1018
|
+
* Send a compact task-end card: the model used, input/output tokens, elapsed
|
|
1019
|
+
* time, and whether compaction is advisable given context-window usage.
|
|
1020
|
+
*/
|
|
1021
|
+
async sendTurnStats(msg, outcome) {
|
|
1022
|
+
if (outcome.model === undefined && outcome.inputTokens === undefined && outcome.outputTokens === undefined)
|
|
1023
|
+
return;
|
|
1024
|
+
const lines = [
|
|
1025
|
+
this.t.taskStatsHeader(outcome.elapsedMs === undefined ? "—" : this.t.taskDuration(outcome.elapsedMs)),
|
|
1026
|
+
];
|
|
1027
|
+
if (outcome.model !== undefined)
|
|
1028
|
+
lines.push(this.t.taskStatsModel(outcome.model));
|
|
1029
|
+
if (outcome.inputTokens !== undefined) {
|
|
1030
|
+
lines.push(this.t.taskStatsTokensIn(fmtTokens(outcome.inputTokens), outcome.cacheReadTokens !== undefined ? fmtTokens(outcome.cacheReadTokens) : undefined));
|
|
1031
|
+
}
|
|
1032
|
+
if (outcome.outputTokens !== undefined)
|
|
1033
|
+
lines.push(this.t.taskStatsTokensOut(fmtTokens(outcome.outputTokens)));
|
|
1034
|
+
if (outcome.steps !== undefined)
|
|
1035
|
+
lines.push(this.t.taskStatsSteps(outcome.steps));
|
|
1036
|
+
if (outcome.contextSize !== undefined && outcome.contextWindow !== undefined && outcome.contextWindow > 0) {
|
|
1037
|
+
const pct = Math.round((outcome.contextSize / outcome.contextWindow) * 100);
|
|
1038
|
+
lines.push(this.t.taskStatsContext(`${pct}`, fmtTokens(outcome.contextWindow)));
|
|
1039
|
+
lines.push(pct >= AgentRunner.COMPACT_THRESHOLD_PCT ? this.t.taskStatsCompactSuggest : this.t.taskStatsCompactOk);
|
|
1040
|
+
}
|
|
336
1041
|
const card = { markdown: lines.join("\n") };
|
|
337
1042
|
await this.adapter.sendCard(this.target(msg), card).catch(() => undefined);
|
|
338
1043
|
}
|
|
@@ -341,17 +1046,17 @@ export class AgentRunner {
|
|
|
341
1046
|
switch (command.kind) {
|
|
342
1047
|
case "new": {
|
|
343
1048
|
await this.newChat(msg);
|
|
344
|
-
await this.adapter.sendText(target,
|
|
1049
|
+
await this.adapter.sendText(target, this.t.newChatDone);
|
|
345
1050
|
break;
|
|
346
1051
|
}
|
|
347
1052
|
case "clear": {
|
|
348
1053
|
await this.clearChat(msg);
|
|
349
|
-
await this.adapter.sendText(target,
|
|
1054
|
+
await this.adapter.sendText(target, this.t.chatCleared);
|
|
350
1055
|
break;
|
|
351
1056
|
}
|
|
352
1057
|
case "stop": {
|
|
353
1058
|
this.agent?.cancel({ kind: "user" });
|
|
354
|
-
await this.adapter.sendText(target,
|
|
1059
|
+
await this.adapter.sendText(target, this.t.stopRequested);
|
|
355
1060
|
break;
|
|
356
1061
|
}
|
|
357
1062
|
case "status": {
|
|
@@ -369,22 +1074,22 @@ export class AgentRunner {
|
|
|
369
1074
|
}
|
|
370
1075
|
const path = command.path;
|
|
371
1076
|
if (!isAbsolute(path)) {
|
|
372
|
-
await this.adapter.sendText(target,
|
|
1077
|
+
await this.adapter.sendText(target, this.t.dirUsage);
|
|
373
1078
|
break;
|
|
374
1079
|
}
|
|
375
1080
|
try {
|
|
376
1081
|
if (!existsSync(path) || !statSync(path).isDirectory()) {
|
|
377
|
-
await this.adapter.sendText(target,
|
|
1082
|
+
await this.adapter.sendText(target, this.t.dirNotExists(path));
|
|
378
1083
|
break;
|
|
379
1084
|
}
|
|
380
1085
|
}
|
|
381
1086
|
catch {
|
|
382
|
-
await this.adapter.sendText(target,
|
|
1087
|
+
await this.adapter.sendText(target, this.t.dirUnreadable(path));
|
|
383
1088
|
break;
|
|
384
1089
|
}
|
|
385
1090
|
this.workDir = path;
|
|
386
1091
|
await this.newChat(msg);
|
|
387
|
-
await this.adapter.sendText(target,
|
|
1092
|
+
await this.adapter.sendText(target, this.t.dirSwitched(path));
|
|
388
1093
|
break;
|
|
389
1094
|
}
|
|
390
1095
|
case "chat": {
|
|
@@ -405,7 +1110,7 @@ export class AgentRunner {
|
|
|
405
1110
|
}
|
|
406
1111
|
case "workspace": {
|
|
407
1112
|
if (command.path === undefined) {
|
|
408
|
-
await this.adapter.sendText(target,
|
|
1113
|
+
await this.adapter.sendText(target, this.t.workspaceUsage);
|
|
409
1114
|
break;
|
|
410
1115
|
}
|
|
411
1116
|
await this.createWorkspace(command.path, target);
|
|
@@ -423,16 +1128,44 @@ export class AgentRunner {
|
|
|
423
1128
|
await this.showGoals(target);
|
|
424
1129
|
break;
|
|
425
1130
|
}
|
|
1131
|
+
case "schedule": {
|
|
1132
|
+
await this.showSchedule(target);
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
426
1135
|
case "model": {
|
|
427
1136
|
await this.openMenu(target, msg, "model", ["root"]);
|
|
428
1137
|
break;
|
|
429
1138
|
}
|
|
1139
|
+
case "notify": {
|
|
1140
|
+
await this.openNotifyPicker(target, msg);
|
|
1141
|
+
break;
|
|
1142
|
+
}
|
|
1143
|
+
case "progress": {
|
|
1144
|
+
await this.openProgressPicker(target, msg);
|
|
1145
|
+
break;
|
|
1146
|
+
}
|
|
430
1147
|
case "workspaces": {
|
|
431
1148
|
await this.showAllWorkspaces(target);
|
|
432
1149
|
break;
|
|
433
1150
|
}
|
|
1151
|
+
case "mirror": {
|
|
1152
|
+
await this.handleMirror(target, msg);
|
|
1153
|
+
break;
|
|
1154
|
+
}
|
|
1155
|
+
case "unlock": {
|
|
1156
|
+
await this.handleUnlock(target);
|
|
1157
|
+
break;
|
|
1158
|
+
}
|
|
1159
|
+
case "renew": {
|
|
1160
|
+
await this.handleRenewLock(target);
|
|
1161
|
+
break;
|
|
1162
|
+
}
|
|
1163
|
+
case "export": {
|
|
1164
|
+
await this.handleExport(target, command.format);
|
|
1165
|
+
break;
|
|
1166
|
+
}
|
|
434
1167
|
case "help": {
|
|
435
|
-
await this.adapter.sendText(target,
|
|
1168
|
+
await this.adapter.sendText(target, helpText(this.t));
|
|
436
1169
|
break;
|
|
437
1170
|
}
|
|
438
1171
|
default:
|
|
@@ -469,6 +1202,37 @@ export class AgentRunner {
|
|
|
469
1202
|
sessions,
|
|
470
1203
|
});
|
|
471
1204
|
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Automatically create a Web mirror for the session.
|
|
1207
|
+
* This enables DSH Web GUI to view and interact with the conversation
|
|
1208
|
+
* without requiring manual `/mirror` command.
|
|
1209
|
+
*/
|
|
1210
|
+
autoCreateWebMirror(sessionId, ownerKey) {
|
|
1211
|
+
// Check if auto-mirror is enabled in config
|
|
1212
|
+
if (!this.config.autoMirror)
|
|
1213
|
+
return;
|
|
1214
|
+
// Only auto-create for Feishu channel (not for Web itself)
|
|
1215
|
+
if (this.channel !== "feishu")
|
|
1216
|
+
return;
|
|
1217
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
1218
|
+
if (binding === undefined)
|
|
1219
|
+
return;
|
|
1220
|
+
// Check if mirror already exists
|
|
1221
|
+
if (binding.webMirrorSessionId !== undefined) {
|
|
1222
|
+
return; // Mirror already configured
|
|
1223
|
+
}
|
|
1224
|
+
// Auto-create mirror with default settings
|
|
1225
|
+
const defaultTimeoutMs = 5 * 60 * 1000; // 5 minutes
|
|
1226
|
+
this.bindings.put({
|
|
1227
|
+
...binding,
|
|
1228
|
+
webMirrorSessionId: sessionId,
|
|
1229
|
+
lockOwner: "feishu", // Feishu owns the lock initially
|
|
1230
|
+
lockAcquiredAt: Date.now(),
|
|
1231
|
+
lockTimeoutMs: defaultTimeoutMs,
|
|
1232
|
+
lastActiveAt: Date.now(),
|
|
1233
|
+
});
|
|
1234
|
+
this.log(`connect: auto-created Web mirror for session ${sessionId}`);
|
|
1235
|
+
}
|
|
472
1236
|
async newChat(msg) {
|
|
473
1237
|
await this.disposeAgent();
|
|
474
1238
|
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
@@ -506,7 +1270,7 @@ export class AgentRunner {
|
|
|
506
1270
|
seen.add(key);
|
|
507
1271
|
out.push({ path, title: title || path });
|
|
508
1272
|
};
|
|
509
|
-
add(this.workDir,
|
|
1273
|
+
add(this.workDir, this.t.currentDir);
|
|
510
1274
|
for (const w of registry?.list?.() ?? [])
|
|
511
1275
|
add(w.path, w.title);
|
|
512
1276
|
for (const p of this.config.workspaces)
|
|
@@ -516,14 +1280,22 @@ export class AgentRunner {
|
|
|
516
1280
|
async openMenu(target, msg, menuId, stack = [], cardId) {
|
|
517
1281
|
const items = await this.menuItems(menuId);
|
|
518
1282
|
const options = items.map((i) => ({ id: i.id, label: i.label }));
|
|
519
|
-
options.push({ id: "menu:exit", label:
|
|
1283
|
+
options.push({ id: "menu:exit", label: this.t.menuExit });
|
|
520
1284
|
if (stack.length > 0)
|
|
521
|
-
options.push({ id: "menu:back", label:
|
|
522
|
-
|
|
1285
|
+
options.push({ id: "menu:back", label: this.t.menuBack });
|
|
1286
|
+
// Determine columns per row based on menu type
|
|
1287
|
+
// Workspace and chat lists use 1 column (full width), others use 2 columns
|
|
1288
|
+
const columnsPerRow = (menuId === "workspace" || menuId === "chat") ? 1 : 2;
|
|
1289
|
+
const { choice, messageId } = await this.adapter.promptChoice(target, {
|
|
1290
|
+
title: this.menuTitle(menuId),
|
|
1291
|
+
options,
|
|
1292
|
+
columnsPerRow,
|
|
1293
|
+
...(menuId === "root" ? { sections: this.rootMenuSections(), footer: this.t.rootMenuFooter } : {}),
|
|
1294
|
+
}, cardId);
|
|
523
1295
|
if (choice === undefined)
|
|
524
1296
|
return;
|
|
525
1297
|
if (choice === "menu:exit") {
|
|
526
|
-
await this.adapter.closeMenu(messageId,
|
|
1298
|
+
await this.adapter.closeMenu(messageId, this.t.menuClosed);
|
|
527
1299
|
return;
|
|
528
1300
|
}
|
|
529
1301
|
if (choice === "menu:back") {
|
|
@@ -544,15 +1316,16 @@ export class AgentRunner {
|
|
|
544
1316
|
switch (menuId) {
|
|
545
1317
|
case "root":
|
|
546
1318
|
return [
|
|
547
|
-
{ id: "workspace", label:
|
|
548
|
-
{ id: "chat", label:
|
|
549
|
-
{ id: "status", label:
|
|
550
|
-
{ id: "task", label:
|
|
551
|
-
{ id: "history", label:
|
|
552
|
-
{ id: "goals", label:
|
|
553
|
-
{ id: "
|
|
554
|
-
{ id: "
|
|
555
|
-
{ id: "
|
|
1319
|
+
{ id: "workspace", label: this.t.menuWorkspaceAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "workspace", ["root"], cardId) },
|
|
1320
|
+
{ id: "chat", label: this.t.menuChatAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "chat", ["root"], cardId) },
|
|
1321
|
+
{ id: "status", label: this.t.menuStatusAction, leaf: true, onSelect: async (t) => { await this.showStatus(t); } },
|
|
1322
|
+
{ id: "task", label: this.t.menuTaskAction, leaf: true, onSelect: async (t) => { await this.showTasks(t); } },
|
|
1323
|
+
{ id: "history", label: this.t.menuHistoryAction, leaf: true, onSelect: async (t) => { await this.showHistory(t, 10); } },
|
|
1324
|
+
{ id: "goals", label: this.t.menuGoalsAction, leaf: true, onSelect: async (t) => { await this.showGoals(t); } },
|
|
1325
|
+
{ id: "schedule", label: this.t.menuScheduleAction, leaf: true, onSelect: async (t) => { await this.showSchedule(t); } },
|
|
1326
|
+
{ id: "compact", label: this.t.menuCompactAction, leaf: true, onSelect: async (t) => { await this.compact(t); } },
|
|
1327
|
+
{ id: "plugins", label: this.t.menuPluginsAction, leaf: true, onSelect: async (t) => { await this.showPlugins(t); } },
|
|
1328
|
+
{ id: "settings", label: this.t.menuSettingsAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "settings", ["root"], cardId) },
|
|
556
1329
|
];
|
|
557
1330
|
case "workspace": {
|
|
558
1331
|
const workspaces = this.listWorkspaces();
|
|
@@ -570,17 +1343,24 @@ export class AgentRunner {
|
|
|
570
1343
|
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
571
1344
|
const sessions = binding?.sessions ?? [];
|
|
572
1345
|
const active = binding?.sessionId;
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
1346
|
+
const hasWebMirror = binding?.webMirrorSessionId !== undefined;
|
|
1347
|
+
const items = sessions.map((s) => {
|
|
1348
|
+
// Check if this session is mirrored to Web
|
|
1349
|
+
const isMirrored = hasWebMirror && binding.webMirrorSessionId === s.sessionId;
|
|
1350
|
+
const mirrorIndicator = isMirrored ? ` ${this.t.webMirrorIndicator}` : "";
|
|
1351
|
+
const activeIndicator = s.sessionId === active ? "●" : "○";
|
|
1352
|
+
return {
|
|
1353
|
+
id: `session:${s.sessionId}`,
|
|
1354
|
+
label: `${activeIndicator} ${s.title || s.sessionId}${mirrorIndicator}`,
|
|
1355
|
+
leaf: true,
|
|
1356
|
+
onSelect: async (t, m) => {
|
|
1357
|
+
await this.switchTo(s.sessionId, m.senderKey);
|
|
1358
|
+
},
|
|
1359
|
+
};
|
|
1360
|
+
});
|
|
581
1361
|
items.push({
|
|
582
1362
|
id: "action:new",
|
|
583
|
-
label:
|
|
1363
|
+
label: this.t.menuNewChat,
|
|
584
1364
|
leaf: true,
|
|
585
1365
|
onSelect: async (t, m) => {
|
|
586
1366
|
await this.newChat(m);
|
|
@@ -590,63 +1370,141 @@ export class AgentRunner {
|
|
|
590
1370
|
}
|
|
591
1371
|
case "settings":
|
|
592
1372
|
return [
|
|
593
|
-
{ id: "model", label:
|
|
594
|
-
{ id: "reasoning", label:
|
|
595
|
-
{ id: "notify", label:
|
|
596
|
-
{ id: "
|
|
1373
|
+
{ id: "model", label: this.t.menuSettingsModel, onSelect: (t, m, cardId) => this.openMenu(t, m, "model", ["settings", "root"], cardId) },
|
|
1374
|
+
{ id: "reasoning", label: this.t.menuSettingsReasoning, onSelect: (t, m, cardId) => this.openMenu(t, m, "reasoning", ["settings", "root"], cardId) },
|
|
1375
|
+
{ id: "notify", label: this.t.menuSettingsNotify, onSelect: (t, m, cardId) => this.openMenu(t, m, "notify", ["settings", "root"], cardId) },
|
|
1376
|
+
{ id: "progress", label: this.t.menuSettingsProgress, onSelect: (t, m, cardId) => this.openMenu(t, m, "progress", ["settings", "root"], cardId) },
|
|
1377
|
+
{ id: "language", label: this.t.menuSettingsLanguage, onSelect: (t, m, cardId) => this.openMenu(t, m, "language", ["settings", "root"], cardId) },
|
|
1378
|
+
{ id: "overview", label: this.t.menuSettingsOverview, leaf: true, onSelect: async (t) => { await this.showSettings(t); } },
|
|
597
1379
|
];
|
|
598
|
-
case "
|
|
599
|
-
return await this.modelMenuItems();
|
|
600
|
-
case "reasoning":
|
|
601
|
-
return this.reasoningMenuItems();
|
|
602
|
-
case "notify":
|
|
1380
|
+
case "language":
|
|
603
1381
|
return [
|
|
604
1382
|
{
|
|
605
|
-
id: "
|
|
606
|
-
label:
|
|
1383
|
+
id: "lang:zh",
|
|
1384
|
+
label: `${this.language === "zh" ? "● " : ""}${this.t.languageZh}`,
|
|
607
1385
|
leaf: true,
|
|
608
|
-
onSelect: async () => {
|
|
609
|
-
this.streamEnabled = !this.streamEnabled;
|
|
610
|
-
},
|
|
1386
|
+
onSelect: async (t, m) => { await this.setLanguage("zh", t, m); },
|
|
611
1387
|
},
|
|
612
1388
|
{
|
|
613
|
-
id: "
|
|
614
|
-
label:
|
|
1389
|
+
id: "lang:en",
|
|
1390
|
+
label: `${this.language === "en" ? "● " : ""}${this.t.languageEn}`,
|
|
615
1391
|
leaf: true,
|
|
616
|
-
onSelect: async () => {
|
|
617
|
-
this.summaryEnabled = !this.summaryEnabled;
|
|
618
|
-
},
|
|
1392
|
+
onSelect: async (t, m) => { await this.setLanguage("en", t, m); },
|
|
619
1393
|
},
|
|
620
1394
|
];
|
|
1395
|
+
case "model":
|
|
1396
|
+
return await this.modelMenuItems();
|
|
1397
|
+
case "reasoning":
|
|
1398
|
+
return this.reasoningMenuItems();
|
|
1399
|
+
case "notify":
|
|
1400
|
+
return [
|
|
1401
|
+
{ id: "full", label: this.t.notifyFull },
|
|
1402
|
+
{ id: "important", label: this.t.notifyImportant },
|
|
1403
|
+
{ id: "result", label: this.t.notifyResult },
|
|
1404
|
+
].map((o) => ({
|
|
1405
|
+
id: `notify:${o.id}`,
|
|
1406
|
+
label: `${this.notifyLevel === o.id ? "● " : ""}${o.label}`,
|
|
1407
|
+
leaf: true,
|
|
1408
|
+
onSelect: async (t, m) => {
|
|
1409
|
+
await this.setNotifyLevel(o.id, t, m);
|
|
1410
|
+
},
|
|
1411
|
+
}));
|
|
1412
|
+
case "progress": {
|
|
1413
|
+
const presets = [
|
|
1414
|
+
{ id: "progress:0", label: this.t.progressOff, ms: 0 },
|
|
1415
|
+
{ id: "progress:120000", label: this.t.progressMinutes(2), ms: 2 * 60_000 },
|
|
1416
|
+
{ id: "progress:300000", label: this.t.progressMinutes(5), ms: 5 * 60_000 },
|
|
1417
|
+
{ id: "progress:600000", label: this.t.progressMinutes(10), ms: 10 * 60_000 },
|
|
1418
|
+
{ id: "progress:900000", label: this.t.progressMinutes(15), ms: 15 * 60_000 },
|
|
1419
|
+
{ id: "progress:1800000", label: this.t.progressMinutes(30), ms: 30 * 60_000 },
|
|
1420
|
+
];
|
|
1421
|
+
return presets.map((o) => ({
|
|
1422
|
+
id: o.id,
|
|
1423
|
+
label: `${this.progressTimeoutMs === o.ms ? "● " : ""}${o.label}`,
|
|
1424
|
+
leaf: true,
|
|
1425
|
+
onSelect: async (t, m) => {
|
|
1426
|
+
await this.setProgressTimeout(o.ms, t, m);
|
|
1427
|
+
},
|
|
1428
|
+
}));
|
|
1429
|
+
}
|
|
621
1430
|
}
|
|
622
1431
|
}
|
|
623
1432
|
async showStatus(target) {
|
|
624
1433
|
const agent = this.agent;
|
|
625
1434
|
if (agent === undefined) {
|
|
626
|
-
await this.adapter.sendText(target,
|
|
1435
|
+
await this.adapter.sendText(target, this.t.statusNoSession(this.workDir));
|
|
627
1436
|
return;
|
|
628
1437
|
}
|
|
629
|
-
|
|
1438
|
+
// Determine detailed execution state
|
|
1439
|
+
let statusLabel;
|
|
1440
|
+
if (agent.status === "running") {
|
|
1441
|
+
statusLabel = this.t.statusExecuting;
|
|
1442
|
+
}
|
|
1443
|
+
else if (this.queue.length > 0) {
|
|
1444
|
+
statusLabel = this.t.statusWaiting;
|
|
1445
|
+
}
|
|
1446
|
+
else {
|
|
1447
|
+
statusLabel = this.t.statusIdle;
|
|
1448
|
+
}
|
|
630
1449
|
const model = `${agent.options.provider ?? "-"}/${agent.options.model ?? "-"}`;
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1450
|
+
const lines = [
|
|
1451
|
+
this.t.statusField(statusLabel),
|
|
1452
|
+
this.t.modelField(model),
|
|
1453
|
+
this.t.workdirField(this.workDir),
|
|
1454
|
+
];
|
|
1455
|
+
// Queue status with detail
|
|
1456
|
+
if (this.queue.length === 0) {
|
|
1457
|
+
lines.push(`${this.t.queuedField(0)} ${this.t.queueEmpty}`);
|
|
1458
|
+
}
|
|
1459
|
+
else {
|
|
1460
|
+
lines.push(`${this.t.queuedField(this.queue.length)} ${this.t.queueDetail(this.queue.length)}`);
|
|
1461
|
+
}
|
|
1462
|
+
// Last turn completion info
|
|
1463
|
+
const lastTurn = this.getLastTurnInfo(agent);
|
|
1464
|
+
if (lastTurn !== undefined) {
|
|
1465
|
+
lines.push(this.t.lastTurnReason(this.reasonLabel(lastTurn.reason)));
|
|
1466
|
+
lines.push(this.t.lastTurnTime(lastTurn.completedAt));
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
lines.push(this.t.noTurnHistory);
|
|
1470
|
+
}
|
|
1471
|
+
lines.push(this.t.sessionField(agent.id));
|
|
1472
|
+
const tokenMeter = this.ctx.get("tokenMeter");
|
|
1473
|
+
try {
|
|
1474
|
+
const m = tokenMeter?.measure?.(agent.session);
|
|
1475
|
+
if (m !== undefined)
|
|
1476
|
+
lines.push(this.t.contextField(m.totalTokens, m.surfaceTokens));
|
|
1477
|
+
}
|
|
1478
|
+
catch {
|
|
1479
|
+
// Token meter unavailable: skip.
|
|
1480
|
+
}
|
|
1481
|
+
await this.adapter.sendText(target, lines.join("\n"));
|
|
1482
|
+
}
|
|
1483
|
+
/** Extract the most recent turn's completion info from session events. */
|
|
1484
|
+
getLastTurnInfo(agent) {
|
|
1485
|
+
const events = agent.session.events;
|
|
1486
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
1487
|
+
const event = events[i];
|
|
1488
|
+
if (event.type === "turn/end") {
|
|
1489
|
+
const reason = mapReason(event.data.reason.kind);
|
|
1490
|
+
// Use the event's timestamp if available, otherwise approximate
|
|
1491
|
+
const completedAt = new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
1492
|
+
return { reason, completedAt };
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return undefined;
|
|
638
1496
|
}
|
|
639
1497
|
async showTasks(target) {
|
|
640
1498
|
const todos = this.readTodos();
|
|
641
1499
|
if (todos.length === 0) {
|
|
642
|
-
await this.adapter.sendText(target,
|
|
1500
|
+
await this.adapter.sendText(target, this.t.noTodos);
|
|
643
1501
|
return;
|
|
644
1502
|
}
|
|
645
1503
|
const lines = todos.map((todo) => {
|
|
646
1504
|
const mark = todo.status === "completed" ? "✅" : todo.status === "in_progress" ? "🔄" : "⬜";
|
|
647
1505
|
return `${mark} ${todo.content}`;
|
|
648
1506
|
});
|
|
649
|
-
await this.adapter.sendText(target,
|
|
1507
|
+
await this.adapter.sendText(target, this.t.currentTodos(todos.length, lines.join("\n")));
|
|
650
1508
|
}
|
|
651
1509
|
async showSettings(target) {
|
|
652
1510
|
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
@@ -654,21 +1512,22 @@ export class AgentRunner {
|
|
|
654
1512
|
const model = agent ? `${agent.options.provider ?? "-"}/${agent.options.model ?? "-"}` : "-";
|
|
655
1513
|
const sel = this.defaultSelection();
|
|
656
1514
|
const lines = [
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
1515
|
+
this.t.settingsHeader,
|
|
1516
|
+
this.t.modelSetting(model),
|
|
1517
|
+
this.t.reasoningSetting(sel.reasoningEffort ?? this.t.effortDefault),
|
|
1518
|
+
this.t.agentPresetSetting(this.config.agentPreset ?? this.t.defaultLabel),
|
|
1519
|
+
this.t.workdirSetting(this.workDir),
|
|
1520
|
+
this.t.workspacesSetting + (this.config.workspaces.length === 0 ? this.t.notConfigured : ""),
|
|
663
1521
|
];
|
|
664
1522
|
for (const w of this.config.workspaces)
|
|
665
1523
|
lines.push(` - ${w}`);
|
|
666
|
-
lines.push(
|
|
667
|
-
lines.push(
|
|
668
|
-
lines.push(
|
|
669
|
-
lines.push(
|
|
670
|
-
lines.push(
|
|
671
|
-
lines.push(
|
|
1524
|
+
lines.push(this.t.chatCountField(binding?.sessions.length ?? 0));
|
|
1525
|
+
lines.push(this.t.streamLabel(this.streamEnabled));
|
|
1526
|
+
lines.push(this.t.summaryLabel(this.summaryEnabled));
|
|
1527
|
+
lines.push(this.t.progressSetting(this.progressTimeoutMs === 0 ? this.t.progressOff : this.t.progressMinutes(Math.round(this.progressTimeoutMs / 60_000))));
|
|
1528
|
+
lines.push(this.t.allowUsersField(this.config.allowUsers.length, this.config.allowUsers.length === 0));
|
|
1529
|
+
lines.push(this.t.allowChatsField(this.config.allowChats.length, this.config.allowChats.length === 0));
|
|
1530
|
+
lines.push(this.t.stateDirField(this.config.stateDir ?? ".dsh-connect"));
|
|
672
1531
|
await this.adapter.sendText(target, lines.join("\n"));
|
|
673
1532
|
}
|
|
674
1533
|
async modelMenuItems() {
|
|
@@ -683,7 +1542,7 @@ export class AgentRunner {
|
|
|
683
1542
|
},
|
|
684
1543
|
}));
|
|
685
1544
|
if (items.length === 0) {
|
|
686
|
-
items.push({ id: "model:none", label:
|
|
1545
|
+
items.push({ id: "model:none", label: this.t.noModelsFound, onSelect: async () => { } });
|
|
687
1546
|
}
|
|
688
1547
|
return items;
|
|
689
1548
|
}
|
|
@@ -700,7 +1559,7 @@ export class AgentRunner {
|
|
|
700
1559
|
// Skip providers whose catalog cannot be listed.
|
|
701
1560
|
}
|
|
702
1561
|
for (const m of models) {
|
|
703
|
-
out.push({ provider: p.id, model: m.id, name:
|
|
1562
|
+
out.push({ provider: p.id, model: m.id, name: this.t.modelName(m.name || m.id, p.name || p.id) });
|
|
704
1563
|
}
|
|
705
1564
|
}
|
|
706
1565
|
return out;
|
|
@@ -718,14 +1577,14 @@ export class AgentRunner {
|
|
|
718
1577
|
reasoningMenuItems() {
|
|
719
1578
|
const current = this.defaultSelection();
|
|
720
1579
|
const efforts = [
|
|
721
|
-
{ id: "low", name:
|
|
722
|
-
{ id: "medium", name:
|
|
723
|
-
{ id: "high", name:
|
|
1580
|
+
{ id: "low", name: this.t.effortLow },
|
|
1581
|
+
{ id: "medium", name: this.t.effortMedium },
|
|
1582
|
+
{ id: "high", name: this.t.effortHigh },
|
|
724
1583
|
];
|
|
725
1584
|
const items = [
|
|
726
1585
|
{
|
|
727
1586
|
id: "effort:default",
|
|
728
|
-
label: `${current.reasoningEffort === undefined ? "● " : ""}
|
|
1587
|
+
label: `${current.reasoningEffort === undefined ? "● " : ""}${this.t.effortDefault}`,
|
|
729
1588
|
leaf: true,
|
|
730
1589
|
onSelect: async (t, m) => {
|
|
731
1590
|
await this.setReasoning(undefined, m);
|
|
@@ -754,84 +1613,198 @@ export class AgentRunner {
|
|
|
754
1613
|
});
|
|
755
1614
|
await this.newChat(msg);
|
|
756
1615
|
}
|
|
1616
|
+
/** Switch the user-facing language for this chat, persisted in its binding. */
|
|
1617
|
+
async setLanguage(lang, target, msg) {
|
|
1618
|
+
if (lang === this.language)
|
|
1619
|
+
return;
|
|
1620
|
+
this.language = lang;
|
|
1621
|
+
this.t = messages(lang);
|
|
1622
|
+
// Persist into the chat's binding (create one if the chat has no session yet).
|
|
1623
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
1624
|
+
if (binding !== undefined) {
|
|
1625
|
+
this.bindings.put({ ...binding, language: lang, lastActiveAt: Date.now() });
|
|
1626
|
+
}
|
|
1627
|
+
else {
|
|
1628
|
+
this.bindings.put({
|
|
1629
|
+
channel: this.channel,
|
|
1630
|
+
chatKey: this.chatKey,
|
|
1631
|
+
chatType: this.chatType,
|
|
1632
|
+
sessionId: "",
|
|
1633
|
+
ownerKey: msg.senderKey,
|
|
1634
|
+
createdAt: Date.now(),
|
|
1635
|
+
lastActiveAt: Date.now(),
|
|
1636
|
+
language: lang,
|
|
1637
|
+
sessions: [],
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
await this.adapter.sendText(target, this.t.languageSet(lang === "zh" ? this.t.languageZh : this.t.languageEn));
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Switch the notification level for this chat (persisted in its binding and
|
|
1644
|
+
* effective immediately for the next streaming reply).
|
|
1645
|
+
*/
|
|
1646
|
+
async setNotifyLevel(level, target, msg) {
|
|
1647
|
+
this.notifyLevel = level;
|
|
1648
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
1649
|
+
if (binding !== undefined) {
|
|
1650
|
+
this.bindings.put({ ...binding, notifyLevel: level, lastActiveAt: Date.now() });
|
|
1651
|
+
}
|
|
1652
|
+
else {
|
|
1653
|
+
this.bindings.put({
|
|
1654
|
+
channel: this.channel,
|
|
1655
|
+
chatKey: this.chatKey,
|
|
1656
|
+
chatType: this.chatType,
|
|
1657
|
+
sessionId: "",
|
|
1658
|
+
ownerKey: msg.senderKey,
|
|
1659
|
+
createdAt: Date.now(),
|
|
1660
|
+
lastActiveAt: Date.now(),
|
|
1661
|
+
notifyLevel: level,
|
|
1662
|
+
sessions: [],
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
const label = level === "full" ? this.t.notifyFull : level === "important" ? this.t.notifyImportant : this.t.notifyResult;
|
|
1666
|
+
const desc = level === "full" ? this.t.notifyFullDesc : level === "important" ? this.t.notifyImportantDesc : this.t.notifyResultDesc;
|
|
1667
|
+
await this.adapter.sendText(target, this.t.notifySet(label, desc));
|
|
1668
|
+
}
|
|
1669
|
+
/** Show the three-level notification picker (used by the `/notify` command). */
|
|
1670
|
+
async openNotifyPicker(target, msg) {
|
|
1671
|
+
const options = [
|
|
1672
|
+
{ id: "notify:full", label: this.t.notifyFull },
|
|
1673
|
+
{ id: "notify:important", label: this.t.notifyImportant },
|
|
1674
|
+
{ id: "notify:result", label: this.t.notifyResult },
|
|
1675
|
+
];
|
|
1676
|
+
const { choice } = await this.adapter.promptChoice(target, { title: this.t.menuNotify, options });
|
|
1677
|
+
if (choice === undefined)
|
|
1678
|
+
return;
|
|
1679
|
+
const level = choice.startsWith("notify:") ? choice.slice("notify:".length) : undefined;
|
|
1680
|
+
if (level !== "full" && level !== "important" && level !== "result")
|
|
1681
|
+
return;
|
|
1682
|
+
await this.setNotifyLevel(level, target, msg);
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Switch the proactive progress-notice interval for this chat (persisted in
|
|
1686
|
+
* its binding and effective for the next turn; 0 disables the watchdog).
|
|
1687
|
+
*/
|
|
1688
|
+
async setProgressTimeout(ms, target, msg) {
|
|
1689
|
+
this.progressTimeoutMs = ms;
|
|
1690
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
1691
|
+
if (binding !== undefined) {
|
|
1692
|
+
this.bindings.put({ ...binding, progressTimeoutMs: ms, lastActiveAt: Date.now() });
|
|
1693
|
+
}
|
|
1694
|
+
else {
|
|
1695
|
+
this.bindings.put({
|
|
1696
|
+
channel: this.channel,
|
|
1697
|
+
chatKey: this.chatKey,
|
|
1698
|
+
chatType: this.chatType,
|
|
1699
|
+
sessionId: "",
|
|
1700
|
+
ownerKey: msg.senderKey,
|
|
1701
|
+
createdAt: Date.now(),
|
|
1702
|
+
lastActiveAt: Date.now(),
|
|
1703
|
+
progressTimeoutMs: ms,
|
|
1704
|
+
sessions: [],
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
const label = ms === 0 ? this.t.progressOff : this.t.progressMinutes(Math.round(ms / 60_000));
|
|
1708
|
+
await this.adapter.sendText(target, this.t.progressSet(label));
|
|
1709
|
+
}
|
|
1710
|
+
/** Show the progress-interval picker (used by the `/progress` command). */
|
|
1711
|
+
async openProgressPicker(target, msg) {
|
|
1712
|
+
const options = [
|
|
1713
|
+
{ id: "progress:0", label: this.t.progressOff },
|
|
1714
|
+
{ id: "progress:120000", label: this.t.progressMinutes(2) },
|
|
1715
|
+
{ id: "progress:300000", label: this.t.progressMinutes(5) },
|
|
1716
|
+
{ id: "progress:600000", label: this.t.progressMinutes(10) },
|
|
1717
|
+
{ id: "progress:900000", label: this.t.progressMinutes(15) },
|
|
1718
|
+
{ id: "progress:1800000", label: this.t.progressMinutes(30) },
|
|
1719
|
+
];
|
|
1720
|
+
const { choice } = await this.adapter.promptChoice(target, { title: this.t.progressMenuTitle, options });
|
|
1721
|
+
if (choice === undefined || !choice.startsWith("progress:"))
|
|
1722
|
+
return;
|
|
1723
|
+
const ms = Number(choice.slice("progress:".length));
|
|
1724
|
+
if (!Number.isInteger(ms) || ms < 0)
|
|
1725
|
+
return;
|
|
1726
|
+
await this.setProgressTimeout(ms, target, msg);
|
|
1727
|
+
}
|
|
757
1728
|
async showPlugins(target) {
|
|
758
1729
|
const loader = this.ctx.get("loader");
|
|
759
1730
|
const all = [...(loader?.entries?.() ?? [])];
|
|
760
1731
|
if (all.length === 0) {
|
|
761
|
-
await this.adapter.sendText(target,
|
|
1732
|
+
await this.adapter.sendText(target, this.t.noPlugins);
|
|
762
1733
|
return;
|
|
763
1734
|
}
|
|
764
1735
|
const shown = all.slice(0, 50);
|
|
765
1736
|
const lines = shown.map((e) => {
|
|
766
|
-
const status = e.disabled ?
|
|
1737
|
+
const status = e.disabled ? this.t.pluginDisabled : this.t.pluginEnabled;
|
|
767
1738
|
return `${status} ${e.id}${e.options.name ? ` (${e.options.name})` : ""}`;
|
|
768
1739
|
});
|
|
769
1740
|
if (all.length > shown.length)
|
|
770
|
-
lines.push(
|
|
771
|
-
await this.adapter.sendText(target,
|
|
1741
|
+
lines.push(this.t.pluginsTruncated(all.length, shown.length));
|
|
1742
|
+
await this.adapter.sendText(target, this.t.pluginsCount(all.length) + lines.join("\n"));
|
|
772
1743
|
}
|
|
773
1744
|
async createWorkspace(path, target) {
|
|
774
1745
|
if (!isAbsolute(path)) {
|
|
775
|
-
await this.adapter.sendText(target,
|
|
1746
|
+
await this.adapter.sendText(target, this.t.workspaceUsage);
|
|
776
1747
|
return;
|
|
777
1748
|
}
|
|
778
1749
|
try {
|
|
779
1750
|
if (!existsSync(path) || !statSync(path).isDirectory()) {
|
|
780
|
-
await this.adapter.sendText(target,
|
|
1751
|
+
await this.adapter.sendText(target, this.t.dirNotExistsHint(path));
|
|
781
1752
|
return;
|
|
782
1753
|
}
|
|
783
1754
|
}
|
|
784
1755
|
catch {
|
|
785
|
-
await this.adapter.sendText(target,
|
|
1756
|
+
await this.adapter.sendText(target, this.t.dirUnreadable(path));
|
|
786
1757
|
return;
|
|
787
1758
|
}
|
|
788
1759
|
const registry = this.ctx.get("workspaceRegistry");
|
|
789
1760
|
if (registry?.create === undefined) {
|
|
790
|
-
await this.adapter.sendText(target,
|
|
1761
|
+
await this.adapter.sendText(target, this.t.workspaceServiceUnavailable);
|
|
791
1762
|
return;
|
|
792
1763
|
}
|
|
793
1764
|
try {
|
|
794
1765
|
const ws = await registry.create(path);
|
|
795
|
-
await this.adapter.sendText(target,
|
|
1766
|
+
await this.adapter.sendText(target, this.t.workspaceCreated(ws.title, ws.path));
|
|
796
1767
|
}
|
|
797
1768
|
catch (error) {
|
|
798
|
-
await this.adapter.sendText(target,
|
|
1769
|
+
await this.adapter.sendText(target, this.t.createFailed(error instanceof Error ? error.message : String(error)));
|
|
799
1770
|
}
|
|
800
1771
|
}
|
|
801
1772
|
async compact(target) {
|
|
802
1773
|
const agent = this.agent;
|
|
803
1774
|
if (agent === undefined) {
|
|
804
|
-
await this.adapter.sendText(target,
|
|
1775
|
+
await this.adapter.sendText(target, this.t.noActiveSessionCompact);
|
|
805
1776
|
return;
|
|
806
1777
|
}
|
|
807
1778
|
if (agent.status !== "idle") {
|
|
808
|
-
await this.adapter.sendText(target,
|
|
1779
|
+
await this.adapter.sendText(target, this.t.sessionRunning);
|
|
809
1780
|
return;
|
|
810
1781
|
}
|
|
811
1782
|
const presets = this.ctx.get("agentPresets");
|
|
812
1783
|
const compaction = presets?.serviceFor?.(agent, "compaction");
|
|
813
1784
|
if (compaction?.compactNow === undefined) {
|
|
814
|
-
await this.adapter.sendText(target,
|
|
1785
|
+
await this.adapter.sendText(target, this.t.compactionUnavailable);
|
|
815
1786
|
return;
|
|
816
1787
|
}
|
|
817
1788
|
try {
|
|
1789
|
+
// Compaction can take a while — tell the user it started, then report the outcome.
|
|
1790
|
+
await this.adapter.sendText(target, this.t.compactStarted);
|
|
818
1791
|
const result = await compaction.compactNow(agent, new AbortController().signal);
|
|
819
1792
|
if (result === null) {
|
|
820
|
-
await this.adapter.sendText(target,
|
|
1793
|
+
await this.adapter.sendText(target, this.t.nothingToCompact);
|
|
821
1794
|
}
|
|
822
1795
|
else {
|
|
823
|
-
await this.adapter.sendText(target,
|
|
1796
|
+
await this.adapter.sendText(target, this.t.compactDone);
|
|
824
1797
|
}
|
|
825
1798
|
}
|
|
826
1799
|
catch (error) {
|
|
827
1800
|
const msg = error instanceof Error ? error.message : String(error);
|
|
828
|
-
await this.adapter.sendText(target,
|
|
1801
|
+
await this.adapter.sendText(target, this.t.compactFailed(truncate(msg)));
|
|
829
1802
|
}
|
|
830
1803
|
}
|
|
831
1804
|
async showHistory(target, limit) {
|
|
832
1805
|
const agent = this.agent;
|
|
833
1806
|
if (agent === undefined) {
|
|
834
|
-
await this.adapter.sendText(target,
|
|
1807
|
+
await this.adapter.sendText(target, this.t.noActiveSession);
|
|
835
1808
|
return;
|
|
836
1809
|
}
|
|
837
1810
|
const events = agent.session.events;
|
|
@@ -850,51 +1823,414 @@ export class AgentRunner {
|
|
|
850
1823
|
}
|
|
851
1824
|
}
|
|
852
1825
|
if (rows.length === 0) {
|
|
853
|
-
await this.adapter.sendText(target,
|
|
1826
|
+
await this.adapter.sendText(target, this.t.noMessagesYet);
|
|
854
1827
|
return;
|
|
855
1828
|
}
|
|
856
|
-
await this.adapter.sendText(target,
|
|
1829
|
+
await this.adapter.sendText(target, this.t.recentMessages(rows.length, rows.reverse().join("\n\n")));
|
|
857
1830
|
}
|
|
858
1831
|
async showGoals(target) {
|
|
859
1832
|
const agent = this.agent;
|
|
860
1833
|
if (agent === undefined) {
|
|
861
|
-
await this.adapter.sendText(target,
|
|
1834
|
+
await this.adapter.sendText(target, this.t.noActiveSession);
|
|
862
1835
|
return;
|
|
863
1836
|
}
|
|
864
1837
|
const goals = this.ctx.get("goals");
|
|
865
1838
|
const view = goals?.get?.(agent);
|
|
866
1839
|
if (view === undefined) {
|
|
867
|
-
await this.adapter.sendText(target,
|
|
1840
|
+
await this.adapter.sendText(target, this.t.noActiveGoals);
|
|
868
1841
|
return;
|
|
869
1842
|
}
|
|
870
|
-
const phaseLabels = {
|
|
871
|
-
active: "🟢 进行中",
|
|
872
|
-
paused: "⏸️ 已暂停",
|
|
873
|
-
blocked: "🚫 受阻",
|
|
874
|
-
complete: "✅ 已完成",
|
|
875
|
-
};
|
|
876
1843
|
const lines = [
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1844
|
+
this.t.goalObjective(view.objective),
|
|
1845
|
+
this.t.goalStatus(this.goalPhaseLabel(view.phase)),
|
|
1846
|
+
this.t.goalRounds(view.roundsStarted, view.maxGoalRounds),
|
|
1847
|
+
this.t.goalAutoRun(view.activation === "armed"),
|
|
881
1848
|
];
|
|
882
1849
|
if (view.blockedReason !== undefined)
|
|
883
|
-
lines.push(
|
|
1850
|
+
lines.push(this.t.goalBlockedReason(view.blockedReason.message));
|
|
884
1851
|
await this.adapter.sendText(target, lines.join("\n"));
|
|
885
1852
|
}
|
|
1853
|
+
goalPhaseLabel(phase) {
|
|
1854
|
+
switch (phase) {
|
|
1855
|
+
case "active":
|
|
1856
|
+
return this.t.goalPhaseActive;
|
|
1857
|
+
case "paused":
|
|
1858
|
+
return this.t.goalPhasePaused;
|
|
1859
|
+
case "blocked":
|
|
1860
|
+
return this.t.goalPhaseBlocked;
|
|
1861
|
+
case "complete":
|
|
1862
|
+
return this.t.goalPhaseComplete;
|
|
1863
|
+
default:
|
|
1864
|
+
return phase;
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
async showSchedule(target) {
|
|
1868
|
+
const agent = this.agent;
|
|
1869
|
+
if (agent === undefined) {
|
|
1870
|
+
await this.adapter.sendText(target, this.t.noActiveSession);
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
const reminders = foldReminders(agent.session.events);
|
|
1874
|
+
if (reminders.length === 0) {
|
|
1875
|
+
await this.adapter.sendText(target, this.t.noReminders);
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
const now = Date.now();
|
|
1879
|
+
const locale = this.config.language === "en" ? "en-US" : "zh-CN";
|
|
1880
|
+
const lines = reminders.map((r) => {
|
|
1881
|
+
const due = Date.parse(r.scheduledAt) <= now;
|
|
1882
|
+
const state = due ? this.t.reminderDue : this.t.reminderClock;
|
|
1883
|
+
const kind = r.kind === "every" ? this.t.reminderEvery(Math.round((r.everySeconds ?? 0) / 60)) : this.t.reminderOnce;
|
|
1884
|
+
const when = new Date(Date.parse(r.scheduledAt)).toLocaleString(locale);
|
|
1885
|
+
return this.t.reminderLine(state, r.id, r.prompt, kind, when);
|
|
1886
|
+
});
|
|
1887
|
+
await this.adapter.sendText(target, this.t.remindersCount(reminders.length, lines.join("\n")));
|
|
1888
|
+
}
|
|
886
1889
|
async showAllWorkspaces(target) {
|
|
887
1890
|
const registry = this.ctx.get("workspaceRegistry");
|
|
888
1891
|
const all = registry?.list?.() ?? [];
|
|
889
1892
|
if (all.length === 0) {
|
|
890
|
-
await this.adapter.sendText(target,
|
|
1893
|
+
await this.adapter.sendText(target, this.t.noWorkspaces);
|
|
891
1894
|
return;
|
|
892
1895
|
}
|
|
893
1896
|
const lines = all.map((w, i) => {
|
|
894
|
-
const sess = w.sessionIds !== undefined ?
|
|
1897
|
+
const sess = w.sessionIds !== undefined ? this.t.workspaceSessions(w.sessionIds.length) : "";
|
|
895
1898
|
return `${i + 1}. ${w.title}${w.title !== w.path ? ` (${w.path})` : ""}${sess}`;
|
|
896
1899
|
});
|
|
897
|
-
await this.adapter.sendText(target,
|
|
1900
|
+
await this.adapter.sendText(target, this.t.workspacesCount(all.length, lines.join("\n")));
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Create or show Web mirror session for this chat.
|
|
1904
|
+
* The mirror shares the same DSH session but enforces mutual exclusion:
|
|
1905
|
+
* when Feishu is executing a task, Web is read-only, and vice versa.
|
|
1906
|
+
*/
|
|
1907
|
+
async handleMirror(target, msg) {
|
|
1908
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
1909
|
+
// Check timeout before showing status
|
|
1910
|
+
this.checkAndReleaseTimeoutLock();
|
|
1911
|
+
// Reload binding after potential timeout release
|
|
1912
|
+
const updatedBinding = this.bindings.get(this.channel, this.chatKey);
|
|
1913
|
+
// Check if mirror already exists
|
|
1914
|
+
if (updatedBinding?.webMirrorSessionId !== undefined) {
|
|
1915
|
+
const queuedCount = updatedBinding.queuedMessages?.length ?? 0;
|
|
1916
|
+
const timeoutMin = updatedBinding.lockTimeoutMs ? Math.round(updatedBinding.lockTimeoutMs / 60000) : undefined;
|
|
1917
|
+
await this.adapter.sendText(target, this.t.mirrorStatus(updatedBinding.webMirrorSessionId, updatedBinding.lockOwner, timeoutMin, queuedCount));
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
// Create new mirror session (reuse current session)
|
|
1921
|
+
const agent = this.agent;
|
|
1922
|
+
if (agent === undefined) {
|
|
1923
|
+
await this.adapter.sendText(target, this.t.mirrorNotConfigured);
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
// The mirror uses the same session ID - it's a shared view
|
|
1927
|
+
const mirrorSessionId = agent.id;
|
|
1928
|
+
// Use custom timeout if provided, otherwise default to 5 minutes
|
|
1929
|
+
const command = parseCommand(msg.text);
|
|
1930
|
+
const customTimeoutMin = command.kind === "mirror" ? command.timeoutMin : undefined;
|
|
1931
|
+
const defaultTimeoutMs = (customTimeoutMin ?? 5) * 60 * 1000;
|
|
1932
|
+
// Update binding with mirror info and set initial lock to feishu
|
|
1933
|
+
const newBinding = updatedBinding !== undefined
|
|
1934
|
+
? {
|
|
1935
|
+
...updatedBinding,
|
|
1936
|
+
webMirrorSessionId: mirrorSessionId,
|
|
1937
|
+
lockOwner: "feishu",
|
|
1938
|
+
lockAcquiredAt: Date.now(),
|
|
1939
|
+
lockTimeoutMs: defaultTimeoutMs,
|
|
1940
|
+
}
|
|
1941
|
+
: {
|
|
1942
|
+
channel: this.channel,
|
|
1943
|
+
chatKey: this.chatKey,
|
|
1944
|
+
chatType: this.chatType,
|
|
1945
|
+
sessionId: mirrorSessionId,
|
|
1946
|
+
ownerKey: msg.senderKey,
|
|
1947
|
+
createdAt: Date.now(),
|
|
1948
|
+
lastActiveAt: Date.now(),
|
|
1949
|
+
webMirrorSessionId: mirrorSessionId,
|
|
1950
|
+
lockOwner: "feishu",
|
|
1951
|
+
lockAcquiredAt: Date.now(),
|
|
1952
|
+
lockTimeoutMs: defaultTimeoutMs,
|
|
1953
|
+
sessions: [],
|
|
1954
|
+
};
|
|
1955
|
+
this.bindings.put(newBinding);
|
|
1956
|
+
const timeoutMsg = customTimeoutMin !== undefined ? `(超时时间:${customTimeoutMin} 分钟)` : "";
|
|
1957
|
+
await this.adapter.sendText(target, this.t.mirrorCreated(mirrorSessionId) + timeoutMsg);
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Manually release session lock.
|
|
1961
|
+
*/
|
|
1962
|
+
async handleUnlock(target) {
|
|
1963
|
+
// Check timeout first
|
|
1964
|
+
this.checkAndReleaseTimeoutLock();
|
|
1965
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
1966
|
+
if (binding?.lockOwner === undefined) {
|
|
1967
|
+
await this.adapter.sendText(target, this.t.unlockNoLock);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
// Process any queued messages before releasing
|
|
1971
|
+
const queuedCount = binding.queuedMessages?.length ?? 0;
|
|
1972
|
+
this.releaseLock();
|
|
1973
|
+
let message = this.t.unlockSuccess;
|
|
1974
|
+
if (queuedCount > 0) {
|
|
1975
|
+
message += `\n${this.t.queueProcessed(queuedCount)}`;
|
|
1976
|
+
}
|
|
1977
|
+
await this.adapter.sendText(target, message);
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Renew the current session lock timeout.
|
|
1981
|
+
* Extends the lock by another full timeout period from now.
|
|
1982
|
+
*/
|
|
1983
|
+
async handleRenewLock(target) {
|
|
1984
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
1985
|
+
if (binding?.lockOwner === undefined) {
|
|
1986
|
+
await this.adapter.sendText(target, this.t.unlockNoLock);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
const currentChannel = this.channel === "feishu" ? "feishu" : "web";
|
|
1990
|
+
if (binding.lockOwner !== currentChannel) {
|
|
1991
|
+
await this.adapter.sendText(target, this.t.sessionLockedBy(binding.lockOwner));
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
// Renew the lock by resetting the acquisition time
|
|
1995
|
+
const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000;
|
|
1996
|
+
this.bindings.put({
|
|
1997
|
+
...binding,
|
|
1998
|
+
lockAcquiredAt: Date.now(),
|
|
1999
|
+
lastActiveAt: Date.now()
|
|
2000
|
+
});
|
|
2001
|
+
const timeoutMin = Math.round(timeoutMs / 60000);
|
|
2002
|
+
await this.adapter.sendText(target, this.t.lockRenewed(timeoutMin));
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* Export conversation history to Markdown or PDF.
|
|
2006
|
+
*/
|
|
2007
|
+
async handleExport(target, format) {
|
|
2008
|
+
const agent = this.agent;
|
|
2009
|
+
if (agent === undefined) {
|
|
2010
|
+
await this.adapter.sendText(target, this.t.exportNoSession);
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
// Default to markdown if not specified
|
|
2014
|
+
const exportFormat = format ?? "markdown";
|
|
2015
|
+
if (exportFormat === "pdf") {
|
|
2016
|
+
await this.adapter.sendText(target, this.t.exportPdfNotSupported);
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
try {
|
|
2020
|
+
const markdown = this.generateMarkdown(agent);
|
|
2021
|
+
const fileName = `conversation-${agent.id}-${Date.now()}.md`;
|
|
2022
|
+
const filePath = join(this.workDir, fileName);
|
|
2023
|
+
// Write to file
|
|
2024
|
+
writeFileSync(filePath, markdown, "utf8");
|
|
2025
|
+
await this.adapter.sendText(target, this.t.exportMarkdown(filePath));
|
|
2026
|
+
}
|
|
2027
|
+
catch (error) {
|
|
2028
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2029
|
+
await this.adapter.sendText(target, this.t.exportFailed(truncate(detail)));
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Generate Markdown representation of the conversation history.
|
|
2034
|
+
*/
|
|
2035
|
+
generateMarkdown(agent) {
|
|
2036
|
+
const events = agent.session.events;
|
|
2037
|
+
const lines = [];
|
|
2038
|
+
// Header
|
|
2039
|
+
lines.push(`# Conversation History`);
|
|
2040
|
+
lines.push(``);
|
|
2041
|
+
lines.push(`- **Session ID**: ${agent.id}`);
|
|
2042
|
+
lines.push(`- **Exported At**: ${new Date().toISOString()}`);
|
|
2043
|
+
lines.push(`- **Total Events**: ${events.length}`);
|
|
2044
|
+
lines.push(``);
|
|
2045
|
+
lines.push(`---`);
|
|
2046
|
+
lines.push(``);
|
|
2047
|
+
// Messages
|
|
2048
|
+
for (const event of events) {
|
|
2049
|
+
if (event.type === "user/message") {
|
|
2050
|
+
const text = textOf(event.data);
|
|
2051
|
+
if (text) {
|
|
2052
|
+
lines.push(`**👤 User**:`);
|
|
2053
|
+
lines.push(``);
|
|
2054
|
+
lines.push(text);
|
|
2055
|
+
lines.push(``);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
else if (event.type === "assistant/message") {
|
|
2059
|
+
const text = textOf(event.data.message);
|
|
2060
|
+
if (text) {
|
|
2061
|
+
lines.push(`**🤖 Assistant**:`);
|
|
2062
|
+
lines.push(``);
|
|
2063
|
+
lines.push(text);
|
|
2064
|
+
lines.push(``);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
else if (event.type === "turn/start") {
|
|
2068
|
+
lines.push(`*Turn started at ${new Date().toLocaleTimeString()}*`);
|
|
2069
|
+
lines.push(``);
|
|
2070
|
+
}
|
|
2071
|
+
else if (event.type === "turn/end") {
|
|
2072
|
+
const reason = mapReason(event.data.reason.kind);
|
|
2073
|
+
lines.push(`*Turn ended: ${this.reasonLabel(reason)}*`);
|
|
2074
|
+
lines.push(``);
|
|
2075
|
+
lines.push(`---`);
|
|
2076
|
+
lines.push(``);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
return lines.join("\n");
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* Check if the current channel has write access to the session.
|
|
2083
|
+
* Returns true if allowed to send messages, false if read-only.
|
|
2084
|
+
*/
|
|
2085
|
+
canWrite(channel) {
|
|
2086
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2087
|
+
if (binding?.lockOwner === undefined)
|
|
2088
|
+
return true; // No lock, free access
|
|
2089
|
+
// Check timeout
|
|
2090
|
+
if (this.isLockTimedOut(binding)) {
|
|
2091
|
+
this.releaseTimeoutLock(binding);
|
|
2092
|
+
return true;
|
|
2093
|
+
}
|
|
2094
|
+
return binding.lockOwner === channel;
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Acquire session lock for the given channel.
|
|
2098
|
+
* Returns true if lock acquired, false if already locked by another channel.
|
|
2099
|
+
*/
|
|
2100
|
+
acquireLock(channel) {
|
|
2101
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2102
|
+
if (binding === undefined)
|
|
2103
|
+
return true;
|
|
2104
|
+
// Check timeout before acquiring
|
|
2105
|
+
if (this.isLockTimedOut(binding)) {
|
|
2106
|
+
this.releaseTimeoutLock(binding);
|
|
2107
|
+
// After timeout release, try to acquire again
|
|
2108
|
+
const refreshed = this.bindings.get(this.channel, this.chatKey);
|
|
2109
|
+
if (refreshed !== undefined && (refreshed.lockOwner === undefined || refreshed.lockOwner === channel)) {
|
|
2110
|
+
const defaultTimeoutMs = refreshed.lockTimeoutMs ?? 5 * 60 * 1000;
|
|
2111
|
+
this.bindings.put({
|
|
2112
|
+
...refreshed,
|
|
2113
|
+
lockOwner: channel,
|
|
2114
|
+
lockAcquiredAt: Date.now(),
|
|
2115
|
+
lockTimeoutMs: defaultTimeoutMs,
|
|
2116
|
+
lastActiveAt: Date.now()
|
|
2117
|
+
});
|
|
2118
|
+
return true;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
if (binding.lockOwner === undefined || binding.lockOwner === channel) {
|
|
2122
|
+
// Lock is free or already owned by this channel
|
|
2123
|
+
const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000;
|
|
2124
|
+
this.bindings.put({
|
|
2125
|
+
...binding,
|
|
2126
|
+
lockOwner: channel,
|
|
2127
|
+
lockAcquiredAt: Date.now(),
|
|
2128
|
+
lockTimeoutMs: timeoutMs,
|
|
2129
|
+
lastActiveAt: Date.now()
|
|
2130
|
+
});
|
|
2131
|
+
return true;
|
|
2132
|
+
}
|
|
2133
|
+
// Locked by other channel - queue the message if from Web
|
|
2134
|
+
return false;
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2137
|
+
* Queue a message for later execution when lock is released.
|
|
2138
|
+
* Returns the position in queue.
|
|
2139
|
+
*/
|
|
2140
|
+
queueMessage(msg) {
|
|
2141
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2142
|
+
if (binding === undefined)
|
|
2143
|
+
return -1;
|
|
2144
|
+
const queued = binding.queuedMessages ?? [];
|
|
2145
|
+
queued.push({
|
|
2146
|
+
text: msg.text,
|
|
2147
|
+
senderKey: msg.senderKey,
|
|
2148
|
+
timestamp: Date.now(),
|
|
2149
|
+
replyRef: msg.replyRef,
|
|
2150
|
+
images: msg.images,
|
|
2151
|
+
files: msg.files,
|
|
2152
|
+
});
|
|
2153
|
+
this.bindings.put({ ...binding, queuedMessages: queued });
|
|
2154
|
+
return queued.length;
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Process all queued messages after lock release.
|
|
2158
|
+
* Re-enqueues them into the runner's message queue for actual execution.
|
|
2159
|
+
*/
|
|
2160
|
+
async processQueuedMessages() {
|
|
2161
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2162
|
+
if (binding === undefined || !binding.queuedMessages || binding.queuedMessages.length === 0) {
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
const messages = [...binding.queuedMessages];
|
|
2166
|
+
const count = messages.length;
|
|
2167
|
+
// Clear queue immediately to prevent duplicate processing
|
|
2168
|
+
this.bindings.put({ ...binding, queuedMessages: [] });
|
|
2169
|
+
// Notify about queued messages being processed
|
|
2170
|
+
if (count > 0) {
|
|
2171
|
+
const adapter = this.adapters?.get(this.channel);
|
|
2172
|
+
if (adapter) {
|
|
2173
|
+
await adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.queueProcessed(count)).catch(() => undefined);
|
|
2174
|
+
}
|
|
2175
|
+
// Re-enqueue messages into the runner's processing queue
|
|
2176
|
+
// Construct minimal InboundMessage objects from queued data
|
|
2177
|
+
for (const queued of messages) {
|
|
2178
|
+
const inboundMsg = {
|
|
2179
|
+
channel: "web", // Queued messages are from Web channel
|
|
2180
|
+
chatKey: this.chatKey,
|
|
2181
|
+
chatType: this.chatType,
|
|
2182
|
+
senderKey: queued.senderKey,
|
|
2183
|
+
text: queued.text,
|
|
2184
|
+
replyRef: queued.replyRef,
|
|
2185
|
+
images: queued.images,
|
|
2186
|
+
files: queued.files,
|
|
2187
|
+
};
|
|
2188
|
+
// Add to the runner's internal queue for processing
|
|
2189
|
+
this.queue.push(inboundMsg);
|
|
2190
|
+
}
|
|
2191
|
+
// Trigger processing if not already running
|
|
2192
|
+
void this.drain();
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
/** Release session lock. */
|
|
2196
|
+
releaseLock() {
|
|
2197
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2198
|
+
if (binding !== undefined && binding.lockOwner !== undefined) {
|
|
2199
|
+
// Process queued messages before releasing
|
|
2200
|
+
void this.processQueuedMessages();
|
|
2201
|
+
this.bindings.put({ ...binding, lockOwner: undefined, lockAcquiredAt: undefined });
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
/** Check if lock has timed out. */
|
|
2205
|
+
isLockTimedOut(binding) {
|
|
2206
|
+
if (binding.lockOwner === undefined || binding.lockAcquiredAt === undefined) {
|
|
2207
|
+
return false;
|
|
2208
|
+
}
|
|
2209
|
+
const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000; // Default 5 minutes
|
|
2210
|
+
const elapsed = Date.now() - binding.lockAcquiredAt;
|
|
2211
|
+
return elapsed > timeoutMs;
|
|
2212
|
+
}
|
|
2213
|
+
/** Check and release timed-out locks. */
|
|
2214
|
+
checkAndReleaseTimeoutLock() {
|
|
2215
|
+
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2216
|
+
if (binding && this.isLockTimedOut(binding)) {
|
|
2217
|
+
this.releaseTimeoutLock(binding);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
/** Release a timed-out lock and notify. */
|
|
2221
|
+
releaseTimeoutLock(binding) {
|
|
2222
|
+
const timeoutMin = Math.round((binding.lockTimeoutMs ?? 5 * 60 * 1000) / 60000);
|
|
2223
|
+
const lockedBy = binding.lockOwner;
|
|
2224
|
+
this.bindings.put({
|
|
2225
|
+
...binding,
|
|
2226
|
+
lockOwner: undefined,
|
|
2227
|
+
lockAcquiredAt: undefined
|
|
2228
|
+
});
|
|
2229
|
+
// Notify users about timeout release
|
|
2230
|
+
const adapter = this.adapters?.get(this.channel);
|
|
2231
|
+
if (adapter && lockedBy) {
|
|
2232
|
+
void adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.lockTimeoutReleased(timeoutMin)).catch(() => undefined);
|
|
2233
|
+
}
|
|
898
2234
|
}
|
|
899
2235
|
}
|
|
900
2236
|
//# sourceMappingURL=runner.js.map
|