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/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 {
|
|
9
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
606
|
-
|
|
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
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
updateCard(processingTitle(), buildCardContent(
|
|
834
|
+
if (liveCardUpdates && content.trim()) {
|
|
835
|
+
toolStatus = "";
|
|
836
|
+
updateCard(processingTitle(), buildCardContent(content));
|
|
837
|
+
}
|
|
723
838
|
},
|
|
724
839
|
onToolUse: (toolName, stats) => {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
if (liveCardUpdates)
|
|
729
|
-
|
|
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
|
|
752
|
-
const
|
|
753
|
-
if (responseMode === "card")
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/bot-worker.js
CHANGED
|
@@ -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
|
-
|
|
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 "
|
|
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"));
|