dsh-connect 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/runner.js CHANGED
@@ -5,32 +5,27 @@
5
5
  * @module dsh-connect/runner
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
8
- import { existsSync, statSync } from "node:fs";
9
- import { isAbsolute } from "node:path";
8
+ import { existsSync, mkdirSync, statSync } 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
12
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
12
13
  import { createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
13
14
  import { createAsyncQueue } from "./stream.js";
14
- import { HELP_TEXT, parseCommand } from "./commands.js";
15
+ import { helpText, parseCommand } from "./commands.js";
16
+ import { messages } from "./i18n.js";
15
17
  export function resolveConnectConfig(config) {
16
18
  return {
17
19
  agentPreset: config.agentPreset,
18
20
  workDir: config.workDir,
19
21
  workspaces: config.workspaces ?? [],
22
+ visionModel: config.visionModel,
23
+ language: config.language ?? "zh",
20
24
  allowUsers: config.allowUsers ?? [],
21
25
  allowChats: config.allowChats ?? [],
22
26
  stateDir: config.stateDir,
23
27
  };
24
28
  }
25
- const REASON_LABELS = {
26
- completed: "✅ 完成",
27
- aborted: "⏹️ 已中止",
28
- blocked: "🚫 阻塞",
29
- error: "❌ 出错",
30
- "max-tokens": "⚠️ 达到输出上限",
31
- interrupted: "⚠️ 被中断",
32
- unknown: "❓ 未知",
33
- };
34
29
  function mapReason(kind) {
35
30
  switch (kind) {
36
31
  case "completed":
@@ -80,15 +75,64 @@ export function summarizeTurn(events, firstSeq) {
80
75
  function truncate(text, max = 500) {
81
76
  return text.length <= max ? text : `${text.slice(0, max)}…`;
82
77
  }
83
- const MENU_TITLES = {
84
- root: "主菜单",
85
- workspace: "切换工作目录",
86
- chat: "切换对话",
87
- settings: "设置",
88
- model: "切换模型",
89
- reasoning: "推理强度",
90
- notify: "通知设置",
91
- };
78
+ function textOf(message) {
79
+ if (message === undefined)
80
+ return "";
81
+ return message.content
82
+ .filter((b) => b.type === "text" && typeof b.text === "string")
83
+ .map((b) => b.text ?? "")
84
+ .join("");
85
+ }
86
+ /** Lightweight fold of `schedule/change` events (create / delete / dispatch). */
87
+ function foldReminders(events) {
88
+ const active = new Map();
89
+ for (const e of events) {
90
+ // `schedule/change` is a plugin-extended event type outside dsh-session's map.
91
+ const evt = e;
92
+ if (evt.type !== "schedule/change")
93
+ continue;
94
+ const d = evt.data;
95
+ if (d.version !== 1)
96
+ continue;
97
+ if (d.operation === "create" && d.schedule !== undefined) {
98
+ active.set(d.schedule.id, {
99
+ id: d.schedule.id,
100
+ kind: d.schedule.kind,
101
+ prompt: d.schedule.prompt,
102
+ scheduledAt: d.schedule.scheduledAt,
103
+ everySeconds: d.schedule.everySeconds,
104
+ });
105
+ }
106
+ else if (d.operation === "delete" && d.id !== undefined) {
107
+ active.delete(d.id);
108
+ }
109
+ else if (d.operation === "dispatch" && d.id !== undefined) {
110
+ const rec = active.get(d.id);
111
+ if (rec === undefined)
112
+ continue;
113
+ if (rec.kind === "every" && rec.everySeconds !== undefined && d.acceptedAt !== undefined) {
114
+ const next = Date.parse(d.acceptedAt) + rec.everySeconds * 1000;
115
+ active.set(d.id, { ...rec, scheduledAt: new Date(next).toISOString() });
116
+ }
117
+ else {
118
+ active.delete(d.id);
119
+ }
120
+ }
121
+ }
122
+ return [...active.values()];
123
+ }
124
+ /** Sniff an image's media type from its magic bytes (defaults to PNG). */
125
+ function detectImageMediaType(buf) {
126
+ if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
127
+ return "image/png";
128
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
129
+ return "image/jpeg";
130
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP")
131
+ return "image/webp";
132
+ if (buf.length >= 6 && buf.toString("ascii", 0, 3) === "GIF")
133
+ return "image/gif";
134
+ return "image/png";
135
+ }
92
136
  export class AgentRunner {
93
137
  ctx;
94
138
  config;
@@ -105,6 +149,7 @@ export class AgentRunner {
105
149
  workDir;
106
150
  streamEnabled = true;
107
151
  summaryEnabled = true;
152
+ t;
108
153
  constructor(ctx, config, channel, chatKey, chatType, adapter, bindings) {
109
154
  this.ctx = ctx;
110
155
  this.config = config;
@@ -114,6 +159,51 @@ export class AgentRunner {
114
159
  this.adapter = adapter;
115
160
  this.bindings = bindings;
116
161
  this.workDir = config.workDir ?? this.resolveDefaultWorkDir();
162
+ this.t = messages(config.language);
163
+ }
164
+ menuTitle(menuId) {
165
+ switch (menuId) {
166
+ case "root":
167
+ return this.t.menuRoot;
168
+ case "workspace":
169
+ return this.t.menuWorkspace;
170
+ case "chat":
171
+ return this.t.menuChat;
172
+ case "settings":
173
+ return this.t.menuSettings;
174
+ case "model":
175
+ return this.t.menuModel;
176
+ case "reasoning":
177
+ return this.t.menuReasoning;
178
+ case "notify":
179
+ return this.t.menuNotify;
180
+ }
181
+ }
182
+ rootMenuSections() {
183
+ return [
184
+ { title: this.t.rootSectionWorkspace, ids: ["workspace"] },
185
+ { title: this.t.rootSectionChat, ids: ["chat", "history"] },
186
+ { title: this.t.rootSectionTask, ids: ["task", "goals", "schedule"] },
187
+ { title: this.t.rootSectionSystem, ids: ["status", "plugins", "compact", "settings"] },
188
+ ];
189
+ }
190
+ reasonLabel(reason) {
191
+ switch (reason) {
192
+ case "completed":
193
+ return this.t.reasonCompleted;
194
+ case "aborted":
195
+ return this.t.reasonAborted;
196
+ case "blocked":
197
+ return this.t.reasonBlocked;
198
+ case "error":
199
+ return this.t.reasonError;
200
+ case "max-tokens":
201
+ return this.t.reasonMaxTokens;
202
+ case "interrupted":
203
+ return this.t.reasonInterrupted;
204
+ default:
205
+ return this.t.reasonUnknown;
206
+ }
117
207
  }
118
208
  /** Prefer the user's first DSH workspace over the process cwd as default. */
119
209
  resolveDefaultWorkDir() {
@@ -147,7 +237,7 @@ export class AgentRunner {
147
237
  }
148
238
  else if (chunk.type === "reasoning-delta" && !turn.reasoning) {
149
239
  turn.reasoning = true;
150
- turn.chunks.push("🤔 深度思考中…\n");
240
+ turn.chunks.push(this.t.thinkingHint);
151
241
  }
152
242
  }
153
243
  }
@@ -186,7 +276,7 @@ export class AgentRunner {
186
276
  catch (error) {
187
277
  const detail = error instanceof Error ? error.message : String(error);
188
278
  await this.adapter
189
- .sendText(this.target(msg), `⚠️ 处理失败:${truncate(detail)}`)
279
+ .sendText(this.target(msg), this.t.processingFailed(truncate(detail)))
190
280
  .catch(() => undefined);
191
281
  }
192
282
  }
@@ -270,11 +360,12 @@ export class AgentRunner {
270
360
  }
271
361
  async driveAgent(agent, msg) {
272
362
  const firstSeq = agent.session.seq;
363
+ const userMessage = async () => {
364
+ const content = await this.buildUserContent(msg);
365
+ return createUserMessage({ content, source: { kind: "user" } });
366
+ };
273
367
  if (!this.streamEnabled) {
274
- agent.followup(createUserMessage({
275
- content: [{ type: "text", text: msg.text }],
276
- source: { kind: "user" },
277
- }));
368
+ agent.followup(await userMessage());
278
369
  await agent.whenIdle();
279
370
  await this.sessions.flush(agent.session);
280
371
  const outcome = summarizeTurn(agent.session.events, firstSeq);
@@ -286,10 +377,7 @@ export class AgentRunner {
286
377
  const chunks = createAsyncQueue();
287
378
  this.turn = { firstSeq, chunks, lastText: "", reasoning: false };
288
379
  const streamPromise = this.adapter.streamText(this.target(msg), chunks);
289
- agent.followup(createUserMessage({
290
- content: [{ type: "text", text: msg.text }],
291
- source: { kind: "user" },
292
- }));
380
+ agent.followup(await userMessage());
293
381
  await agent.whenIdle();
294
382
  await this.sessions.flush(agent.session);
295
383
  chunks.end();
@@ -299,6 +387,187 @@ export class AgentRunner {
299
387
  this.turn = undefined;
300
388
  return { ...outcome, text };
301
389
  }
390
+ /**
391
+ * Build the user content for one inbound message. When the message carries
392
+ * images, either pass them directly to the main model (if it supports
393
+ * vision) or run a vision sub-task with a capable model and inject its
394
+ * description as text — so a text-only main model never stalls on images.
395
+ */
396
+ async buildUserContent(msg) {
397
+ const content = [{ type: "text", text: msg.text }];
398
+ const rawImages = msg.images;
399
+ if (rawImages === undefined || rawImages.length === 0) {
400
+ if (msg.imageError !== undefined) {
401
+ content.push({ type: "text", text: this.t.imageDownloadFailed(msg.imageError) });
402
+ }
403
+ return content;
404
+ }
405
+ // Copy images into the agent's work directory so its tools can access them.
406
+ const paths = await this.stageImages(rawImages);
407
+ const locationText = paths.map((p) => `- ${p}`).join("\n");
408
+ const mainVision = await this.modelSupportsVision(this.agent?.options.provider, this.agent?.options.model);
409
+ if (mainVision) {
410
+ const imageBlocks = await this.imageBlocks(paths);
411
+ if (imageBlocks.length > 0) {
412
+ this.log(`connect: main model supports vision — passing ${imageBlocks.length} image block(s) directly`);
413
+ return [{ type: "text", text: msg.text }, ...imageBlocks];
414
+ }
415
+ this.log("connect: main model supports vision but image blocks failed to attach — falling back to paths");
416
+ return content;
417
+ }
418
+ const description = await this.describeImages(paths);
419
+ const note = this.t.imagesStaged(locationText, description);
420
+ content.push({ type: "text", text: note });
421
+ return content;
422
+ }
423
+ /** Copy images into `<workDir>/.dsh-connect-images` so the agent's tools can reach them. */
424
+ async stageImages(paths) {
425
+ const dir = join(this.workDir, ".dsh-connect-images");
426
+ try {
427
+ mkdirSync(dir, { recursive: true });
428
+ }
429
+ catch {
430
+ return [...paths];
431
+ }
432
+ const staged = [];
433
+ for (const p of paths) {
434
+ const target = join(dir, basename(p));
435
+ try {
436
+ await copyFile(p, target);
437
+ staged.push(target);
438
+ }
439
+ catch {
440
+ staged.push(p);
441
+ }
442
+ }
443
+ return staged;
444
+ }
445
+ async imageBlocks(paths) {
446
+ const attachments = this.ctx.get("attachments");
447
+ if (attachments?.saveImage === undefined)
448
+ return [];
449
+ const blocks = [];
450
+ for (const path of paths) {
451
+ try {
452
+ const buf = await readFile(path);
453
+ const mediaType = detectImageMediaType(buf);
454
+ const ref = await attachments.saveImage({ data: new Uint8Array(buf), mediaType });
455
+ blocks.push({ type: "image", attachment: ref });
456
+ }
457
+ catch {
458
+ // Skip unreadable / unsupported images.
459
+ }
460
+ }
461
+ return blocks;
462
+ }
463
+ async describeImages(paths) {
464
+ const vision = await this.findVisionModel();
465
+ if (vision === null) {
466
+ this.log("connect: describeImages skipped — no vision model available");
467
+ return "";
468
+ }
469
+ const attachments = this.ctx.get("attachments");
470
+ const llm = this.ctx.get("llm");
471
+ if (attachments?.saveImage === undefined || llm?.stream === undefined) {
472
+ this.log(`connect: describeImages skipped — attachments.saveImage=${attachments?.saveImage !== undefined}, llm.stream=${llm?.stream !== undefined}`);
473
+ return "";
474
+ }
475
+ const refs = [];
476
+ for (const path of paths) {
477
+ try {
478
+ const buf = await readFile(path);
479
+ refs.push(await attachments.saveImage({ data: new Uint8Array(buf), mediaType: detectImageMediaType(buf) }));
480
+ }
481
+ catch (error) {
482
+ this.log(`connect: saveImage failed for ${path}: ${String(error)}`);
483
+ }
484
+ }
485
+ if (refs.length === 0) {
486
+ this.log("connect: describeImages skipped — no image could be attached");
487
+ return "";
488
+ }
489
+ this.log(`connect: describing ${refs.length} image(s) with ${vision.provider}/${vision.model}`);
490
+ const messages = [
491
+ {
492
+ role: "user",
493
+ content: [
494
+ { type: "text", text: this.t.visionPrompt },
495
+ ...refs.map((ref) => ({ type: "image", attachment: ref })),
496
+ ],
497
+ },
498
+ ];
499
+ try {
500
+ let text = "";
501
+ const stream = llm.stream({ provider: vision.provider, model: vision.model, messages, maxTokens: 2048 });
502
+ for await (const chunk of stream) {
503
+ if (chunk.type === "text-delta" && typeof chunk.text === "string")
504
+ text += chunk.text;
505
+ }
506
+ this.log(`connect: vision description produced ${text.length} char(s)`);
507
+ return text.trim();
508
+ }
509
+ catch (error) {
510
+ this.log(`connect: vision description call failed: ${error instanceof Error ? error.message : String(error)}`);
511
+ return "";
512
+ }
513
+ }
514
+ async modelSupportsVision(provider, model) {
515
+ if (provider === undefined || model === undefined)
516
+ return false;
517
+ const llm = this.ctx.get("llm");
518
+ try {
519
+ const info = await llm?.resolveModelInfo?.(provider, model);
520
+ const supports = info?.inputModalities?.includes("image") ?? false;
521
+ this.log(`connect: main model vision check ${provider}/${model} -> ${supports} (inputModalities=${JSON.stringify(info?.inputModalities)})`);
522
+ return supports;
523
+ }
524
+ catch (error) {
525
+ this.log(`connect: main model vision check ${provider}/${model} failed: ${String(error)}`);
526
+ return false;
527
+ }
528
+ }
529
+ async findVisionModel() {
530
+ if (this.config.visionModel !== undefined) {
531
+ this.log(`connect: vision model configured: ${this.config.visionModel.provider}/${this.config.visionModel.model}`);
532
+ return this.config.visionModel;
533
+ }
534
+ const llm = this.ctx.get("llm");
535
+ if (llm?.listProviders === undefined || llm.resolveModelInfo === undefined) {
536
+ this.log("connect: vision auto-detection unavailable — llm service lacks listProviders/resolveModelInfo");
537
+ return null;
538
+ }
539
+ const providers = llm.listProviders() ?? [];
540
+ this.log(`connect: vision auto-detection scanning ${providers.length} provider(s): ${providers.map((p) => p.id).join(", ") || "(none)"}`);
541
+ for (const p of providers) {
542
+ let models = [];
543
+ try {
544
+ models = (await llm.listModels?.(p.id)) ?? [];
545
+ }
546
+ catch (error) {
547
+ this.log(`connect: listModels(${p.id}) failed: ${String(error)}`);
548
+ continue;
549
+ }
550
+ this.log(`connect: provider ${p.id} advertises ${models.length} model(s)`);
551
+ for (const m of models) {
552
+ try {
553
+ const info = await llm.resolveModelInfo(p.id, m.id);
554
+ this.log(`connect: ${p.id}/${m.id} inputModalities=${JSON.stringify(info.inputModalities)}`);
555
+ if (info.inputModalities?.includes("image")) {
556
+ this.log(`connect: vision model selected: ${p.id}/${m.id}`);
557
+ return { provider: p.id, model: m.id };
558
+ }
559
+ }
560
+ catch (error) {
561
+ this.log(`connect: resolveModelInfo(${p.id}/${m.id}) failed: ${String(error)}`);
562
+ }
563
+ }
564
+ }
565
+ this.log("connect: no image-capable model found via auto-detection");
566
+ return null;
567
+ }
568
+ log(message) {
569
+ this.ctx.get("logger")?.info?.(message);
570
+ }
302
571
  readTodos() {
303
572
  const agent = this.agent;
304
573
  if (agent === undefined)
@@ -318,13 +587,13 @@ export class AgentRunner {
318
587
  this.bindings.put({ ...binding, ownerKey: msg.senderKey, lastActiveAt: Date.now() });
319
588
  }
320
589
  async sendSummary(msg, outcome) {
321
- const lines = [`**任务结束**:${REASON_LABELS[outcome.reason]}`];
590
+ const lines = [this.t.taskEnded(this.reasonLabel(outcome.reason))];
322
591
  if (outcome.code !== undefined)
323
- lines.push(`错误码:\`${outcome.code}\``);
592
+ lines.push(this.t.errorCode(outcome.code));
324
593
  if (outcome.message !== undefined)
325
- lines.push(`原因:${truncate(outcome.message)}`);
594
+ lines.push(this.t.reasonMessage(truncate(outcome.message)));
326
595
  if (outcome.text !== "")
327
- lines.push(`已产出:${truncate(outcome.text, 300)}`);
596
+ lines.push(this.t.produced(truncate(outcome.text, 300)));
328
597
  const card = { markdown: lines.join("\n") };
329
598
  await this.adapter.sendCard(this.target(msg), card).catch(() => undefined);
330
599
  }
@@ -333,17 +602,17 @@ export class AgentRunner {
333
602
  switch (command.kind) {
334
603
  case "new": {
335
604
  await this.newChat(msg);
336
- await this.adapter.sendText(target, "已开启新会话。");
605
+ await this.adapter.sendText(target, this.t.newChatDone);
337
606
  break;
338
607
  }
339
608
  case "clear": {
340
609
  await this.clearChat(msg);
341
- await this.adapter.sendText(target, "会话已清空。");
610
+ await this.adapter.sendText(target, this.t.chatCleared);
342
611
  break;
343
612
  }
344
613
  case "stop": {
345
614
  this.agent?.cancel({ kind: "user" });
346
- await this.adapter.sendText(target, "已请求停止当前任务。");
615
+ await this.adapter.sendText(target, this.t.stopRequested);
347
616
  break;
348
617
  }
349
618
  case "status": {
@@ -361,22 +630,22 @@ export class AgentRunner {
361
630
  }
362
631
  const path = command.path;
363
632
  if (!isAbsolute(path)) {
364
- await this.adapter.sendText(target, "请输入绝对路径,例如 `/dir D:\\projects\\my-app`。");
633
+ await this.adapter.sendText(target, this.t.dirUsage);
365
634
  break;
366
635
  }
367
636
  try {
368
637
  if (!existsSync(path) || !statSync(path).isDirectory()) {
369
- await this.adapter.sendText(target, `目录不存在或不是文件夹:${path}`);
638
+ await this.adapter.sendText(target, this.t.dirNotExists(path));
370
639
  break;
371
640
  }
372
641
  }
373
642
  catch {
374
- await this.adapter.sendText(target, `无法访问目录:${path}`);
643
+ await this.adapter.sendText(target, this.t.dirUnreadable(path));
375
644
  break;
376
645
  }
377
646
  this.workDir = path;
378
647
  await this.newChat(msg);
379
- await this.adapter.sendText(target, `工作目录已切换为:\n${path}\n(已开启新会话)`);
648
+ await this.adapter.sendText(target, this.t.dirSwitched(path));
380
649
  break;
381
650
  }
382
651
  case "chat": {
@@ -391,8 +660,44 @@ export class AgentRunner {
391
660
  await this.openMenu(target, msg, "settings", ["root"]);
392
661
  break;
393
662
  }
663
+ case "plugins": {
664
+ await this.showPlugins(target);
665
+ break;
666
+ }
667
+ case "workspace": {
668
+ if (command.path === undefined) {
669
+ await this.adapter.sendText(target, this.t.workspaceUsage);
670
+ break;
671
+ }
672
+ await this.createWorkspace(command.path, target);
673
+ break;
674
+ }
675
+ case "compact": {
676
+ await this.compact(target);
677
+ break;
678
+ }
679
+ case "history": {
680
+ await this.showHistory(target, command.limit ?? 10);
681
+ break;
682
+ }
683
+ case "goals": {
684
+ await this.showGoals(target);
685
+ break;
686
+ }
687
+ case "schedule": {
688
+ await this.showSchedule(target);
689
+ break;
690
+ }
691
+ case "model": {
692
+ await this.openMenu(target, msg, "model", ["root"]);
693
+ break;
694
+ }
695
+ case "workspaces": {
696
+ await this.showAllWorkspaces(target);
697
+ break;
698
+ }
394
699
  case "help": {
395
- await this.adapter.sendText(target, HELP_TEXT);
700
+ await this.adapter.sendText(target, helpText(this.t));
396
701
  break;
397
702
  }
398
703
  default:
@@ -466,50 +771,68 @@ export class AgentRunner {
466
771
  seen.add(key);
467
772
  out.push({ path, title: title || path });
468
773
  };
469
- add(this.workDir, "当前目录");
774
+ add(this.workDir, this.t.currentDir);
470
775
  for (const w of registry?.list?.() ?? [])
471
776
  add(w.path, w.title);
472
777
  for (const p of this.config.workspaces)
473
778
  add(p, p);
474
779
  return out;
475
780
  }
476
- async openMenu(target, msg, menuId, stack = []) {
781
+ async openMenu(target, msg, menuId, stack = [], cardId) {
477
782
  const items = await this.menuItems(menuId);
478
783
  const options = items.map((i) => ({ id: i.id, label: i.label }));
784
+ options.push({ id: "menu:exit", label: this.t.menuExit });
479
785
  if (stack.length > 0)
480
- options.push({ id: "menu:back", label: "🔙 返回" });
481
- const choice = await this.adapter.promptChoice(target, { title: MENU_TITLES[menuId], options });
786
+ options.push({ id: "menu:back", label: this.t.menuBack });
787
+ const { choice, messageId } = await this.adapter.promptChoice(target, {
788
+ title: this.menuTitle(menuId),
789
+ options,
790
+ ...(menuId === "root" ? { sections: this.rootMenuSections(), footer: this.t.rootMenuFooter } : {}),
791
+ }, cardId);
482
792
  if (choice === undefined)
483
793
  return;
794
+ if (choice === "menu:exit") {
795
+ await this.adapter.closeMenu(messageId, this.t.menuClosed);
796
+ return;
797
+ }
484
798
  if (choice === "menu:back") {
485
799
  const parent = stack[stack.length - 1];
486
- await this.openMenu(target, msg, parent, stack.slice(0, -1));
800
+ await this.openMenu(target, msg, parent, stack.slice(0, -1), messageId);
487
801
  return;
488
802
  }
489
803
  const item = items.find((i) => i.id === choice);
490
804
  if (item === undefined)
491
805
  return;
492
- await item.onSelect(target, msg);
806
+ await item.onSelect(target, msg, messageId);
807
+ if (item.leaf === true) {
808
+ // Return to the root menu on the same card so the user can keep operating.
809
+ await this.openMenu(target, msg, "root", [], messageId);
810
+ }
493
811
  }
494
812
  async menuItems(menuId) {
495
813
  switch (menuId) {
496
814
  case "root":
497
815
  return [
498
- { id: "workspace", label: "📁 切换工作目录", onSelect: (t, m) => this.openMenu(t, m, "workspace", ["root"]) },
499
- { id: "chat", label: "💬 切换对话", onSelect: (t, m) => this.openMenu(t, m, "chat", ["root"]) },
500
- { id: "status", label: "📊 查看状态", onSelect: (t) => this.showStatus(t) },
501
- { id: "task", label: "📋 查看任务", onSelect: (t) => this.showTasks(t) },
502
- { id: "settings", label: "⚙️ 设置", onSelect: (t, m) => this.openMenu(t, m, "settings", ["root"]) },
816
+ { id: "workspace", label: this.t.menuWorkspaceAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "workspace", ["root"], cardId) },
817
+ { id: "chat", label: this.t.menuChatAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "chat", ["root"], cardId) },
818
+ { id: "status", label: this.t.menuStatusAction, leaf: true, onSelect: async (t) => { await this.showStatus(t); } },
819
+ { id: "task", label: this.t.menuTaskAction, leaf: true, onSelect: async (t) => { await this.showTasks(t); } },
820
+ { id: "history", label: this.t.menuHistoryAction, leaf: true, onSelect: async (t) => { await this.showHistory(t, 10); } },
821
+ { id: "goals", label: this.t.menuGoalsAction, leaf: true, onSelect: async (t) => { await this.showGoals(t); } },
822
+ { id: "schedule", label: this.t.menuScheduleAction, leaf: true, onSelect: async (t) => { await this.showSchedule(t); } },
823
+ { id: "compact", label: this.t.menuCompactAction, leaf: true, onSelect: async (t) => { await this.compact(t); } },
824
+ { id: "plugins", label: this.t.menuPluginsAction, leaf: true, onSelect: async (t) => { await this.showPlugins(t); } },
825
+ { id: "settings", label: this.t.menuSettingsAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "settings", ["root"], cardId) },
503
826
  ];
504
827
  case "workspace": {
505
828
  const workspaces = this.listWorkspaces();
506
829
  return workspaces.map((w) => ({
507
830
  id: `dir:${w.path}`,
508
831
  label: `${w.path === this.workDir ? "● " : ""}${w.title}${w.title !== w.path ? ` (${w.path})` : ""}`,
832
+ leaf: true,
509
833
  onSelect: async (t, m) => {
510
834
  this.workDir = w.path;
511
835
  await this.newChat(m);
512
- await this.adapter.sendText(t, `工作目录已切换为:\n${w.path}`);
513
836
  },
514
837
  }));
515
838
  }
@@ -520,27 +843,27 @@ export class AgentRunner {
520
843
  const items = sessions.map((s) => ({
521
844
  id: `session:${s.sessionId}`,
522
845
  label: `${s.sessionId === active ? "●" : "○"} ${s.title || s.sessionId}`,
846
+ leaf: true,
523
847
  onSelect: async (t, m) => {
524
848
  await this.switchTo(s.sessionId, m.senderKey);
525
- await this.adapter.sendText(t, `已切换到对话:${s.title || s.sessionId}`);
526
849
  },
527
850
  }));
528
851
  items.push({
529
852
  id: "action:new",
530
- label: "➕ 新建对话",
853
+ label: this.t.menuNewChat,
854
+ leaf: true,
531
855
  onSelect: async (t, m) => {
532
856
  await this.newChat(m);
533
- await this.adapter.sendText(t, "已开启新对话。");
534
857
  },
535
858
  });
536
859
  return items;
537
860
  }
538
861
  case "settings":
539
862
  return [
540
- { id: "model", label: "🤖 切换模型", onSelect: (t, m) => this.openMenu(t, m, "model", ["settings", "root"]) },
541
- { id: "reasoning", label: "🧠 推理强度", onSelect: (t, m) => this.openMenu(t, m, "reasoning", ["settings", "root"]) },
542
- { id: "notify", label: "🔔 通知设置", onSelect: (t, m) => this.openMenu(t, m, "notify", ["settings", "root"]) },
543
- { id: "overview", label: "📄 配置总览", onSelect: (t) => this.showSettings(t) },
863
+ { id: "model", label: this.t.menuSettingsModel, onSelect: (t, m, cardId) => this.openMenu(t, m, "model", ["settings", "root"], cardId) },
864
+ { id: "reasoning", label: this.t.menuSettingsReasoning, onSelect: (t, m, cardId) => this.openMenu(t, m, "reasoning", ["settings", "root"], cardId) },
865
+ { id: "notify", label: this.t.menuSettingsNotify, onSelect: (t, m, cardId) => this.openMenu(t, m, "notify", ["settings", "root"], cardId) },
866
+ { id: "overview", label: this.t.menuSettingsOverview, leaf: true, onSelect: async (t) => { await this.showSettings(t); } },
544
867
  ];
545
868
  case "model":
546
869
  return await this.modelMenuItems();
@@ -550,18 +873,18 @@ export class AgentRunner {
550
873
  return [
551
874
  {
552
875
  id: "stream",
553
- label: `流式输出:${this.streamEnabled ? "开" : "关"}`,
554
- onSelect: async (t) => {
876
+ label: this.t.streamLabel(this.streamEnabled),
877
+ leaf: true,
878
+ onSelect: async () => {
555
879
  this.streamEnabled = !this.streamEnabled;
556
- await this.adapter.sendText(t, `流式输出已${this.streamEnabled ? "开启" : "关闭"}。`);
557
880
  },
558
881
  },
559
882
  {
560
883
  id: "summary",
561
- label: `结束摘要:${this.summaryEnabled ? "开" : "关"}`,
562
- onSelect: async (t) => {
884
+ label: this.t.summaryLabel(this.summaryEnabled),
885
+ leaf: true,
886
+ onSelect: async () => {
563
887
  this.summaryEnabled = !this.summaryEnabled;
564
- await this.adapter.sendText(t, `结束摘要已${this.summaryEnabled ? "开启" : "关闭"}。`);
565
888
  },
566
889
  },
567
890
  ];
@@ -570,30 +893,40 @@ export class AgentRunner {
570
893
  async showStatus(target) {
571
894
  const agent = this.agent;
572
895
  if (agent === undefined) {
573
- await this.adapter.sendText(target, `状态:无活动会话\n工作目录:${this.workDir}`);
896
+ await this.adapter.sendText(target, this.t.statusNoSession(this.workDir));
574
897
  return;
575
898
  }
576
- const label = agent.status === "running" ? "🟢 运行中" : "⚪ 空闲";
899
+ const label = agent.status === "running" ? this.t.statusRunning : this.t.statusIdle;
577
900
  const model = `${agent.options.provider ?? "-"}/${agent.options.model ?? "-"}`;
578
- await this.adapter.sendText(target, [
579
- `状态:${label}`,
580
- `模型:${model}`,
581
- `工作目录:${this.workDir}`,
582
- `待处理消息:${this.queue.length}`,
583
- `会话:${agent.id}`,
584
- ].join("\n"));
901
+ const lines = [
902
+ this.t.statusField(label),
903
+ this.t.modelField(model),
904
+ this.t.workdirField(this.workDir),
905
+ this.t.queuedField(this.queue.length),
906
+ this.t.sessionField(agent.id),
907
+ ];
908
+ const tokenMeter = this.ctx.get("tokenMeter");
909
+ try {
910
+ const m = tokenMeter?.measure?.(agent.session);
911
+ if (m !== undefined)
912
+ lines.push(this.t.contextField(m.totalTokens, m.surfaceTokens));
913
+ }
914
+ catch {
915
+ // Token meter unavailable: skip.
916
+ }
917
+ await this.adapter.sendText(target, lines.join("\n"));
585
918
  }
586
919
  async showTasks(target) {
587
920
  const todos = this.readTodos();
588
921
  if (todos.length === 0) {
589
- await this.adapter.sendText(target, "当前没有任务清单。");
922
+ await this.adapter.sendText(target, this.t.noTodos);
590
923
  return;
591
924
  }
592
925
  const lines = todos.map((todo) => {
593
926
  const mark = todo.status === "completed" ? "✅" : todo.status === "in_progress" ? "🔄" : "⬜";
594
927
  return `${mark} ${todo.content}`;
595
928
  });
596
- await this.adapter.sendText(target, `当前任务(${todos.length} 项):\n${lines.join("\n")}`);
929
+ await this.adapter.sendText(target, this.t.currentTodos(todos.length, lines.join("\n")));
597
930
  }
598
931
  async showSettings(target) {
599
932
  const binding = this.bindings.get(this.channel, this.chatKey);
@@ -601,21 +934,21 @@ export class AgentRunner {
601
934
  const model = agent ? `${agent.options.provider ?? "-"}/${agent.options.model ?? "-"}` : "-";
602
935
  const sel = this.defaultSelection();
603
936
  const lines = [
604
- "⚙️ 设置:",
605
- `模型:${model}`,
606
- `推理强度:${sel.reasoningEffort ?? "默认"}`,
607
- `Agent 预设:${this.config.agentPreset ?? "默认"}`,
608
- `工作目录:${this.workDir}`,
609
- `工作区列表:${this.config.workspaces.length === 0 ? "(未配置)" : ""}`,
937
+ this.t.settingsHeader,
938
+ this.t.modelSetting(model),
939
+ this.t.reasoningSetting(sel.reasoningEffort ?? this.t.effortDefault),
940
+ this.t.agentPresetSetting(this.config.agentPreset ?? this.t.defaultLabel),
941
+ this.t.workdirSetting(this.workDir),
942
+ this.t.workspacesSetting + (this.config.workspaces.length === 0 ? this.t.notConfigured : ""),
610
943
  ];
611
944
  for (const w of this.config.workspaces)
612
945
  lines.push(` - ${w}`);
613
- lines.push(`对话数:${binding?.sessions.length ?? 0}`);
614
- lines.push(`流式输出:${this.streamEnabled ? "开" : "关"}`);
615
- lines.push(`结束摘要:${this.summaryEnabled ? "开" : "关"}`);
616
- lines.push(`发送者白名单:${this.config.allowUsers.length === 0 ? "全部放行" : `${this.config.allowUsers.length} 人`}`);
617
- lines.push(`会话白名单:${this.config.allowChats.length === 0 ? "全部放行" : `${this.config.allowChats.length} 个`}`);
618
- lines.push(`状态目录:${this.config.stateDir ?? ".dsh-connect"}`);
946
+ lines.push(this.t.chatCountField(binding?.sessions.length ?? 0));
947
+ lines.push(this.t.streamLabel(this.streamEnabled));
948
+ lines.push(this.t.summaryLabel(this.summaryEnabled));
949
+ lines.push(this.t.allowUsersField(this.config.allowUsers.length, this.config.allowUsers.length === 0));
950
+ lines.push(this.t.allowChatsField(this.config.allowChats.length, this.config.allowChats.length === 0));
951
+ lines.push(this.t.stateDirField(this.config.stateDir ?? ".dsh-connect"));
619
952
  await this.adapter.sendText(target, lines.join("\n"));
620
953
  }
621
954
  async modelMenuItems() {
@@ -624,13 +957,13 @@ export class AgentRunner {
624
957
  const items = choices.map((c) => ({
625
958
  id: `model:${c.provider}:${c.model}`,
626
959
  label: `${c.provider === current.provider && c.model === current.model ? "● " : ""}${c.name}`,
960
+ leaf: true,
627
961
  onSelect: async (t, m) => {
628
962
  await this.setModel(c.provider, c.model, m);
629
- await this.adapter.sendText(t, `模型已切换为:${c.name}`);
630
963
  },
631
964
  }));
632
965
  if (items.length === 0) {
633
- items.push({ id: "model:none", label: "(未发现可用模型)", onSelect: async () => { } });
966
+ items.push({ id: "model:none", label: this.t.noModelsFound, onSelect: async () => { } });
634
967
  }
635
968
  return items;
636
969
  }
@@ -647,7 +980,7 @@ export class AgentRunner {
647
980
  // Skip providers whose catalog cannot be listed.
648
981
  }
649
982
  for (const m of models) {
650
- out.push({ provider: p.id, model: m.id, name: `${m.name || m.id}(${p.name || p.id})` });
983
+ out.push({ provider: p.id, model: m.id, name: this.t.modelName(m.name || m.id, p.name || p.id) });
651
984
  }
652
985
  }
653
986
  return out;
@@ -665,17 +998,17 @@ export class AgentRunner {
665
998
  reasoningMenuItems() {
666
999
  const current = this.defaultSelection();
667
1000
  const efforts = [
668
- { id: "low", name: "低 (low)" },
669
- { id: "medium", name: "中 (medium)" },
670
- { id: "high", name: "高 (high)" },
1001
+ { id: "low", name: this.t.effortLow },
1002
+ { id: "medium", name: this.t.effortMedium },
1003
+ { id: "high", name: this.t.effortHigh },
671
1004
  ];
672
1005
  const items = [
673
1006
  {
674
1007
  id: "effort:default",
675
- label: `${current.reasoningEffort === undefined ? "● " : ""}默认(跟随模型)`,
1008
+ label: `${current.reasoningEffort === undefined ? "● " : ""}${this.t.effortDefault}`,
1009
+ leaf: true,
676
1010
  onSelect: async (t, m) => {
677
1011
  await this.setReasoning(undefined, m);
678
- await this.adapter.sendText(t, "推理强度已设为默认。");
679
1012
  },
680
1013
  },
681
1014
  ];
@@ -683,9 +1016,9 @@ export class AgentRunner {
683
1016
  items.push({
684
1017
  id: `effort:${e.id}`,
685
1018
  label: `${e.id === current.reasoningEffort ? "● " : ""}${e.name}`,
1019
+ leaf: true,
686
1020
  onSelect: async (t, m) => {
687
1021
  await this.setReasoning(e.id, m);
688
- await this.adapter.sendText(t, `推理强度已切换为:${e.name}`);
689
1022
  },
690
1023
  });
691
1024
  }
@@ -701,5 +1034,177 @@ export class AgentRunner {
701
1034
  });
702
1035
  await this.newChat(msg);
703
1036
  }
1037
+ async showPlugins(target) {
1038
+ const loader = this.ctx.get("loader");
1039
+ const all = [...(loader?.entries?.() ?? [])];
1040
+ if (all.length === 0) {
1041
+ await this.adapter.sendText(target, this.t.noPlugins);
1042
+ return;
1043
+ }
1044
+ const shown = all.slice(0, 50);
1045
+ const lines = shown.map((e) => {
1046
+ const status = e.disabled ? this.t.pluginDisabled : this.t.pluginEnabled;
1047
+ return `${status} ${e.id}${e.options.name ? ` (${e.options.name})` : ""}`;
1048
+ });
1049
+ if (all.length > shown.length)
1050
+ lines.push(this.t.pluginsTruncated(all.length, shown.length));
1051
+ await this.adapter.sendText(target, this.t.pluginsCount(all.length) + lines.join("\n"));
1052
+ }
1053
+ async createWorkspace(path, target) {
1054
+ if (!isAbsolute(path)) {
1055
+ await this.adapter.sendText(target, this.t.workspaceUsage);
1056
+ return;
1057
+ }
1058
+ try {
1059
+ if (!existsSync(path) || !statSync(path).isDirectory()) {
1060
+ await this.adapter.sendText(target, this.t.dirNotExistsHint(path));
1061
+ return;
1062
+ }
1063
+ }
1064
+ catch {
1065
+ await this.adapter.sendText(target, this.t.dirUnreadable(path));
1066
+ return;
1067
+ }
1068
+ const registry = this.ctx.get("workspaceRegistry");
1069
+ if (registry?.create === undefined) {
1070
+ await this.adapter.sendText(target, this.t.workspaceServiceUnavailable);
1071
+ return;
1072
+ }
1073
+ try {
1074
+ const ws = await registry.create(path);
1075
+ await this.adapter.sendText(target, this.t.workspaceCreated(ws.title, ws.path));
1076
+ }
1077
+ catch (error) {
1078
+ await this.adapter.sendText(target, this.t.createFailed(error instanceof Error ? error.message : String(error)));
1079
+ }
1080
+ }
1081
+ async compact(target) {
1082
+ const agent = this.agent;
1083
+ if (agent === undefined) {
1084
+ await this.adapter.sendText(target, this.t.noActiveSessionCompact);
1085
+ return;
1086
+ }
1087
+ if (agent.status !== "idle") {
1088
+ await this.adapter.sendText(target, this.t.sessionRunning);
1089
+ return;
1090
+ }
1091
+ const presets = this.ctx.get("agentPresets");
1092
+ const compaction = presets?.serviceFor?.(agent, "compaction");
1093
+ if (compaction?.compactNow === undefined) {
1094
+ await this.adapter.sendText(target, this.t.compactionUnavailable);
1095
+ return;
1096
+ }
1097
+ try {
1098
+ const result = await compaction.compactNow(agent, new AbortController().signal);
1099
+ if (result === null) {
1100
+ await this.adapter.sendText(target, this.t.nothingToCompact);
1101
+ }
1102
+ else {
1103
+ await this.adapter.sendText(target, this.t.contextCompacted);
1104
+ }
1105
+ }
1106
+ catch (error) {
1107
+ const msg = error instanceof Error ? error.message : String(error);
1108
+ await this.adapter.sendText(target, this.t.compactFailed(truncate(msg)));
1109
+ }
1110
+ }
1111
+ async showHistory(target, limit) {
1112
+ const agent = this.agent;
1113
+ if (agent === undefined) {
1114
+ await this.adapter.sendText(target, this.t.noActiveSession);
1115
+ return;
1116
+ }
1117
+ const events = agent.session.events;
1118
+ const rows = [];
1119
+ for (let i = events.length - 1; i >= 0 && rows.length < limit; i--) {
1120
+ const e = events[i];
1121
+ if (e.type === "user/message") {
1122
+ const text = textOf(e.data);
1123
+ if (text !== "")
1124
+ rows.push(`👤 ${truncate(text, 100)}`);
1125
+ }
1126
+ else if (e.type === "assistant/message") {
1127
+ const text = textOf(e.data.message);
1128
+ if (text !== "")
1129
+ rows.push(`🤖 ${truncate(text, 100)}`);
1130
+ }
1131
+ }
1132
+ if (rows.length === 0) {
1133
+ await this.adapter.sendText(target, this.t.noMessagesYet);
1134
+ return;
1135
+ }
1136
+ await this.adapter.sendText(target, this.t.recentMessages(rows.length, rows.reverse().join("\n\n")));
1137
+ }
1138
+ async showGoals(target) {
1139
+ const agent = this.agent;
1140
+ if (agent === undefined) {
1141
+ await this.adapter.sendText(target, this.t.noActiveSession);
1142
+ return;
1143
+ }
1144
+ const goals = this.ctx.get("goals");
1145
+ const view = goals?.get?.(agent);
1146
+ if (view === undefined) {
1147
+ await this.adapter.sendText(target, this.t.noActiveGoals);
1148
+ return;
1149
+ }
1150
+ const lines = [
1151
+ this.t.goalObjective(view.objective),
1152
+ this.t.goalStatus(this.goalPhaseLabel(view.phase)),
1153
+ this.t.goalRounds(view.roundsStarted, view.maxGoalRounds),
1154
+ this.t.goalAutoRun(view.activation === "armed"),
1155
+ ];
1156
+ if (view.blockedReason !== undefined)
1157
+ lines.push(this.t.goalBlockedReason(view.blockedReason.message));
1158
+ await this.adapter.sendText(target, lines.join("\n"));
1159
+ }
1160
+ goalPhaseLabel(phase) {
1161
+ switch (phase) {
1162
+ case "active":
1163
+ return this.t.goalPhaseActive;
1164
+ case "paused":
1165
+ return this.t.goalPhasePaused;
1166
+ case "blocked":
1167
+ return this.t.goalPhaseBlocked;
1168
+ case "complete":
1169
+ return this.t.goalPhaseComplete;
1170
+ default:
1171
+ return phase;
1172
+ }
1173
+ }
1174
+ async showSchedule(target) {
1175
+ const agent = this.agent;
1176
+ if (agent === undefined) {
1177
+ await this.adapter.sendText(target, this.t.noActiveSession);
1178
+ return;
1179
+ }
1180
+ const reminders = foldReminders(agent.session.events);
1181
+ if (reminders.length === 0) {
1182
+ await this.adapter.sendText(target, this.t.noReminders);
1183
+ return;
1184
+ }
1185
+ const now = Date.now();
1186
+ const locale = this.config.language === "en" ? "en-US" : "zh-CN";
1187
+ const lines = reminders.map((r) => {
1188
+ const due = Date.parse(r.scheduledAt) <= now;
1189
+ const state = due ? this.t.reminderDue : this.t.reminderClock;
1190
+ const kind = r.kind === "every" ? this.t.reminderEvery(Math.round((r.everySeconds ?? 0) / 60)) : this.t.reminderOnce;
1191
+ const when = new Date(Date.parse(r.scheduledAt)).toLocaleString(locale);
1192
+ return this.t.reminderLine(state, r.id, r.prompt, kind, when);
1193
+ });
1194
+ await this.adapter.sendText(target, this.t.remindersCount(reminders.length, lines.join("\n")));
1195
+ }
1196
+ async showAllWorkspaces(target) {
1197
+ const registry = this.ctx.get("workspaceRegistry");
1198
+ const all = registry?.list?.() ?? [];
1199
+ if (all.length === 0) {
1200
+ await this.adapter.sendText(target, this.t.noWorkspaces);
1201
+ return;
1202
+ }
1203
+ const lines = all.map((w, i) => {
1204
+ const sess = w.sessionIds !== undefined ? this.t.workspaceSessions(w.sessionIds.length) : "";
1205
+ return `${i + 1}. ${w.title}${w.title !== w.path ? ` (${w.path})` : ""}${sess}`;
1206
+ });
1207
+ await this.adapter.sendText(target, this.t.workspacesCount(all.length, lines.join("\n")));
1208
+ }
704
1209
  }
705
1210
  //# sourceMappingURL=runner.js.map