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/README.md +89 -32
- package/dist/agents/codex-agent.js +16 -4
- package/dist/agents/opencode-agent.js +1 -1
- package/dist/agents/pi-agent.js +10 -2
- package/dist/bot-app.js +230 -67
- package/dist/bot-worker.js +29 -7
- package/dist/{config.js → core/config.js} +3 -3
- package/dist/{version.js → core/version.js} +1 -1
- package/dist/dashboard-worker.js +81 -21
- package/dist/dingtalk/ai-card.js +99 -0
- package/dist/dingtalk/dingtalk-ai-card.js +210 -0
- package/dist/{dingtalk-card.js → dingtalk/dingtalk-card.js} +1 -1
- package/dist/{dingtalk.js → dingtalk/dingtalk.js} +1 -1
- package/dist/dingtalk/markdown.js +112 -0
- package/dist/{dws-client.js → dws/dws-client.js} +14 -0
- package/dist/{dws-history.js → dws/dws-history.js} +1 -1
- package/dist/dws-dashboard.js +128 -36
- package/dist/group-worker.js +283 -79
- package/dist/omi.js +1 -1
- package/outputs/favicon/apple-touch-icon.png +0 -0
- package/outputs/favicon/icon-192.png +0 -0
- package/outputs/favicon/icon-512.png +0 -0
- package/package.json +1 -1
- /package/dist/{conversation-log.js → core/conversation-log.js} +0 -0
- /package/dist/{logger.js → core/logger.js} +0 -0
- /package/dist/{monitor-command.js → core/monitor-command.js} +0 -0
- /package/dist/{dingtalk-robot.js → dingtalk/dingtalk-robot.js} +0 -0
package/dist/group-worker.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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 ?? {})
|
|
233
|
-
groupId,
|
|
234
|
-
|
|
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 =
|
|
581
|
-
?
|
|
582
|
-
:
|
|
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=${
|
|
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
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
return content
|
|
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
|
|
675
|
+
function completionNote(modelName, messageCount, toolStats) {
|
|
643
676
|
const toolCount = Object.values(toolStats).reduce((total, count) => total + count, 0);
|
|
644
|
-
|
|
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
|
-
|
|
692
|
-
|
|
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
|
|
745
|
-
|
|
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
|
-
|
|
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
|
|
783
|
-
const
|
|
784
|
-
const
|
|
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 =
|
|
789
|
-
const processingLabel =
|
|
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
|
|
834
|
-
|
|
835
|
-
|
|
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
|
|
873
|
-
//
|
|
874
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
|
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
|
|
1165
|
+
await finishCard(activeCard, finishedTitle("✅", "完成"), completedContent, "completed", note);
|
|
979
1166
|
else
|
|
980
|
-
await sendRobotText(groupId, getDashboardConfig(),
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
|
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
|
|
Binary file
|