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.
@@ -1,18 +1,21 @@
1
1
  import { mkdir, open, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
- import { unlinkSync } from "node:fs";
3
+ import { existsSync, mkdirSync, unlinkSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { createInterface } from "node:readline";
7
7
  import { agentLabel, agentSwitchMessage, runAgent } from "./agents/index.js";
8
- import { DingTalkCardClient } from "./dingtalk-card.js";
9
- import { applyMonitorCommand, parseMonitorCommand } from "./monitor-command.js";
8
+ import { DingTalkCardClient } from "./dingtalk/dingtalk-card.js";
9
+ import { DingTalkAiCardClient } from "./dingtalk/dingtalk-ai-card.js";
10
+ import { AiCardSession } from "./dingtalk/ai-card.js";
11
+ import { applyMonitorCommand, parseMonitorCommand } from "./core/monitor-command.js";
10
12
  import { normalizeAgentModel, } from "./dws-dashboard.js";
11
- import { dwsPath, listConversations, getCurrentDwsUser, listGroupBots, addBotToGroup, listGroupMembers, searchBots, startGroupEventStream, } from "./dws-client.js";
12
- import { createLogger } from "./logger.js";
13
- import { sendRobotGroupText, sendRobotWebhookText } from "./dingtalk-robot.js";
14
- import { appendConversationLog } from "./conversation-log.js";
15
- import { startPersonalHistoryPolling } from "./dws-history.js";
13
+ import { dwsPath, listConversations, getCurrentDwsUser, listGroupBots, listGroupBotMembers, addBotToGroup, listGroupMembers, searchBots, startGroupEventStream, } from "./dws/dws-client.js";
14
+ import { createLogger } from "./core/logger.js";
15
+ import { normalizeDingTalkMarkdown } from "./dingtalk/markdown.js";
16
+ import { sendRobotGroupText, sendRobotWebhookText } from "./dingtalk/dingtalk-robot.js";
17
+ import { appendConversationLog } from "./core/conversation-log.js";
18
+ import { startPersonalHistoryPolling } from "./dws/dws-history.js";
16
19
  const log = createLogger("group-worker");
17
20
  function notifyGroupFailure(groupId, context, err, config) {
18
21
  const reason = err instanceof Error ? err.message : String(err);
@@ -32,15 +35,18 @@ async function sendRobotText(openConversationId, config, content) {
32
35
  log.info(`robot API send openConversationId=${openConversationId} robotCode=${config.clientId} robotName=${config.robotName}`);
33
36
  return sendRobotGroupText(openConversationId, content, config.clientId, config.clientId, config.clientSecret);
34
37
  }
35
- const DEFAULT_CODEX_WORK_DIR = process.cwd();
36
38
  const DEFAULT_DWS_CODEX_TIMEOUT_MS = 300_000;
37
39
  const DWS_GROUP_MESSAGE_EVENT = "user_im_message_receive_group_all";
40
+ // Shared AI card client. Credentials are (re)applied whenever the dashboard
41
+ // config is loaded so the console template ID takes effect without a restart.
42
+ const aiCardClient = new DingTalkAiCardClient();
38
43
  const DATA_DIR = join(homedir(), ".oh-my-im");
39
44
  const LEGACY_DATA_DIR = join(process.cwd(), ".oh-my-im");
40
45
  const CONFIG_MIGRATION_FILE = join(DATA_DIR, ".config-location-v1");
41
46
  const LISTENER_LOCK_FILE = join(DATA_DIR, "group-worker.lock");
42
47
  const CARD_STATE_FILE = join(DATA_DIR, "dws-cards.json");
43
48
  const GROUP_SESSIONS_FILE = join(DATA_DIR, "group-sessions.json");
49
+ const GROUP_AGENTS_FILE = join(DATA_DIR, "group-agents.json");
44
50
  const DASHBOARD_CONFIG_FILE = join(DATA_DIR, "dws-dashboard.json");
45
51
  const DASHBOARD_SERVER_CONFIG_FILE = join(DATA_DIR, "dws-dashboard-server.json");
46
52
  const REPLY_HISTORY_DIR = join(DATA_DIR, "replies");
@@ -67,6 +73,23 @@ async function saveGroupSessions(sessions) {
67
73
  await writeFile(temporary, `${JSON.stringify(Object.fromEntries(sessions), null, 2)}\n`, "utf8");
68
74
  await rename(temporary, GROUP_SESSIONS_FILE);
69
75
  }
76
+ // Agent 按群绑定:某群切了 Agent 不影响其他群,重启后每个群各自记住。
77
+ let groupAgentBindings = new Map();
78
+ async function loadGroupAgents() {
79
+ try {
80
+ const value = JSON.parse(await readFile(GROUP_AGENTS_FILE, "utf8"));
81
+ return new Map(Object.entries(value).filter((entry) => entry[1] === "pi" || entry[1] === "codex" || entry[1] === "opencode"));
82
+ }
83
+ catch {
84
+ return new Map();
85
+ }
86
+ }
87
+ async function saveGroupAgents(agents) {
88
+ await mkdir(DATA_DIR, { recursive: true });
89
+ const temporary = `${GROUP_AGENTS_FILE}.${process.pid}.tmp`;
90
+ await writeFile(temporary, `${JSON.stringify(Object.fromEntries(agents), null, 2)}\n`, "utf8");
91
+ await rename(temporary, GROUP_AGENTS_FILE);
92
+ }
70
93
  function hasMention(event) {
71
94
  const hasValue = (value) => {
72
95
  if (Array.isArray(value))
@@ -229,10 +252,10 @@ async function acquireListenerLock() {
229
252
  async function loadCardState() {
230
253
  try {
231
254
  const parsed = JSON.parse(await readFile(CARD_STATE_FILE, "utf8"));
232
- const cards = Object.fromEntries(Object.entries(parsed.cards ?? {}).map(([groupId, card]) => [
233
- groupId,
234
- typeof card === "string" ? { cardBizId: card, status: "completed" } : card,
235
- ]));
255
+ const cards = Object.fromEntries(Object.entries(parsed.cards ?? {})
256
+ .map(([groupId, card]) => [groupId, typeof card === "string" ? { cardBizId: card, status: "completed" } : card])
257
+ // 旧格式遗留的空条目(key cardBizId/status 等、cardBizId 为空)直接丢弃。
258
+ .filter((entry) => Boolean(entry[1]?.cardBizId)));
236
259
  return { cards };
237
260
  }
238
261
  catch (err) {
@@ -260,8 +283,6 @@ function defaultBotAllowedUserIds(targets) {
260
283
  function normalizeDashboardConfig(parsed) {
261
284
  if (!Array.isArray(parsed.targets))
262
285
  throw new Error("targets is invalid");
263
- if (parsed.replyFormat !== "markdown" && parsed.replyFormat !== "plain")
264
- throw new Error("replyFormat is invalid");
265
286
  const targets = parsed.targets;
266
287
  const legacyModel = parsed.agentModel?.trim() || "";
267
288
  const configuredModels = parsed.agentModels ?? { codex: "", pi: "", opencode: "" };
@@ -292,7 +313,7 @@ function normalizeDashboardConfig(parsed) {
292
313
  : {},
293
314
  robotSenderOpenDingTalkId: parsed.robotSenderOpenDingTalkId?.trim() || "",
294
315
  agentModels: {
295
- codex: "",
316
+ codex: configuredModels.codex?.trim() || "",
296
317
  pi: normalizeAgentModel("pi", configuredModels.pi?.trim() || (parsed.agent === "pi" ? legacyModel : "")),
297
318
  opencode: normalizeAgentModel("opencode", configuredModels.opencode?.trim() || (parsed.agent === "opencode" ? legacyModel : "")),
298
319
  },
@@ -301,6 +322,9 @@ function normalizeDashboardConfig(parsed) {
301
322
  ? parsed.commandKeywords
302
323
  : structuredClone(EMPTY_COMMAND_KEYWORDS),
303
324
  groupPromptSuffix: promptSuffix,
325
+ aiCardTemplateId: typeof parsed.aiCardTemplateId === "string" ? parsed.aiCardTemplateId.trim() : "",
326
+ aiCardContentKey: (typeof parsed.aiCardContentKey === "string" ? parsed.aiCardContentKey.trim() : "") || "content",
327
+ aiCardStreamIntervalMs: Number.isFinite(parsed.aiCardStreamIntervalMs) ? Math.max(0, Math.min(60_000, Number(parsed.aiCardStreamIntervalMs))) : 500,
304
328
  robotName: parsed.robotName?.trim() || DEFAULT_ROBOT_NAME,
305
329
  clientId: parsed.clientId?.trim() || DEFAULT_DINGTALK_CLIENT_ID,
306
330
  clientSecret: parsed.clientSecret?.trim() || DEFAULT_DINGTALK_CLIENT_SECRET,
@@ -382,11 +406,13 @@ async function loadDashboardConfig() {
382
406
  botSuperAdminUserIds: [],
383
407
  botSuperAdminUserNames: {},
384
408
  robotSenderOpenDingTalkId: "",
385
- replyFormat: "markdown",
386
409
  agentModels: { codex: "", pi: "", opencode: "" },
387
410
  agent: "codex",
388
411
  commandKeywords: structuredClone(EMPTY_COMMAND_KEYWORDS),
389
412
  groupPromptSuffix: DEFAULT_GROUP_PROMPT_SUFFIX,
413
+ aiCardTemplateId: "",
414
+ aiCardContentKey: "content",
415
+ aiCardStreamIntervalMs: 500,
390
416
  robotName: DEFAULT_ROBOT_NAME,
391
417
  clientId: DEFAULT_DINGTALK_CLIENT_ID,
392
418
  clientSecret: DEFAULT_DINGTALK_CLIENT_SECRET,
@@ -574,17 +600,24 @@ async function handleMonitorCommand(command, event, groupId, getDashboardConfig,
574
600
  senderId,
575
601
  senderName,
576
602
  };
603
+ if (typeof command === "object" && command.type === "switch-agent") {
604
+ // Agent 只绑定到当前群,不再改全局默认。
605
+ groupAgentBindings.set(groupId, command.agent);
606
+ await saveGroupAgents(groupAgentBindings);
607
+ if (sendReply)
608
+ await sendRobotText(groupId, config, agentSwitchMessage(command.agent));
609
+ log.info(`monitor command=switch-agent:${command.agent} group=${groupId} replied=${sendReply}`);
610
+ return;
611
+ }
577
612
  const result = applyMonitorCommand(config, command, target);
578
613
  if (result.changed)
579
614
  await updateDashboardConfig(result.config);
580
- const detail = typeof command === "object"
581
- ? agentSwitchMessage(command.agent)
582
- : command === "open"
583
- ? `已开启 ${senderName} 在本群的AI 能力。`
584
- : `已关闭 ${senderName} 在本群的AI 能力。`;
615
+ const detail = command === "open"
616
+ ? `已开启 ${senderName} 在本群的AI 能力。`
617
+ : `已关闭 ${senderName} 在本群的AI 能力。`;
585
618
  if (sendReply)
586
619
  await sendRobotText(groupId, config, detail);
587
- log.info(`monitor command=${typeof command === "object" ? command.type + ":" + command.agent : command} group=${groupId} changed=${result.changed} replied=${sendReply}`);
620
+ log.info(`monitor command=${command} group=${groupId} changed=${result.changed} replied=${sendReply}`);
588
621
  }
589
622
  function senderDisplayName(event) {
590
623
  const rawSender = event.sender;
@@ -634,19 +667,14 @@ function isCurrentDwsUser(event, currentUser) {
634
667
  function configuredGroupIds(config) {
635
668
  return Array.from(new Set(config.targets.map((target) => target.groupId)));
636
669
  }
637
- function formatReply(content, format) {
638
- if (format === "markdown")
639
- return content;
640
- return content.replace(/[\\`*_{}\[\]<>()#+\-.!|]/g, "\\$&");
670
+ function formatReply(content) {
671
+ // DingTalk markdown has no table support; rewrite tables into readable lists
672
+ // for both the card and the plain-text reply path.
673
+ return normalizeDingTalkMarkdown(content);
641
674
  }
642
- function completedCardContent(content, modelName, messageCount, toolStats, showDetails) {
675
+ function completionNote(modelName, messageCount, toolStats) {
643
676
  const toolCount = Object.values(toolStats).reduce((total, count) => total + count, 0);
644
- if (!showDetails)
645
- return content.trim() || "(无输出)";
646
- return [
647
- content.trim() || "(无输出)",
648
- `[夯爆了] ${modelName || "默认模型"} ${messageCount}条消息 ${toolCount}次工具`,
649
- ].join("\n\n\n");
677
+ return `${modelName || "默认模型"} ${messageCount}条消息,${toolCount}次工具`;
650
678
  }
651
679
  function batchQuestion(events) {
652
680
  return events.map((event) => event.content?.trim()).filter(Boolean).join("\n");
@@ -685,11 +713,53 @@ function getSenderId(event) {
685
713
  (typeof sender.user_id === "string" ? sender.user_id.trim() : "") ||
686
714
  "unknown";
687
715
  }
716
+ const groupBotCache = new Map();
717
+ const groupBotRefreshing = new Set();
718
+ const GROUP_BOT_CACHE_TTL_MS = 5 * 60 * 1000;
719
+ async function refreshGroupBotCache(groupId, force = false) {
720
+ const cached = groupBotCache.get(groupId);
721
+ if (!force && cached && Date.now() - cached.fetchedAt < GROUP_BOT_CACHE_TTL_MS)
722
+ return;
723
+ if (groupBotRefreshing.has(groupId))
724
+ return;
725
+ groupBotRefreshing.add(groupId);
726
+ try {
727
+ const bots = await listGroupBotMembers(groupId);
728
+ groupBotCache.set(groupId, {
729
+ ids: new Map(bots.map((bot) => [bot.senderId.toLowerCase(), bot.senderName])),
730
+ fetchedAt: Date.now(),
731
+ });
732
+ log.info(`group bot members refreshed group=${groupId} bots=${bots.map((bot) => bot.senderName).join(",") || "<none>"}`);
733
+ }
734
+ catch (err) {
735
+ log.warn(`unable to refresh group bot members group=${groupId}: ${String(err)}`);
736
+ // Cache the failure briefly so a broken DWS call does not run on every message.
737
+ if (!cached)
738
+ groupBotCache.set(groupId, { ids: new Map(), fetchedAt: Date.now() });
739
+ }
740
+ finally {
741
+ groupBotRefreshing.delete(groupId);
742
+ }
743
+ }
744
+ // Values of the DingTalk "AI sent" badge/flag that mean the message came from
745
+ // an AI. Anything else (including normal user messages) is not treated as AI so
746
+ // a real person can never be silenced by an unexpected flag value.
747
+ const AI_SEND_FLAG_VALUES = new Set(["DWS", "AI", "AIGC", "AI_TAG", "AITAG", "ROBOT", "BOT", "ASSISTANT", "TRUE", "1", "Y", "YES", "ON"]);
748
+ function isAiSendFlag(event) {
749
+ const raw = [event.messageAiSendFlag, event.aiSendFlag, event.message_ai_send_flag, event.aiTag, event.ai_tag, event.messageAiTag, event.message_ai_tag]
750
+ .find((value) => typeof value === "string" && Boolean(value.trim()));
751
+ if (!raw)
752
+ return false;
753
+ return AI_SEND_FLAG_VALUES.has(raw.trim().toUpperCase());
754
+ }
688
755
  function isIgnoredRobotEvent(event, config) {
689
756
  const sender = event.sender ?? {};
690
757
  const senderId = getSenderId(event).toLowerCase();
691
- const messageAiSendFlag = typeof event.messageAiSendFlag === "string" ? event.messageAiSendFlag.trim().toUpperCase() : "";
692
- if (messageAiSendFlag === "DWS")
758
+ if (isAiSendFlag(event))
759
+ return true;
760
+ // Any bot that is a member of this group is an AI sender.
761
+ const groupId = eventGroupId(event);
762
+ if (groupId && groupBotCache.get(groupId)?.ids.has(senderId))
693
763
  return true;
694
764
  const configuredRobotId = config.robotSenderOpenDingTalkId?.trim().toLowerCase();
695
765
  if (configuredRobotId && senderId === configuredRobotId)
@@ -741,8 +811,38 @@ function isIgnoredRobotEvent(event, config) {
741
811
  return true;
742
812
  return false;
743
813
  }
744
- function codexConfig() {
745
- const workDir = process.env.CODEX_WORK_DIR?.trim() || DEFAULT_CODEX_WORK_DIR;
814
+ function safeDirName(value, fallback) {
815
+ // Keep Chinese characters and normal punctuation, strip anything that could
816
+ // escape the target directory or break on the filesystem.
817
+ const cleaned = value
818
+ .replace(/[\u0000-\u001f\u007f]/g, "")
819
+ .replace(/[\\/:*?"<>|]/g, "_")
820
+ .replace(/\s+/g, " ")
821
+ .replace(/^\.+/, "")
822
+ .trim()
823
+ .slice(0, 80)
824
+ .trim();
825
+ return cleaned || fallback;
826
+ }
827
+ function groupWorkDir(groupId, dashboardConfig, event) {
828
+ // Explicit env override wins (exact directory, no group subfolder).
829
+ const override = process.env.CODEX_WORK_DIR?.trim();
830
+ if (override)
831
+ return override;
832
+ const name = event ? eventGroupName(event, groupId, dashboardConfig) : knownGroupName(groupId);
833
+ return join(DATA_DIR, "group", safeDirName(name, safeDirName(groupId, "unknown")));
834
+ }
835
+ function codexConfig(workDir) {
836
+ // Auto-create a configured directory so all three Agents can spawn in it.
837
+ // Failures are logged (not thrown) and surface later as a spawn error.
838
+ if (!existsSync(workDir)) {
839
+ try {
840
+ mkdirSync(workDir, { recursive: true });
841
+ }
842
+ catch (err) {
843
+ log.warn(`cannot create group work dir ${workDir}: ${String(err)}`);
844
+ }
845
+ }
746
846
  const timeout = Number.parseInt(process.env.DWS_CODEX_TIMEOUT_MS || String(DEFAULT_DWS_CODEX_TIMEOUT_MS), 10);
747
847
  return {
748
848
  dingtalkClientId: "",
@@ -765,12 +865,14 @@ function cleanAgentContent(value) {
765
865
  .replace(/\s*(?:\[?语音消息\]?)(?:\([^)]*\))?\s*$/u, "")
766
866
  .trim();
767
867
  }
768
- function buildPrompt(events) {
868
+ function buildPrompt(events, suffix) {
769
869
  const messageContent = events
770
870
  .map((event) => cleanAgentContent(event.content?.trim() || event.text?.trim() || ""))
771
871
  .filter(Boolean)
772
872
  .join("\n");
773
- return messageContent;
873
+ // Restore the prompt suffix that the OpenCode refactor dropped: it is
874
+ // appended below the user message and applies to every group Agent.
875
+ return [messageContent, suffix.trim()].filter(Boolean).join("\n\n");
774
876
  }
775
877
  async function handleBatch(events, groupId, cardState, sessions, cardClient, getDashboardConfig, replies, liveReplies, queue) {
776
878
  if (events.length === 0)
@@ -779,18 +881,32 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
779
881
  // an already queued history/stream duplicate must not create a new card
780
882
  // after the stopped card has been finalized.
781
883
  const dashboardConfig = getDashboardConfig();
782
- const responseMode = dashboardConfig.responseMode ?? "card";
783
- const agent = dashboardConfig.agent;
784
- const agentConfig = codexConfig();
884
+ const requestedMode = dashboardConfig.responseMode ?? "card";
885
+ const aiTemplateId = dashboardConfig.aiCardTemplateId?.trim() || "";
886
+ const aiContentKey = dashboardConfig.aiCardContentKey?.trim() || "content";
887
+ const aiStreamIntervalMs = Number.isFinite(dashboardConfig.aiCardStreamIntervalMs)
888
+ ? Math.max(0, Number(dashboardConfig.aiCardStreamIntervalMs))
889
+ : 500;
890
+ // AI card needs a template ID and app credentials. Without them fall back to
891
+ // the standard card so a missing configuration never breaks replies.
892
+ let useAiCard = requestedMode === "aiCard" && Boolean(aiTemplateId) && aiCardClient.configured;
893
+ if (requestedMode === "aiCard" && !useAiCard) {
894
+ log.warn(`AI card unavailable (template=${aiTemplateId ? "set" : "empty"}, credentials=${aiCardClient.configured}); using standard card`);
895
+ }
896
+ const responseMode = requestedMode === "text" ? "text" : "card";
897
+ const agent = groupAgentBindings.get(groupId) ?? dashboardConfig.agent;
898
+ const workDir = groupWorkDir(groupId, dashboardConfig, events[0]);
899
+ const agentConfig = codexConfig(workDir);
785
900
  agentConfig.agent = agent;
786
- agentConfig.agentModel = agent === "pi" || agent === "opencode" ? dashboardConfig.agentModels[agent] || undefined : undefined;
901
+ agentConfig.agentModel = agent === "pi" || agent === "opencode" || agent === "codex" ? dashboardConfig.agentModels[agent] || undefined : undefined;
787
902
  const modelName = agentConfig.agentModel?.trim().split("/").pop() || "";
788
- const label = `${agentLabel(agent)} Agent`;
789
- const processingLabel = agent === "codex" ? `${agentLabel(agent)} Agent` : `${agentLabel(agent)} ${modelName || "默认模型"}`;
903
+ const label = agentLabel(agent);
904
+ const processingLabel = `${agentLabel(agent)} ${modelName || "默认模型"}`;
790
905
  const processingMessage = `[OMG] ${processingLabel} 正在分析...`;
791
- const sessionKey = `${agent}:${groupId}`;
906
+ const sessionKey = `${agent}:${groupId}:${workDir}`;
792
907
  const sessionId = sessions.get(sessionKey);
793
908
  log.info(`processing batch size=${events.length} groupSession=${sessionId ? "resume" : "new"}`);
909
+ const cardIdentity = (value) => value.kind === "aiCard" ? value.session.outTrackId : value.handle.cardBizId;
794
910
  let card;
795
911
  let stopCardUpdates = () => undefined;
796
912
  let latestVisibleContent = `${label} 正在处理...`;
@@ -814,12 +930,42 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
814
930
  // The setting only controls the live processing title. Completion always
815
931
  // includes the elapsed time as a useful final result summary.
816
932
  const finishedTitle = (icon, state) => `${icon} ${title}${state} 总耗时 ${formatElapsed()}`;
933
+ // Push a card snapshot. AI cards stream full markdown via the streaming API;
934
+ // standard cards use the cardBizId update API. AI card failures are
935
+ // best-effort and must never fail the agent task.
936
+ const pushCard = async (target, title, content) => {
937
+ if (target.kind === "aiCard") {
938
+ await target.session.push(formatReply(content));
939
+ return;
940
+ }
941
+ await cardClient.update(target.handle, title, formatReply(content));
942
+ };
943
+ const finishCard = async (target, title, content, state, endText) => {
944
+ if (target.kind === "aiCard") {
945
+ const stateLabel = state === "completed" ? "完成" : state === "paused" ? "处理暂停" : "处理失败";
946
+ const statusTitle = `【${label}】${stateLabel} 总耗时 ${formatElapsed()}`;
947
+ const delivered = await target.session.finish({
948
+ content: formatReply(content),
949
+ title: statusTitle,
950
+ endText: endText ? formatReply(endText) : undefined,
951
+ error: state !== "completed",
952
+ });
953
+ // 流式接口完全失败时发一条文本,保证用户仍能拿到结果。
954
+ if (!delivered && state === "completed") {
955
+ await sendRobotText(groupId, getDashboardConfig(), formatReply(`${content}\n\n总耗时 ${formatElapsed()}`))
956
+ .catch((sendErr) => log.warn(`AI card text fallback failed: ${String(sendErr)}`));
957
+ }
958
+ return;
959
+ }
960
+ const cardContent = state === "completed" && endText ? `${content}\n\n\n[夯爆了] ${endText}` : content;
961
+ await cardClient.update(target.handle, title, formatReply(cardContent));
962
+ };
817
963
  try {
818
964
  const storedCard = cardState.cards[groupId];
819
- if (responseMode === "card" && storedCard?.status === "processing") {
820
- card = { groupId, cardBizId: storedCard.cardBizId };
965
+ if (responseMode === "card" && storedCard?.status === "processing" && !useAiCard) {
966
+ card = { kind: "card", handle: { groupId, cardBizId: storedCard.cardBizId } };
821
967
  try {
822
- await cardClient.update(card, processingTitle(), processingMessage);
968
+ await cardClient.update(card.handle, processingTitle(), processingMessage);
823
969
  }
824
970
  catch (err) {
825
971
  if (!isMissingCardError(err))
@@ -829,10 +975,46 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
829
975
  card = undefined;
830
976
  }
831
977
  }
978
+ if (responseMode === "card" && useAiCard && storedCard?.status === "processing") {
979
+ // A previous AI card was left in the "输入中" state by a restart. Close it
980
+ // as an error so it does not stay in the typing state forever.
981
+ const staleSession = new AiCardSession({
982
+ client: aiCardClient,
983
+ templateId: aiTemplateId,
984
+ contentKey: aiContentKey,
985
+ log,
986
+ outTrackId: storedCard.cardBizId,
987
+ });
988
+ await staleSession.closeStale();
989
+ delete cardState.cards[groupId];
990
+ }
832
991
  if (responseMode === "card" && !card) {
833
- const cardBizId = randomUUID();
834
- card = await cardClient.create(groupId, cardBizId, processingTitle(), processingMessage);
835
- cardState.cards[groupId] = { cardBizId, status: "processing" };
992
+ const cardId = randomUUID();
993
+ if (useAiCard) {
994
+ try {
995
+ const session = new AiCardSession({
996
+ client: aiCardClient,
997
+ templateId: aiTemplateId,
998
+ contentKey: aiContentKey,
999
+ log,
1000
+ outTrackId: cardId,
1001
+ });
1002
+ await session.openForGroup({
1003
+ openConversationId: groupId,
1004
+ title: `【${label}】${modelName || "默认模型"} 进行中...`,
1005
+ });
1006
+ card = { kind: "aiCard", session };
1007
+ }
1008
+ catch (err) {
1009
+ log.warn(`AI card create failed; falling back to standard card: ${String(err)}`);
1010
+ useAiCard = false;
1011
+ card = { kind: "card", handle: await cardClient.create(groupId, cardId, processingTitle(), processingMessage) };
1012
+ }
1013
+ }
1014
+ else {
1015
+ card = { kind: "card", handle: await cardClient.create(groupId, cardId, processingTitle(), processingMessage) };
1016
+ }
1017
+ cardState.cards[groupId] = { cardBizId: cardId, status: "processing" };
836
1018
  await saveCardState(cardState);
837
1019
  }
838
1020
  if (responseMode === "text")
@@ -869,9 +1051,10 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
869
1051
  pendingCardTimer = undefined;
870
1052
  pendingCardUpdate = undefined;
871
1053
  };
872
- // Pi emits very small deltas quickly. Coalesce them into one complete card
873
- // snapshot per second so DingTalk receives fresh content without a backlog.
874
- const getCardIntervalMs = () => getDashboardConfig().cardUpdateIntervalMs;
1054
+ // Pi emits very small deltas quickly. Coalesce them into one snapshot so
1055
+ // DingTalk receives fresh content without a backlog. AI cards use their own
1056
+ // (usually faster) typing interval.
1057
+ const getCardIntervalMs = () => useAiCard ? aiStreamIntervalMs : getDashboardConfig().cardUpdateIntervalMs;
875
1058
  const pumpCardUpdate = () => {
876
1059
  if (cardUpdatesStopped || cardUpdateInFlight || !pendingCardUpdate)
877
1060
  return;
@@ -897,7 +1080,7 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
897
1080
  if (!activeCard)
898
1081
  return;
899
1082
  lastCardUpdateAt = Date.now();
900
- cardUpdateInFlight = cardClient.update(activeCard, latest.title, formatReply(latest.content, getDashboardConfig().replyFormat))
1083
+ cardUpdateInFlight = pushCard(activeCard, latest.title, latest.content)
901
1084
  .catch((err) => log.warn(`card update skipped: ${String(err)}`))
902
1085
  .finally(() => {
903
1086
  cardUpdateInFlight = undefined;
@@ -916,7 +1099,7 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
916
1099
  if (pendingCardUpdate && !cardUpdatesStopped && activeCard) {
917
1100
  const latest = pendingCardUpdate;
918
1101
  pendingCardUpdate = undefined;
919
- await cardClient.update(activeCard, latest.title, formatReply(latest.content, getDashboardConfig().replyFormat))
1102
+ await pushCard(activeCard, latest.title, latest.content)
920
1103
  .catch((err) => log.warn(`card update skipped: ${String(err)}`));
921
1104
  }
922
1105
  };
@@ -928,7 +1111,7 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
928
1111
  pendingCardUpdate = { title, content };
929
1112
  pumpCardUpdate();
930
1113
  };
931
- if (responseMode === "card") {
1114
+ if (responseMode === "card" && !useAiCard) {
932
1115
  elapsedTimer = setInterval(() => {
933
1116
  updateCard(processingTitle(), liveReply.content.slice(-8_000));
934
1117
  }, 1_000);
@@ -936,17 +1119,19 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
936
1119
  }
937
1120
  let streamedText = "";
938
1121
  let toolStatus = "";
939
- const result = await runAgent(agent, buildPrompt(events), sessionId, agentConfig, {
1122
+ const result = await runAgent(agent, buildPrompt(events, dashboardConfig.groupPromptSuffix), sessionId, agentConfig, {
940
1123
  onAbortReady: (abort) => { if (queue)
941
1124
  queue.abort = abort; },
942
1125
  onSteerReady: (steer) => { if (queue && agent === "pi")
943
1126
  queue.steer = steer; },
944
1127
  onToolUse: (toolName, stats) => {
945
- log.info(`codex tool=${toolName} count=${stats[toolName] ?? 1}`);
946
- toolStatus = `[OMG] 调用工具:${toolName} x${stats[toolName] ?? 1}`;
947
- liveReply.content = streamedText ? `${streamedText}\n\n\n${toolStatus}` : toolStatus;
948
- latestVisibleContent = liveReply.content;
949
- updateCard(processingTitle(), liveReply.content);
1128
+ const totalCalls = Object.values(stats).reduce((sum, count) => sum + (count || 0), 0);
1129
+ log.info(`tool=${toolName} total=${totalCalls}`);
1130
+ // AI 卡片在工具调用/等待期显示实时调用次数,出字后清除。
1131
+ if (!useAiCard)
1132
+ return;
1133
+ toolStatus = `[OMG] 正在调用工具(已 ${totalCalls} 次)…`;
1134
+ updateCard(processingTitle(), streamedText ? `${streamedText}\n\n${toolStatus}` : toolStatus);
950
1135
  },
951
1136
  onText: (text) => {
952
1137
  streamedText = text;
@@ -973,14 +1158,16 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
973
1158
  await saveGroupSessions(sessions);
974
1159
  }
975
1160
  await flushPendingCardUpdate();
976
- const replyText = formatReply(completedCardContent(result.text, modelName, events.length, result.toolStats, getDashboardConfig().showProcessingDetails), getDashboardConfig().replyFormat);
1161
+ const showDetails = getDashboardConfig().showProcessingDetails;
1162
+ const note = showDetails ? completionNote(modelName, events.length, result.toolStats) : undefined;
1163
+ const completedContent = result.text.trim() || "(无输出)";
977
1164
  if (responseMode === "card" && activeCard)
978
- await cardClient.update(activeCard, finishedTitle("✅", "完成"), replyText);
1165
+ await finishCard(activeCard, finishedTitle("✅", "完成"), completedContent, "completed", note);
979
1166
  else
980
- await sendRobotText(groupId, getDashboardConfig(), replyText);
1167
+ await sendRobotText(groupId, getDashboardConfig(), formatReply(note ? `${completedContent}\n\n\n[夯爆了] ${note}` : completedContent));
981
1168
  liveReplies.delete(groupId);
982
1169
  if (activeCard)
983
- cardState.cards[groupId] = { cardBizId: activeCard.cardBizId, status: "completed" };
1170
+ cardState.cards[groupId] = { cardBizId: cardIdentity(activeCard), status: "completed" };
984
1171
  await saveCardState(cardState);
985
1172
  await recordReply(replies, {
986
1173
  id: randomUUID(),
@@ -1025,9 +1212,9 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
1025
1212
  // paused/failed card update. This prevents a queued update from arriving
1026
1213
  // after stop and changing the title back to "处理中".
1027
1214
  stopCardUpdates();
1028
- await cardClient.update(card, stopped ? finishedTitle("🔴", "处理暂停") : finishedTitle("❌", "处理失败"), stopped ? stoppedContent : `${label} 处理失败:${message.slice(0, 2_000)}`)
1215
+ await finishCard(card, stopped ? finishedTitle("🔴", "处理暂停") : finishedTitle("❌", "处理失败"), stopped ? stoppedContent : `${label} 处理失败:${message.slice(0, 2_000)}`, stopped ? "paused" : "failed")
1029
1216
  .catch((updateErr) => log.warn(`card failure update skipped: ${String(updateErr)}`));
1030
- cardState.cards[groupId] = { cardBizId: card.cardBizId, status: stopped ? "failed" : "failed" };
1217
+ cardState.cards[groupId] = { cardBizId: cardIdentity(card), status: stopped ? "failed" : "failed" };
1031
1218
  await saveCardState(cardState);
1032
1219
  await recordReply(replies, {
1033
1220
  id: randomUUID(),
@@ -1064,7 +1251,7 @@ async function enqueueGroupEvent(event, groupId, queues, cardState, sessions, ca
1064
1251
  if (queue.activeAgent === "pi" && queue.steer && content) {
1065
1252
  const steered = queue.steer(content);
1066
1253
  if (steered) {
1067
- void sendRobotText(groupId, getDashboardConfig(), "[灵感]已将这条消息作为引导发送给当前 Pi 任务。")
1254
+ void sendRobotText(groupId, getDashboardConfig(), "[灵感] 已将这条消息作为引导发送给当前 Pi 任务。")
1068
1255
  .catch((err) => log.warn(`steer acknowledgement failed: ${String(err)}`));
1069
1256
  log.info(`steered message=${event.message_id || "unknown"} group=${groupId}`);
1070
1257
  return;
@@ -1145,6 +1332,10 @@ function startGroupListener(seen, queues, cardState, sessions, cardClient, getDa
1145
1332
  log.warn("group event ignored: missing conversation_id");
1146
1333
  return;
1147
1334
  }
1335
+ // Keep the group-bot cache warm for monitored groups. Non-blocking: the
1336
+ // current message uses the cached copy, which is prefetched at startup.
1337
+ if (config.targets.some((target) => target.groupId === groupId))
1338
+ void refreshGroupBotCache(groupId);
1148
1339
  if (isIgnoredRobotEvent(event, config)) {
1149
1340
  log.debug(`ignored robot message event=${event.event_id || "unknown"}`);
1150
1341
  return;
@@ -1153,7 +1344,7 @@ function startGroupListener(seen, queues, cardState, sessions, cardClient, getDa
1153
1344
  // metadata is complete. Never feed our own steer acknowledgement back into
1154
1345
  // the active Pi task, even if robot detection missed that event.
1155
1346
  const eventContent = (event.content || event.text || "").trim();
1156
- if (queues.get(groupId)?.running && /^\[灵感\]已将这条消息作为引导发送给当前 Pi 任务[。.!!]?$/u.test(eventContent)) {
1347
+ if (queues.get(groupId)?.running && /^\[灵感\] 已将这条消息作为引导发送给当前 Pi 任务[。.!!]?$/u.test(eventContent)) {
1157
1348
  log.debug(`ignored self steer acknowledgement group=${groupId}`);
1158
1349
  return;
1159
1350
  }
@@ -1251,9 +1442,12 @@ function startGroupListener(seen, queues, cardState, sessions, cardClient, getDa
1251
1442
  await sendRobotText(groupId, getDashboardConfig(), "当前 Agent 任务正在运行,请先暂停后再新建会话。");
1252
1443
  return;
1253
1444
  }
1254
- sessions.delete(`pi:${groupId}`);
1255
- sessions.delete(`codex:${groupId}`);
1256
- sessions.delete(`opencode:${groupId}`);
1445
+ for (const agent of ["pi", "codex", "opencode"]) {
1446
+ const prefix = `${agent}:${groupId}:`;
1447
+ for (const key of [...sessions.keys()])
1448
+ if (key.startsWith(prefix))
1449
+ sessions.delete(key);
1450
+ }
1257
1451
  await saveGroupSessions(sessions);
1258
1452
  await sendRobotText(groupId, getDashboardConfig(), "已清空当前群会话的 Agent session,下一条消息将使用新会话处理。");
1259
1453
  log.info(`group sessions cleared group=${groupId}`);
@@ -1268,9 +1462,7 @@ function startGroupListener(seen, queues, cardState, sessions, cardClient, getDa
1268
1462
  if (options.commandsOnly && !command)
1269
1463
  return;
1270
1464
  if (command) {
1271
- // Agent switching is scoped to an existing "group + sender" monitor
1272
- // rule. Group robot membership is checked asynchronously below before
1273
- // changing the shared agent setting.
1465
+ // Agent 切换只影响当前群(每个群在 group-agents.json 各自保存)。
1274
1466
  if (typeof command === "object" && command.type === "switch-agent" && !acceptsTarget(event, config)) {
1275
1467
  log.debug(`ignored agent switch outside monitor rule group=${groupId} sender=${getSenderId(event)}`);
1276
1468
  return;
@@ -1450,6 +1642,7 @@ async function main() {
1450
1642
  const queues = new Map();
1451
1643
  const cardState = await loadCardState();
1452
1644
  const sessions = await loadGroupSessions();
1645
+ groupAgentBindings = await loadGroupAgents();
1453
1646
  const cardClient = new DingTalkCardClient();
1454
1647
  let dashboardConfig = await loadDashboardConfig();
1455
1648
  const applyDashboardConfig = async (config) => {
@@ -1457,11 +1650,21 @@ async function main() {
1457
1650
  dashboardConfig = config;
1458
1651
  cardClient.setCredentials(config.clientId, config.clientSecret);
1459
1652
  cardClient.setRobotCode(config.clientId);
1653
+ aiCardClient.setCredentials(config.clientId, config.clientSecret, config.clientId);
1460
1654
  log.info(`dashboard config applied: ${configuredGroupIds(config).length} group(s), ${config.targets.length} rule(s)`);
1461
1655
  };
1462
1656
  await saveDashboardConfig(dashboardConfig);
1463
1657
  cardClient.setCredentials(dashboardConfig.clientId, dashboardConfig.clientSecret);
1464
1658
  cardClient.setRobotCode(dashboardConfig.clientId);
1659
+ aiCardClient.setCredentials(dashboardConfig.clientId, dashboardConfig.clientSecret, dashboardConfig.clientId);
1660
+ // Warm the group-bot cache for every monitored group so a message from any
1661
+ // AI robot in the group is ignored from the very first event.
1662
+ void Promise.all(configuredGroupIds(dashboardConfig).map((groupId) => refreshGroupBotCache(groupId, true)));
1663
+ // Refresh periodically so bots added after startup are also ignored.
1664
+ const groupBotRefreshTimer = setInterval(() => {
1665
+ void Promise.all(configuredGroupIds(dashboardConfig).map((groupId) => refreshGroupBotCache(groupId, true)));
1666
+ }, GROUP_BOT_CACHE_TTL_MS);
1667
+ groupBotRefreshTimer.unref();
1465
1668
  const replies = await loadReplyHistory();
1466
1669
  const liveReplies = new Map();
1467
1670
  const runtime = {
@@ -1479,6 +1682,7 @@ async function main() {
1479
1682
  dashboardConfig = latest;
1480
1683
  cardClient.setCredentials(latest.clientId, latest.clientSecret);
1481
1684
  cardClient.setRobotCode(latest.clientId);
1685
+ aiCardClient.setCredentials(latest.clientId, latest.clientSecret, latest.clientId);
1482
1686
  log.info(`dashboard config reloaded: ${configuredGroupIds(latest).length} group(s), ${latest.targets.length} rule(s)`);
1483
1687
  }).catch((err) => log.warn(`dashboard config reload failed: ${String(err)}`));
1484
1688
  }, 1_000);
package/dist/omi.js CHANGED
@@ -5,7 +5,7 @@ import { createInterface } from "node:readline/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { dirname, join } from "node:path";
8
- import { readVersion } from "./version.js";
8
+ import { readVersion } from "./core/version.js";
9
9
  const args = process.argv.slice(2);
10
10
  const command = args.find((argument) => !argument.startsWith("-")) ?? "start";
11
11
  const noListen = args.includes("--no-listen");
Binary file
Binary file