oh-my-im 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bot-app.js +12 -3
- package/dist/bot-worker.js +11 -1
- package/dist/dashboard-worker.js +118 -0
- package/dist/dws-client.js +10 -4
- package/dist/dws-dashboard.js +7 -8
- package/dist/dws-history.js +41 -24
- package/dist/dws-listener.js +27 -48
- package/dist/omi.js +5 -1
- package/package.json +1 -1
package/dist/bot-app.js
CHANGED
|
@@ -534,8 +534,17 @@ export async function runApp(configOverride, options = {}) {
|
|
|
534
534
|
return minutes > 0 ? `${minutes}分${seconds}秒` : `${seconds}秒`;
|
|
535
535
|
};
|
|
536
536
|
const title = `【${label}】`;
|
|
537
|
-
|
|
538
|
-
|
|
537
|
+
// Keep the title lightweight. Detailed phases are already shown in the
|
|
538
|
+
// card body; only alternate the icon as a heartbeat to avoid duplicate text.
|
|
539
|
+
let heartbeat = 0;
|
|
540
|
+
const processingTitle = () => {
|
|
541
|
+
const heartbeatIcon = heartbeat++ % 2 === 0 ? "🔵" : "🔷";
|
|
542
|
+
const elapsed = options.getShowElapsed?.() === false ? "" : ` ${formatElapsed()}`;
|
|
543
|
+
return `${heartbeatIcon} ${title}处理中...${elapsed}`;
|
|
544
|
+
};
|
|
545
|
+
// The setting only controls the live processing title. Completion always
|
|
546
|
+
// includes the elapsed time as a useful final result summary.
|
|
547
|
+
const finishedTitle = (icon, state) => `${icon} ${title}${state} 总耗时 ${formatElapsed()}`;
|
|
539
548
|
const reply = await bot.sendThinkingCard(message, `${label} 正在处理...`, processingTitle());
|
|
540
549
|
let elapsedTimer;
|
|
541
550
|
let latestCardContent = `${label} 正在处理...`;
|
|
@@ -585,7 +594,7 @@ export async function runApp(configOverride, options = {}) {
|
|
|
585
594
|
const stats = formatStats(latestStats);
|
|
586
595
|
updateCard(processingTitle(), buildCardContent(content, stats ? `工具:${stats}` : undefined));
|
|
587
596
|
},
|
|
588
|
-
onToolUse: (
|
|
597
|
+
onToolUse: (toolName, stats) => {
|
|
589
598
|
latestStats = stats;
|
|
590
599
|
const statsText = formatStats(stats);
|
|
591
600
|
updateCard(processingTitle(), buildCardContent(`${label} 正在处理...`, statsText ? `工具:${statsText}` : undefined));
|
package/dist/bot-worker.js
CHANGED
|
@@ -92,7 +92,17 @@ process.once("exit", () => {
|
|
|
92
92
|
});
|
|
93
93
|
try {
|
|
94
94
|
const config = loadBotConfig();
|
|
95
|
-
|
|
95
|
+
// Publish the configured state immediately. On startup the stream is not
|
|
96
|
+
// connected yet, and a disabled stream must not briefly appear enabled.
|
|
97
|
+
const initiallyEnabled = (() => {
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(readFileSync(dashboardConfigFile, "utf8")).privateChatEnabled !== false;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
writeBotStatus(initiallyEnabled, false);
|
|
96
106
|
await runApp(config, {
|
|
97
107
|
singleChatOnly: true,
|
|
98
108
|
getAgent: () => {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, writeFile, rename, readdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { startDashboard, } from "./dws-dashboard.js";
|
|
6
|
+
import { getDwsAuthStatus, getCurrentDwsUser, startDwsDeviceLogin, getDwsDeviceLoginOutput, logoutDws, searchGroups, listGroupMembers, searchUsers, searchBots, } from "./dws-client.js";
|
|
7
|
+
const dataDir = join(homedir(), ".oh-my-im");
|
|
8
|
+
const configFile = join(dataDir, "dws-dashboard.json");
|
|
9
|
+
const serverFile = join(dataDir, "dws-dashboard-server.json");
|
|
10
|
+
const botStatusFile = join(dataDir, "omi-bot-status.json");
|
|
11
|
+
const repliesDir = join(dataDir, "replies");
|
|
12
|
+
const legacyRepliesFile = join(dataDir, "dws-replies.json");
|
|
13
|
+
const defaultConfig = () => ({
|
|
14
|
+
privateChatEnabled: true,
|
|
15
|
+
cardUpdateIntervalMs: 3_000,
|
|
16
|
+
showElapsed: true,
|
|
17
|
+
historyGroupLimit: 10,
|
|
18
|
+
historyMessageLimit: 20,
|
|
19
|
+
historyPollIntervalSeconds: 5,
|
|
20
|
+
targets: [], botAllowedUserIds: [], botAllowedUserNames: {},
|
|
21
|
+
botSuperAdminUserIds: [], botSuperAdminUserNames: {}, robotSenderOpenDingTalkId: "",
|
|
22
|
+
commandKeywords: { pause: [], monitorOpen: [], monitorStop: [], switchPi: [], switchCodex: [] },
|
|
23
|
+
groupPromptPrefix: "", replyFormat: "markdown", robotName: "AI Agent",
|
|
24
|
+
clientId: "", clientSecret: "", agent: "codex",
|
|
25
|
+
});
|
|
26
|
+
async function loadConfig() {
|
|
27
|
+
try {
|
|
28
|
+
const value = JSON.parse(await readFile(configFile, "utf8"));
|
|
29
|
+
return { ...defaultConfig(), ...value, commandKeywords: { ...defaultConfig().commandKeywords, ...(value.commandKeywords ?? {}) } };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return defaultConfig();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function saveConfig(config) {
|
|
36
|
+
await mkdir(dataDir, { recursive: true });
|
|
37
|
+
const temporary = `${configFile}.${process.pid}.tmp`;
|
|
38
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
39
|
+
await rename(temporary, configFile);
|
|
40
|
+
}
|
|
41
|
+
async function loadServerConfig() {
|
|
42
|
+
try {
|
|
43
|
+
const value = JSON.parse(await readFile(serverFile, "utf8"));
|
|
44
|
+
if (Number.isInteger(value.port) && value.port > 0 && typeof value.host === "string")
|
|
45
|
+
return { port: value.port, host: value.host };
|
|
46
|
+
}
|
|
47
|
+
catch { /* use defaults */ }
|
|
48
|
+
const config = { port: 12525, host: "127.0.0.1" };
|
|
49
|
+
await mkdir(dataDir, { recursive: true });
|
|
50
|
+
await writeFile(serverFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
function validReplies(value) {
|
|
54
|
+
if (!Array.isArray(value))
|
|
55
|
+
return [];
|
|
56
|
+
return value.filter((item) => Boolean(item && typeof item === "object" &&
|
|
57
|
+
typeof item.id === "string" && typeof item.content === "string" &&
|
|
58
|
+
(item.status === "completed" || item.status === "failed")));
|
|
59
|
+
}
|
|
60
|
+
async function loadReplies() {
|
|
61
|
+
const records = [];
|
|
62
|
+
try {
|
|
63
|
+
const files = (await readdir(repliesDir)).filter((file) => /^\\d{4}-\\d{2}-\\d{2}\\.json$/.test(file));
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
try {
|
|
66
|
+
records.push(...validReplies(JSON.parse(await readFile(join(repliesDir, file), "utf8"))));
|
|
67
|
+
}
|
|
68
|
+
catch { /* ignore a file being written concurrently */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { /* the directory does not exist on first run */ }
|
|
72
|
+
try {
|
|
73
|
+
records.push(...validReplies(JSON.parse(await readFile(legacyRepliesFile, "utf8"))));
|
|
74
|
+
}
|
|
75
|
+
catch { /* legacy file is optional */ }
|
|
76
|
+
return [...new Map(records.map((item) => [item.id, item])).values()]
|
|
77
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 100);
|
|
78
|
+
}
|
|
79
|
+
async function botStatus() {
|
|
80
|
+
const config = await loadConfig();
|
|
81
|
+
try {
|
|
82
|
+
const value = JSON.parse(await readFile(botStatusFile, "utf8"));
|
|
83
|
+
return { enabled: config.privateChatEnabled !== false, connected: config.privateChatEnabled !== false && value.connected !== false, updatedAt: value.updatedAt };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { enabled: config.privateChatEnabled !== false, connected: false };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function main() {
|
|
90
|
+
// Do not create a partial config here. The listener owns initialization and
|
|
91
|
+
// migration; this process only reads it and writes complete configurations
|
|
92
|
+
// submitted by the dashboard.
|
|
93
|
+
let config = await loadConfig();
|
|
94
|
+
const serverConfig = await loadServerConfig();
|
|
95
|
+
const runtime = { startedAt: new Date().toISOString(), eventConnected: false, activeBatches: 0 };
|
|
96
|
+
let replies = await loadReplies();
|
|
97
|
+
const replyReloadTimer = setInterval(() => {
|
|
98
|
+
void loadReplies().then((latest) => { replies = latest; }).catch(() => undefined);
|
|
99
|
+
}, 1_000);
|
|
100
|
+
replyReloadTimer.unref();
|
|
101
|
+
startDashboard(serverConfig.port, {
|
|
102
|
+
getConfig: () => config,
|
|
103
|
+
updateConfig: async (next) => { await saveConfig(next); config = next; },
|
|
104
|
+
getStatus: () => ({ ...runtime }),
|
|
105
|
+
// Replies are persisted by the listener/bot. The dashboard remains usable
|
|
106
|
+
// even when those workers are absent or have not been configured yet.
|
|
107
|
+
getReplies: () => replies,
|
|
108
|
+
searchGroups, listGroupMembers, searchUsers, searchBots,
|
|
109
|
+
getCurrentDwsUser: async () => ({ ...(await getCurrentDwsUser()), auth: await getDwsAuthStatus() }),
|
|
110
|
+
getDwsAuthStatus, startDwsDeviceLogin, getDwsDeviceLoginOutput, logoutDws, getBotStatus: botStatus,
|
|
111
|
+
}, { host: serverConfig.host });
|
|
112
|
+
console.log(`[OmiDashboard] dashboard started at http://${serverConfig.host}:${serverConfig.port}`);
|
|
113
|
+
}
|
|
114
|
+
main().catch((err) => {
|
|
115
|
+
console.error(`[OmiDashboard] ${err instanceof Error ? err.stack || err.message : String(err)}`);
|
|
116
|
+
// The web process must not take down the manager because an optional status
|
|
117
|
+
// provider (for example dws on a fresh machine) is unavailable.
|
|
118
|
+
});
|
package/dist/dws-client.js
CHANGED
|
@@ -29,7 +29,8 @@ export async function listGroupMessages(groupId, from, limit = 20) {
|
|
|
29
29
|
"chat", "message", "list", "--group", groupId, "--time", formatDwsTime(from),
|
|
30
30
|
"--direction", "newer", "--limit", String(limit),
|
|
31
31
|
]);
|
|
32
|
-
|
|
32
|
+
const messages = result.messages ?? result.result?.messages ?? result.data?.messages ?? result.data?.result?.messages ?? [];
|
|
33
|
+
return messages.map((message) => ({
|
|
33
34
|
conversation_id: message.conversationId || message.openConversationId,
|
|
34
35
|
create_time: message.createTime,
|
|
35
36
|
message_id: message.messageId || message.openMessageId,
|
|
@@ -43,7 +44,8 @@ export async function listGroupMessages(groupId, from, limit = 20) {
|
|
|
43
44
|
}
|
|
44
45
|
export async function getCurrentDwsUser() {
|
|
45
46
|
const result = await runDwsJson(["contact", "+me"]);
|
|
46
|
-
|
|
47
|
+
const user = result.data ?? result;
|
|
48
|
+
return { userId: user.userId?.trim(), openDingTalkId: (user.openDingTalkId || user.openDingtalkId)?.trim(), name: user.name?.trim(), email: user.email?.trim(), dept: user.dept?.trim(), org: user.org?.trim() };
|
|
47
49
|
}
|
|
48
50
|
export async function getDwsAuthStatus() {
|
|
49
51
|
const result = await runDwsJson(["auth", "status"]);
|
|
@@ -146,9 +148,13 @@ export async function listGroupMembers(groupId) {
|
|
|
146
148
|
}
|
|
147
149
|
export async function listConversations() {
|
|
148
150
|
const result = await runDwsJson([
|
|
149
|
-
"chat", "+chat-list", "--types", "group", "--page-size", "
|
|
151
|
+
"chat", "+chat-list", "--types", "group", "--page-size", "20",
|
|
150
152
|
]);
|
|
151
|
-
|
|
153
|
+
const data = result.data;
|
|
154
|
+
const nested = data?.result;
|
|
155
|
+
const chats = result.chats ?? data?.chats ?? nested?.chats;
|
|
156
|
+
return result.conversations ?? data?.conversations ?? nested?.conversations
|
|
157
|
+
?? chats?.map((chat) => ({ openConversationId: chat.openConversationId, conversationName: chat.name })) ?? [];
|
|
152
158
|
}
|
|
153
159
|
export function startGroupEventStream(eventName, maxEvents, groupId) {
|
|
154
160
|
const args = ["event", "consume", eventName, "--flatten", "--format", "ndjson"];
|
package/dist/dws-dashboard.js
CHANGED
|
@@ -61,12 +61,13 @@ function normalizeConfig(value) {
|
|
|
61
61
|
if (!Array.isArray(source.botAllowedUserIds))
|
|
62
62
|
throw new Error("请至少配置一名机器人单聊授权人员");
|
|
63
63
|
const botAllowedUserIds = [...new Set(source.botAllowedUserIds.filter((id) => typeof id === "string").map((id) => id.trim()).filter(Boolean))];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
// The dashboard is also the first-run setup screen. Allow an incomplete
|
|
65
|
+
// draft to be saved so a fresh machine can be configured before the bot
|
|
66
|
+
// worker has credentials or DWS login.
|
|
67
|
+
if (typeof source.robotName !== "string")
|
|
68
|
+
throw new Error("机器人名称格式无效");
|
|
69
|
+
if (typeof source.clientId !== "string")
|
|
70
|
+
throw new Error("Client ID 格式无效");
|
|
70
71
|
if (typeof source.clientSecret !== "string")
|
|
71
72
|
throw new Error("Client Secret 格式无效");
|
|
72
73
|
const botAllowedUserNames = source.botAllowedUserNames && typeof source.botAllowedUserNames === "object"
|
|
@@ -215,8 +216,6 @@ export function startDashboard(port, hooks, options = {}) {
|
|
|
215
216
|
if (!config.clientSecret)
|
|
216
217
|
config.clientSecret = hooks.getConfig().clientSecret;
|
|
217
218
|
console.log(`[Dashboard] saving config agent=${config.agent} targets=${config.targets.length} botUsers=${config.botAllowedUserIds.length}`);
|
|
218
|
-
if (!config.clientSecret)
|
|
219
|
-
throw new Error("请填写钉钉应用 Client Secret");
|
|
220
219
|
void hooks.updateConfig(config).then(() => {
|
|
221
220
|
console.log(`[Dashboard] config saved agent=${config.agent} targets=${config.targets.length}`);
|
|
222
221
|
sendJson(response, 200, { config: publicConfig(config), saved: true });
|
package/dist/dws-history.js
CHANGED
|
@@ -5,37 +5,38 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
|
|
5
5
|
const DEFAULT_GROUP_LIMIT = 10;
|
|
6
6
|
function senderName(event) {
|
|
7
7
|
const raw = event.sender;
|
|
8
|
-
if (typeof raw === "string")
|
|
9
|
-
return raw.trim();
|
|
10
8
|
if (!raw || typeof raw !== "object")
|
|
11
9
|
return "";
|
|
12
10
|
const sender = raw;
|
|
13
11
|
return [sender.name, sender.nick, sender.displayName]
|
|
14
|
-
.find((value) => typeof value === "string" && Boolean(value.trim()))
|
|
15
|
-
?.trim() || "";
|
|
12
|
+
.find((value) => typeof value === "string" && Boolean(value.trim()))?.trim() || "";
|
|
16
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Historical messages intentionally use one and only one identity rule:
|
|
16
|
+
* the sender name must equal `dws contact +me`'s name. Do not mix in the
|
|
17
|
+
* configured monitor sender ID, because that can accidentally accept another
|
|
18
|
+
* person's message when DWS returns different ID namespaces.
|
|
19
|
+
*/
|
|
17
20
|
function isCurrentUser(event, currentName) {
|
|
18
21
|
return Boolean(currentName && senderName(event) === currentName);
|
|
19
22
|
}
|
|
20
|
-
/**
|
|
21
|
-
* Single historical-message compensation loop.
|
|
22
|
-
* It reads only the first ten group chats, keeps at most twenty messages per
|
|
23
|
-
* group (the message client applies --limit 20), and only forwards messages
|
|
24
|
-
* authored by the current DWS login account. All command classification,
|
|
25
|
-
* de-duplication and permission checks remain in the listener's acceptEvent().
|
|
26
|
-
*/
|
|
27
23
|
export function startDwsHistoryPolling(options) {
|
|
28
24
|
let stopped = false;
|
|
29
25
|
let polling = false;
|
|
30
26
|
let currentName = "";
|
|
27
|
+
let lastUserResolveFailure = "";
|
|
31
28
|
const refreshCurrentUser = async () => {
|
|
32
29
|
try {
|
|
33
30
|
const user = await getCurrentDwsUser();
|
|
34
31
|
currentName = user.name?.trim() || "";
|
|
35
|
-
|
|
32
|
+
lastUserResolveFailure = "";
|
|
33
|
+
log.info(`current DWS user for history name=${currentName || "<none>"}`);
|
|
36
34
|
}
|
|
37
35
|
catch (err) {
|
|
38
|
-
|
|
36
|
+
const message = String(err);
|
|
37
|
+
if (message !== lastUserResolveFailure)
|
|
38
|
+
log.warn(`unable to resolve current DWS user for history: ${message}`);
|
|
39
|
+
lastUserResolveFailure = message;
|
|
39
40
|
}
|
|
40
41
|
};
|
|
41
42
|
const poll = async () => {
|
|
@@ -46,21 +47,35 @@ export function startDwsHistoryPolling(options) {
|
|
|
46
47
|
if (!currentName)
|
|
47
48
|
await refreshCurrentUser();
|
|
48
49
|
const config = options.getConfig();
|
|
49
|
-
const intervalSeconds = Number.isFinite(config.historyPollIntervalSeconds)
|
|
50
|
-
|
|
50
|
+
const intervalSeconds = Number.isFinite(config.historyPollIntervalSeconds)
|
|
51
|
+
? config.historyPollIntervalSeconds : DEFAULT_POLL_INTERVAL_SECONDS;
|
|
52
|
+
if (intervalSeconds <= 0 || !currentName)
|
|
51
53
|
return;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
// Group discovery is deliberately kept to the DWS command requested by
|
|
55
|
+
// the user: dws chat +chat-list --types group --page-size 20.
|
|
56
|
+
let conversations = [];
|
|
57
|
+
try {
|
|
58
|
+
conversations = await listConversations();
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
log.warn(`history group list failed: ${String(err)}`);
|
|
62
|
+
}
|
|
63
|
+
const groups = [...new Set(conversations
|
|
64
|
+
.map((item) => item.openConversationId?.trim())
|
|
65
|
+
.filter((id) => Boolean(id)))]
|
|
66
|
+
.slice(0, config.historyGroupLimit || DEFAULT_GROUP_LIMIT);
|
|
67
|
+
log.info(`history poll groups=${groups.length} messages=${config.historyMessageLimit} currentUserName=${currentName}`);
|
|
58
68
|
for (const groupId of groups) {
|
|
59
69
|
if (stopped)
|
|
60
70
|
break;
|
|
61
71
|
try {
|
|
72
|
+
// The second and only message command is dws chat message list.
|
|
62
73
|
const messages = await listGroupMessages(groupId, new Date(Date.now() - 20_000), config.historyMessageLimit || 20);
|
|
63
|
-
messages.filter((event) => isCurrentUser(event, currentName))
|
|
74
|
+
const currentUserMessages = messages.filter((event) => isCurrentUser(event, currentName));
|
|
75
|
+
if (messages.length > 0) {
|
|
76
|
+
log.debug(`history poll group=${groupId} received=${messages.length} matchedCurrentUserName=${currentUserMessages.length}`);
|
|
77
|
+
}
|
|
78
|
+
currentUserMessages.forEach(options.acceptEvent);
|
|
64
79
|
}
|
|
65
80
|
catch (err) {
|
|
66
81
|
log.warn(`history poll group failed group=${groupId}: ${String(err)}`);
|
|
@@ -71,8 +86,10 @@ export function startDwsHistoryPolling(options) {
|
|
|
71
86
|
polling = false;
|
|
72
87
|
}
|
|
73
88
|
};
|
|
74
|
-
void poll();
|
|
75
|
-
const timer = setInterval(() => {
|
|
89
|
+
void poll().catch((err) => log.warn(`history poll failed: ${String(err)}`));
|
|
90
|
+
const timer = setInterval(() => {
|
|
91
|
+
void poll().catch((err) => log.warn(`history poll failed: ${String(err)}`));
|
|
92
|
+
}, Math.max(1, Number(options.getConfig().historyPollIntervalSeconds || DEFAULT_POLL_INTERVAL_SECONDS)) * 1_000);
|
|
76
93
|
timer.unref();
|
|
77
94
|
return () => { stopped = true; clearInterval(timer); };
|
|
78
95
|
}
|
package/dist/dws-listener.js
CHANGED
|
@@ -7,8 +7,7 @@ import { createInterface } from "node:readline";
|
|
|
7
7
|
import { agentLabel, agentSwitchMessage, runAgent } from "./agents/index.js";
|
|
8
8
|
import { DingTalkCardClient } from "./dingtalk-card.js";
|
|
9
9
|
import { applyMonitorCommand, parseMonitorCommand } from "./monitor-command.js";
|
|
10
|
-
import {
|
|
11
|
-
import { dwsPath, listConversations, getCurrentDwsUser, getDwsAuthStatus, startDwsDeviceLogin, getDwsDeviceLoginOutput, logoutDws, listGroupBots, addBotToGroup, listGroupMembers, searchGroups, searchUsers, searchBots, startGroupEventStream, } from "./dws-client.js";
|
|
10
|
+
import { dwsPath, listConversations, getCurrentDwsUser, listGroupBots, addBotToGroup, listGroupMembers, searchBots, startGroupEventStream, } from "./dws-client.js";
|
|
12
11
|
import { createLogger } from "./logger.js";
|
|
13
12
|
import { sendRobotGroupText } from "./dingtalk-robot.js";
|
|
14
13
|
import { runDwsJson } from "./dws-client.js";
|
|
@@ -726,8 +725,17 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
|
|
|
726
725
|
const seconds = totalSeconds % 60;
|
|
727
726
|
return minutes > 0 ? `${minutes}分${seconds}秒` : `${seconds}秒`;
|
|
728
727
|
};
|
|
729
|
-
|
|
730
|
-
|
|
728
|
+
// Keep the title lightweight. Detailed phases are already shown in the
|
|
729
|
+
// card body; only alternate the icon as a heartbeat to avoid duplicate text.
|
|
730
|
+
let heartbeat = 0;
|
|
731
|
+
const processingTitle = () => {
|
|
732
|
+
const heartbeatIcon = heartbeat++ % 2 === 0 ? "🔵" : "🔷";
|
|
733
|
+
const elapsed = getDashboardConfig().showElapsed ? ` ${formatElapsed()}` : "";
|
|
734
|
+
return `${heartbeatIcon} ${title}处理中...${elapsed}`;
|
|
735
|
+
};
|
|
736
|
+
// The setting only controls the live processing title. Completion always
|
|
737
|
+
// includes the elapsed time as a useful final result summary.
|
|
738
|
+
const finishedTitle = (icon, state) => `${icon} ${title}${state} 总耗时 ${formatElapsed()}`;
|
|
731
739
|
try {
|
|
732
740
|
const storedCard = cardState.cards[groupId];
|
|
733
741
|
if (storedCard?.status === "processing") {
|
|
@@ -832,7 +840,7 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
|
|
|
832
840
|
log.info(`codex tool=${toolName} count=${stats[toolName] ?? 1}`);
|
|
833
841
|
liveReply.content = `正在调用工具:${toolName}`;
|
|
834
842
|
latestVisibleContent = liveReply.content;
|
|
835
|
-
updateCard(processingTitle(),
|
|
843
|
+
updateCard(processingTitle(), liveReply.content);
|
|
836
844
|
},
|
|
837
845
|
onText: (text) => {
|
|
838
846
|
const delta = text.startsWith(lastText) ? text.slice(lastText.length) : text;
|
|
@@ -1238,63 +1246,34 @@ async function main() {
|
|
|
1238
1246
|
log.info(`dashboard config applied: ${configuredGroupIds(config).length} group(s), ${config.targets.length} rule(s)`);
|
|
1239
1247
|
};
|
|
1240
1248
|
await saveDashboardConfig(dashboardConfig);
|
|
1241
|
-
const dashboardServerConfig = await loadDashboardServerConfig();
|
|
1242
1249
|
cardClient.setCredentials(dashboardConfig.clientId, dashboardConfig.clientSecret);
|
|
1243
1250
|
cardClient.setRobotCode(dashboardConfig.clientId);
|
|
1244
1251
|
const replies = await loadReplyHistory();
|
|
1245
1252
|
const liveReplies = new Map();
|
|
1246
|
-
// The bot worker writes personal-chat logs in the same daily files. Reload
|
|
1247
|
-
// them periodically so the dashboard can show personal and group exchanges
|
|
1248
|
-
// without requiring a listener restart.
|
|
1249
|
-
const reloadReplyHistory = async () => {
|
|
1250
|
-
const latest = await loadReplyHistory();
|
|
1251
|
-
const byId = new Map([...replies, ...latest].map((reply) => [reply.id, reply]));
|
|
1252
|
-
replies.splice(0, replies.length, ...Array.from(byId.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 100));
|
|
1253
|
-
};
|
|
1254
|
-
const replyReloadTimer = setInterval(() => {
|
|
1255
|
-
void reloadReplyHistory().catch((err) => log.warn(`reply history reload failed: ${String(err)}`));
|
|
1256
|
-
}, 1_000);
|
|
1257
|
-
replyReloadTimer.unref();
|
|
1258
1253
|
const runtime = {
|
|
1259
1254
|
startedAt: new Date().toISOString(),
|
|
1260
1255
|
eventConnected: false,
|
|
1261
1256
|
activeBatches: 0,
|
|
1262
1257
|
};
|
|
1263
|
-
startDashboard(dashboardServerConfig.port, {
|
|
1264
|
-
getConfig: () => dashboardConfig,
|
|
1265
|
-
updateConfig: applyDashboardConfig,
|
|
1266
|
-
getStatus: () => ({ ...runtime }),
|
|
1267
|
-
getReplies: () => [...liveReplies.values(), ...replies],
|
|
1268
|
-
searchGroups,
|
|
1269
|
-
listGroupMembers,
|
|
1270
|
-
searchUsers,
|
|
1271
|
-
searchBots,
|
|
1272
|
-
getCurrentDwsUser: async () => {
|
|
1273
|
-
const [user, auth] = await Promise.all([getCurrentDwsUser(), getDwsAuthStatus()]);
|
|
1274
|
-
return { ...user, auth };
|
|
1275
|
-
},
|
|
1276
|
-
getDwsAuthStatus,
|
|
1277
|
-
startDwsDeviceLogin,
|
|
1278
|
-
getDwsDeviceLoginOutput,
|
|
1279
|
-
logoutDws,
|
|
1280
|
-
getBotStatus: async () => {
|
|
1281
|
-
const enabled = dashboardConfig.privateChatEnabled !== false;
|
|
1282
|
-
try {
|
|
1283
|
-
const value = JSON.parse(await readFile(join(DATA_DIR, "omi-bot-status.json"), "utf8"));
|
|
1284
|
-
return { enabled, connected: enabled && value.connected !== false, updatedAt: value.updatedAt };
|
|
1285
|
-
}
|
|
1286
|
-
catch {
|
|
1287
|
-
return { enabled, connected: false };
|
|
1288
|
-
}
|
|
1289
|
-
},
|
|
1290
|
-
}, { host: dashboardServerConfig.host });
|
|
1291
|
-
log.info(`dashboard started at http://${dashboardServerConfig.host}:${dashboardServerConfig.port}`);
|
|
1292
1258
|
log.info(`monitoring ${configuredGroupIds(dashboardConfig).length} group(s) with ${dashboardConfig.targets.length} rule(s)`);
|
|
1259
|
+
// The dashboard is a separate process. Reload its file so changes made in
|
|
1260
|
+
// the web UI take effect in the listener without requiring a restart.
|
|
1261
|
+
const configReloadTimer = setInterval(() => {
|
|
1262
|
+
void loadDashboardConfig().then((latest) => {
|
|
1263
|
+
if (JSON.stringify(latest) === JSON.stringify(dashboardConfig))
|
|
1264
|
+
return;
|
|
1265
|
+
dashboardConfig = latest;
|
|
1266
|
+
cardClient.setCredentials(latest.clientId, latest.clientSecret);
|
|
1267
|
+
cardClient.setRobotCode(latest.clientId);
|
|
1268
|
+
log.info(`dashboard config reloaded: ${configuredGroupIds(latest).length} group(s), ${latest.targets.length} rule(s)`);
|
|
1269
|
+
}).catch((err) => log.warn(`dashboard config reload failed: ${String(err)}`));
|
|
1270
|
+
}, 1_000);
|
|
1271
|
+
configReloadTimer.unref();
|
|
1293
1272
|
try {
|
|
1294
1273
|
await startGroupListener(seen, queues, cardState, sessions, cardClient, () => dashboardConfig, applyDashboardConfig, replies, liveReplies, runtime);
|
|
1295
1274
|
}
|
|
1296
1275
|
finally {
|
|
1297
|
-
clearInterval(
|
|
1276
|
+
clearInterval(configReloadTimer);
|
|
1298
1277
|
}
|
|
1299
1278
|
}
|
|
1300
1279
|
main().catch((err) => {
|
package/dist/omi.js
CHANGED
|
@@ -15,6 +15,7 @@ const logFile = join(stateDir, "omi.log");
|
|
|
15
15
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const listenerPath = join(here, "dws-listener.js");
|
|
17
17
|
const botPath = join(here, "bot-worker.js");
|
|
18
|
+
const dashboardPath = join(here, "dashboard-worker.js");
|
|
18
19
|
const packageFile = join(here, "..", "package.json");
|
|
19
20
|
const packageName = "oh-my-im";
|
|
20
21
|
const repositoryUrl = "https://github.com/duzhenxun/oh-my-im";
|
|
@@ -115,7 +116,7 @@ function readState() {
|
|
|
115
116
|
const value = JSON.parse(readFileSync(stateFile, "utf8"));
|
|
116
117
|
if ((value.mode !== "bot" && value.mode !== "listen") || !Array.isArray(value.processes))
|
|
117
118
|
return undefined;
|
|
118
|
-
const processes = value.processes.filter((item) => Boolean(item && (item.role === "bot" || item.role === "listener") && Number.isInteger(item.pid) && item.pid > 0 && isRunning(item.pid)));
|
|
119
|
+
const processes = value.processes.filter((item) => Boolean(item && (item.role === "bot" || item.role === "listener" || item.role === "dashboard") && Number.isInteger(item.pid) && item.pid > 0 && isRunning(item.pid)));
|
|
119
120
|
if (processes.length === 0) {
|
|
120
121
|
unlinkSync(stateFile);
|
|
121
122
|
return undefined;
|
|
@@ -169,6 +170,9 @@ function start(mode, workspace = launchWorkspace) {
|
|
|
169
170
|
}
|
|
170
171
|
mkdirSync(stateDir, { recursive: true });
|
|
171
172
|
const processes = [];
|
|
173
|
+
// Keep the management UI independent: missing DWS login or bot credentials
|
|
174
|
+
// must never prevent users from opening the configuration page.
|
|
175
|
+
processes.push(startProcess("dashboard", dashboardPath, workspace));
|
|
172
176
|
if (mode === "listen")
|
|
173
177
|
processes.push(startProcess("listener", listenerPath, workspace));
|
|
174
178
|
processes.push(startProcess("bot", botPath, workspace));
|