oh-my-im 0.1.21 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bot-app.js CHANGED
@@ -1,22 +1,51 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
- import { loadConfig } from "./config.js";
4
+ import { loadConfig } from "./core/config.js";
5
5
  import { agentLabel, agentSwitchMessage, listAgentSessions, runAgent } from "./agents/index.js";
6
- import { DingTalkBot, isSingleConversation } from "./dingtalk.js";
7
- import { createLogger } from "./logger.js";
8
- import { parseAgentControlCommand } from "./monitor-command.js";
9
- import { appendConversationLog } from "./conversation-log.js";
6
+ import { DingTalkBot, isSingleConversation } from "./dingtalk/dingtalk.js";
7
+ import { createLogger } from "./core/logger.js";
8
+ import { normalizeDingTalkMarkdown } from "./dingtalk/markdown.js";
9
+ import { DingTalkAiCardClient } from "./dingtalk/dingtalk-ai-card.js";
10
+ import { AiCardSession } from "./dingtalk/ai-card.js";
11
+ import { parseAgentControlCommand } from "./core/monitor-command.js";
12
+ import { appendConversationLog } from "./core/conversation-log.js";
10
13
  const PRIVATE_SESSIONS_FILE = join(homedir(), ".oh-my-im", "private-sessions.json");
14
+ const PRIVATE_AGENTS_FILE = join(homedir(), ".oh-my-im", "private-agents.json");
11
15
  const privateSessionBindings = new Map();
16
+ // Agent 与具体会话绑定:私聊 A 切了 Agent 不影响私聊 B,重启后也能各自记住。
17
+ const privateAgentBindings = new Map();
12
18
  try {
13
19
  const stored = JSON.parse(readFileSync(PRIVATE_SESSIONS_FILE, "utf8"));
14
20
  Object.entries(stored).forEach(([key, value]) => { if (typeof value === "string" && value.trim())
15
21
  privateSessionBindings.set(key, value.trim()); });
16
22
  }
17
23
  catch { /* first run */ }
18
- function privateSessionKey(conversationId, agent) {
19
- return `${agent}:${conversationId}`;
24
+ try {
25
+ const stored = JSON.parse(readFileSync(PRIVATE_AGENTS_FILE, "utf8"));
26
+ Object.entries(stored).forEach(([key, value]) => {
27
+ if (value === "pi" || value === "codex" || value === "opencode")
28
+ privateAgentBindings.set(key, value);
29
+ });
30
+ }
31
+ catch { /* first run */ }
32
+ function savePrivateAgents() {
33
+ mkdirSync(dirname(PRIVATE_AGENTS_FILE), { recursive: true });
34
+ const temporary = `${PRIVATE_AGENTS_FILE}.${process.pid}.tmp`;
35
+ writeFileSync(temporary, `${JSON.stringify(Object.fromEntries(privateAgentBindings), null, 2)}\n`, "utf8");
36
+ renameSync(temporary, PRIVATE_AGENTS_FILE);
37
+ }
38
+ function setPrivateAgent(conversationId, agent) {
39
+ if (privateAgentBindings.get(conversationId) === agent)
40
+ return;
41
+ privateAgentBindings.set(conversationId, agent);
42
+ savePrivateAgents();
43
+ }
44
+ function privateSessionKey(conversationId, agent, workDir) {
45
+ // The work directory is part of the key: a stored session created in a
46
+ // different directory must never be resumed, otherwise Pi/Codex/OpenCode
47
+ // fail with a project mismatch after the working directory changes.
48
+ return `${agent}:${conversationId}:${workDir}`;
20
49
  }
21
50
  function savePrivateSessions() {
22
51
  mkdirSync(dirname(PRIVATE_SESSIONS_FILE), { recursive: true });
@@ -25,18 +54,45 @@ function savePrivateSessions() {
25
54
  renameSync(temporary, PRIVATE_SESSIONS_FILE);
26
55
  }
27
56
  function clearPrivateSessionBindings(conversationId) {
28
- privateSessionBindings.delete(privateSessionKey(conversationId, "pi"));
29
- privateSessionBindings.delete(privateSessionKey(conversationId, "codex"));
30
- privateSessionBindings.delete(privateSessionKey(conversationId, "opencode"));
31
- savePrivateSessions();
57
+ let changed = false;
58
+ for (const agent of ["pi", "codex", "opencode"]) {
59
+ const prefix = `${agent}:${conversationId}:`;
60
+ for (const key of [...privateSessionBindings.keys()]) {
61
+ if (key.startsWith(prefix)) {
62
+ privateSessionBindings.delete(key);
63
+ changed = true;
64
+ }
65
+ }
66
+ }
67
+ if (changed)
68
+ savePrivateSessions();
32
69
  }
33
70
  const log = createLogger("Main");
34
- function safePathPart(value) {
35
- return value.trim().replace(/[^\w.-]+/g, "_").slice(0, 120) || "unknown";
71
+ function safeDirName(value, fallback) {
72
+ // Chinese display names are valid directory names; only strip characters
73
+ // that are illegal on the filesystem or could escape the parent directory.
74
+ const cleaned = value
75
+ .replace(/[\u0000-\u001f\u007f]/g, "")
76
+ .replace(/[\\/:*?"<>|]/g, "_")
77
+ .replace(/\s+/g, " ")
78
+ .replace(/^\.+/, "")
79
+ .trim()
80
+ .slice(0, 80)
81
+ .trim();
82
+ return cleaned || fallback;
36
83
  }
37
84
  function privateUserWorkDir(message) {
85
+ // An explicit AGENT_WORK_DIR override is used as-is (single shared dir).
86
+ const override = process.env.AGENT_WORK_DIR?.trim();
87
+ if (override) {
88
+ mkdirSync(override, { recursive: true });
89
+ return override;
90
+ }
91
+ // Otherwise each DingTalk user gets their own directory named after the
92
+ // sender display name, for example ~/.oh-my-im/users/杜振训.
38
93
  const userId = message.senderStaffId?.trim() || message.senderId.trim();
39
- const dir = join(homedir(), ".oh-my-im", "users", safePathPart(userId), "workspace");
94
+ const displayName = safeDirName(message.senderNick ?? "", safeDirName(userId, "unknown"));
95
+ const dir = join(homedir(), ".oh-my-im", "users", displayName);
40
96
  mkdirSync(dir, { recursive: true });
41
97
  return dir;
42
98
  }
@@ -48,14 +104,30 @@ function pathInside(root, candidate) {
48
104
  }
49
105
  function getState(conversations, conversationId, defaultWorkDir) {
50
106
  const existing = conversations.get(conversationId);
51
- if (existing)
107
+ if (existing) {
108
+ // The directory is derived from the sender display name, so a nickname
109
+ // change must take effect on the next message. Reset the bound sessions
110
+ // and selections so no Agent keeps running in (or resumes a session from)
111
+ // the previous directory.
112
+ if (existing.defaultWorkDir !== defaultWorkDir) {
113
+ existing.defaultWorkDir = defaultWorkDir;
114
+ existing.selectedSessions = {};
115
+ existing.visibleSessionLists = {};
116
+ existing.sessions = {};
117
+ // Drop persisted session bindings too; resuming a session created in the
118
+ // previous directory makes Pi/Codex/OpenCode fail with a project
119
+ // mismatch, so the next message must start a fresh session.
120
+ clearPrivateSessionBindings(conversationId);
121
+ }
52
122
  return existing;
123
+ }
53
124
  const created = {
54
125
  defaultWorkDir,
55
126
  sessions: {}, selectedSessions: {}, visibleSessionLists: {}, pendingMessages: [], pendingNoticeSent: false, busy: false,
127
+ selectedAgent: privateAgentBindings.get(conversationId),
56
128
  };
57
129
  ['codex', 'pi', 'opencode'].forEach((agent) => {
58
- const sessionId = privateSessionBindings.get(privateSessionKey(conversationId, agent));
130
+ const sessionId = privateSessionBindings.get(privateSessionKey(conversationId, agent, defaultWorkDir));
59
131
  if (sessionId)
60
132
  created.sessions[agent] = sessionId;
61
133
  });
@@ -132,18 +204,14 @@ function formatSessionList(agent, cwd, sessions, selected, admin = false) {
132
204
  admin ? "返回目录:/admin-sessions" : `重新查看:/sessions ${agent}`,
133
205
  ].join("\n");
134
206
  }
135
- function formatStats(stats) {
136
- const entries = Object.entries(stats);
137
- if (entries.length === 0)
138
- return "";
139
- return entries.map(([name, count]) => `${name} x${count}`).join(", ");
140
- }
141
207
  function shortModelName(model) {
142
208
  const value = model?.trim() || "";
143
209
  return value.includes("/") ? value.slice(value.lastIndexOf("/") + 1) : value;
144
210
  }
145
211
  function buildCardContent(content, note) {
146
- const safeContent = content.trim() || "[OMG] 正在分析...";
212
+ // DingTalk cards render a limited Markdown subset without table support, so
213
+ // convert tables to lists before they reach the card.
214
+ const safeContent = normalizeDingTalkMarkdown(content).trim() || "[OMG] 正在分析...";
147
215
  const safeNote = note?.trim();
148
216
  return safeNote ? `${safeContent}\n\n\n${safeNote}` : safeContent;
149
217
  }
@@ -289,6 +357,7 @@ async function handleCommand(bot, config, conversations, message, text, isSuperA
289
357
  state.sessions[agent] = selected.id;
290
358
  state.selectedSessions[agent] = { id: selected.id, cwd: selected.cwd };
291
359
  state.selectedAgent = agent;
360
+ setPrivateAgent(message.conversationId, agent);
292
361
  await bot.sendText(message.conversationId, [
293
362
  `已切换到 ${agentLabel(agent)} Session。`,
294
363
  `Session: ${selected.id}`,
@@ -383,6 +452,7 @@ async function handleCommand(bot, config, conversations, message, text, isSuperA
383
452
  state.sessions[agent] = selected.id;
384
453
  state.selectedSessions[agent] = { id: selected.id, cwd: selected.cwd };
385
454
  state.selectedAgent = agent;
455
+ setPrivateAgent(message.conversationId, agent);
386
456
  await bot.sendText(message.conversationId, [
387
457
  `已切换到管理员 ${agentLabel(agent)} Session。`,
388
458
  `Session: ${selected.id}`,
@@ -446,7 +516,8 @@ async function handleCommand(bot, config, conversations, message, text, isSuperA
446
516
  state.sessions = {};
447
517
  state.selectedSessions = {};
448
518
  state.visibleSessionLists = {};
449
- state.selectedAgent = undefined;
519
+ // 只清空 session 与工作路径绑定,保留当前选中的 Agent,
520
+ // 否则下一条消息会退回默认 Agent(看起来像被自动切换了)。
450
521
  state.adminWorkDir = undefined;
451
522
  state.adminDirectories = undefined;
452
523
  await bot.sendText(message.conversationId, "已清空当前会话的 Agent session 和工作路径绑定。");
@@ -457,6 +528,8 @@ async function handleCommand(bot, config, conversations, message, text, isSuperA
457
528
  export async function runApp(configOverride, options = {}) {
458
529
  const config = configOverride ?? loadConfig();
459
530
  const bot = new DingTalkBot(config);
531
+ const aiCardClient = new DingTalkAiCardClient();
532
+ aiCardClient.setCredentials(config.dingtalkClientId, config.dingtalkClientSecret, config.dingtalkClientId);
460
533
  const conversations = new Map();
461
534
  // Stream callbacks can still be redelivered when an ACK is lost, and DWS /
462
535
  // history paths may surface the same content twice. Without message-level
@@ -546,6 +619,8 @@ export async function runApp(configOverride, options = {}) {
546
619
  if (control && typeof control === "object") {
547
620
  const state = getState(conversations, message.conversationId, privateUserWorkDir(message));
548
621
  state.selectedAgent = control.agent;
622
+ // 只绑定到当前会话:别的私聊/群聊不受影响,重启后这个会话仍记得。
623
+ setPrivateAgent(message.conversationId, control.agent);
549
624
  await bot.sendText(message.conversationId, agentSwitchMessage(control.agent));
550
625
  return;
551
626
  }
@@ -557,10 +632,11 @@ export async function runApp(configOverride, options = {}) {
557
632
  const selectedAgent = state.selectedAgent ?? options.getAgent?.() ?? config.agent;
558
633
  const currentConfig = {
559
634
  ...config,
635
+ // Session listing and command handling must run in the same isolated
636
+ // directory the Agent will execute in, for Codex, Pi and OpenCode alike.
637
+ codexWorkDir: state.defaultWorkDir,
560
638
  agent: selectedAgent,
561
- agentModel: selectedAgent !== "codex"
562
- ? options.getAgentModel?.(selectedAgent) ?? (config.agentModels[selectedAgent] || undefined)
563
- : undefined,
639
+ agentModel: options.getAgentModel?.(selectedAgent) ?? (config.agentModels[selectedAgent] || undefined),
564
640
  };
565
641
  if (message.msgtype === "text" && await handleCommand(bot, currentConfig, conversations, message, text, isSuperAdminUser))
566
642
  return;
@@ -575,8 +651,13 @@ export async function runApp(configOverride, options = {}) {
575
651
  const steer = state.activeAgent === "pi" && state.busy && state.steer && state.steerTaskToken ? state.steer : undefined;
576
652
  if (steer && text) {
577
653
  const steered = steer(text);
654
+ if (!steered) {
655
+ // 引导失败时必须真的入队,否则“已排队等待处理”只是空话,消息会被丢掉。
656
+ state.pendingMessages.push(message);
657
+ state.pendingNoticeSent = true;
658
+ }
578
659
  await bot.sendText(message.conversationId, steered
579
- ? "[灵感]已将这条消息作为引导发送给当前 Pi 任务。"
660
+ ? "[灵感] 已将这条消息作为引导发送给当前 Pi 任务。"
580
661
  : "当前 Pi 任务暂时无法接收引导,消息已排队等待处理。");
581
662
  }
582
663
  else if ((state.activeAgent === "codex" || state.activeAgent === "opencode") && text) {
@@ -602,13 +683,9 @@ export async function runApp(configOverride, options = {}) {
602
683
  state.activeFingerprint = messageFingerprint;
603
684
  const taskToken = `${message.conversationId}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
604
685
  state.steerTaskToken = taskToken;
605
- const modelName = selectedAgent !== "codex"
606
- ? shortModelName(options.getAgentModel?.(selectedAgent) ?? config.agentModels[selectedAgent])
607
- : "";
608
- const label = `${agentLabel(selectedAgent)} Agent`;
609
- const processingLabel = selectedAgent === "codex"
610
- ? `${agentLabel(selectedAgent)} Agent`
611
- : `${agentLabel(selectedAgent)} ${modelName || "默认模型"}`;
686
+ const modelName = shortModelName(options.getAgentModel?.(selectedAgent) ?? config.agentModels[selectedAgent]);
687
+ const label = agentLabel(selectedAgent);
688
+ const processingLabel = `${agentLabel(selectedAgent)} ${modelName || "默认模型"}`;
612
689
  const processingMessage = `[OMG] ${processingLabel} 正在分析...`;
613
690
  const taskStartedAt = Date.now();
614
691
  const formatElapsed = () => {
@@ -629,10 +706,45 @@ export async function runApp(configOverride, options = {}) {
629
706
  // The setting only controls the live processing title. Completion always
630
707
  // includes the elapsed time as a useful final result summary.
631
708
  const finishedTitle = (icon, state) => `${icon} ${title}${state} 总耗时 ${formatElapsed()}`;
632
- const responseMode = options.getResponseMode?.() ?? "card";
633
- const reply = responseMode === "card"
634
- ? await bot.sendThinkingCard(message, processingMessage, processingTitle())
635
- : { conversationId: message.conversationId, mode: "text" };
709
+ const requestedMode = options.getResponseMode?.() ?? "card";
710
+ const aiCardConfig = options.getAiCardConfig?.() ?? { templateId: "", contentKey: "content", streamIntervalMs: 500 };
711
+ let useAiCard = requestedMode === "aiCard" && Boolean(aiCardConfig.templateId) && aiCardClient.configured;
712
+ if (requestedMode === "aiCard" && !useAiCard) {
713
+ log.warn(`AI card unavailable (template=${aiCardConfig.templateId ? "set" : "empty"}, credentials=${aiCardClient.configured}); using standard card`);
714
+ }
715
+ const responseMode = requestedMode === "text" ? "text" : "card";
716
+ let aiCardSession;
717
+ let reply;
718
+ if (responseMode === "card") {
719
+ if (useAiCard) {
720
+ try {
721
+ aiCardClient.setCredentials(config.dingtalkClientId, config.dingtalkClientSecret, message.robotCode || config.dingtalkClientId);
722
+ const session = new AiCardSession({
723
+ client: aiCardClient,
724
+ templateId: aiCardConfig.templateId,
725
+ contentKey: aiCardConfig.contentKey,
726
+ log,
727
+ });
728
+ await session.openForSingle({
729
+ userId: message.senderStaffId || message.senderId,
730
+ title: `【${label}】${modelName || "默认模型"} 进行中...`,
731
+ });
732
+ aiCardSession = session;
733
+ reply = { conversationId: message.conversationId, mode: "card", cardBizId: session.outTrackId };
734
+ }
735
+ catch (err) {
736
+ log.warn(`AI card create failed; falling back to standard card: ${String(err)}`);
737
+ useAiCard = false;
738
+ reply = await bot.sendThinkingCard(message, processingMessage, processingTitle());
739
+ }
740
+ }
741
+ else {
742
+ reply = await bot.sendThinkingCard(message, processingMessage, processingTitle());
743
+ }
744
+ }
745
+ else {
746
+ reply = { conversationId: message.conversationId, mode: "text" };
747
+ }
636
748
  if (responseMode === "text")
637
749
  await bot.sendText(message.conversationId, processingMessage);
638
750
  let elapsedTimer;
@@ -640,14 +752,13 @@ export async function runApp(configOverride, options = {}) {
640
752
  const agent = selectedAgent;
641
753
  let prompt = "";
642
754
  try {
643
- let latestStats = {};
644
755
  let streamedText = "";
645
756
  let toolStatus = "";
646
757
  let lastUpdateAt = 0;
647
758
  let pendingUpdate;
648
759
  let cardUpdatesStopped = false;
649
760
  let cardUpdateChain = Promise.resolve();
650
- const getCardUpdateInterval = () => Math.max(0, options.getCardUpdateIntervalMs?.() ?? 3_000);
761
+ const getCardUpdateInterval = () => useAiCard ? Math.max(0, aiCardConfig.streamIntervalMs) : Math.max(0, options.getCardUpdateIntervalMs?.() ?? 3_000);
651
762
  const liveCardUpdates = responseMode === "card" && getCardUpdateInterval() > 0;
652
763
  const stopCardUpdates = () => {
653
764
  cardUpdatesStopped = true;
@@ -677,6 +788,10 @@ export async function runApp(configOverride, options = {}) {
677
788
  cardUpdateChain = cardUpdateChain.then(async () => {
678
789
  if (cardUpdatesStopped)
679
790
  return;
791
+ if (aiCardSession) {
792
+ await aiCardSession.push(content);
793
+ return;
794
+ }
680
795
  await bot.updateReply(reply, title, content, { fallbackToText: false });
681
796
  }).catch((err) => {
682
797
  log.warn(`card update skipped: ${err instanceof Error ? err.message : String(err)}`);
@@ -695,7 +810,7 @@ export async function runApp(configOverride, options = {}) {
695
810
  pendingUpdate.unref();
696
811
  }
697
812
  };
698
- if (liveCardUpdates) {
813
+ if (liveCardUpdates && !useAiCard) {
699
814
  elapsedTimer = setInterval(() => {
700
815
  if (!cardUpdatesStopped)
701
816
  updateCard(processingTitle(), latestCardContent);
@@ -715,18 +830,20 @@ export async function runApp(configOverride, options = {}) {
715
830
  state.steer = steer;
716
831
  },
717
832
  onText: (content) => {
718
- const stats = formatStats(latestStats);
719
833
  streamedText = content;
720
- toolStatus = "";
721
- if (liveCardUpdates)
722
- updateCard(processingTitle(), buildCardContent(toolStatus ? `${streamedText}\n\n${toolStatus}` : (streamedText || content), stats ? `工具:${stats}` : undefined));
834
+ if (liveCardUpdates && content.trim()) {
835
+ toolStatus = "";
836
+ updateCard(processingTitle(), buildCardContent(content));
837
+ }
723
838
  },
724
839
  onToolUse: (toolName, stats) => {
725
- latestStats = stats;
726
- const statsText = formatStats(stats);
727
- toolStatus = `[OMG] 调用工具:${toolName} x${stats[toolName] ?? 1}`;
728
- if (liveCardUpdates)
729
- updateCard(processingTitle(), buildCardContent(streamedText ? `${streamedText}\n\n${toolStatus}` : toolStatus, statsText ? `工具:${statsText}` : undefined));
840
+ const totalCalls = Object.values(stats).reduce((sum, count) => sum + (count || 0), 0);
841
+ log.info(`tool=${toolName} total=${totalCalls}`);
842
+ // AI 卡片在工具调用/等待期显示实时调用次数,出字后清除。
843
+ if (!useAiCard || !liveCardUpdates)
844
+ return;
845
+ toolStatus = `[OMG] 正在调用工具(已 ${totalCalls} 次)…`;
846
+ updateCard(processingTitle(), buildCardContent(streamedText ? `${streamedText}\n\n${toolStatus}` : toolStatus));
730
847
  },
731
848
  });
732
849
  if (elapsedTimer) {
@@ -744,16 +861,32 @@ export async function runApp(configOverride, options = {}) {
744
861
  stopCardUpdatesForCurrentTask = undefined;
745
862
  state.sessions[agent] = result.sessionId ?? state.sessions[agent];
746
863
  if (state.sessions[agent]) {
747
- privateSessionBindings.set(privateSessionKey(message.conversationId, agent), state.sessions[agent]);
864
+ privateSessionBindings.set(privateSessionKey(message.conversationId, agent, selectedSession?.cwd ?? state.defaultWorkDir), state.sessions[agent]);
748
865
  savePrivateSessions();
749
866
  }
750
867
  const toolCount = Object.values(result.toolStats).reduce((total, count) => total + count, 0);
751
- const note = `[夯爆了] ${modelName || "默认模型"} 1条消息 ${toolCount}次工具`;
752
- const finalContent = buildCardContent(result.text, options.getShowProcessingDetails?.() === true ? note : undefined);
753
- if (responseMode === "card")
754
- await bot.updateReply(reply, finishedTitle("✅", "完成"), finalContent);
755
- else
756
- await bot.sendText(message.conversationId, result.text.trim() || "(无输出)");
868
+ const showDetails = options.getShowProcessingDetails?.() === true;
869
+ const note = `${modelName || "默认模型"} 1条消息,${toolCount}次工具`;
870
+ if (responseMode === "card" && aiCardSession) {
871
+ // AI 卡片:正文不带结束语,结束语走模板的 $end_text 变量。
872
+ const cardContent = buildCardContent(result.text);
873
+ const delivered = await aiCardSession.finish({
874
+ content: cardContent,
875
+ title: `【${label}】完成 总耗时 ${formatElapsed()}`,
876
+ endText: showDetails ? note : undefined,
877
+ });
878
+ if (!delivered) {
879
+ await bot.sendText(message.conversationId, `${normalizeDingTalkMarkdown(result.text).trim() || "(无输出)"}\n\n总耗时 ${formatElapsed()}`)
880
+ .catch((sendErr) => log.warn(`AI card text fallback failed: ${String(sendErr)}`));
881
+ }
882
+ }
883
+ else if (responseMode === "card") {
884
+ // 非 AI 卡片在结束语前面加上「[夯爆了]」标记。
885
+ await bot.updateReply(reply, finishedTitle("✅", "完成"), buildCardContent(result.text, showDetails ? `[夯爆了] ${note}` : undefined));
886
+ }
887
+ else {
888
+ await bot.sendText(message.conversationId, normalizeDingTalkMarkdown(result.text).trim() || "(无输出)");
889
+ }
757
890
  await appendConversationLog({
758
891
  id: `${message.conversationId}:${taskStartedAt}`,
759
892
  createdAt: new Date().toISOString(),
@@ -780,10 +913,15 @@ export async function runApp(configOverride, options = {}) {
780
913
  state.stopCardUpdates = undefined;
781
914
  stopCardUpdatesForCurrentTask = undefined;
782
915
  log.info(`${label} task paused by user`);
783
- if (responseMode === "card")
916
+ if (responseMode === "card" && aiCardSession) {
917
+ await aiCardSession.finish({ content: latestCardContent, title: `【${label}】处理暂停 总耗时 ${formatElapsed()}`, error: true });
918
+ }
919
+ else if (responseMode === "card") {
784
920
  await bot.updateReply(reply, finishedTitle("🔴", "处理暂停"), latestCardContent);
785
- else
921
+ }
922
+ else {
786
923
  await bot.sendText(message.conversationId, "Agent 任务已暂停。");
924
+ }
787
925
  await appendConversationLog({
788
926
  id: `${message.conversationId}:${taskStartedAt}`,
789
927
  createdAt: new Date().toISOString(), conversationType: "personal",
@@ -800,10 +938,15 @@ export async function runApp(configOverride, options = {}) {
800
938
  stopCardUpdatesForCurrentTask = undefined;
801
939
  log.error(`${label} execution failed`, err);
802
940
  const errorMessage = err instanceof Error ? err.message : String(err);
803
- if (responseMode === "card")
941
+ if (responseMode === "card" && aiCardSession) {
942
+ await aiCardSession.finish({ content: `${label} 执行失败:${errorMessage}`, title: `【${label}】处理失败 总耗时 ${formatElapsed()}`, error: true });
943
+ }
944
+ else if (responseMode === "card") {
804
945
  await bot.updateReply(reply, finishedTitle("❌", "处理失败"), `${label} 执行失败:${errorMessage}`);
805
- else
946
+ }
947
+ else {
806
948
  await bot.sendText(message.conversationId, `${label} 执行失败:${errorMessage}`);
949
+ }
807
950
  await appendConversationLog({
808
951
  id: `${message.conversationId}:${taskStartedAt}`,
809
952
  createdAt: new Date().toISOString(),
@@ -859,6 +1002,16 @@ export async function runApp(configOverride, options = {}) {
859
1002
  let privateChatConnected = false;
860
1003
  let privateChatStarting = false;
861
1004
  let privateChatGeneration = 0;
1005
+ // 只在(启用状态, 连接状态)真的变化时写一次状态文件,避免看板读到过期值。
1006
+ let lastReportedStatusKey;
1007
+ const reportConnectionStatus = (connected) => {
1008
+ const enabled = options.getPrivateChatEnabled?.() ?? true;
1009
+ const key = `${enabled}|${connected}`;
1010
+ if (key === lastReportedStatusKey)
1011
+ return;
1012
+ lastReportedStatusKey = key;
1013
+ options.onConnectionStatus?.(connected);
1014
+ };
862
1015
  const privateChatTimer = setInterval(() => {
863
1016
  const enabled = options.getPrivateChatEnabled?.() ?? true;
864
1017
  if (!enabled) {
@@ -867,29 +1020,37 @@ export async function runApp(configOverride, options = {}) {
867
1020
  privateChatConnected = false;
868
1021
  privateChatStarting = false;
869
1022
  bot.stop();
870
- options.onConnectionStatus?.(false);
1023
+ reportConnectionStatus(false);
871
1024
  log.info("private chat stream stopped by configuration");
872
1025
  }
873
1026
  return;
874
1027
  }
875
- if (privateChatConnected || privateChatStarting)
1028
+ if (privateChatConnected || privateChatStarting) {
1029
+ // 流保持连接时,如果开关变了也要刷新状态,否则看板会一直显示未连接。
1030
+ reportConnectionStatus(privateChatConnected);
876
1031
  return;
1032
+ }
877
1033
  privateChatStarting = true;
878
1034
  const generation = privateChatGeneration;
879
1035
  log.info("private chat stream starting by configuration");
880
1036
  void bot.start(handleMessage).then(() => {
881
1037
  if (generation !== privateChatGeneration || options.getPrivateChatEnabled?.() === false) {
1038
+ // 启动过程中开关又变了:停掉并复位状态。不复位 privateChatStarting
1039
+ // 会让定时器以后每秒都提前 return,流永远不再重连。
1040
+ privateChatStarting = false;
1041
+ privateChatConnected = false;
882
1042
  bot.stop();
1043
+ reportConnectionStatus(false);
883
1044
  return;
884
1045
  }
885
1046
  privateChatStarting = false;
886
1047
  privateChatConnected = true;
887
- options.onConnectionStatus?.(true);
1048
+ reportConnectionStatus(true);
888
1049
  log.info("private chat stream started by configuration");
889
1050
  }).catch((err) => {
890
1051
  privateChatStarting = false;
891
1052
  privateChatConnected = false;
892
- options.onConnectionStatus?.(false);
1053
+ reportConnectionStatus(false);
893
1054
  log.error("private chat stream restart failed", err);
894
1055
  });
895
1056
  }, 1_000);
@@ -904,7 +1065,9 @@ export async function runApp(configOverride, options = {}) {
904
1065
  await bot.start(handleMessage);
905
1066
  privateChatStarting = false;
906
1067
  privateChatConnected = true;
907
- options.onConnectionStatus?.(true);
908
1068
  }
1069
+ // 启动时无论开关状态都写一次状态文件(enabled 取实时配置、connected 取实际连接),
1070
+ // 避免看板看到上一次进程遗留的旧值。
1071
+ reportConnectionStatus(privateChatConnected);
909
1072
  log.info(`ready workDir=${config.codexWorkDir} codex=${config.codexCliPath}`);
910
1073
  }
@@ -3,7 +3,7 @@ import { open, unlink } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { runApp } from "./bot-app.js";
6
- import { resolveAgentModel } from "./config.js";
6
+ import { resolveAgentModel } from "./core/config.js";
7
7
  const stateDir = join(homedir(), ".oh-my-im");
8
8
  const dashboardConfigFile = join(stateDir, "dws-dashboard.json");
9
9
  const lockFile = join(stateDir, "omi-bot.lock");
@@ -62,11 +62,11 @@ function loadBotConfig() {
62
62
  codexCliPath: "codex",
63
63
  codexWorkDir: defaultWorkDir,
64
64
  agentModels: {
65
- codex: "",
65
+ codex: resolveAgentModel("codex", credentials.agentModels, credentials.agent, credentials.agentModel) || "",
66
66
  pi: resolveAgentModel("pi", credentials.agentModels, credentials.agent, credentials.agentModel) || "",
67
67
  opencode: resolveAgentModel("opencode", credentials.agentModels, credentials.agent, credentials.agentModel) || "",
68
68
  },
69
- agentModel: credentials.agent === "pi" || credentials.agent === "opencode"
69
+ agentModel: credentials.agent === "pi" || credentials.agent === "opencode" || credentials.agent === "codex"
70
70
  ? resolveAgentModel(credentials.agent, credentials.agentModels, credentials.agent, credentials.agentModel)
71
71
  : undefined,
72
72
  codexPermissionMode: "bypass",
@@ -98,6 +98,13 @@ function markBotStopped() {
98
98
  }
99
99
  const releaseLock = await acquireLock();
100
100
  process.once("exit", () => {
101
+ // 进程真正退出时才写“已停止”,否则会覆盖 runApp 初始化后写入的“已连接”。
102
+ try {
103
+ markBotStopped();
104
+ }
105
+ catch {
106
+ // status is diagnostic only
107
+ }
101
108
  try {
102
109
  unlinkSync(lockFile);
103
110
  }
@@ -130,8 +137,6 @@ try {
130
137
  }
131
138
  },
132
139
  getAgentModel: (agent) => {
133
- if (agent === "codex")
134
- return undefined;
135
140
  try {
136
141
  const current = JSON.parse(readFileSync(dashboardConfigFile, "utf8"));
137
142
  const model = resolveAgentModel(agent, current.agentModels, current.agent, current.agentModel);
@@ -145,12 +150,26 @@ try {
145
150
  getResponseMode: () => {
146
151
  try {
147
152
  const current = JSON.parse(readFileSync(dashboardConfigFile, "utf8"));
148
- return current.responseMode === "text" ? "text" : "card";
153
+ return current.responseMode === "text" ? "text" : current.responseMode === "aiCard" ? "aiCard" : "card";
149
154
  }
150
155
  catch {
151
156
  return "card";
152
157
  }
153
158
  },
159
+ getAiCardConfig: () => {
160
+ // Live value so a template ID saved in the console applies to the next message.
161
+ try {
162
+ const current = JSON.parse(readFileSync(dashboardConfigFile, "utf8"));
163
+ return {
164
+ templateId: typeof current.aiCardTemplateId === "string" ? current.aiCardTemplateId.trim() : "",
165
+ contentKey: typeof current.aiCardContentKey === "string" && current.aiCardContentKey.trim() ? current.aiCardContentKey.trim() : "content",
166
+ streamIntervalMs: Number.isFinite(current.aiCardStreamIntervalMs) ? Number(current.aiCardStreamIntervalMs) : 500,
167
+ };
168
+ }
169
+ catch {
170
+ return { templateId: "", contentKey: "content", streamIntervalMs: 500 };
171
+ }
172
+ },
154
173
  getShowProcessingDetails: () => {
155
174
  try {
156
175
  const current = JSON.parse(readFileSync(dashboardConfigFile, "utf8"));
@@ -228,7 +247,10 @@ try {
228
247
  },
229
248
  });
230
249
  }
231
- finally {
250
+ catch (err) {
251
+ // 只有初始化失败才在这里收尾;正常情况 runApp 返回后 Stream/定时器仍在运行,
252
+ // markBotStopped 交给 process exit 处理。
232
253
  markBotStopped();
233
254
  await releaseLock();
255
+ throw err;
234
256
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
- import { normalizeAgentModel } from "./dws-dashboard.js";
3
+ import { normalizeAgentModel } from "../dws-dashboard.js";
4
4
  export function resolveAgentModel(agent, agentModels, activeAgent, legacyModel) {
5
5
  const configured = agentModels?.[agent]?.trim() || (activeAgent === agent ? legacyModel?.trim() : "") || "";
6
6
  return normalizeAgentModel(agent, configured) || undefined;
@@ -32,11 +32,11 @@ export function loadConfig() {
32
32
  codexCliPath: "codex",
33
33
  codexWorkDir: resolveWorkDir(),
34
34
  agentModels: {
35
- codex: "",
35
+ codex: resolveAgentModel("codex", local.agentModels, local.agent, local.agentModel) || "",
36
36
  pi: resolveAgentModel("pi", local.agentModels, local.agent, local.agentModel) || "",
37
37
  opencode: resolveAgentModel("opencode", local.agentModels, local.agent, local.agentModel) || "",
38
38
  },
39
- agentModel: local.agent === "pi" || local.agent === "opencode"
39
+ agentModel: local.agent === "pi" || local.agent === "opencode" || local.agent === "codex"
40
40
  ? resolveAgentModel(local.agent, local.agentModels, local.agent, local.agentModel)
41
41
  : undefined,
42
42
  codexPermissionMode: "bypass",
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  * through here instead of parsing package.json on their own, and callers can
8
8
  * read it again after an in-place upgrade so they never report a stale value.
9
9
  */
10
- const packageFile = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
10
+ const packageFile = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
11
11
  export function readVersion() {
12
12
  try {
13
13
  const parsed = JSON.parse(readFileSync(packageFile, "utf8"));