oh-my-im 0.1.18 → 0.1.19
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 +1 -1
- package/dist/bot-app.js +28 -14
- package/dist/dashboard-worker.js +179 -30
- package/dist/dingtalk.js +5 -2
- package/dist/dws-dashboard.js +77 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -147,7 +147,7 @@ omi
|
|
|
147
147
|
- 暂停、开启/关闭监听、切换 Agent 的关键词。多个关键词用 `|` 分隔。
|
|
148
148
|
- 可选的 Webhook,用于 Agent 处理失败时向群发送文本通知。
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
管理页首次启动默认密码为 `5552123`,登录后可在“机器人配置”页修改密码。密码使用 Node `scrypt` 哈希保存于 `~/.oh-my-im/dashboard-password.json`,不会写入公开配置;登录会话仅保存在内存中的 HttpOnly Cookie。修改密码后现有会话会失效。若把绑定地址改为 `0.0.0.0` 或 `::`,仍应在前面增加 HTTPS 反向代理或 VPN,不要直接暴露管理页。
|
|
151
151
|
|
|
152
152
|
## 单聊命令
|
|
153
153
|
|
package/dist/bot-app.js
CHANGED
|
@@ -458,8 +458,8 @@ export async function runApp(configOverride, options = {}) {
|
|
|
458
458
|
const config = configOverride ?? loadConfig();
|
|
459
459
|
const bot = new DingTalkBot(config);
|
|
460
460
|
const conversations = new Map();
|
|
461
|
-
// Stream callbacks
|
|
462
|
-
//
|
|
461
|
+
// Stream callbacks can still be redelivered when an ACK is lost, and DWS /
|
|
462
|
+
// history paths may surface the same content twice. Without message-level
|
|
463
463
|
// deduplication, one user message can enter the busy branch again and emit
|
|
464
464
|
// the misleading "后续消息" acknowledgement even though the user sent it
|
|
465
465
|
// only once.
|
|
@@ -467,12 +467,15 @@ export async function runApp(configOverride, options = {}) {
|
|
|
467
467
|
const handledCallbackIdLimit = 2_000;
|
|
468
468
|
const handledMessageFingerprints = new Map();
|
|
469
469
|
const messageFingerprintTtlMs = 15_000;
|
|
470
|
-
|
|
470
|
+
// `replay` marks an internally replayed queue batch: its callback id and
|
|
471
|
+
// fingerprint were already registered when the messages first arrived, so
|
|
472
|
+
// deduplication must be skipped or the queued follow-ups would be dropped.
|
|
473
|
+
async function handleMessage(message, replay = false) {
|
|
471
474
|
if (options.getPrivateChatEnabled?.() === false) {
|
|
472
475
|
log.info(`ignored private message while private chat is disabled conversation=${message.conversationId}`);
|
|
473
476
|
return;
|
|
474
477
|
}
|
|
475
|
-
if (message.callbackId?.trim()) {
|
|
478
|
+
if (!replay && message.callbackId?.trim()) {
|
|
476
479
|
const callbackId = message.callbackId.trim();
|
|
477
480
|
if (handledCallbackIds.has(callbackId)) {
|
|
478
481
|
log.warn(`ignored duplicate DingTalk callback callbackId=${callbackId} conversation=${message.conversationId} text=${JSON.stringify(message.text.slice(0, 120))}`);
|
|
@@ -491,15 +494,17 @@ export async function runApp(configOverride, options = {}) {
|
|
|
491
494
|
message.attachments.map((attachment) => `${attachment.type}:${attachment.downloadCode}`).join(","),
|
|
492
495
|
].join("\u0000");
|
|
493
496
|
const now = Date.now();
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
497
|
+
if (!replay) {
|
|
498
|
+
const previousMessageAt = handledMessageFingerprints.get(messageFingerprint);
|
|
499
|
+
if (previousMessageAt !== undefined && now - previousMessageAt < messageFingerprintTtlMs) {
|
|
500
|
+
log.warn(`ignored duplicate DingTalk message fingerprint conversation=${message.conversationId} text=${JSON.stringify(message.text.slice(0, 120))}`);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
handledMessageFingerprints.set(messageFingerprint, now);
|
|
504
|
+
for (const [fingerprint, timestamp] of handledMessageFingerprints) {
|
|
505
|
+
if (now - timestamp >= messageFingerprintTtlMs)
|
|
506
|
+
handledMessageFingerprints.delete(fingerprint);
|
|
507
|
+
}
|
|
503
508
|
}
|
|
504
509
|
log.info(`received message conversation=${message.conversationId} conversationType=${message.conversationType ?? "<none>"} msgtype=${message.msgtype} senderNick=${message.senderNick ?? "<none>"} senderId=${message.senderId} senderStaffId=${message.senderStaffId ?? "<none>"} text=${JSON.stringify(message.text.slice(0, 500))} textLen=${message.text.length} attachmentCount=${message.attachments.length}`);
|
|
505
510
|
if (options.singleChatOnly && !isSingleConversation(message.conversationType)) {
|
|
@@ -560,6 +565,13 @@ export async function runApp(configOverride, options = {}) {
|
|
|
560
565
|
if (message.msgtype === "text" && await handleCommand(bot, currentConfig, conversations, message, text, isSuperAdminUser))
|
|
561
566
|
return;
|
|
562
567
|
if (state.busy) {
|
|
568
|
+
// A redelivery of the message that started the running task must never
|
|
569
|
+
// be treated as a new user message (steered or queued). Callbacks are
|
|
570
|
+
// ACKed immediately now, but keep this guard in case an ACK is lost.
|
|
571
|
+
if (state.activeFingerprint && messageFingerprint === state.activeFingerprint) {
|
|
572
|
+
log.warn(`ignored duplicate of the in-flight task message conversation=${message.conversationId} text=${JSON.stringify(message.text.slice(0, 120))}`);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
563
575
|
const steer = state.activeAgent === "pi" && state.busy && state.steer && state.steerTaskToken ? state.steer : undefined;
|
|
564
576
|
if (steer && text) {
|
|
565
577
|
const steered = steer(text);
|
|
@@ -587,6 +599,7 @@ export async function runApp(configOverride, options = {}) {
|
|
|
587
599
|
state.busy = true;
|
|
588
600
|
state.paused = false;
|
|
589
601
|
state.activeAgent = selectedAgent;
|
|
602
|
+
state.activeFingerprint = messageFingerprint;
|
|
590
603
|
const taskToken = `${message.conversationId}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
591
604
|
state.steerTaskToken = taskToken;
|
|
592
605
|
const modelName = selectedAgent !== "codex"
|
|
@@ -815,6 +828,7 @@ export async function runApp(configOverride, options = {}) {
|
|
|
815
828
|
state.steer = undefined;
|
|
816
829
|
state.steerTaskToken = undefined;
|
|
817
830
|
state.activeAgent = undefined;
|
|
831
|
+
state.activeFingerprint = undefined;
|
|
818
832
|
state.paused = false;
|
|
819
833
|
const pending = state.pendingMessages.splice(0);
|
|
820
834
|
state.pendingNoticeSent = false;
|
|
@@ -829,7 +843,7 @@ export async function runApp(configOverride, options = {}) {
|
|
|
829
843
|
text: pending.map((item) => item.text.trim()).filter(Boolean).join("\n"),
|
|
830
844
|
attachments: [],
|
|
831
845
|
};
|
|
832
|
-
void handleMessage(combined).catch((error) => log.error("queued private messages failed", error));
|
|
846
|
+
void handleMessage(combined, true).catch((error) => log.error("queued private messages failed", error));
|
|
833
847
|
}
|
|
834
848
|
}
|
|
835
849
|
}
|
package/dist/dashboard-worker.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdir, open, readFile, stat, writeFile, rename } from "node:fs/promises";
|
|
2
|
+
import { mkdir, open, readFile, stat, writeFile, rename, chmod } from "node:fs/promises";
|
|
3
|
+
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { promisify } from "node:util";
|
|
3
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
6
|
import { homedir } from "node:os";
|
|
5
7
|
import { dirname, join } from "node:path";
|
|
@@ -17,6 +19,34 @@ const omiPath = join(workerDir, "omi.js");
|
|
|
17
19
|
const processPaths = { "group-worker": join(workerDir, "group-worker.js"), bot: join(workerDir, "bot-worker.js") };
|
|
18
20
|
const packageFile = join(new URL(".", import.meta.url).pathname, "..", "package.json");
|
|
19
21
|
const repliesDir = join(dataDir, "replies");
|
|
22
|
+
const passwordFile = join(dataDir, "dashboard-password.json");
|
|
23
|
+
const scrypt = promisify(scryptCallback);
|
|
24
|
+
const sessions = new Map();
|
|
25
|
+
const DEFAULT_PASSWORD = "5552123";
|
|
26
|
+
async function passwordHash(password, salt = randomBytes(16).toString("hex")) {
|
|
27
|
+
const derived = await scrypt(password, salt, 64);
|
|
28
|
+
return { salt, hash: derived.toString("hex") };
|
|
29
|
+
}
|
|
30
|
+
async function loadPassword() {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(await readFile(passwordFile, "utf8"));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
const record = await passwordHash(DEFAULT_PASSWORD);
|
|
36
|
+
await savePassword(record);
|
|
37
|
+
return record;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function savePassword(record) {
|
|
41
|
+
await mkdir(dataDir, { recursive: true });
|
|
42
|
+
const temporary = `${passwordFile}.${process.pid}.tmp`;
|
|
43
|
+
await writeFile(temporary, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
44
|
+
await chmod(temporary, 0o600);
|
|
45
|
+
await rename(temporary, passwordFile);
|
|
46
|
+
}
|
|
47
|
+
function cookieValue(request) {
|
|
48
|
+
return request.headers.cookie?.split(";").map((part) => part.trim()).find((part) => part.startsWith("omi_session="))?.slice("omi_session=".length);
|
|
49
|
+
}
|
|
20
50
|
const defaultConfig = () => ({
|
|
21
51
|
privateChatEnabled: false,
|
|
22
52
|
responseMode: "card",
|
|
@@ -31,7 +61,7 @@ const defaultConfig = () => ({
|
|
|
31
61
|
botSuperAdminUserIds: [], botSuperAdminUserNames: {}, robotSenderOpenDingTalkId: "",
|
|
32
62
|
commandKeywords: { pause: [], monitorOpen: [], monitorStop: [], switchPi: [], switchCodex: [], switchOpencode: [] },
|
|
33
63
|
groupPromptSuffix: "", replyFormat: "markdown", robotName: "AI Agent",
|
|
34
|
-
clientId: "", clientSecret: "", agentModels: { codex: "", pi: "", opencode: "" }, agent: "
|
|
64
|
+
clientId: "", clientSecret: "", agentModels: { codex: "", pi: "", opencode: "" }, agent: "pi",
|
|
35
65
|
});
|
|
36
66
|
async function loadConfig() {
|
|
37
67
|
try {
|
|
@@ -101,6 +131,18 @@ function processIsRunning(pid) {
|
|
|
101
131
|
return false;
|
|
102
132
|
}
|
|
103
133
|
}
|
|
134
|
+
const workerProcessPattern = /\/dist\/(dashboard-worker|group-worker|bot-worker)\.js(?:\s|$)/;
|
|
135
|
+
// omi-state.json and the UI use short role names ("bot", "dashboard") while ps
|
|
136
|
+
// discovery sees the script file names ("bot-worker.js"). Normalize so both
|
|
137
|
+
// sources describe the same role; otherwise one process renders as two cards
|
|
138
|
+
// with different labels.
|
|
139
|
+
function normalizeProcessRole(role) {
|
|
140
|
+
if (role === "bot-worker")
|
|
141
|
+
return "bot";
|
|
142
|
+
if (role === "dashboard-worker")
|
|
143
|
+
return "dashboard";
|
|
144
|
+
return role;
|
|
145
|
+
}
|
|
104
146
|
async function systemStatus() {
|
|
105
147
|
let state = {};
|
|
106
148
|
try {
|
|
@@ -115,36 +157,57 @@ async function systemStatus() {
|
|
|
115
157
|
const roles = new Map();
|
|
116
158
|
for (const item of state.processes ?? [])
|
|
117
159
|
if (item.pid && item.role)
|
|
118
|
-
roles.set(item.pid, item.role);
|
|
119
|
-
rows.filter((item) =>
|
|
160
|
+
roles.set(item.pid, normalizeProcessRole(item.role));
|
|
161
|
+
rows.filter((item) => workerProcessPattern.test(item.command)).forEach((item) => {
|
|
120
162
|
if (!roles.has(item.pid))
|
|
121
|
-
roles.set(item.pid, item.command.match(/\/dist\/([^/]+)\.js/)?.[1] || "worker");
|
|
163
|
+
roles.set(item.pid, normalizeProcessRole(item.command.match(/\/dist\/([^/]+)\.js/)?.[1] || "worker"));
|
|
122
164
|
});
|
|
123
|
-
const
|
|
165
|
+
const all = [...roles.entries()].map(([pid, role]) => {
|
|
124
166
|
const row = rows.find((item) => item.pid === pid);
|
|
125
|
-
|
|
167
|
+
// A pid recorded in the state file only counts as running when the live
|
|
168
|
+
// process is still one of our workers; otherwise an unrelated process
|
|
169
|
+
// that reused the pid would resurrect a stopped card.
|
|
170
|
+
const running = Boolean(row && workerProcessPattern.test(row.command) && processIsRunning(pid));
|
|
171
|
+
return { role, pid, ppid: row?.ppid, running, command: row?.command };
|
|
172
|
+
});
|
|
173
|
+
// One card per role: a stopped entry left behind by an earlier stop must be
|
|
174
|
+
// replaced (not duplicated) once the role runs again, and repeated stops of
|
|
175
|
+
// the same role must not accumulate stale cards either.
|
|
176
|
+
const byRole = new Map();
|
|
177
|
+
for (const item of all) {
|
|
178
|
+
const list = byRole.get(item.role) ?? [];
|
|
179
|
+
list.push(item);
|
|
180
|
+
byRole.set(item.role, list);
|
|
181
|
+
}
|
|
182
|
+
const processes = [...byRole.values()].flatMap((list) => {
|
|
183
|
+
const running = list.filter((item) => item.running);
|
|
184
|
+
if (running.length > 0)
|
|
185
|
+
return running;
|
|
186
|
+
return [list.sort((a, b) => b.pid - a.pid)[0]];
|
|
126
187
|
});
|
|
127
188
|
return { mode: state.mode || "unknown", startedAt: state.startedAt, workspace: state.workspace, processes, checkedAt: new Date().toISOString() };
|
|
128
189
|
}
|
|
129
|
-
async function systemLogs(offset) {
|
|
190
|
+
async function systemLogs(offset, initialize = false) {
|
|
130
191
|
try {
|
|
131
192
|
const info = await stat(logFile);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
193
|
+
if (initialize)
|
|
194
|
+
return { content: "", path: logFile, size: info.size, nextOffset: info.size, reset: false };
|
|
195
|
+
// The monitor only tails new output: an unknown/absent offset re-anchors at
|
|
196
|
+
// the current end instead of dumping history, and so does a rotation reset
|
|
197
|
+
// (offset beyond the truncated file).
|
|
198
|
+
const requestedOffset = offset === undefined ? info.size : Math.max(0, Math.floor(offset));
|
|
199
|
+
if (requestedOffset > info.size) {
|
|
200
|
+
return { content: "", path: logFile, size: info.size, nextOffset: info.size, reset: true };
|
|
201
|
+
}
|
|
202
|
+
const length = info.size - requestedOffset;
|
|
137
203
|
const handle = await open(logFile, "r");
|
|
138
204
|
try {
|
|
139
205
|
const buffer = Buffer.alloc(length);
|
|
140
206
|
if (length > 0)
|
|
141
|
-
await handle.read(buffer, 0, length,
|
|
142
|
-
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
if (offset === undefined && start > 0)
|
|
146
|
-
content = content.replace(/^[^\n]*\n/, "");
|
|
147
|
-
return { content, path: logFile, size: info.size, nextOffset: info.size, reset };
|
|
207
|
+
await handle.read(buffer, 0, length, requestedOffset);
|
|
208
|
+
// Incremental reads always start at the exact previous byte offset and
|
|
209
|
+
// must stay intact; never cut a partial first line here.
|
|
210
|
+
return { content: buffer.toString("utf8"), path: logFile, size: info.size, nextOffset: info.size, reset: false };
|
|
148
211
|
}
|
|
149
212
|
finally {
|
|
150
213
|
await handle.close();
|
|
@@ -157,25 +220,81 @@ async function systemLogs(offset) {
|
|
|
157
220
|
throw err;
|
|
158
221
|
}
|
|
159
222
|
}
|
|
223
|
+
function findWorkerPids(role) {
|
|
224
|
+
const marker = `/dist/${role === "bot" ? "bot-worker" : role}.js`;
|
|
225
|
+
const ps = spawnSync("ps", ["-eo", "pid=,args="], { encoding: "utf8" });
|
|
226
|
+
if (ps.status !== 0 || typeof ps.stdout !== "string")
|
|
227
|
+
return [];
|
|
228
|
+
return ps.stdout.split("\n").flatMap((line) => {
|
|
229
|
+
const match = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
230
|
+
return match && match[2].includes(marker) ? [Number(match[1])] : [];
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// Keep omi-state.json in sync with manual start/stop actions. The 系统进程
|
|
234
|
+
// cards are driven by this file: a start must replace the role's (stopped)
|
|
235
|
+
// entry with the new pid, otherwise the dashboard shows an extra card next to
|
|
236
|
+
// the stale one.
|
|
237
|
+
async function replaceStateProcess(role, pid) {
|
|
238
|
+
let state = {};
|
|
239
|
+
try {
|
|
240
|
+
state = JSON.parse(await readFile(stateFile, "utf8"));
|
|
241
|
+
}
|
|
242
|
+
catch { /* create a fresh state below */ }
|
|
243
|
+
const existing = (Array.isArray(state.processes) ? state.processes : []).filter((item) => Boolean(item && typeof item.role === "string" && Number.isInteger(item.pid) && item.pid > 0));
|
|
244
|
+
const processes = existing.filter((item) => normalizeProcessRole(item.role) !== role);
|
|
245
|
+
if (pid !== undefined)
|
|
246
|
+
processes.push({ role, pid });
|
|
247
|
+
const next = {
|
|
248
|
+
mode: state.mode ?? "unknown",
|
|
249
|
+
startedAt: state.startedAt ?? new Date().toISOString(),
|
|
250
|
+
workspace: state.workspace ?? process.cwd(),
|
|
251
|
+
processes,
|
|
252
|
+
};
|
|
253
|
+
await mkdir(dataDir, { recursive: true });
|
|
254
|
+
const temporary = `${stateFile}.${process.pid}.tmp`;
|
|
255
|
+
await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
256
|
+
await rename(temporary, stateFile);
|
|
257
|
+
}
|
|
160
258
|
async function controlSystemProcess(role, action) {
|
|
161
259
|
if (action === "start") {
|
|
162
260
|
const path = processPaths[role];
|
|
163
261
|
if (!path)
|
|
164
262
|
throw new Error("未知进程");
|
|
263
|
+
// Never spawn a second copy of a role that is already running; adopt the
|
|
264
|
+
// live pid into the state file so the dashboard keeps a single card.
|
|
265
|
+
const running = findWorkerPids(role);
|
|
266
|
+
if (running.length > 0) {
|
|
267
|
+
await replaceStateProcess(role, running[0]);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
165
270
|
const child = spawn(process.execPath, [path], { cwd: process.cwd(), detached: true, stdio: "ignore" });
|
|
166
271
|
child.unref();
|
|
272
|
+
if (!child.pid)
|
|
273
|
+
throw new Error("进程启动失败");
|
|
274
|
+
await replaceStateProcess(role, child.pid);
|
|
167
275
|
return;
|
|
168
276
|
}
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
277
|
+
const pids = findWorkerPids(role).filter((pid) => pid !== process.pid);
|
|
278
|
+
for (const pid of pids) {
|
|
279
|
+
try {
|
|
280
|
+
process.kill(pid, "SIGTERM");
|
|
281
|
+
}
|
|
282
|
+
catch { /* already stopped */ }
|
|
283
|
+
}
|
|
284
|
+
const deadline = Date.now() + 3_000;
|
|
285
|
+
while (pids.some((pid) => processIsRunning(pid)) && Date.now() < deadline) {
|
|
286
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
287
|
+
}
|
|
288
|
+
for (const pid of pids.filter((pid) => processIsRunning(pid))) {
|
|
289
|
+
try {
|
|
290
|
+
process.kill(pid, "SIGKILL");
|
|
178
291
|
}
|
|
292
|
+
catch { /* already stopped */ }
|
|
293
|
+
}
|
|
294
|
+
// Keep the role's entry (with its now-dead pid) so the card stays visible
|
|
295
|
+
// as 已停止 and a later 启动 replaces it in place instead of adding a card.
|
|
296
|
+
if (pids.length > 0)
|
|
297
|
+
await replaceStateProcess(role, pids[0]);
|
|
179
298
|
}
|
|
180
299
|
async function restartSystem() {
|
|
181
300
|
// Delay the detached manager so the HTTP 202 response can reach the browser
|
|
@@ -266,6 +385,36 @@ async function main() {
|
|
|
266
385
|
void loadReplies().then((latest) => { replies = latest; }).catch(() => undefined);
|
|
267
386
|
}, 1_000);
|
|
268
387
|
replyReloadTimer.unref();
|
|
388
|
+
const password = await loadPassword();
|
|
389
|
+
// Password-free access only for genuinely local use. A reverse proxy or
|
|
390
|
+
// tunnel (nginx/frp) on this machine also connects from 127.0.0.1, so the
|
|
391
|
+
// socket alone cannot separate local use from proxied external traffic.
|
|
392
|
+
// Require BOTH a loopback peer AND a loopback Host header — visits through
|
|
393
|
+
// a domain keep the domain Host across the proxy hop — and treat any
|
|
394
|
+
// forwarding header as proof that a proxy is involved (never use those
|
|
395
|
+
// headers to prove a request IS local).
|
|
396
|
+
const isLoopbackRequest = (request) => {
|
|
397
|
+
const address = request.socket.remoteAddress?.trim() ?? "";
|
|
398
|
+
if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1")
|
|
399
|
+
return false;
|
|
400
|
+
if (request.headers["x-forwarded-for"] || request.headers["x-real-ip"] || request.headers["forwarded"])
|
|
401
|
+
return false;
|
|
402
|
+
const rawHost = (request.headers.host ?? "").trim().toLowerCase();
|
|
403
|
+
const hostName = rawHost.startsWith("[") ? rawHost.slice(1, rawHost.indexOf("]")) : rawHost.split(":")[0];
|
|
404
|
+
return hostName === "127.0.0.1" || hostName === "localhost" || hostName === "::1";
|
|
405
|
+
};
|
|
406
|
+
const auth = {
|
|
407
|
+
isAuthenticated: (request) => { if (isLoopbackRequest(request))
|
|
408
|
+
return true; const token = cookieValue(request); const expires = token ? sessions.get(token) : undefined; return Boolean(expires && expires > Date.now()); },
|
|
409
|
+
login: async (candidate) => { const derived = await passwordHash(candidate, password.salt); const matches = derived.hash.length === password.hash.length && timingSafeEqual(Buffer.from(derived.hash, "hex"), Buffer.from(password.hash, "hex")); if (!matches)
|
|
410
|
+
return null; const token = randomBytes(32).toString("base64url"); sessions.set(token, Date.now() + 8 * 60 * 60 * 1000); return token; },
|
|
411
|
+
logout: (request) => { const token = cookieValue(request); if (token)
|
|
412
|
+
sessions.delete(token); },
|
|
413
|
+
changePassword: async (request, current, next) => { if (!auth.isAuthenticated(request))
|
|
414
|
+
return "未登录"; if (next.length < 8 || next.length > 200)
|
|
415
|
+
return "新密码长度需为 8-200 位"; const currentHash = await passwordHash(current, password.salt); if (currentHash.hash.length !== password.hash.length || !timingSafeEqual(Buffer.from(currentHash.hash, "hex"), Buffer.from(password.hash, "hex")))
|
|
416
|
+
return "当前密码错误"; const record = await passwordHash(next); await savePassword(record); password.salt = record.salt; password.hash = record.hash; sessions.clear(); return null; },
|
|
417
|
+
};
|
|
269
418
|
startDashboard(serverConfig.port, {
|
|
270
419
|
getConfig: () => config,
|
|
271
420
|
updateConfig: async (next) => {
|
|
@@ -281,7 +430,7 @@ async function main() {
|
|
|
281
430
|
getCurrentDwsUser: async () => ({ ...(await getCurrentDwsUser()), auth: await getDwsAuthStatus() }),
|
|
282
431
|
getDwsAuthStatus, startDwsDeviceLogin, getDwsDeviceLoginOutput, logoutDws, getBotStatus: botStatus,
|
|
283
432
|
getSystemStatus: systemStatus, getSystemLogs: systemLogs, restartSystem, controlSystemProcess, listAgentModels,
|
|
284
|
-
}, { host: serverConfig.host, version });
|
|
433
|
+
}, { host: serverConfig.host, version, auth });
|
|
285
434
|
console.log(`[OmiDashboard] dashboard started at http://${serverConfig.host}:${serverConfig.port}`);
|
|
286
435
|
}
|
|
287
436
|
main().catch((err) => {
|
package/dist/dingtalk.js
CHANGED
|
@@ -185,13 +185,16 @@ export class DingTalkBot {
|
|
|
185
185
|
return;
|
|
186
186
|
}
|
|
187
187
|
log.info(`incoming conversation=${message.conversationId} msgtype=${message.msgtype} senderId=${message.senderId} senderStaffId=${message.senderStaffId ?? "<none>"} senderNick=${message.senderNick ?? "<none>"} conversationType=${message.conversationType ?? "<none>"} text=${JSON.stringify(message.text.slice(0, 200))} textLen=${message.text.length} attachments=${message.attachments.length} hasWebhook=${Boolean(message.sessionWebhook)} hasRobotCode=${Boolean(message.robotCode)}`);
|
|
188
|
+
// ACK before running the handler. Agent turns take minutes, and
|
|
189
|
+
// DingTalk re-pushes callbacks that stay unacknowledged; a redelivered
|
|
190
|
+
// copy of the task-starting message used to re-enter the handler and
|
|
191
|
+
// was steered into the running task as a phantom user message.
|
|
192
|
+
this.ack(message.callbackId, { accepted: true });
|
|
188
193
|
try {
|
|
189
194
|
await onMessage(message);
|
|
190
|
-
this.ack(message.callbackId, { handled: true });
|
|
191
195
|
}
|
|
192
196
|
catch (err) {
|
|
193
197
|
console.error("[DingTalk] message handling failed:", err);
|
|
194
|
-
this.ack(message.callbackId, { error: String(err) });
|
|
195
198
|
}
|
|
196
199
|
});
|
|
197
200
|
await this.client.connect();
|
package/dist/dws-dashboard.js
CHANGED
|
@@ -23,8 +23,8 @@ export function normalizeAgentModel(agent, value) {
|
|
|
23
23
|
const parts = model.split(/\s+/);
|
|
24
24
|
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : model;
|
|
25
25
|
}
|
|
26
|
-
function sendJson(response, status, value) {
|
|
27
|
-
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
26
|
+
function sendJson(response, status, value, headers = {}) {
|
|
27
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
|
|
28
28
|
response.end(JSON.stringify(value));
|
|
29
29
|
}
|
|
30
30
|
function publicConfig(config) {
|
|
@@ -139,7 +139,7 @@ function normalizeConfig(value) {
|
|
|
139
139
|
throw new Error("消息后缀格式无效");
|
|
140
140
|
return { privateChatEnabled: source.privateChatEnabled === true, responseMode, cardUpdateIntervalMs, showElapsed: source.showElapsed !== false, showProcessingDetails: source.showProcessingDetails === true, agentModels, personalHistoryMessageLimit, personalHistoryPollIntervalSeconds, personalHistoryLookbackMinutes, webhookUrl, targets, botAllowedUserIds, botAllowedUserNames, botSuperAdminUserIds, botSuperAdminUserNames, robotSenderOpenDingTalkId: typeof source.robotSenderOpenDingTalkId === "string" ? source.robotSenderOpenDingTalkId.trim() : "", commandKeywords, groupPromptSuffix: groupPromptSuffix.trim(), replyFormat: source.replyFormat, robotName: source.robotName.trim(), clientId: source.clientId.trim(), clientSecret: source.clientSecret.trim(), agent: source.agent };
|
|
141
141
|
}
|
|
142
|
-
export function startDashboard(port, hooks, options
|
|
142
|
+
export function startDashboard(port, hooks, options) {
|
|
143
143
|
const host = options.host?.trim() || "127.0.0.1";
|
|
144
144
|
const modelSettingsScript = String.raw `<script>
|
|
145
145
|
(() => {
|
|
@@ -164,12 +164,18 @@ export function startDashboard(port, hooks, options = {}) {
|
|
|
164
164
|
field.lastChild.textContent = ' 秒(0:完成后一次性发送,1-60:按间隔更新)';
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
+
panel.style.marginTop = '26px';
|
|
167
168
|
panel.innerHTML = '<div class="field"><label>Pi 默认模型</label><select id="piModel"><option value="">使用 Pi CLI 默认模型</option></select><div class="field-help">群聊和私聊使用 Pi 时都采用此模型。</div></div><div class="field" style="margin-top:14px"><label>OpenCode 默认模型</label><select id="opencodeModel"><option value="">使用 OpenCode CLI 默认模型</option></select><div class="field-help">模型来自 OpenCode CLI,格式为 provider/model。</div></div>';
|
|
168
169
|
agentSettings.querySelector('.agent-row')?.append(panel);
|
|
169
|
-
|
|
170
|
+
// The configured value stored in the server config must be applied AFTER
|
|
171
|
+
// the option list is rebuilt: assigning select.value while no matching
|
|
172
|
+
// option exists silently resets the select to '', which used to drop the
|
|
173
|
+
// saved model on every page load and pin the wrong value on the next save.
|
|
174
|
+
let modelsReady = false;
|
|
175
|
+
const loadModels = async (agent, configured) => {
|
|
170
176
|
const select = document.querySelector('#' + agent + 'Model');
|
|
171
177
|
if (!select) return;
|
|
172
|
-
const selected = select.value;
|
|
178
|
+
const selected = typeof configured === 'string' ? configured : select.value;
|
|
173
179
|
try {
|
|
174
180
|
const response = await fetch('/api/agent-models?agent=' + encodeURIComponent(agent), { cache: 'no-store' });
|
|
175
181
|
const body = await response.json();
|
|
@@ -179,7 +185,10 @@ export function startDashboard(port, hooks, options = {}) {
|
|
|
179
185
|
body.models.forEach((model) => select.append(new Option(model, model)));
|
|
180
186
|
const selectedAllowed = agent === 'codex' || agent === 'pi' && !selected.startsWith('nvidia') || agent === 'opencode' && ((selected.startsWith('opencode') && selected.endsWith('-free')) || selected.startsWith('inke'));
|
|
181
187
|
if (selected && selectedAllowed && ![...select.options].some((option) => option.value === selected)) select.append(new Option(selected + '(当前配置)', selected));
|
|
182
|
-
|
|
188
|
+
// Keep the empty option when nothing is configured: its label already
|
|
189
|
+
// shows the OpenCode CLI default, and pre-selecting that default here
|
|
190
|
+
// would persist it as an explicit config value on the next save.
|
|
191
|
+
select.value = [...select.options].some((option) => option.value === selected) ? selected : '';
|
|
183
192
|
} catch (error) {
|
|
184
193
|
select.replaceChildren(new Option((agent === 'codex' ? 'Codex' : agent === 'opencode' ? 'OpenCode' : 'Pi') + ' 模型列表不可用,请检查 CLI', ''));
|
|
185
194
|
if (selected) select.append(new Option(selected + '(当前配置)', selected));
|
|
@@ -197,10 +206,9 @@ export function startDashboard(port, hooks, options = {}) {
|
|
|
197
206
|
}
|
|
198
207
|
const responseMode = body.config?.responseMode === 'text' ? 'text' : 'card';
|
|
199
208
|
document.querySelectorAll('input[name=responseMode]').forEach((input) => { input.checked = input.value === responseMode; });
|
|
200
|
-
|
|
201
|
-
await loadModels('
|
|
202
|
-
|
|
203
|
-
await loadModels('opencode');
|
|
209
|
+
await loadModels('pi', models.pi || '');
|
|
210
|
+
await loadModels('opencode', models.opencode || '');
|
|
211
|
+
modelsReady = true;
|
|
204
212
|
};
|
|
205
213
|
const nativeFetch = window.fetch.bind(window);
|
|
206
214
|
window.fetch = (input, init) => {
|
|
@@ -208,11 +216,16 @@ export function startDashboard(port, hooks, options = {}) {
|
|
|
208
216
|
if (url.endsWith('/api/config') && init?.method === 'PUT' && typeof init.body === 'string') {
|
|
209
217
|
try {
|
|
210
218
|
const body = JSON.parse(init.body);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
// Only inject the select values after the model lists have loaded;
|
|
220
|
+
// otherwise an early save (e.g. the private-chat toggle) would write
|
|
221
|
+
// the still-empty selects back and wipe the configured models.
|
|
222
|
+
if (modelsReady) {
|
|
223
|
+
body.agentModels = {
|
|
224
|
+
codex: '',
|
|
225
|
+
pi: document.querySelector('#piModel')?.value || '',
|
|
226
|
+
opencode: document.querySelector('#opencodeModel')?.value || '',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
216
229
|
body.commandKeywords = {
|
|
217
230
|
...body.commandKeywords,
|
|
218
231
|
switchOpencode: (document.querySelector('#keywordsSwitchOpencode')?.value || '').split(/[||]+/).map((value) => value.trim()).filter(Boolean),
|
|
@@ -226,7 +239,10 @@ export function startDashboard(port, hooks, options = {}) {
|
|
|
226
239
|
init = { ...init, body: JSON.stringify(body) };
|
|
227
240
|
} catch {}
|
|
228
241
|
}
|
|
229
|
-
return nativeFetch(input, init)
|
|
242
|
+
return nativeFetch(input, init).then((response) => {
|
|
243
|
+
if (response.status === 401 && !String(url).endsWith('/api/login')) { window.location.href = '/login'; }
|
|
244
|
+
return response;
|
|
245
|
+
});
|
|
230
246
|
};
|
|
231
247
|
void loadState();
|
|
232
248
|
setInterval(async () => {
|
|
@@ -245,6 +261,39 @@ export function startDashboard(port, hooks, options = {}) {
|
|
|
245
261
|
.replace('</body></html>', modelSettingsScript + '</body></html>');
|
|
246
262
|
const server = createServer((request, response) => {
|
|
247
263
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
264
|
+
const readBody = () => new Promise((resolve, reject) => { let body = ""; request.setEncoding("utf8"); request.on("data", (chunk) => { body += chunk; if (body.length > 10_000)
|
|
265
|
+
reject(new Error("请求内容过大")); }); request.on("end", () => resolve(body)); request.on("error", reject); });
|
|
266
|
+
if (request.method === "POST" && url.pathname === "/api/login") {
|
|
267
|
+
void readBody().then((body) => { let password = ""; try {
|
|
268
|
+
password = String(JSON.parse(body).password ?? "");
|
|
269
|
+
}
|
|
270
|
+
catch { /* invalid body */ } return options.auth.login(password); }).then((token) => token ? sendJson(response, 200, { loggedIn: true }, { "set-cookie": `omi_session=${token}; HttpOnly; SameSite=Strict; Path=/` }) : sendJson(response, 401, { error: "密码错误" })).catch(() => sendJson(response, 400, { error: "请求无效" }));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (request.method === "POST" && url.pathname === "/api/logout") {
|
|
274
|
+
options.auth.logout(request);
|
|
275
|
+
sendJson(response, 200, { loggedOut: true }, { "set-cookie": "omi_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0" });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (url.pathname === "/login") {
|
|
279
|
+
if (options.auth.isAuthenticated(request)) {
|
|
280
|
+
response.writeHead(302, { location: "/" });
|
|
281
|
+
response.end();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
285
|
+
response.end(LOGIN_PAGE);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (url.pathname.startsWith("/api/") && url.pathname !== "/api/login" && !options.auth.isAuthenticated(request)) {
|
|
289
|
+
sendJson(response, 401, { error: "未登录" });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (url.pathname === "/" && !options.auth.isAuthenticated(request)) {
|
|
293
|
+
response.writeHead(302, { location: "/login" });
|
|
294
|
+
response.end();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
248
297
|
if (request.method === "GET" && url.pathname === "/favicon.ico") {
|
|
249
298
|
response.writeHead(200, { "content-type": "image/x-icon", "cache-control": "public, max-age=86400" });
|
|
250
299
|
createReadStream(join(dirname(fileURLToPath(import.meta.url)), "..", "outputs", "favicon", "favicon.ico")).pipe(response);
|
|
@@ -270,6 +319,10 @@ export function startDashboard(port, hooks, options = {}) {
|
|
|
270
319
|
response.end();
|
|
271
320
|
return;
|
|
272
321
|
}
|
|
322
|
+
if (request.method === "POST" && url.pathname === "/api/password") {
|
|
323
|
+
void readBody().then((body) => { const value = JSON.parse(body); return options.auth.changePassword(request, String(value.currentPassword ?? ""), String(value.newPassword ?? "")); }).then((error) => error ? sendJson(response, 400, { error }) : sendJson(response, 200, { changed: true })).catch(() => sendJson(response, 400, { error: "请求无效" }));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
273
326
|
if (request.method === "GET" && url.pathname === "/api/state") {
|
|
274
327
|
sendJson(response, 200, { config: publicConfig(hooks.getConfig()), status: hooks.getStatus(), replies: hooks.getReplies().slice(0, 10) });
|
|
275
328
|
return;
|
|
@@ -293,7 +346,8 @@ export function startDashboard(port, hooks, options = {}) {
|
|
|
293
346
|
return sendJson(response, 501, { error: "系统日志不可用" });
|
|
294
347
|
const rawOffset = url.searchParams.get("offset");
|
|
295
348
|
const offset = rawOffset === null ? undefined : Number(rawOffset);
|
|
296
|
-
|
|
349
|
+
const initialize = url.searchParams.get("initial") === "1";
|
|
350
|
+
void hooks.getSystemLogs(Number.isFinite(offset) && offset >= 0 ? offset : undefined, initialize).then((logs) => sendJson(response, 200, logs)).catch((err) => sendJson(response, 500, { error: err instanceof Error ? err.message : String(err) }));
|
|
297
351
|
return;
|
|
298
352
|
}
|
|
299
353
|
if (request.method === "POST" && url.pathname === "/api/system-restart") {
|
|
@@ -418,17 +472,18 @@ export function startDashboard(port, hooks, options = {}) {
|
|
|
418
472
|
server.listen(port, host);
|
|
419
473
|
return server;
|
|
420
474
|
}
|
|
475
|
+
const LOGIN_PAGE = String.raw `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>oh-my-im 登录</title><style>body{font-family:system-ui,sans-serif;background:#f4f6fa;display:grid;place-items:center;min-height:100vh;margin:0}.box{width:min(360px,calc(100% - 32px));padding:28px;background:white;border:1px solid #dbe2ed;border-radius:8px;box-shadow:0 4px 18px #0001}h1{font-size:22px;margin:0 0 20px}input,button{width:100%;height:42px;box-sizing:border-box;margin-top:10px;padding:8px;border:1px solid #c9d3e0;border-radius:5px;font-size:15px}button{border:0;background:#1769e0;color:white;cursor:pointer}.error{color:#b42318;font-size:13px;margin-top:12px;min-height:18px}</style></head><body><form class="box"><h1>oh-my-im 控制台登录</h1><input id="password" type="password" autocomplete="current-password" autofocus placeholder="控制台密码"><button>登录</button><div class="error"></div></form><script>document.querySelector('form').onsubmit=async e=>{e.preventDefault();const r=await fetch('/api/login',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({password:document.querySelector('#password').value})});if(r.ok)location.href='/';else document.querySelector('.error').textContent=(await r.json()).error||'登录失败'}</script></body></html>`;
|
|
421
476
|
const PAGE = String.raw `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" type="image/x-icon" href="/favicon.ico"><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png"><title>oh-my-im 控制台</title><style>
|
|
422
477
|
:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f4f6fa}*{box-sizing:border-box}body{margin:0}.wrap{max-width:1240px;margin:auto;padding:30px 24px 56px}header{display:flex;justify-content:space-between;gap:18px;margin-bottom:22px}h1{font-size:26px;margin:0}h2{font-size:17px;margin:0 0 14px}p{margin:6px 0;color:#596579}.status{font-size:13px;padding:8px 11px;border:1px solid #c8d1df;background:#fff;border-radius:6px;height:max-content}.connected{color:#047857}.stopped,.error{color:#b42318}.summary{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:18px}.metric,.panel{background:#fff;border:1px solid #dbe2ed;border-radius:7px}.metric{padding:12px}.metric small,.meta{color:#657287}.metric strong{display:block;font-size:18px;margin-top:5px}.grid{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:18px}.panel{padding:20px}.rule{border-top:1px solid #e7edf5;padding:16px 0}.rule:first-of-type{border-top:0;padding-top:0}.rule-grid{display:grid;grid-template-columns:minmax(380px,1.1fr) minmax(280px,1fr) 32px;gap:12px;align-items:start}.configured-grid{grid-template-columns:minmax(280px,.8fr) minmax(380px,1.2fr) 32px;align-items:center}.field{min-width:0}.field label{display:block;font-size:12px;font-weight:600;color:#526176;margin-bottom:6px}.rule-value{min-height:32px;display:flex;align-items:center;font-size:14px;color:#172033}.people{display:flex;flex-wrap:wrap;gap:6px}.person{padding:4px 8px;background:#f1f5fa;border-radius:4px;font-size:13px}.line{display:flex;gap:7px}.platform-link{display:inline-flex;align-items:center;justify-content:center;height:22px;padding:0 6px;border:1px solid #c9d3e0;border-radius:4px;background:#f5f8fc;color:#175cd3;font-size:11px;font-weight:400;text-decoration:none;white-space:nowrap;vertical-align:middle}.platform-link:hover{border-color:#8bb5ed;background:#edf5ff}.field input,.field select{width:100%;height:36px;padding:7px 9px;border:1px solid #c9d3e0;border-radius:5px;background:#fff;font:14px inherit;color:#172033}.field input:focus,.field select:focus{outline:2px solid #b9d7ff;border-color:#1677ff}.group-picker select{margin-top:7px}.member-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2px 8px;max-height:106px;overflow:auto;border:1px solid #c9d3e0;border-radius:5px;background:#fff;padding:4px}.member-option{display:flex;align-items:center;min-width:0;min-height:28px;gap:10px;padding:4px 6px;font-size:13px;line-height:20px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.member-option:hover{background:#f2f7ff}.member-option input{width:14px;height:14px;flex:0 0 auto;align-self:center;margin:0;accent-color:#1677ff}.member-name{display:block;margin-left:2px;overflow:hidden;text-overflow:ellipsis}.member-role{color:#657287;font-size:11px;overflow:hidden;text-overflow:ellipsis}.search{height:36px;border:1px solid #a9c8f5;background:#eef5ff;color:#1769c2;border-radius:5px;cursor:pointer;padding:0 12px;font:13px inherit;white-space:nowrap}.search:hover{border-color:#79a9e8;background:#e2efff;color:#1058aa}.delete{height:36px;width:32px;border:0;background:transparent;color:#b42318;border-radius:5px;cursor:pointer;font-size:20px}.delete:hover{background:#fff0f0}.members{grid-column:1/3;font-size:12px;color:#657287;min-height:18px;padding-top:1px}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:16px;padding-top:16px;border-top:1px solid #e7edf5}.button{border:0;border-radius:5px;background:#1677ff;color:#fff;padding:9px 13px;font:inherit;cursor:pointer}.secondary{background:#eef4ff;color:#175cd3}.format{display:flex;gap:10px;align-items:center;font-size:14px}.notice{position:fixed;left:50%;bottom:82px;transform:translateX(-50%);z-index:20;display:none;min-width:220px;max-width:calc(100vw - 32px);padding:10px 16px;margin:0;border:1px solid #b7ebc6;border-radius:6px;background:#f0fff4;color:#166534;box-shadow:0 4px 14px rgba(0,0,0,.12);font-size:13px;text-align:center}.notice:not(:empty){display:block}.notice.error{border-color:#f3b4b4;background:#fff1f0;color:#b42318}.reply{border-top:1px solid #e8edf4;padding:15px 0}.reply:first-of-type{border-top:0;padding:0}.meta{font-size:12px;margin-bottom:7px}.reply pre{white-space:pre-wrap;word-break:break-word;margin:0;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.empty{color:#657287;font-size:14px}@media(max-width:980px){.grid{grid-template-columns:1fr}.rule-grid,.configured-grid{grid-template-columns:minmax(300px,1fr) minmax(260px,1fr) 32px}.members{grid-column:1/3}}@media(max-width:640px){.wrap{padding:22px 14px 40px}header{display:block}.status{display:inline-block;margin-top:14px}.summary{grid-template-columns:1fr 1fr}.rule-grid,.configured-grid{grid-template-columns:1fr}.member-list{grid-template-columns:1fr 1fr}.members{grid-column:1}.delete{position:absolute;right:0;top:12px}.rule{position:relative;padding-right:38px}.toolbar{align-items:flex-start;flex-wrap:wrap}.format{order:3;width:100%}}
|
|
423
|
-
.agent-settings{margin-bottom:18px}.agent-settings .agent-row{max-width:520px}.dws-settings{margin-bottom:18px}.dws-settings h2{display:none}.private-chat-settings{margin-bottom:18px}.private-chat-settings h2,.history-settings h2,.rule-history-settings h2{display:none}.history-settings,.rule-history-settings{margin-bottom:18px}.history-settings h2,.rule-history-settings h2{display:block;margin:0 0 14px;color:#26354d;font-size:15px}.private-chat-row{display:flex;align-items:center;gap:14px;min-height:54px}.private-chat-row>div:first-child{flex:1}.private-chat-title{display:flex;align-items:center;gap:12px}.private-chat-row label{display:block;color:#26354d;font-size:15px;font-weight:600}.private-chat-row p{margin:4px 0 0;color:#718096;font-size:12px}.private-chat-row strong{min-width:58px;color:#657287;font-size:12px}.switch{display:inline-flex!important;align-items:center;cursor:pointer}.switch input{position:absolute;opacity:0;width:1px!important;height:1px!important}.switch span{position:relative;display:block;width:44px;height:24px;border-radius:20px;background:#cbd5e1;transition:.2s}.switch span::after{content:'';position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #64748b;transition:.2s}.switch input:checked+span{background:#1769e0}.switch input:checked+span::after{left:23px}.dws-actions{display:flex;gap:10px;margin-bottom:14px}.check-field{display:flex!important;align-items:center;gap:8px;font-weight:400!important}.check-field input{width:16px!important;height:16px!important;margin:0!important}.history-config{grid-column:1/-1;margin-top:4px;padding:14px 16px;border:1px solid #e1e7ef;border-radius:6px;background:#f8fafc}.history-config h3{margin:0 0 12px;color:#26354d;font-size:14px}.history-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px 16px}.field-help{margin-top:5px;color:#718096;font-size:11px}.dws-device-panel{display:none;margin-bottom:14px;padding:14px 16px;border:1px solid #9fc4f5;border-radius:6px;background:#f5f9ff}.dws-device-panel.visible{display:block}.dws-device-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;color:#174f9f;font-size:13px}.dws-device-head .button{height:32px;padding:0 10px}.dws-device-output{padding:11px 12px;border:1px solid #d6e3f5;border-radius:5px;background:#fff;color:#172033;font:12px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-all}.dws-device-output a{color:#175cd3;text-decoration:underline}.dws-device-tip{margin-top:9px;color:#5c6d82;font-size:12px;line-height:1.5}.danger-button{background:#fff1f0;color:#b42318;border:1px solid #f1b8b5}.dws-status-box{padding:14px 16px;border:1px solid #dfe6ef;border-radius:6px;background:#f8fafc}.dws-auth-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 14px}.dws-auth-fields>div{padding:11px 12px;border:1px solid #e2e8f0;border-radius:6px;background:#fff}.dws-auth-fields span{display:block;margin-bottom:5px;color:#718096;font-size:11px}.dws-auth-fields strong,.dws-auth-fields code{display:block;color:#172033;font-size:13px;word-break:break-all}.dws-auth-fields code{font:12px ui-monospace,SFMono-Regular,Menlo,monospace}.dws-status-box pre{margin:0;white-space:pre-wrap;word-break:break-word;font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace;color:#172033}.dws-status-loading{color:#657287;font-size:13px}.dws-login-guide{margin-top:12px;padding-top:12px;border-top:1px solid #e1e7ef;color:#526176;font-size:13px;line-height:1.6}.dws-login-guide a{color:#175cd3}.dws-help{margin:12px 0 0;color:#657287;font-size:12px;line-height:1.5}.agent-command-grid{margin-top:16px;padding-top:14px;border-top:1px solid #e7edf5}.prompt-settings{margin-bottom:18px}.prompt-settings textarea{width:100%;min-height:220px;padding:10px 11px;border:1px solid #c9d3e0;border-radius:5px;resize:vertical;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.prompt-help{margin-top:6px;color:#657287;font-size:12px;line-height:1.5}.section-toggle{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;margin:0}.section-toggle::after{content:'展开';font-size:13px;font-weight:400;color:#175cd3}.collapsible-panel.expanded>.section-toggle::after{content:'收起'}.collapsible-panel:not(.expanded)>.collapsible-content{display:none}.collapsible-panel.expanded>.collapsible-content{margin-top:14px}.bot-user-results{display:flex;flex-direction:column;gap:5px;margin-top:7px;max-height:180px;overflow:auto}.bot-user-result,.bot-user-chip{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 9px;border:1px solid #dbe2ed;border-radius:5px;background:#f8fafc;font-size:13px}.super-admin-field{display:none!important;grid-column:1/-1;width:100%;padding:14px 16px;border:1px solid #dbe2ed;border-radius:8px;background:#f8fafc}.super-admin-field>label{display:flex;align-items:center;gap:8px;width:100%;margin:0 0 0;color:#172033;font-size:13px;line-height:20px}.super-admin-field>label::before{content:none;display:inline-flex;align-items:center;height:20px;padding:0 6px;border-radius:4px;background:#eaf2ff;color:#175cd3;font-size:11px;font-weight:700}.super-admin-help{margin-top:5px;color:#657287;font-size:12px;line-height:1.5}.super-admin-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:10px;width:100%;max-height:none}.super-admin-list .bot-user-result{justify-content:flex-start;min-height:42px;padding:9px 11px;background:#fff;cursor:pointer;transition:border-color .15s,background .15s}.super-admin-list .bot-user-result:has(input:checked){border-color:#86b7ff;background:#eef6ff}.super-admin-list .bot-user-result input{width:16px;height:16px;margin:0;accent-color:#1677ff}.super-admin-list .bot-user-result span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bot-user-result{width:100%;text-align:left;cursor:pointer}.bot-user-result:hover{background:#eef5ff;border-color:#9fc4f5}.bot-user-chip{display:inline-flex;margin:6px 6px 0 0;background:#eef4ff;border-color:#c5dafa;color:#175cd3}.bot-user-chip button{border:0;background:transparent;color:#175cd3;cursor:pointer;font-size:16px;line-height:1;padding:0}.bot-user-selected{margin-top:6px;display:flex;flex-wrap:wrap}.save-floating{position:fixed;left:50%;bottom:10px;transform:translateX(-50%);z-index:10;background:#16a34a;color:#fff;box-shadow:0 4px 14px rgba(22,163,74,.35)}.rules-panel{margin-bottom:18px}.rules-toggle{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;margin:0}.rules-toggle::after{content:'展开';font-size:13px;font-weight:400;color:#175cd3}.rules-panel.expanded .rules-toggle::after{content:'收起'}.rules-panel:not(.expanded) .rules-content{display:none}.rules-panel.expanded .rules-content{margin-top:14px}.command-settings{margin-bottom:18px}.command-toggle{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;margin:0}.command-toggle::after{content:'展开';font-size:13px;font-weight:400;color:#175cd3}.command-settings.expanded .command-toggle::after{content:'收起'}.command-settings:not(.expanded) .command-grid{display:none}.command-settings.expanded .command-grid{margin-top:14px}.command-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 16px}.command-grid input{width:100%;height:36px;padding:7px 9px;border:1px solid #c9d3e0;border-radius:5px;background:#fff;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.binding-commands{margin-bottom:18px;padding:14px 16px;border:1px solid #e1e8f2;border-radius:6px;background:#f8fafc}.binding-commands h3{margin:0 0 12px;font-size:14px;color:#26354d}.binding-help{margin:-5px 0 12px;color:#657287;font-size:12px;line-height:1.5}@media(max-width:640px){.command-grid{grid-template-columns:1fr}.dws-auth-fields{grid-template-columns:1fr}.dws-actions{display:grid;grid-template-columns:1fr}.dws-actions .button{width:100%}}.agent-options{display:flex;gap:18px;align-items:center;flex-wrap:wrap}.agent-options label{display:flex;align-items:center;gap:6px;font-size:14px;cursor:pointer}.agent-options input{width:16px;height:16px;accent-color:#1677ff}.robot-settings{margin-bottom:28px}.robot-settings .robot-row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 16px;align-items:start}.card-settings-field{grid-column:1/-1}.robot-settings .robot-row>.field{grid-column:1/-1;width:100%}.card-settings-row{display:flex;align-items:center;gap:24px}.compact-number-field{display:inline-flex!important;align-items:center;gap:6px;font-weight:400!important}.compact-number-field input{width:58px!important;height:30px!important;padding:4px 6px!important}@media(max-width:640px){.agent-settings .agent-row{max-width:none}.robot-settings .robot-row{grid-template-columns:1fr}.card-settings-row{align-items:flex-start;flex-direction:column;gap:10px}.super-admin-list{grid-template-columns:1fr}}
|
|
478
|
+
.agent-settings{margin-bottom:18px}.agent-settings .agent-row{max-width:520px}.dws-settings{margin-bottom:18px}.dws-settings h2{display:none}.private-chat-settings{margin-bottom:18px}.private-chat-settings h2,.history-settings h2,.rule-history-settings h2{display:none}.history-settings,.rule-history-settings{margin-bottom:18px}.history-settings h2,.rule-history-settings h2{display:block;margin:0 0 14px;color:#26354d;font-size:15px}.private-chat-row{display:flex;align-items:center;gap:14px;min-height:54px}.private-chat-row>div:first-child{flex:1}.private-chat-title{display:flex;align-items:center;gap:12px}.private-chat-row label{display:block;color:#26354d;font-size:15px;font-weight:600}.private-chat-row p{margin:4px 0 0;color:#718096;font-size:12px}.private-chat-row strong{min-width:58px;color:#657287;font-size:12px}.switch{display:inline-flex!important;align-items:center;cursor:pointer}.switch input{position:absolute;opacity:0;width:1px!important;height:1px!important}.switch span{position:relative;display:block;width:44px;height:24px;border-radius:20px;background:#cbd5e1;transition:.2s}.switch span::after{content:'';position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #64748b;transition:.2s}.switch input:checked+span{background:#1769e0}.switch input:checked+span::after{left:23px}.dws-actions{display:flex;gap:10px;margin-bottom:14px}.check-field{display:flex!important;align-items:center;gap:8px;font-weight:400!important}.check-field input{width:16px!important;height:16px!important;margin:0!important}.history-config{grid-column:1/-1;margin-top:4px;padding:14px 16px;border:1px solid #e1e7ef;border-radius:6px;background:#f8fafc}.history-config h3{margin:0 0 12px;color:#26354d;font-size:14px}.history-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px 16px}.field-help{margin-top:5px;color:#718096;font-size:11px}.dws-device-panel{display:none;margin-bottom:14px;padding:14px 16px;border:1px solid #9fc4f5;border-radius:6px;background:#f5f9ff}.dws-device-panel.visible{display:block}.dws-device-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;color:#174f9f;font-size:13px}.dws-device-head .button{height:32px;padding:0 10px}.dws-device-output{padding:11px 12px;border:1px solid #d6e3f5;border-radius:5px;background:#fff;color:#172033;font:12px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;word-break:break-all}.dws-device-output a{color:#175cd3;text-decoration:underline}.dws-device-tip{margin-top:9px;color:#5c6d82;font-size:12px;line-height:1.5}.danger-button{background:#fff1f0;color:#b42318;border:1px solid #f1b8b5}.dws-status-box{padding:14px 16px;border:1px solid #dfe6ef;border-radius:6px;background:#f8fafc}.dws-auth-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 14px}.dws-auth-fields>div{padding:11px 12px;border:1px solid #e2e8f0;border-radius:6px;background:#fff}.dws-auth-fields span{display:block;margin-bottom:5px;color:#718096;font-size:11px}.dws-auth-fields strong,.dws-auth-fields code{display:block;color:#172033;font-size:13px;word-break:break-all}.dws-auth-fields code{font:12px ui-monospace,SFMono-Regular,Menlo,monospace}.dws-status-box pre{margin:0;white-space:pre-wrap;word-break:break-word;font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace;color:#172033}.dws-status-loading{color:#657287;font-size:13px}.dws-login-guide{margin-top:12px;padding-top:12px;border-top:1px solid #e1e7ef;color:#526176;font-size:13px;line-height:1.6}.dws-login-guide a{color:#175cd3}.dws-help{margin:12px 0 0;color:#657287;font-size:12px;line-height:1.5}.agent-command-grid{margin-top:16px;padding-top:14px;border-top:1px solid #e7edf5}.prompt-settings{margin-bottom:18px}.prompt-settings textarea{width:100%;min-height:220px;padding:10px 11px;border:1px solid #c9d3e0;border-radius:5px;resize:vertical;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.prompt-help{margin-top:6px;color:#657287;font-size:12px;line-height:1.5}.section-toggle{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;margin:0}.section-toggle::after{content:'展开';font-size:13px;font-weight:400;color:#175cd3}.collapsible-panel.expanded>.section-toggle::after{content:'收起'}.collapsible-panel:not(.expanded)>.collapsible-content{display:none}.collapsible-panel.expanded>.collapsible-content{margin-top:14px}.bot-user-results{display:flex;flex-direction:column;gap:5px;margin-top:7px;max-height:180px;overflow:auto}.bot-user-result,.bot-user-chip{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 9px;border:1px solid #dbe2ed;border-radius:5px;background:#f8fafc;font-size:13px}.super-admin-field{display:none!important;grid-column:1/-1;width:100%;padding:14px 16px;border:1px solid #dbe2ed;border-radius:8px;background:#f8fafc}.super-admin-field>label{display:flex;align-items:center;gap:8px;width:100%;margin:0 0 0;color:#172033;font-size:13px;line-height:20px}.super-admin-field>label::before{content:none;display:inline-flex;align-items:center;height:20px;padding:0 6px;border-radius:4px;background:#eaf2ff;color:#175cd3;font-size:11px;font-weight:700}.super-admin-help{margin-top:5px;color:#657287;font-size:12px;line-height:1.5}.super-admin-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:10px;width:100%;max-height:none}.security-field{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 14px;margin-top:14px;padding:14px 16px;border:1px solid #dbe2ed;border-radius:6px;background:#f8fafc}.security-field label{grid-column:1/-1;margin:0}.security-field input{min-width:0}.security-field button{width:100%;min-height:36px}.security-field button:last-child{grid-column:2}.super-admin-list .bot-user-result{justify-content:flex-start;min-height:42px;padding:9px 11px;background:#fff;cursor:pointer;transition:border-color .15s,background .15s}.super-admin-list .bot-user-result:has(input:checked){border-color:#86b7ff;background:#eef6ff}.super-admin-list .bot-user-result input{width:16px;height:16px;margin:0;accent-color:#1677ff}.super-admin-list .bot-user-result span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bot-user-result{width:100%;text-align:left;cursor:pointer}.bot-user-result:hover{background:#eef5ff;border-color:#9fc4f5}.bot-user-chip{display:inline-flex;margin:6px 6px 0 0;background:#eef4ff;border-color:#c5dafa;color:#175cd3}.bot-user-chip button{border:0;background:transparent;color:#175cd3;cursor:pointer;font-size:16px;line-height:1;padding:0}.bot-user-selected{margin-top:6px;display:flex;flex-wrap:wrap}.save-floating{position:fixed;left:50%;bottom:10px;transform:translateX(-50%);z-index:10;background:#16a34a;color:#fff;box-shadow:0 4px 14px rgba(22,163,74,.35)}.rules-panel{margin-bottom:18px}.rules-toggle{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;margin:0}.rules-toggle::after{content:'展开';font-size:13px;font-weight:400;color:#175cd3}.rules-panel.expanded .rules-toggle::after{content:'收起'}.rules-panel:not(.expanded) .rules-content{display:none}.rules-panel.expanded .rules-content{margin-top:14px}.command-settings{margin-bottom:18px}.command-toggle{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;margin:0}.command-toggle::after{content:'展开';font-size:13px;font-weight:400;color:#175cd3}.command-settings.expanded .command-toggle::after{content:'收起'}.command-settings:not(.expanded) .command-grid{display:none}.command-settings.expanded .command-grid{margin-top:14px}.command-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 16px}.command-grid input{width:100%;height:36px;padding:7px 9px;border:1px solid #c9d3e0;border-radius:5px;background:#fff;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.binding-commands{margin-bottom:18px;padding:14px 16px;border:1px solid #e1e8f2;border-radius:6px;background:#f8fafc}.binding-commands h3{margin:0 0 12px;font-size:14px;color:#26354d}.binding-help{margin:-5px 0 12px;color:#657287;font-size:12px;line-height:1.5}@media(max-width:640px){.command-grid{grid-template-columns:1fr}.dws-auth-fields{grid-template-columns:1fr}.dws-actions{display:grid;grid-template-columns:1fr}.dws-actions .button{width:100%}.security-field{grid-template-columns:1fr;padding:12px}.security-field button:last-child{grid-column:auto}}.agent-options{display:flex;gap:18px;align-items:center;flex-wrap:wrap}.agent-options label{display:flex;align-items:center;gap:6px;font-size:14px;cursor:pointer}.agent-options input{width:16px;height:16px;accent-color:#1677ff}.robot-settings{margin-bottom:28px}.robot-settings .robot-row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 16px;align-items:start}.card-settings-field{grid-column:1/-1}.robot-settings .robot-row>.field{grid-column:1/-1;width:100%}.card-settings-row{display:flex;align-items:center;gap:24px}.compact-number-field{display:inline-flex!important;align-items:center;gap:6px;font-weight:400!important}.compact-number-field input{width:58px!important;height:30px!important;padding:4px 6px!important}@media(max-width:640px){.agent-settings .agent-row{max-width:none}.robot-settings .robot-row{grid-template-columns:1fr}.card-settings-row{align-items:flex-start;flex-direction:column;gap:10px}.super-admin-list{grid-template-columns:1fr}}
|
|
424
479
|
.member-list{display:flex;flex-wrap:wrap;align-items:center;gap:6px 14px;max-height:128px}.field label.member-option{display:inline-flex!important;align-items:center;gap:8px;flex:0 0 auto;width:max-content;min-width:max-content;min-height:28px;margin:0!important;padding:4px 6px;overflow:visible;white-space:nowrap;line-height:18px}.member-option input[type=checkbox]{display:block;flex:0 0 14px;width:14px!important;height:14px!important;min-width:14px;min-height:14px;padding:0!important;margin:0!important;align-self:center;vertical-align:middle}.member-name,.member-role{display:inline-flex;align-items:center;height:18px;margin:0;overflow:visible;text-overflow:clip;white-space:nowrap;line-height:18px}.member-role{margin-left:-4px}
|
|
425
480
|
.replies-panel .replies-toggle{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;margin:0}.replies-panel .replies-toggle::after{content:'展开';font-size:13px;font-weight:400;color:#175cd3}.replies-panel.expanded .replies-toggle::after{content:'收起'}.replies-panel:not(.expanded) #replies{display:none!important}.reply{border:1px dashed #8fa4c2!important;border-radius:6px;padding:14px!important;background:#fff}.reply .meta{margin:0 0 6px;padding:9px 11px;border:1px solid #c8d7ec;border-radius:4px;background:#eaf2ff;box-shadow:inset 0 1px 0 #fff,0 2px 5px rgba(48,74,110,.16);font-size:15px;font-weight:700;color:#25324a}
|
|
426
481
|
@media(max-width:640px){.save-floating{bottom:6px}body{min-width:0}.wrap{padding:16px 12px 32px}h1{font-size:22px;line-height:1.3}h2{font-size:18px;margin-bottom:12px}.panel{padding:14px}.robot-settings{margin-bottom:18px}.robot-settings .robot-row{gap:12px}.field label{font-size:13px}.field input,.field select,.search,.button{min-height:44px;height:44px;font-size:16px}.line{gap:8px}.line input{min-width:0}.search{padding:0 13px;flex:0 0 auto}.rule{padding:14px 42px 14px 0}.rule-grid,.configured-grid{gap:12px}.delete{top:17px;right:0;width:36px;height:44px;font-size:24px}.member-list{max-height:220px;padding:5px;gap:4px 6px;align-content:start}.field label.member-option{min-height:40px;padding:8px 7px;gap:9px;font-size:15px}.member-option input[type=checkbox]{flex-basis:18px;width:18px!important;height:18px!important;min-width:18px;min-height:18px}.member-name,.member-role{height:20px;line-height:20px}.member-role{font-size:12px}.members{padding-top:3px;line-height:1.45}.toolbar{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:14px;padding-top:14px}.toolbar .button{width:100%;padding:8px}.toolbar .format{grid-column:1/-1;order:0;display:flex;flex-wrap:wrap;gap:8px 12px;min-height:34px}.format span{width:100%;font-weight:600}.format label{display:inline-flex;align-items:center;min-height:30px}.format input{width:18px;height:18px;margin:0 5px 0 0}.reply{padding:10px!important}.reply .meta{padding:9px 10px;font-size:14px;line-height:1.45;word-break:break-word}.reply pre{font-size:12px!important}.notice{line-height:1.45}.status{font-size:13px}.empty{padding:4px 0}.grid{gap:14px}#replies{max-height:58vh!important}}
|
|
427
482
|
/* Application shell */
|
|
428
483
|
:root{--nav:#172033;--nav-muted:#9ba8bb;--line:#dfe5ed;--blue:#1769e0;--canvas:#f5f6f8}body{background:var(--canvas);color:#172033}.wrap{max-width:none;min-height:100vh;padding:0}.app-shell{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh}.app-sidebar{position:fixed;inset:0 auto 0 0;width:232px;z-index:30;display:flex;flex-direction:column;padding:14px 12px;background:var(--nav);color:#fff}.brand{display:flex;align-items:center;gap:11px;padding:0 6px 16px}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid #48566b;border-radius:7px;background:#222d3e;color:#fff;font:700 14px ui-monospace,SFMono-Regular,Menlo,monospace}.brand-icon{object-fit:cover}.brand-name{font-size:15px;font-weight:700;letter-spacing:.01em}.brand-sub{margin-top:2px;color:var(--nav-muted);font-size:11px}.nav-label{padding:0 10px 8px;color:#718096;font-size:10px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}.app-nav{display:flex;flex-direction:column;gap:3px}.nav-item{display:flex;align-items:center;gap:10px;width:100%;min-height:42px;padding:0 11px;border:0;border-radius:6px;background:transparent;color:#b9c3d1;text-align:left;font:13px inherit;cursor:pointer}.nav-item:hover{background:#202c3e;color:#fff}.nav-item.active{background:#2a3850;color:#fff;font-weight:600}.nav-index{width:22px;color:#718096;font:11px ui-monospace,SFMono-Regular,Menlo,monospace}.nav-item.active .nav-index{color:#78aaf8}.current-user-card{margin-top:18px;padding:12px 14px;border:1px solid #dfe6ef;border-radius:6px;background:#f8fafc}.current-user-card .user-value{margin-top:4px;color:#172033;font-size:14px;font-weight:600}.current-user-card .user-id{margin-top:3px;color:#657287;font:11px ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.sidebar-foot{margin-top:auto;padding:18px 8px 0;border-top:1px solid #2e3a4d;color:#8996a9;font-size:11px;line-height:1.6}.sidebar-foot .version{display:inline-block;margin-top:5px;color:#c0cad8;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.sidebar-foot a{color:#8996a9;text-decoration:none;word-break:break-all}.sidebar-foot a:hover{color:#fff;text-decoration:underline}.app-main{grid-column:2;min-width:0;transition:margin-left .2s}.sidebar-toggle{display:grid;place-items:center;width:32px;height:32px;margin-left:auto;border:1px solid #3b495e;border-radius:5px;background:transparent;color:#b9c3d1;cursor:pointer;font-size:16px}.sidebar-toggle:hover{background:#2a3850;color:#fff}.fullscreen-button{display:inline-flex;align-items:center;gap:6px;height:34px;padding:0 11px;border:1px solid #dbe2eb;border-radius:5px;background:#fff;color:#344054;cursor:pointer;font:12px inherit}.fullscreen-button:hover{border-color:#9fc4f5;background:#f5f9ff;color:#175cd3}body.sidebar-collapsed .app-sidebar{width:72px}body.sidebar-collapsed .app-main{grid-column:2;margin-left:-160px}body.sidebar-collapsed .brand{padding-inline:7px}body.sidebar-collapsed .brand-name,body.sidebar-collapsed .brand-sub,body.sidebar-collapsed .nav-label,body.sidebar-collapsed .nav-item>span:last-child,body.sidebar-collapsed .sidebar-foot{display:none}body.sidebar-collapsed .brand-mark{flex:0 0 34px}body.sidebar-collapsed .sidebar-toggle{position:absolute;top:62px;left:19px;width:34px}body.sidebar-collapsed .app-nav{margin-top:48px}body.sidebar-collapsed .nav-item{justify-content:center;padding:0}body.sidebar-collapsed .nav-index{width:auto}body.sidebar-collapsed .save-floating{left:calc(72px + (100vw - 72px)/2)}.app-topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;min-height:58px;padding:8px 24px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.94);backdrop-filter:blur(12px)}.topbar-copy h1{margin:0;color:#152033;font-size:21px;line-height:1.3}.topbar-copy p{margin:4px 0 0;color:#6b7789;font-size:12px}.topbar-actions{display:flex;align-items:center;gap:12px}.status{display:inline-flex;align-items:center;gap:7px;height:32px;padding:0 10px;border-color:#dbe2eb;border-radius:5px;background:#fff;color:#5c6878;font-size:12px}.status::before{content:'';width:7px;height:7px;border-radius:50%;background:#98a2b3}.status.connected::before{background:#20a064;box-shadow:0 0 0 3px #e1f5ea}.status.stopped::before,.status.error::before{background:#d14b45}.menu-button{display:none;width:40px;height:40px;border:1px solid var(--line);border-radius:6px;background:#fff;color:#223048;font-size:20px}.app-content{width:100%;padding:10px 24px 100px}.app-page{display:none}.app-page.active{display:block}.app-page>.panel{margin:0;border:1px solid var(--line);border-radius:8px;background:#fff;box-shadow:0 1px 2px rgba(16,24,40,.03)}.app-page>.panel>h2{display:none}.agent-settings .agent-row{max-width:none}.agent-options{padding:10px 0}.agent-options label{min-width:150px;padding:13px 15px;border:1px solid #dbe2eb;border-radius:6px;background:#fafbfc}.agent-options label:has(input:checked){border-color:#7daaf0;background:#f0f6ff;color:#174f9f}.agent-command-grid{display:flex;flex-direction:column;gap:14px;margin-top:20px}.agent-command-grid .field{width:100%}.prompt-inline-field{margin-top:26px}.agent-command-grid textarea,.agent-command-grid input{width:100%;height:36px;padding:7px 9px;border:1px solid #c9d3e0;border-radius:5px;background:#fff;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.agent-command-grid textarea{resize:vertical;min-height:36px}.agent-command-grid .prompt-inline-field .prompt-text-input{height:120px;min-height:120px;line-height:1.5;resize:vertical;vertical-align:top}.command-settings:not(.expanded) .command-grid,.rules-panel:not(.expanded) .rules-content,.replies-panel:not(.expanded) #replies,.collapsible-panel:not(.expanded)>.collapsible-content{display:initial}.command-settings .command-grid,.rules-panel .rules-content,.replies-panel #replies,.collapsible-panel>.collapsible-content{margin-top:0!important}.robot-settings .robot-row{grid-template-columns:repeat(2,minmax(0,1fr))}.prompt-settings textarea{min-height:360px}.binding-commands{border-color:#dfe6ef;background:#fafbfc}.rules-panel{margin:0}.rule{padding:18px 0}.save-floating{position:fixed;right:auto;bottom:24px;left:calc(232px + (100vw - 232px)/2);z-index:40;min-width:148px;height:42px;padding:0 22px;border-radius:6px;background:#1769e0;box-shadow:0 8px 20px rgba(23,105,224,.24);transform:translateX(-50%)}.save-floating:hover{background:#105dc9}.replies-panel{margin:0!important}.reply{border:1px solid #dfe5ed!important;border-radius:7px!important}.reply .meta{border:0;border-bottom:1px solid #e8edf3;border-radius:0;background:#f8fafc;box-shadow:none;color:#344054;font-size:13px}.mobile-scrim{display:none}.notice{bottom:78px}.field input,.field select,.command-grid input,.prompt-settings textarea{border-color:#ccd5e1;border-radius:5px}.field input:focus,.field select:focus,.command-grid input:focus,.prompt-settings textarea:focus{outline:2px solid #c8dcfb;border-color:#4f88dd}.button,.search{border-radius:5px}.secondary{background:#edf4ff;color:#175bb8}.page-agent .panel,.page-prompt .panel,.page-command .panel,.page-robot .panel,.page-monitor .panel{padding:26px}.monitor-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.monitor-toolbar-actions{display:flex;align-items:center;gap:10px}.monitor-processes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-bottom:18px}.process-card{padding:13px 14px;border:1px solid #dfe5ed;border-radius:6px;background:#fafbfc}.process-card strong{display:block;margin-bottom:6px;font-size:13px}.process-card code{color:#657287;font-size:11px;word-break:break-all}.process-state{display:inline-flex;align-items:center;gap:6px;margin-bottom:7px;color:#b42318;font-size:12px}.process-state::before{content:'';width:7px;height:7px;border-radius:50%;background:#d14b45}.process-state.running{color:#087443}.process-state.running::before{background:#20a064}.process-actions{margin-top:8px}.process-action{padding:5px 10px;font-size:12px}.system-log-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}.system-log{height:calc(100vh - 330px);min-height:360px;margin:0;padding:14px;border:1px solid #26364d;border-radius:6px;background:#101722;color:#c8d4e3;overflow:auto;white-space:pre-wrap;word-break:break-word;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.monitor-meta{color:#657287;font-size:12px}.section-toggle::after,.rules-toggle::after,.command-toggle::after,.replies-toggle::after{display:none}
|
|
429
484
|
@media(max-width:820px){body.sidebar-collapsed .app-sidebar{width:min(82vw,280px)}body.sidebar-collapsed .app-main{margin-left:0}body.sidebar-collapsed .brand-name,body.sidebar-collapsed .brand-sub,body.sidebar-collapsed .nav-label,body.sidebar-collapsed .nav-item>span:last-child,body.sidebar-collapsed .sidebar-foot{display:initial}body.sidebar-collapsed .sidebar-toggle{position:static;width:32px}body.sidebar-collapsed .app-nav{margin-top:0}body.sidebar-collapsed .nav-item{justify-content:flex-start;padding:0 11px}body.sidebar-collapsed .nav-index{width:22px}.app-shell{display:block}.app-sidebar{width:min(82vw,280px);padding-top:18px;transform:translateX(-105%);transition:transform .2s ease;box-shadow:16px 0 40px rgba(17,24,39,.18)}body.nav-open .app-sidebar{transform:translateX(0)}.mobile-scrim{position:fixed;inset:0;z-index:25;display:block;background:rgba(15,23,42,.42);opacity:0;pointer-events:none;transition:opacity .2s}body.nav-open .mobile-scrim{opacity:1;pointer-events:auto}.app-main{min-height:100vh}.app-topbar{min-height:70px;padding:12px 16px}.menu-button{display:block;order:-1}.topbar-actions{gap:8px}.topbar-copy{flex:1;min-width:0}.topbar-copy h1{font-size:18px}.topbar-copy p{display:none}.status{max-width:120px;overflow:hidden;white-space:nowrap}.app-content{padding:20px 14px 100px}.app-page>.panel,.page-agent .panel,.page-prompt .panel,.page-command .panel,.page-robot .panel{padding:16px}.robot-settings .robot-row,.command-grid{grid-template-columns:1fr}.agent-command-grid{display:flex}.prompt-settings textarea{min-height:300px}.agent-options{display:grid;grid-template-columns:1fr 1fr;gap:8px}.agent-options label{min-width:0;justify-content:center;padding:11px 8px}.monitor-toolbar{align-items:flex-start;flex-direction:column}.monitor-toolbar-actions{width:100%;justify-content:space-between}.monitor-processes{grid-template-columns:repeat(4,minmax(0,1fr))}.system-log{height:55vh;min-height:300px}.agent-options input[type=radio]{width:18px!important;height:18px!important;min-width:18px!important;min-height:18px!important;padding:0!important}.save-floating{right:14px;bottom:14px;left:14px;width:calc(100% - 28px);height:48px;transform:none}.notice{bottom:72px}.binding-commands{padding:12px}.rule-grid,.configured-grid{grid-template-columns:1fr}.members{grid-column:1}.rule{padding-right:38px}.toolbar{grid-template-columns:1fr}.toolbar .format{grid-column:1}.member-list{max-height:260px}.line{width:100%}}
|
|
430
|
-
</style></head><body><main class="wrap"><header><div><h1>oh-my-im 控制台</h1><p>保存后立即生效。搜索并选择群,再从该群成员中选择需要监听的人。</p></div><div class="status" id="connection">加载中</div></header><section class="panel agent-settings"><h2>Agent 引擎设置</h2><div class="agent-row"><div class="field"><label>当前使用的 Agent</label><div class="agent-options"><label><input type="radio" name="agent" value="codex"
|
|
431
|
-
let selectedBotUsers=[],selectedSuperAdminIds=[];const agentSelect=()=>document.querySelector('input[name=agent]:checked'),rules=document.querySelector('#rules'),notice=document.querySelector('#notice'),replyList=document.querySelector('#replies'),layout=document.querySelector('.grid'),rulesPanel=layout.firstElementChild,repliesPanel=layout.lastElementChild,agentSettings=document.querySelector('.agent-settings'),promptSettings=document.querySelector('.prompt-settings'),robotSettings=document.querySelector('.robot-settings'),commandSettings=document.querySelector('.command-settings'),monitorSettings=document.querySelector('.monitor-settings'),root=document.querySelector('.wrap'),connection=document.querySelector('#connection'),saveControl=document.querySelector('#save');document.querySelector('#add').textContent='添加钉钉规则';const renderDwsDeviceOutput=(text)=>{const output=document.querySelector('#dwsDeviceOutput');if(!output)return;output.replaceChildren();const parts=String(text||'暂无登录信息').split(/(https?:\/\/[^\s]+)/g);parts.forEach(part=>{if(/^https?:\/\//.test(part)){const a=document.createElement('a');a.href=part.replace(/[),.;。!]$/,'');a.target='_blank';a.rel='noreferrer';a.textContent=part;output.append(a)}else output.append(document.createTextNode(part))})};const pollDwsDeviceLogin=async()=>{try{const r=await fetch('/api/dws-device-login',{cache:'no-store'}),b=await r.json();if(r.ok){renderDwsDeviceOutput(b.output);document.querySelector('#dwsDevicePanel')?.classList.add('visible');if(b.running)setTimeout(pollDwsDeviceLogin,1000);else void loadDwsAuth()}}catch(e){renderDwsDeviceOutput(e instanceof Error?e.message:String(e))}};const loadPrivateChatStatus=async()=>{try{const r=await fetch('/api/bot-status',{cache:'no-store'}),b=await r.json();if(!r.ok)throw new Error(b.error||'私聊机器人状态查询失败');const enabled=document.querySelector('#privateChatEnabled'),status=document.querySelector('#privateChatStatus');enabled.checked=b.status?.enabled===true;status.textContent=b.status?.connected?'已连接':'未连接'}catch(e){document.querySelector('#privateChatStatus').textContent='查询失败'}};const bindPrivateChatToggle=()=>{const toggle=document.querySelector('#privateChatEnabled');if(!toggle)return;toggle.onchange=async event=>{const enabled=event.target.checked;try{const stateResponse=await fetch('/api/state',{cache:'no-store'}),state=await stateResponse.json();if(!stateResponse.ok||!state.config)throw new Error('读取当前配置失败');const r=await fetch('/api/config',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({...state.config,privateChatEnabled:enabled,clientSecret:state.config.clientSecret||''})});const body=await r.json().catch(()=>({}));if(!r.ok||body.saved!==true)throw new Error(body.error||'保存个人私聊开关失败');await loadPrivateChatStatus()}catch(e){event.target.checked=!enabled;alert(e instanceof Error?e.message:String(e))}}};setInterval(()=>void loadPrivateChatStatus(),2000);const loadDwsAuth=async()=>{const output=document.querySelector('#dwsAuthStatus'),loading=document.querySelector('.dws-status-loading'),guide=document.querySelector('#dwsLoginGuide');try{const r=await fetch('/api/dws-auth-status',{cache:'no-store'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 登录状态查询失败');if(loading)loading.remove();const status=b.status||{};const set=(id,value)=>{const el=document.querySelector('#'+id);if(el)el.textContent=value||'-'};set('dwsCorpName',status.corp_name);set('dwsUserName',status.user_name);set('dwsUserId',status.user_id);set('dwsExpiresAt',status.expires_at);set('dwsRefreshExpiresAt',status.refresh_expires_at);if(output)output.textContent='';if(b.status?.authenticated!==true&&guide)guide.innerHTML='当前未登录 DWS。请在运行 DWS 的机器执行 <code>dws auth login --device</code>,或点击“使用 Device Flow 登录”。'}catch(e){if(loading)loading.textContent='DWS 登录状态读取失败';if(output)output.textContent=e instanceof Error?e.message:String(e);if(guide)guide.innerHTML='请在运行 DWS 的机器执行 <code>dws auth login --device</code> 完成跨设备登录。'}};const dwsLoginButton=document.querySelector('#dwsDeviceLogin');dwsLoginButton.onclick=async()=>{dwsLoginButton.disabled=true;try{const r=await fetch('/api/dws-device-login',{method:'POST'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 登录启动失败');renderDwsDeviceOutput(b.message);document.querySelector('#dwsDevicePanel')?.classList.add('visible');void pollDwsDeviceLogin()}catch(e){renderDwsDeviceOutput(e instanceof Error?e.message:String(e));document.querySelector('#dwsDevicePanel')?.classList.add('visible')}finally{dwsLoginButton.disabled=false}};document.querySelector('#dwsDeviceCopy').onclick=async()=>{const text=document.querySelector('#dwsDeviceOutput')?.textContent||'';try{await navigator.clipboard.writeText(text);notice.textContent='登录信息已复制';setTimeout(()=>notice.textContent='',2000)}catch{notice.textContent='复制失败,请手动选择上方登录信息复制';}};document.querySelector('#dwsLogout').onclick=async()=>{if(!confirm('确认退出当前 DWS 登录吗?'))return;try{const r=await fetch('/api/dws-logout',{method:'POST'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 退出失败');await loadDwsAuth()}catch(e){alert(e instanceof Error?e.message:String(e))}};void loadDwsAuth();void loadPrivateChatStatus();agentSettings.querySelector('.agent-command-grid').append(...commandSettings.querySelector('.command-grid').children);commandSettings.remove();const promptField=promptSettings.querySelector('.collapsible-content').firstElementChild;const promptTextarea=promptField.querySelector('textarea');promptTextarea.rows=5;promptTextarea.classList.add('prompt-text-input');promptField.classList.add('prompt-inline-field');agentSettings.append(promptField);promptSettings.remove();robotSettings.querySelector('h2')?.remove();robotSettings.querySelector('.robot-row').innerHTML='<div class="field"><label>机器人名称 <a class="platform-link" href="https://open-dev.dingtalk.com/fe/app" target="_blank" rel="noreferrer">(开放平台)</a></label><input id="robotName" placeholder="例如:我的 AI 助手"></div><div class="field bot-identity-field"><label>机器人 openDingTalkId</label><div class="line"><input id="robotSenderOpenDingTalkId" placeholder="点击“获取 ID”自动填写" readonly><button class="search" id="botSearchButton" type="button">获取 ID</button></div><div id="botSearchResult" class="bot-user-results"></div></div><div class="field"><label>钉钉应用 Client ID</label><input id="clientId" placeholder="ding..."></div><div class="field"><label>钉钉应用 Client Secret</label><input id="clientSecret" type="password" autocomplete="new-password" placeholder="已配置时留空不修改"></div><div class="field"><label>异常报警 Webhook(可选)</label><input id="webhookUrl" type="url" placeholder="https://oapi.dingtalk.com/robot/send?..."><div class="field-help">系统或任务异常时发送钉钉机器人报警;保存后不会在页面回显完整地址。</div></div><div class="field card-settings-field"><label>卡片设置</label><div class="card-settings-row"><label class="check-field"><input type="checkbox" id="showElapsed"> 显示任务总耗时</label><label class="check-field"><input type="checkbox" id="showProcessingDetails"> 显示处理详情</label><label class="compact-number-field">更新间隔 <input type="number" id="cardUpdateIntervalMs" min="0" max="60" step="0.5" placeholder="3"> 秒(0 或负数:完成后一次性发送)</label></div></div><div class="field"><label>机器人单聊授权人员</label><div class="line"><input id="botUserSearch" placeholder="搜索钉钉真实姓名"><button class="search" id="botUserSearchButton" type="button">搜索</button></div><div id="botUserResults" class="bot-user-results"></div><div id="botUserSelected" class="bot-user-selected"></div></div><div class="field private-chat-inline-field"><div class="private-chat-title"><label>个人与机器人私聊</label><label class="switch"><input type="checkbox" id="privateChatEnabled"><span></span></label><strong id="privateChatStatus">查询中...</strong></div><p>关闭后会断开单聊 Stream 长连接;开启后自动重新连接。</p></div><div class="field super-admin-field"><label>Session 超级管理员</label><div class="super-admin-help">仅限已加入单聊授权人员的用户。超级管理员可以按目录查看和切换本机 Session。</div><div id="superAdminSelected" class="bot-user-results super-admin-list"></div></div>';bindPrivateChatToggle();[robotSettings,rulesPanel,repliesPanel].forEach(panel=>panel.classList.add('expanded'));const dwsSettings=document.querySelector('.dws-settings'),historySettings=document.querySelector('.history-settings');const robotPage=document.createElement('div');robotPage.append(robotSettings);const pageDefs=[['robot','机器人配置','配置钉钉应用、卡片机器人和单聊授权人员。',robotPage],['dws','DWS 配置','查看当前本机 DWS 登录账号信息。',dwsSettings],['agent','Agent 引擎','选择默认执行引擎,并配置群内切换指令。',agentSettings],['rules','群监控绑定','管理绑定指令以及需要监听的群和人员。',rulesPanel],['monitor','进程日志','查看系统进程和实时日志,必要时重启整个系统。',monitorSettings],['replies','Agent回复','查看最近的群聊与单聊 Agent 处理记录。',repliesPanel]];root.replaceChildren();const shell=document.createElement('div');shell.className='app-shell';const sidebar=document.createElement('aside');sidebar.className='app-sidebar';sidebar.innerHTML='<div class="brand"><img class="brand-mark brand-icon" src="/assets/omi-icon.png" alt="omi" /><div><div class="brand-name">oh-my-im</div><div class="brand-sub">DingTalk Agent Console</div></div><button class="sidebar-toggle" type="button" title="收起侧边栏" aria-label="收起侧边栏">‹</button></div><div class="nav-label">Workspace</div><nav class="app-nav"></nav><div class="sidebar-foot">版本:<span class="version">__OMI_VERSION__</span> · <a href="https://github.com/duzhenxun/oh-my-im" target="_blank" rel="noreferrer">开源项目</a></div>';const main=document.createElement('div');main.className='app-main';const topbar=document.createElement('header');topbar.className='app-topbar';topbar.innerHTML='<button class="menu-button" type="button" aria-label="打开菜单">☰</button><div class="topbar-copy"><h1></h1><p></p></div><div class="topbar-actions"><button class="fullscreen-button" type="button" title="进入全屏">⛶ <span>全屏</span></button></div>';const content=document.createElement('div');content.className='app-content';pageDefs.forEach(([id,title,description,panel],index)=>{const nav=document.createElement('button');nav.type='button';nav.className='nav-item'+(index===0?' active':'');nav.dataset.page=id;nav.innerHTML='<span class="nav-index">0'+(index+1)+'</span><span>'+title+'</span>';sidebar.querySelector('.app-nav').append(nav);const page=document.createElement('section');page.className='app-page page-'+id+(index===0?' active':'');page.dataset.page=id;page.append(panel);content.append(page)});const scrim=document.createElement('div');scrim.className='mobile-scrim';main.append(topbar,content);shell.append(sidebar,main);root.append(shell,scrim,notice,saveControl);const desktopMedia=window.matchMedia('(min-width:821px)');const syncViewport=()=>{if(desktopMedia.matches)document.body.classList.remove('nav-open')};desktopMedia.addEventListener?.('change',syncViewport);syncViewport();const showPage=id=>{document.querySelectorAll('.nav-item').forEach(item=>item.classList.toggle('active',item.dataset.page===id));document.querySelectorAll('.app-page').forEach(page=>page.classList.toggle('active',page.dataset.page===id));const def=pageDefs.find(item=>item[0]===id)||pageDefs[0];topbar.querySelector('h1').textContent=def[1];topbar.querySelector('p').textContent=def[2];saveControl.style.display=id==='replies'||id==='monitor'?'none':'';if(location.hash!=='#'+id)history.replaceState(null,'','#'+id);document.body.classList.remove('nav-open');window.scrollTo({top:0,behavior:'instant'})};sidebar.querySelectorAll('.nav-item').forEach(item=>item.onclick=()=>showPage(item.dataset.page));const sidebarToggle=sidebar.querySelector('.sidebar-toggle'),setSidebarCollapsed=collapsed=>{document.body.classList.toggle('sidebar-collapsed',collapsed);sidebarToggle.textContent=collapsed?'›':'‹';sidebarToggle.title=collapsed?'展开侧边栏':'收起侧边栏';sidebarToggle.setAttribute('aria-label',sidebarToggle.title);localStorage.setItem('omi-sidebar-collapsed',collapsed?'1':'0')};setSidebarCollapsed(localStorage.getItem('omi-sidebar-collapsed')==='1');sidebarToggle.onclick=()=>setSidebarCollapsed(!document.body.classList.contains('sidebar-collapsed'));topbar.querySelector('.menu-button').onclick=()=>document.body.classList.toggle('nav-open');const fullscreenButton=topbar.querySelector('.fullscreen-button'),syncFullscreen=()=>{const active=Boolean(document.fullscreenElement);fullscreenButton.innerHTML=active?'⊠ <span>退出全屏</span>':'⛶ <span>全屏</span>';fullscreenButton.title=active?'退出全屏':'进入全屏'};fullscreenButton.onclick=async()=>{try{if(document.fullscreenElement)await document.exitFullscreen();else await document.documentElement.requestFullscreen()}catch(e){alert(e instanceof Error?e.message:String(e))}};document.addEventListener('fullscreenchange',syncFullscreen);syncFullscreen();scrim.onclick=()=>document.body.classList.remove('nav-open');const initialPage=pageDefs.some(item=>item[0]===location.hash.slice(1))?location.hash.slice(1):'robot';window.addEventListener('hashchange',()=>{const id=location.hash.slice(1);if(pageDefs.some(item=>item[0]===id))showPage(id)});showPage(initialPage);replyList.style.maxHeight='min(680px,calc(100vh - 230px))';replyList.style.overflowY='auto';replyList.style.display='flex';replyList.style.flexDirection='column';replyList.style.gap='12px';let loaded=false;
|
|
485
|
+
</style></head><body><main class="wrap"><header><div><h1>oh-my-im 控制台</h1><p>保存后立即生效。搜索并选择群,再从该群成员中选择需要监听的人。</p></div><div class="status" id="connection">加载中</div></header><section class="panel agent-settings"><h2>Agent 引擎设置</h2><div class="agent-row"><div class="field"><label>当前使用的 Agent</label><div class="agent-options"><label><input type="radio" name="agent" value="codex"> Codex Agent</label><label><input type="radio" name="agent" value="pi"> Pi Agent</label></div></div></div><div class="command-grid agent-command-grid"><div class="field"><label>切换到 Codex 指令</label><input type="text" id="keywordsSwitchCodex" placeholder="例如:切codex|切换codex"></div><div class="field"><label>切换到 Pi 指令</label><input type="text" id="keywordsSwitchPi" placeholder="例如:切pi|切换pi|换pi"></div></div></section><section class="panel dws-settings"><h2>DWS 配置</h2><div class="dws-actions"><button class="button secondary" id="dwsDeviceLogin">使用 Device Flow 登录</button><button class="button danger-button" id="dwsLogout">退出 DWS 登录</button></div><div id="dwsDevicePanel" class="dws-device-panel"><div class="dws-device-head"><strong>Device Flow 登录信息</strong><button class="button secondary" id="dwsDeviceCopy">复制登录信息</button></div><div id="dwsDeviceOutput" class="dws-device-output"></div><div class="dws-device-tip">请在其他电脑或手机打开上面的 URL,完成钉钉授权。登录信息保存在运行 DWS 的服务器上。</div></div><div class="dws-status-box"><div class="dws-status-loading">正在读取 dws auth status...</div><div id="dwsAuthFields" class="dws-auth-fields"><div><span>企业</span><strong id="dwsCorpName">-</strong></div><div><span>账号</span><strong id="dwsUserName">-</strong></div><div><span>user_id</span><code id="dwsUserId">-</code></div><div><span>Token 过期</span><strong id="dwsExpiresAt">-</strong></div><div><span>Refresh Token 过期</span><strong id="dwsRefreshExpiresAt">-</strong></div></div><pre id="dwsAuthStatus" hidden></pre><div id="dwsLoginGuide" class="dws-login-guide"></div></div><p class="dws-help">信息来自本机 <code>dws auth status</code>。登录与退出操作都在运行 DWS 的机器上执行。</p><section class="history-settings"><h2>拉取个人群消息</h2><div class="history-config"><div class="history-grid"><div class="field"><label>每次消息数量</label><input type="number" id="personalHistoryMessageLimit" min="1" max="500" step="1" placeholder="10"></div><div class="field"><label>定时拉取间隔(秒)</label><input type="number" id="personalHistoryPollIntervalSeconds" min="0" max="3600" step="1" placeholder="15"><div class="field-help">只拉取当前登录用户发送的群消息;设为 0 可关闭。</div></div><div class="field"><label>拉取时间范围(分钟)</label><input type="number" id="personalHistoryLookbackMinutes" min="1" max="60" step="1" placeholder="10"><div class="field-help">最大 60 分钟,避免一次查询范围过大。</div></div></div></div></section></section><section class="panel prompt-settings collapsible-panel"><h2 class="section-toggle">提示词后缀</h2><div class="collapsible-content"><div class="field"><label>提示词后缀</label><textarea id="groupPromptPrefix" placeholder="可留空。该内容会追加到用户消息事件 JSON 的最下方。"></textarea><div class="prompt-help">保存后立即用于新的群消息批次;该内容会追加在用户消息事件 JSON 的最下方。</div></div></div></section><section class="panel robot-settings collapsible-panel"><h2 class="section-toggle">机器人设置</h2><div class="robot-row collapsible-content"></div></section><section class="panel command-settings"><h2 class="command-toggle">任务指令关键词</h2><div class="command-grid"><div class="field"><label>暂停当前任务指令</label><input type="text" id="keywordsPause" placeholder="例如:停|暂停|停止当前任务"></div></div></section><section class="panel monitor-settings"><h2>进程日志</h2><div class="monitor-toolbar"><div><strong>系统进程</strong><div class="monitor-meta" id="monitorMeta">正在读取...</div></div><div class="monitor-toolbar-actions"><label class="check-field">日志级别 <select id="monitorLogLevel"><option value="all">全部</option><option value="INFO">INFO</option><option value="WARN">WARN</option><option value="ERROR">ERROR</option><option value="DEBUG">DEBUG</option></select></label><label class="check-field"><input type="checkbox" id="monitorAutoScroll" checked> 日志自动滚动</label><button class="button danger-button" id="restartSystem" type="button">重启系统</button></div></div><div class="monitor-processes" id="monitorProcesses"></div><div class="system-log-head"><strong>实时系统日志</strong><span class="monitor-meta" id="systemLogMeta"></span></div><pre class="system-log" id="systemLog">正在加载日志...</pre></section><section class="summary"><div class="metric"><small>监听规则</small><strong id="targetCount">-</strong></div><div class="metric"><small>处理中批次</small><strong id="activeBatches">-</strong></div><div class="metric"><small>最后收到消息</small><strong id="lastEvent">-</strong></div></section><button class="button save-floating" id="save">保存并生效</button><div class="notice" id="notice" role="status" aria-live="polite"></div><div class="grid"><section class="panel rules-panel"><h2 class="rules-toggle">钉钉群监控绑定</h2><div class="rules-content"><div class="binding-commands"><h3>绑定指令</h3><div class="binding-help">授权人员在包含配置机器人的群中发送以下关键词,可绑定或解绑“当前群 + 当前发送人”的监听规则。</div><div class="command-grid"><div class="field"><label>打开群监听</label><input type="text" id="keywordsMonitorOpen" placeholder="例如:打开ai|启动ai|醒醒"></div><div class="field"><label>关闭群监听</label><input type="text" id="keywordsMonitorStop" placeholder="例如:停止ai|关闭ai|睡吧"></div></div></div><div id="rules"></div><div class="toolbar"><button class="button secondary" id="add">添加监听规则</button><div class="format"><span>回复格式</span><label><input type="radio" name="format" value="markdown"> Markdown</label><label><input type="radio" name="format" value="plain"> 纯文本</label></div></div></div></section><section class="panel replies-panel"><h2 class="replies-toggle">Agent 最近回复</h2><div id="replies" class="empty">暂无回复</div></section></div></main><script>
|
|
486
|
+
let selectedBotUsers=[],selectedSuperAdminIds=[];const agentSelect=()=>document.querySelector('input[name=agent]:checked'),rules=document.querySelector('#rules'),notice=document.querySelector('#notice'),replyList=document.querySelector('#replies'),layout=document.querySelector('.grid'),rulesPanel=layout.firstElementChild,repliesPanel=layout.lastElementChild,agentSettings=document.querySelector('.agent-settings'),promptSettings=document.querySelector('.prompt-settings'),robotSettings=document.querySelector('.robot-settings'),commandSettings=document.querySelector('.command-settings'),monitorSettings=document.querySelector('.monitor-settings'),root=document.querySelector('.wrap'),connection=document.querySelector('#connection'),saveControl=document.querySelector('#save');document.querySelector('#add').textContent='添加钉钉规则';const renderDwsDeviceOutput=(text)=>{const output=document.querySelector('#dwsDeviceOutput');if(!output)return;output.replaceChildren();const parts=String(text||'暂无登录信息').split(/(https?:\/\/[^\s]+)/g);parts.forEach(part=>{if(/^https?:\/\//.test(part)){const a=document.createElement('a');a.href=part.replace(/[),.;。!]$/,'');a.target='_blank';a.rel='noreferrer';a.textContent=part;output.append(a)}else output.append(document.createTextNode(part))})};const pollDwsDeviceLogin=async()=>{try{const r=await fetch('/api/dws-device-login',{cache:'no-store'}),b=await r.json();if(r.ok){renderDwsDeviceOutput(b.output);document.querySelector('#dwsDevicePanel')?.classList.add('visible');if(b.running)setTimeout(pollDwsDeviceLogin,1000);else void loadDwsAuth()}}catch(e){renderDwsDeviceOutput(e instanceof Error?e.message:String(e))}};const loadPrivateChatStatus=async()=>{try{const r=await fetch('/api/bot-status',{cache:'no-store'}),b=await r.json();if(!r.ok)throw new Error(b.error||'私聊机器人状态查询失败');const enabled=document.querySelector('#privateChatEnabled'),status=document.querySelector('#privateChatStatus');enabled.checked=b.status?.enabled===true;status.textContent=b.status?.connected?'已连接':'未连接'}catch(e){document.querySelector('#privateChatStatus').textContent='查询失败'}};const bindPrivateChatToggle=()=>{const toggle=document.querySelector('#privateChatEnabled');if(!toggle)return;toggle.onchange=async event=>{const enabled=event.target.checked;try{const stateResponse=await fetch('/api/state',{cache:'no-store'}),state=await stateResponse.json();if(!stateResponse.ok||!state.config)throw new Error('读取当前配置失败');const r=await fetch('/api/config',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({...state.config,privateChatEnabled:enabled,clientSecret:state.config.clientSecret||''})});const body=await r.json().catch(()=>({}));if(!r.ok||body.saved!==true)throw new Error(body.error||'保存个人私聊开关失败');await loadPrivateChatStatus()}catch(e){event.target.checked=!enabled;alert(e instanceof Error?e.message:String(e))}}};setInterval(()=>void loadPrivateChatStatus(),2000);const loadDwsAuth=async()=>{const output=document.querySelector('#dwsAuthStatus'),loading=document.querySelector('.dws-status-loading'),guide=document.querySelector('#dwsLoginGuide');try{const r=await fetch('/api/dws-auth-status',{cache:'no-store'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 登录状态查询失败');if(loading)loading.remove();const status=b.status||{};const set=(id,value)=>{const el=document.querySelector('#'+id);if(el)el.textContent=value||'-'};set('dwsCorpName',status.corp_name);set('dwsUserName',status.user_name);set('dwsUserId',status.user_id);set('dwsExpiresAt',status.expires_at);set('dwsRefreshExpiresAt',status.refresh_expires_at);if(output)output.textContent='';if(b.status?.authenticated!==true&&guide)guide.innerHTML='当前未登录 DWS。请在运行 DWS 的机器执行 <code>dws auth login --device</code>,或点击“使用 Device Flow 登录”。'}catch(e){if(loading)loading.textContent='DWS 登录状态读取失败';if(output)output.textContent=e instanceof Error?e.message:String(e);if(guide)guide.innerHTML='请在运行 DWS 的机器执行 <code>dws auth login --device</code> 完成跨设备登录。'}};const dwsLoginButton=document.querySelector('#dwsDeviceLogin');dwsLoginButton.onclick=async()=>{dwsLoginButton.disabled=true;try{const r=await fetch('/api/dws-device-login',{method:'POST'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 登录启动失败');renderDwsDeviceOutput(b.message);document.querySelector('#dwsDevicePanel')?.classList.add('visible');void pollDwsDeviceLogin()}catch(e){renderDwsDeviceOutput(e instanceof Error?e.message:String(e));document.querySelector('#dwsDevicePanel')?.classList.add('visible')}finally{dwsLoginButton.disabled=false}};document.querySelector('#dwsDeviceCopy').onclick=async()=>{const text=document.querySelector('#dwsDeviceOutput')?.textContent||'';try{await navigator.clipboard.writeText(text);notice.textContent='登录信息已复制';setTimeout(()=>notice.textContent='',2000)}catch{notice.textContent='复制失败,请手动选择上方登录信息复制';}};document.querySelector('#dwsLogout').onclick=async()=>{if(!confirm('确认退出当前 DWS 登录吗?'))return;try{const r=await fetch('/api/dws-logout',{method:'POST'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 退出失败');await loadDwsAuth()}catch(e){alert(e instanceof Error?e.message:String(e))}};void loadDwsAuth();void loadPrivateChatStatus();agentSettings.querySelector('.agent-command-grid').append(...commandSettings.querySelector('.command-grid').children);commandSettings.remove();const promptField=promptSettings.querySelector('.collapsible-content').firstElementChild;const promptTextarea=promptField.querySelector('textarea');promptTextarea.rows=5;promptTextarea.classList.add('prompt-text-input');promptField.classList.add('prompt-inline-field');agentSettings.append(promptField);promptSettings.remove();robotSettings.querySelector('h2')?.remove();robotSettings.querySelector('.robot-row').innerHTML='<div class="field"><label>机器人名称 <a class="platform-link" href="https://open-dev.dingtalk.com/fe/app" target="_blank" rel="noreferrer">(开放平台)</a></label><input id="robotName" placeholder="例如:我的 AI 助手"></div><div class="field bot-identity-field"><label>机器人 openDingTalkId</label><div class="line"><input id="robotSenderOpenDingTalkId" placeholder="点击“获取 ID”自动填写" readonly><button class="search" id="botSearchButton" type="button">获取 ID</button></div><div id="botSearchResult" class="bot-user-results"></div></div><div class="field"><label>钉钉应用 Client ID</label><input id="clientId" placeholder="ding..."></div><div class="field"><label>钉钉应用 Client Secret</label><input id="clientSecret" type="password" autocomplete="new-password" placeholder="已配置时留空不修改"></div><div class="field"><label>异常报警 Webhook(可选)</label><input id="webhookUrl" type="url" placeholder="https://oapi.dingtalk.com/robot/send?..."><div class="field-help">系统或任务异常时发送钉钉机器人报警;保存后不会在页面回显完整地址。</div></div><div class="field card-settings-field"><label>卡片设置</label><div class="card-settings-row"><label class="check-field"><input type="checkbox" id="showElapsed"> 显示任务总耗时</label><label class="check-field"><input type="checkbox" id="showProcessingDetails"> 显示处理详情</label><label class="compact-number-field">更新间隔 <input type="number" id="cardUpdateIntervalMs" min="0" max="60" step="0.5" placeholder="3"> 秒(0 或负数:完成后一次性发送)</label></div></div><div class="field"><label>机器人单聊授权人员</label><div class="line"><input id="botUserSearch" placeholder="搜索钉钉真实姓名"><button class="search" id="botUserSearchButton" type="button">搜索</button></div><div id="botUserResults" class="bot-user-results"></div><div id="botUserSelected" class="bot-user-selected"></div></div><div class="field private-chat-inline-field"><div class="private-chat-title"><label>个人与机器人私聊</label><label class="switch"><input type="checkbox" id="privateChatEnabled"><span></span></label><strong id="privateChatStatus">查询中...</strong></div><p>关闭后会断开单聊 Stream 长连接;开启后自动重新连接。</p></div><div class="field super-admin-field"><label>Session 超级管理员</label><div class="super-admin-help">仅限已加入单聊授权人员的用户。超级管理员可以按目录查看和切换本机 Session。</div><div id="superAdminSelected" class="bot-user-results super-admin-list"></div></div><div class="field security-field"><label>控制台安全</label><input id="currentDashboardPassword" type="password" autocomplete="current-password" placeholder="当前密码"><input id="newDashboardPassword" type="password" autocomplete="new-password" placeholder="新密码(至少 8 位)"><button class="button secondary" id="changeDashboardPassword" type="button">修改控制台密码</button><button class="button danger-button" id="dashboardLogout" type="button">退出控制台</button></div>';const securityField=robotSettings.querySelector('.security-field');const securityPage=document.createElement('div');securityPage.className='panel security-settings';securityPage.innerHTML='<h2>控制台安全</h2>';if(securityField){securityField.querySelector('label')?.remove();securityPage.append(securityField)}bindPrivateChatToggle();const changePasswordButton=securityField?.querySelector('#changeDashboardPassword');if(changePasswordButton)changePasswordButton.addEventListener('click',async()=>{try{const currentPassword=securityField.querySelector('#currentDashboardPassword');const newPassword=securityField.querySelector('#newDashboardPassword');const r=await fetch('/api/password',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({currentPassword:currentPassword.value,newPassword:newPassword.value})});const b=await r.json();if(!r.ok)throw new Error(b.error||'密码修改失败');notice.textContent='控制台密码已修改';currentPassword.value='';newPassword.value=''}catch(e){notice.textContent=e instanceof Error?e.message:String(e);notice.className='notice error'}});securityField?.querySelector('#dashboardLogout')?.addEventListener('click',async()=>{await fetch('/api/logout',{method:'POST'});location.href='/login'});[robotSettings,rulesPanel,repliesPanel].forEach(panel=>panel.classList.add('expanded'));const dwsSettings=document.querySelector('.dws-settings'),historySettings=document.querySelector('.history-settings');const robotPage=document.createElement('div');robotPage.append(robotSettings);const pageDefs=[['robot','机器人配置','配置钉钉应用、卡片机器人和单聊授权人员。',robotPage],['dws','DWS 配置','查看当前本机 DWS 登录账号信息。',dwsSettings],['agent','Agent 引擎','选择默认执行引擎,并配置群内切换指令。',agentSettings],['rules','群监控绑定','管理绑定指令以及需要监听的群和人员。',rulesPanel],['monitor','进程日志','查看系统进程和实时日志,必要时重启整个系统。',monitorSettings],['replies','Agent回复','查看最近的群聊与单聊 Agent 处理记录。',repliesPanel],['security','控制台安全','修改控制台密码或退出当前登录会话。',securityPage]];root.replaceChildren();const shell=document.createElement('div');shell.className='app-shell';const sidebar=document.createElement('aside');sidebar.className='app-sidebar';sidebar.innerHTML='<div class="brand"><img class="brand-mark brand-icon" src="/assets/omi-icon.png" alt="omi" /><div><div class="brand-name">oh-my-im</div><div class="brand-sub">DingTalk Agent Console</div></div><button class="sidebar-toggle" type="button" title="收起侧边栏" aria-label="收起侧边栏">‹</button></div><div class="nav-label">Workspace</div><nav class="app-nav"></nav><div class="sidebar-foot">版本:<span class="version">__OMI_VERSION__</span> · <a href="https://github.com/duzhenxun/oh-my-im" target="_blank" rel="noreferrer">开源项目</a></div>';const main=document.createElement('div');main.className='app-main';const topbar=document.createElement('header');topbar.className='app-topbar';topbar.innerHTML='<button class="menu-button" type="button" aria-label="打开菜单">☰</button><div class="topbar-copy"><h1></h1><p></p></div><div class="topbar-actions"><button class="fullscreen-button" type="button" title="进入全屏">⛶ <span>全屏</span></button></div>';const content=document.createElement('div');content.className='app-content';pageDefs.forEach(([id,title,description,panel],index)=>{const nav=document.createElement('button');nav.type='button';nav.className='nav-item'+(index===0?' active':'');nav.dataset.page=id;nav.innerHTML='<span class="nav-index">0'+(index+1)+'</span><span>'+title+'</span>';sidebar.querySelector('.app-nav').append(nav);const page=document.createElement('section');page.className='app-page page-'+id+(index===0?' active':'');page.dataset.page=id;page.append(panel);content.append(page)});const scrim=document.createElement('div');scrim.className='mobile-scrim';main.append(topbar,content);shell.append(sidebar,main);root.append(shell,scrim,notice,saveControl);const desktopMedia=window.matchMedia('(min-width:821px)');const syncViewport=()=>{if(desktopMedia.matches)document.body.classList.remove('nav-open')};desktopMedia.addEventListener?.('change',syncViewport);syncViewport();const showPage=id=>{document.querySelectorAll('.nav-item').forEach(item=>item.classList.toggle('active',item.dataset.page===id));document.querySelectorAll('.app-page').forEach(page=>page.classList.toggle('active',page.dataset.page===id));const def=pageDefs.find(item=>item[0]===id)||pageDefs[0];topbar.querySelector('h1').textContent=def[1];topbar.querySelector('p').textContent=def[2];saveControl.style.display=id==='replies'||id==='monitor'?'none':'';if(location.hash!=='#'+id)history.replaceState(null,'','#'+id);document.body.classList.remove('nav-open');window.scrollTo({top:0,behavior:'instant'})};sidebar.querySelectorAll('.nav-item').forEach(item=>item.onclick=()=>showPage(item.dataset.page));const sidebarToggle=sidebar.querySelector('.sidebar-toggle'),setSidebarCollapsed=collapsed=>{document.body.classList.toggle('sidebar-collapsed',collapsed);sidebarToggle.textContent=collapsed?'›':'‹';sidebarToggle.title=collapsed?'展开侧边栏':'收起侧边栏';sidebarToggle.setAttribute('aria-label',sidebarToggle.title);localStorage.setItem('omi-sidebar-collapsed',collapsed?'1':'0')};setSidebarCollapsed(localStorage.getItem('omi-sidebar-collapsed')==='1');sidebarToggle.onclick=()=>setSidebarCollapsed(!document.body.classList.contains('sidebar-collapsed'));topbar.querySelector('.menu-button').onclick=()=>document.body.classList.toggle('nav-open');const fullscreenButton=topbar.querySelector('.fullscreen-button'),syncFullscreen=()=>{const active=Boolean(document.fullscreenElement);fullscreenButton.innerHTML=active?'⊠ <span>退出全屏</span>':'⛶ <span>全屏</span>';fullscreenButton.title=active?'退出全屏':'进入全屏'};fullscreenButton.onclick=async()=>{try{if(document.fullscreenElement)await document.exitFullscreen();else await document.documentElement.requestFullscreen()}catch(e){alert(e instanceof Error?e.message:String(e))}};document.addEventListener('fullscreenchange',syncFullscreen);syncFullscreen();scrim.onclick=()=>document.body.classList.remove('nav-open');const initialPage=pageDefs.some(item=>item[0]===location.hash.slice(1))?location.hash.slice(1):'robot';window.addEventListener('hashchange',()=>{const id=location.hash.slice(1);if(pageDefs.some(item=>item[0]===id))showPage(id)});showPage(initialPage);replyList.style.maxHeight='min(680px,calc(100vh - 230px))';replyList.style.overflowY='auto';replyList.style.display='flex';replyList.style.flexDirection='column';replyList.style.gap='12px';let loaded=false;
|
|
432
487
|
const esc=v=>{const d=document.createElement('div');d.textContent=v??'';return d.innerHTML};const fmt=t=>t?new Date(t).toLocaleString('zh-CN',{hour12:false}):'-';const memberRole=role=>/群主|owner/i.test(role||'')?'(群主)':/管理|admin/i.test(role||'')?'(管理)':'';
|
|
433
488
|
function option(value,label){const o=document.createElement('option');o.value=value;o.textContent=label;return o}
|
|
434
489
|
function renderBotUsers(){const selected=document.querySelector('#botUserSelected'),admins=document.querySelector('#superAdminSelected');selected.replaceChildren();admins.replaceChildren();selectedBotUsers.forEach(user=>{const chip=document.createElement('span');chip.className='bot-user-chip';chip.append(document.createTextNode(user.name));const remove=document.createElement('button');remove.type='button';remove.textContent='×';remove.title='移除';remove.onclick=()=>{selectedBotUsers=selectedBotUsers.filter(item=>item.id!==user.id);selectedSuperAdminIds=selectedSuperAdminIds.filter(id=>id!==user.id);renderBotUsers()};chip.append(remove);selected.append(chip);const adminLabel=document.createElement('label');adminLabel.className='bot-user-result';const box=document.createElement('input');box.type='checkbox';box.checked=selectedSuperAdminIds.includes(user.id);box.onchange=()=>{selectedSuperAdminIds=box.checked?[...new Set([...selectedSuperAdminIds,user.id])]:selectedSuperAdminIds.filter(id=>id!==user.id)};const name=document.createElement('span');name.textContent=user.name;adminLabel.append(box,name);admins.append(adminLabel)})}
|
|
@@ -442,7 +497,7 @@ async function loadConfiguredMembers(rule,group){const people=rule.querySelector
|
|
|
442
497
|
function addConfiguredRule(group){const rule=document.createElement('article');rule.className='rule';rule.innerHTML='<div class="rule-grid configured-grid"><div class="field"><label>钉钉群</label><div class="rule-value group-value"></div></div><div class="field"><label>钉钉人员</label><div class="member-list people"></div></div><button class="delete" title="删除规则">×</button></div>';rule._targets=group.targets;rule._group=group;rule.querySelector('.group-value').textContent=group.groupName;rule.querySelector('.delete').onclick=()=>rule.remove();rules.append(rule);void loadConfiguredMembers(rule,group)}
|
|
443
498
|
const keywordIds={pause:'keywordsPause',monitorOpen:'keywordsMonitorOpen',monitorStop:'keywordsMonitorStop',switchPi:'keywordsSwitchPi',switchCodex:'keywordsSwitchCodex',switchOpencode:'keywordsSwitchOpencode'};
|
|
444
499
|
const parseKeywords=id=>document.querySelector('#'+id).value.split(/[||]+/).map(value=>value.trim()).filter(Boolean);
|
|
445
|
-
function renderStatic(data,replaceConfig=false){rulesPanel.querySelector('.rules-toggle').textContent='钉钉群监控绑定 ('+data.config.targets.length+')';if(replaceConfig){if(document.querySelector('#privateChatEnabled'))document.querySelector('#privateChatEnabled').checked=data.config.privateChatEnabled!==false;document.querySelectorAll('input[name=agent]').forEach(input=>{input.checked=input.value===(data.config.agent||'
|
|
500
|
+
function renderStatic(data,replaceConfig=false){rulesPanel.querySelector('.rules-toggle').textContent='钉钉群监控绑定 ('+data.config.targets.length+')';if(replaceConfig){if(document.querySelector('#privateChatEnabled'))document.querySelector('#privateChatEnabled').checked=data.config.privateChatEnabled!==false;document.querySelectorAll('input[name=agent]').forEach(input=>{input.checked=input.value===(data.config.agent||'pi')});document.querySelector('#robotName').value=data.config.robotName||'';document.querySelector('#clientId').value=data.config.clientId||'';document.querySelector('#showElapsed').checked=data.config.showElapsed!==false;document.querySelector('#showProcessingDetails').checked=data.config.showProcessingDetails===true;document.querySelector('#cardUpdateIntervalMs').value=(data.config.cardUpdateIntervalMs??3000)/1000;document.querySelector('#clientSecret').value='';document.querySelector('#webhookUrl').value='';document.querySelector('#personalHistoryMessageLimit').value=data.config.personalHistoryMessageLimit||10;document.querySelector('#personalHistoryPollIntervalSeconds').value=data.config.personalHistoryPollIntervalSeconds??15;document.querySelector('#personalHistoryLookbackMinutes').value=data.config.personalHistoryLookbackMinutes??10;document.querySelector('#robotSenderOpenDingTalkId').value=data.config.robotSenderOpenDingTalkId||'';document.querySelector('#groupPromptPrefix').value=data.config.groupPromptSuffix||'';setBotUsers(data.config.botAllowedUserIds||[],data.config.botAllowedUserNames||{},data.config.botSuperAdminUserIds||[]);Object.entries(keywordIds).forEach(([key,id])=>{document.querySelector('#'+id).value=(data.config.commandKeywords?.[key]||[]).join('|')})}const c=document.querySelector('#connection');if(c){c.textContent=data.status.eventConnected?'事件连接正常':'事件未连接';c.className='status '+(data.status.eventConnected?'connected':'stopped');}const list=document.querySelector('#replies');if(!list)return;if(!data.replies.length){list.className='empty';list.textContent='暂无回复';return}list.className='';const recentReplies=[...data.replies].sort((a,b)=>new Date(a.createdAt||0)-new Date(b.createdAt||0)).slice(-10);const replySignature=recentReplies.map(r=>r.id+':'+r.status+':'+r.content).join('|');if(list.dataset.signature===replySignature)return;const wasAtBottom=list.scrollHeight-list.scrollTop-list.clientHeight<24;list.dataset.signature=replySignature;list.innerHTML=recentReplies.map(r=>{const state=r.status==='processing'?'处理中':r.status==='completed'?'完成':'失败',replyStyle=r.status==='processing'?'#fffbeb;color:#92400e':r.status==='completed'?'#ecfdf3;color:#166534':'#fff1f2;color:#b42318',senderNames=[...new Set((r.senderNames||[]).filter(Boolean))].join('、');const conversationLabel=r.conversationType==='personal'?'个人':'群聊 · 群名:'+esc(r.conversationName||r.groupName||'-');return '<article class="reply"><div class="meta">'+conversationLabel+' · 发送人:'+esc(senderNames||'-')+' · '+fmt(r.createdAt)+' · '+esc(r.agent==='pi'?'Pi':'Codex')+' · '+state+' · '+r.messageCount+' 条消息</div><div style="margin-top:6px;padding:8px;border-radius:4px;background:#eff6ff;color:#174ea6"><pre style="margin:0;white-space:pre-wrap;word-break:break-word;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace">'+esc(r.question||'')+'</pre></div><div style="margin-top:6px;padding:8px;border-radius:4px;background:'+replyStyle+'"><pre style="margin:0;white-space:pre-wrap;word-break:break-word;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace">'+esc(r.content)+'</pre></div></article>'}).join('');if(wasAtBottom||recentReplies.length>0)list.scrollTop=list.scrollHeight}
|
|
446
501
|
async function refresh(full=false){try{const r=await fetch('/api/state',{cache:'no-store'});if(!r.ok)throw new Error('读取状态失败');const data=await r.json();renderStatic(data,full);if(full){rules.replaceChildren();const grouped=new Map();data.config.targets.forEach(t=>{const key=t.groupId;if(!grouped.has(key))grouped.set(key,{groupId:t.groupId,groupName:t.groupName,targets:[]});grouped.get(key).targets.push(t)});grouped.forEach(addConfiguredRule);const formatInput=document.querySelector('input[name=format][value='+data.config.replyFormat+']');if(formatInput)formatInput.checked=true;loaded=true}}catch(e){notice.textContent=e.message;notice.className='notice error'}}
|
|
447
|
-
let latestSystemLog='',systemLogOffset;const filterSystemLog=(content,level)=>{if(level==='all')return content;const lines=content.split('\n'),entries=[];let current=[];const isEntryStart=line=>/^\[\d{4}-\d{2}-\d{2}T[^\]]+\]\s+\[[^\]]+\]\s+\[(DEBUG|INFO|WARN|ERROR)\]/.test(line);lines.forEach(line=>{if(isEntryStart(line)){if(current.length)entries.push(current);current=[line]}else if(current.length)current.push(line)});if(current.length)entries.push(current);return entries.filter(entry=>entry[0].includes('['+level+']')).map(entry=>entry.join('\n')).join('\n')};const renderSystemLog=()=>{const log=document.querySelector('#systemLog'),level=document.querySelector('#monitorLogLevel').value,filtered=filterSystemLog(latestSystemLog,level),atBottom=log.scrollHeight-log.scrollTop-log.clientHeight<30;if(log.textContent!==filtered){log.textContent=filtered||'暂无匹配日志';if(document.querySelector('#monitorAutoScroll').checked&&(atBottom||!log.dataset.loaded))log.scrollTop=log.scrollHeight;log.dataset.loaded='1'}};document.querySelector('#monitorLogLevel').onchange=renderSystemLog;document.querySelector('#monitorAutoScroll').onchange=event=>{document.querySelector('#systemLogMeta').textContent=event.target.checked?'正在恢复实时拉取...':'实时拉取已暂停';if(event.target.checked)void loadSystemMonitor()};const loadSystemMonitor=async()=>{try{const statusResponse=await fetch('/api/system-status',{cache:'no-store'}),statusBody=await statusResponse.json();if(!statusResponse.ok)throw new Error(statusBody.error||'系统状态读取失败');const status=statusBody.status||{},processes=document.querySelector('#monitorProcesses'),meta=document.querySelector('#monitorMeta');meta.textContent='模式:'+(status.mode||'-')+' · 启动:'+fmt(status.startedAt)+' · 检查:'+fmt(status.checkedAt);processes.innerHTML=(status.processes||[]).map(item=>'<article class="process-card"><strong>'+esc(item.role)+'</strong><div class="process-state '+(item.running?'running':'')+'">'+(item.running?'运行中':'已停止')+'</div><code>PID '+esc(String(item.pid||'-'))+'</code>'+(['group-worker','bot'].includes(item.role)?'<div class="process-actions"><button class="button process-action" data-role="'+esc(item.role)+'" data-action="'+(item.running?'stop':'start')+'">'+(item.running?'停止':'启动')+'</button></div>':'')+'</article>').join('')||'<div class="empty">未发现系统进程</div>';processes.querySelectorAll('.process-action').forEach(button=>button.onclick=async()=>{const role=button.dataset.role,action=button.dataset.action;button.disabled=true;try{const r=await fetch('/api/system-process/'+encodeURIComponent(role)+'/'+action,{method:'POST'});if(!r.ok){const body=await r.json().catch(()=>({}));throw new Error(body.error||'进程操作失败')}setTimeout(loadSystemMonitor,800)}catch(e){alert(e instanceof Error?e.message:String(e))}finally{button.disabled=false}});if(!document.querySelector('#monitorAutoScroll').checked){document.querySelector('#systemLogMeta').textContent='实时拉取已暂停';return}const logUrl='/api/system-logs'+(systemLogOffset===undefined?'':'?offset='+encodeURIComponent(systemLogOffset)),logResponse=await fetch(logUrl,{cache:'no-store'}),logBody=await logResponse.json();if(!logResponse.ok)throw new Error(logBody.error||'系统日志读取失败');if(logBody.reset||systemLogOffset===undefined)latestSystemLog=logBody.content||'';else latestSystemLog+=logBody.content||'';systemLogOffset=Number(logBody.nextOffset||0);if(latestSystemLog.length>1000000){latestSystemLog=latestSystemLog.slice(-1000000).replace(/^[^\n]*\n/,'')}renderSystemLog();document.querySelector('#systemLogMeta').textContent=(logBody.path||'')+' · '+Number(logBody.size||0).toLocaleString()+' bytes · tail 实时增量'}catch(e){document.querySelector('#monitorMeta').textContent=e instanceof Error?e.message:String(e)}};document.querySelector('#restartSystem').onclick=async()=>{if(!confirm('确认重启整个 oh-my-im 系统吗?当前正在执行的任务会被中断。'))return;const button=document.querySelector('#restartSystem');button.disabled=true;button.textContent='正在重启...';try{const r=await fetch('/api/system-restart',{method:'POST'}),body=await r.json().catch(()=>({}));if(!r.ok)throw new Error(body.error||'重启失败');document.querySelector('#monitorMeta').textContent='重启指令已发送,等待服务恢复...';setTimeout(()=>location.reload(),4000)}catch(e){alert(e instanceof Error?e.message:String(e));button.disabled=false;button.textContent='重启系统'}};setInterval(()=>{if(document.querySelector('.page-monitor')?.classList.contains('active'))void loadSystemMonitor()},1000);void loadSystemMonitor();setInterval(()=>{void refresh(false)},1000);document.querySelector('#add').onclick=()=>addDraftRule();document.querySelector('#save').onclick=async()=>{const saveButton=document.querySelector('#save');try{const stateResponse=await fetch('/api/state',{cache:'no-store'}),state=await stateResponse.json();if(!stateResponse.ok||!state.config)throw new Error('读取当前配置失败');const targets=[...rules.children].flatMap(rule=>{if(rule._targets&&!rule._members)return rule._targets;const groupId=rule._group?.groupId||rule.querySelector('[data-key=groupId]')?.value,groupName=rule._group?.groupName||rule.querySelector('[data-key=groupName]')?.value,members=rule._members||[],selected=[...rule.querySelectorAll('[data-sender-id]:checked')];return selected.map(box=>{const m=members.find(x=>x.senderId===box.dataset.senderId);return {groupId,groupName,senderId:box.dataset.senderId,senderName:m?m.senderName:''}})}),robotName=document.querySelector('#robotName').value.trim(),clientId=document.querySelector('#clientId').value.trim(),showElapsed=document.querySelector('#showElapsed').checked,cardUpdateIntervalMs=Number(document.querySelector('#cardUpdateIntervalMs').value||'3'),clientSecret=document.querySelector('#clientSecret').value.trim(),personalHistoryMessageLimit=Number(document.querySelector('#personalHistoryMessageLimit').value||10),personalHistoryPollIntervalSeconds=Number(document.querySelector('#personalHistoryPollIntervalSeconds').value||0),personalHistoryLookbackMinutes=Number(document.querySelector('#personalHistoryLookbackMinutes').value||10),webhookUrl=document.querySelector('#webhookUrl').value.trim(),robotSenderOpenDingTalkId=document.querySelector('#robotSenderOpenDingTalkId').value.trim(),groupPromptSuffix=document.querySelector('#groupPromptPrefix').value.trim(),botAllowedUserIds=selectedBotUsers.map(user=>user.id),botAllowedUserNames=Object.fromEntries(selectedBotUsers.map(user=>[user.id,user.name])),botSuperAdminUserIds=[...selectedSuperAdminIds],botSuperAdminUserNames=Object.fromEntries(selectedBotUsers.filter(user=>selectedSuperAdminIds.includes(user.id)).map(user=>[user.id,user.name])),commandKeywords={pause:parseKeywords(keywordIds.pause),monitorOpen:parseKeywords(keywordIds.monitorOpen),monitorStop:parseKeywords(keywordIds.monitorStop),switchPi:parseKeywords(keywordIds.switchPi),switchCodex:parseKeywords(keywordIds.switchCodex)},agent=agentSelect().value,replyFormat=document.querySelector('input[name=format]:checked')?.value; if(!replyFormat)throw new Error('请选择回复格式');notice.textContent='保存中...';notice.className='notice';saveButton.disabled=true;saveButton.textContent='保存中...';const r=await fetch('/api/config',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({...state.config,targets,botAllowedUserIds,botAllowedUserNames,robotSenderOpenDingTalkId,groupPromptSuffix,cardUpdateIntervalMs:cardUpdateIntervalMs*1000,showElapsed,personalHistoryMessageLimit,personalHistoryPollIntervalSeconds,personalHistoryLookbackMinutes,webhookUrl,botSuperAdminUserIds,botSuperAdminUserNames,commandKeywords,replyFormat,robotName,clientId,clientSecret,agent})});const b=await r.json().catch(()=>({}));if(!r.ok||b.saved!==true)throw new Error(b.error||'保存失败:服务端未确认保存');notice.textContent='已保存,钉钉规则与机器人设置已立即生效。';notice.className='notice';await refresh(true)}catch(e){console.error('[Dashboard] save failed',e);notice.textContent=e instanceof Error?e.message:String(e);notice.className='notice error'}finally{saveButton.disabled=false;saveButton.textContent='保存并生效';setTimeout(()=>{if(!notice.classList.contains('error'))notice.textContent=''},3000)}};refresh(true);
|
|
502
|
+
let latestSystemLog='',systemLogOffset;const filterSystemLog=(content,level)=>{if(level==='all')return content;const lines=content.split('\n'),entries=[];let current=[];const isEntryStart=line=>/^\[\d{4}-\d{2}-\d{2}T[^\]]+\]\s+\[[^\]]+\]\s+\[(DEBUG|INFO|WARN|ERROR)\]/.test(line);lines.forEach(line=>{if(isEntryStart(line)){if(current.length)entries.push(current);current=[line]}else if(current.length)current.push(line)});if(current.length)entries.push(current);return entries.filter(entry=>entry[0].includes('['+level+']')).map(entry=>entry.join('\n')).join('\n')};const renderSystemLog=()=>{const log=document.querySelector('#systemLog'),level=document.querySelector('#monitorLogLevel').value,filtered=filterSystemLog(latestSystemLog,level),atBottom=log.scrollHeight-log.scrollTop-log.clientHeight<30;if(log.textContent!==filtered){log.textContent=filtered||(level==='all'?'等待新的日志输出...':'暂无匹配日志');if(document.querySelector('#monitorAutoScroll').checked&&(atBottom||!log.dataset.loaded))log.scrollTop=log.scrollHeight;log.dataset.loaded='1'}};document.querySelector('#monitorLogLevel').onchange=renderSystemLog;document.querySelector('#monitorAutoScroll').onchange=event=>{document.querySelector('#systemLogMeta').textContent=event.target.checked?'正在恢复实时拉取...':'实时拉取已暂停';if(event.target.checked){latestSystemLog='';systemLogOffset=undefined;void loadSystemMonitor()}};const loadSystemMonitor=async()=>{try{const statusResponse=await fetch('/api/system-status',{cache:'no-store'}),statusBody=await statusResponse.json();if(!statusResponse.ok)throw new Error(statusBody.error||'系统状态读取失败');const status=statusBody.status||{},processes=document.querySelector('#monitorProcesses'),meta=document.querySelector('#monitorMeta');meta.textContent='模式:'+(status.mode||'-')+' · 启动:'+fmt(status.startedAt)+' · 检查:'+fmt(status.checkedAt);processes.innerHTML=(status.processes||[]).map(item=>'<article class="process-card"><strong>'+esc(item.role)+'</strong><div class="process-state '+(item.running?'running':'')+'">'+(item.running?'运行中':'已停止')+'</div><code>PID '+esc(String(item.pid||'-'))+'</code>'+(['group-worker','bot'].includes(item.role)?'<div class="process-actions"><button class="button process-action" data-role="'+esc(item.role)+'" data-action="'+(item.running?'stop':'start')+'">'+(item.running?'停止':'启动')+'</button></div>':'')+'</article>').join('')||'<div class="empty">未发现系统进程</div>';processes.querySelectorAll('.process-action').forEach(button=>button.onclick=async()=>{const role=button.dataset.role,action=button.dataset.action;button.disabled=true;try{const r=await fetch('/api/system-process/'+encodeURIComponent(role)+'/'+action,{method:'POST'});if(!r.ok){const body=await r.json().catch(()=>({}));throw new Error(body.error||'进程操作失败')}setTimeout(loadSystemMonitor,800)}catch(e){alert(e instanceof Error?e.message:String(e))}finally{button.disabled=false}});if(!document.querySelector('#monitorAutoScroll').checked){document.querySelector('#systemLogMeta').textContent='实时拉取已暂停';return}const logUrl='/api/system-logs'+(systemLogOffset===undefined?'?initial=1':'?offset='+encodeURIComponent(systemLogOffset)),logResponse=await fetch(logUrl,{cache:'no-store'}),logBody=await logResponse.json();if(!logResponse.ok)throw new Error(logBody.error||'系统日志读取失败');if(logBody.reset||systemLogOffset===undefined)latestSystemLog=logBody.content||'';else latestSystemLog+=logBody.content||'';systemLogOffset=Number(logBody.nextOffset||0);if(latestSystemLog.length>1000000){latestSystemLog=latestSystemLog.slice(-1000000).replace(/^[^\n]*\n/,'')}renderSystemLog();document.querySelector('#systemLogMeta').textContent=(logBody.path||'')+' · '+Number(logBody.size||0).toLocaleString()+' bytes · tail 实时增量'}catch(e){document.querySelector('#monitorMeta').textContent=e instanceof Error?e.message:String(e)}};document.querySelector('#restartSystem').onclick=async()=>{if(!confirm('确认重启整个 oh-my-im 系统吗?当前正在执行的任务会被中断。'))return;const button=document.querySelector('#restartSystem');button.disabled=true;button.textContent='正在重启...';try{const r=await fetch('/api/system-restart',{method:'POST'}),body=await r.json().catch(()=>({}));if(!r.ok)throw new Error(body.error||'重启失败');document.querySelector('#monitorMeta').textContent='重启指令已发送,等待服务恢复...';setTimeout(()=>location.reload(),4000)}catch(e){alert(e instanceof Error?e.message:String(e));button.disabled=false;button.textContent='重启系统'}};setInterval(()=>{if(document.querySelector('.page-monitor')?.classList.contains('active'))void loadSystemMonitor()},1000);void loadSystemMonitor();setInterval(()=>{void refresh(false)},1000);document.querySelector('#add').onclick=()=>addDraftRule();document.querySelector('#save').onclick=async()=>{const saveButton=document.querySelector('#save');try{const stateResponse=await fetch('/api/state',{cache:'no-store'}),state=await stateResponse.json();if(!stateResponse.ok||!state.config)throw new Error('读取当前配置失败');const targets=[...rules.children].flatMap(rule=>{if(rule._targets&&!rule._members)return rule._targets;const groupId=rule._group?.groupId||rule.querySelector('[data-key=groupId]')?.value,groupName=rule._group?.groupName||rule.querySelector('[data-key=groupName]')?.value,members=rule._members||[],selected=[...rule.querySelectorAll('[data-sender-id]:checked')];return selected.map(box=>{const m=members.find(x=>x.senderId===box.dataset.senderId);return {groupId,groupName,senderId:box.dataset.senderId,senderName:m?m.senderName:''}})}),robotName=document.querySelector('#robotName').value.trim(),clientId=document.querySelector('#clientId').value.trim(),showElapsed=document.querySelector('#showElapsed').checked,cardUpdateIntervalMs=Number(document.querySelector('#cardUpdateIntervalMs').value||'3'),clientSecret=document.querySelector('#clientSecret').value.trim(),personalHistoryMessageLimit=Number(document.querySelector('#personalHistoryMessageLimit').value||10),personalHistoryPollIntervalSeconds=Number(document.querySelector('#personalHistoryPollIntervalSeconds').value||0),personalHistoryLookbackMinutes=Number(document.querySelector('#personalHistoryLookbackMinutes').value||10),webhookUrl=document.querySelector('#webhookUrl').value.trim(),robotSenderOpenDingTalkId=document.querySelector('#robotSenderOpenDingTalkId').value.trim(),groupPromptSuffix=document.querySelector('#groupPromptPrefix').value.trim(),botAllowedUserIds=selectedBotUsers.map(user=>user.id),botAllowedUserNames=Object.fromEntries(selectedBotUsers.map(user=>[user.id,user.name])),botSuperAdminUserIds=[...selectedSuperAdminIds],botSuperAdminUserNames=Object.fromEntries(selectedBotUsers.filter(user=>selectedSuperAdminIds.includes(user.id)).map(user=>[user.id,user.name])),commandKeywords={pause:parseKeywords(keywordIds.pause),monitorOpen:parseKeywords(keywordIds.monitorOpen),monitorStop:parseKeywords(keywordIds.monitorStop),switchPi:parseKeywords(keywordIds.switchPi),switchCodex:parseKeywords(keywordIds.switchCodex)},agent=agentSelect().value,replyFormat=document.querySelector('input[name=format]:checked')?.value; if(!replyFormat)throw new Error('请选择回复格式');notice.textContent='保存中...';notice.className='notice';saveButton.disabled=true;saveButton.textContent='保存中...';const r=await fetch('/api/config',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({...state.config,targets,botAllowedUserIds,botAllowedUserNames,robotSenderOpenDingTalkId,groupPromptSuffix,cardUpdateIntervalMs:cardUpdateIntervalMs*1000,showElapsed,personalHistoryMessageLimit,personalHistoryPollIntervalSeconds,personalHistoryLookbackMinutes,webhookUrl,botSuperAdminUserIds,botSuperAdminUserNames,commandKeywords,replyFormat,robotName,clientId,clientSecret,agent})});const b=await r.json().catch(()=>({}));if(!r.ok||b.saved!==true)throw new Error(b.error||'保存失败:服务端未确认保存');notice.textContent='已保存,钉钉规则与机器人设置已立即生效。';notice.className='notice';await refresh(true)}catch(e){console.error('[Dashboard] save failed',e);notice.textContent=e instanceof Error?e.message:String(e);notice.className='notice error'}finally{saveButton.disabled=false;saveButton.textContent='保存并生效';setTimeout(()=>{if(!notice.classList.contains('error'))notice.textContent=''},3000)}};refresh(true);
|
|
448
503
|
</script></body></html>`;
|