@hzttt/config-portal 2026.2.8
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 +19 -0
- package/index.ts +168 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +14 -0
- package/src/config-state.test.ts +179 -0
- package/src/config-state.ts +508 -0
- package/web/index.html +488 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# OpenClaw Config Portal Plugin
|
|
2
|
+
|
|
3
|
+
Visual config plugin for:
|
|
4
|
+
|
|
5
|
+
- Kimi Coding K2.5 model settings
|
|
6
|
+
- Qwen model settings
|
|
7
|
+
- Default model switching (Kimi/Qwen)
|
|
8
|
+
- Feishu channel settings
|
|
9
|
+
|
|
10
|
+
## Routes
|
|
11
|
+
|
|
12
|
+
- Page: `/plugins/openclaw-config-portal`
|
|
13
|
+
- API: `/plugins/openclaw-config-portal/api/config`
|
|
14
|
+
|
|
15
|
+
## Notes
|
|
16
|
+
|
|
17
|
+
- Saving config writes to OpenClaw config file directly.
|
|
18
|
+
- Restart Gateway after saving so runtime model/channel changes take effect.
|
|
19
|
+
|
package/index.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
5
|
+
import { applyPortalPayload, buildPortalSnapshot } from "./src/config-state.js";
|
|
6
|
+
|
|
7
|
+
const PORTAL_PATH = "/plugins/openclaw-config-portal";
|
|
8
|
+
const PORTAL_PATH_WITH_SLASH = `${PORTAL_PATH}/`;
|
|
9
|
+
const PORTAL_API_PATH = `${PORTAL_PATH}/api/config`;
|
|
10
|
+
const PORTAL_API_PATH_WITH_SLASH = `${PORTAL_API_PATH}/`;
|
|
11
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
14
|
+
res.statusCode = status;
|
|
15
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
16
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
17
|
+
res.end(JSON.stringify(body));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sendText(res: ServerResponse, status: number, text: string): void {
|
|
21
|
+
res.statusCode = status;
|
|
22
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
23
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
24
|
+
res.end(text);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sendMethodNotAllowed(res: ServerResponse, allow: string): void {
|
|
28
|
+
res.statusCode = 405;
|
|
29
|
+
res.setHeader("Allow", allow);
|
|
30
|
+
sendText(res, 405, "Method Not Allowed");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readJsonBody(req: IncomingMessage): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const chunks: Buffer[] = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
|
|
38
|
+
req.on("data", (chunk: Buffer) => {
|
|
39
|
+
size += chunk.length;
|
|
40
|
+
if (size > MAX_BODY_BYTES) {
|
|
41
|
+
resolve({ ok: false, error: "Request body too large." });
|
|
42
|
+
req.destroy();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
chunks.push(chunk);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
req.on("end", () => {
|
|
49
|
+
try {
|
|
50
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
51
|
+
if (!raw) {
|
|
52
|
+
resolve({ ok: false, error: "Request body is empty." });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
resolve({ ok: true, value: JSON.parse(raw) });
|
|
56
|
+
} catch {
|
|
57
|
+
resolve({ ok: false, error: "Invalid JSON payload." });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
req.on("error", () => {
|
|
62
|
+
resolve({ ok: false, error: "Failed to read request body." });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildPageHandler(html: string) {
|
|
68
|
+
return (_req: IncomingMessage, res: ServerResponse) => {
|
|
69
|
+
res.statusCode = 200;
|
|
70
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
71
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
72
|
+
res.end(html);
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function handleConfigRead(api: OpenClawPluginApi, res: ServerResponse) {
|
|
77
|
+
const cfg = api.runtime.config.loadConfig();
|
|
78
|
+
sendJson(res, 200, {
|
|
79
|
+
ok: true,
|
|
80
|
+
config: buildPortalSnapshot(cfg),
|
|
81
|
+
routes: {
|
|
82
|
+
page: PORTAL_PATH,
|
|
83
|
+
api: PORTAL_API_PATH,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function handleConfigWrite(api: OpenClawPluginApi, req: IncomingMessage, res: ServerResponse) {
|
|
89
|
+
const body = await readJsonBody(req);
|
|
90
|
+
if (!body.ok) {
|
|
91
|
+
sendJson(res, 400, { ok: false, error: body.error });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const current = api.runtime.config.loadConfig();
|
|
97
|
+
const next = applyPortalPayload(current, body.value);
|
|
98
|
+
await api.runtime.config.writeConfigFile(next);
|
|
99
|
+
const refreshed = api.runtime.config.loadConfig();
|
|
100
|
+
|
|
101
|
+
sendJson(res, 200, {
|
|
102
|
+
ok: true,
|
|
103
|
+
config: buildPortalSnapshot(refreshed),
|
|
104
|
+
message: "Config saved. Restart gateway to apply model/channel runtime changes.",
|
|
105
|
+
});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
108
|
+
sendJson(res, 400, {
|
|
109
|
+
ok: false,
|
|
110
|
+
error: message,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const plugin = {
|
|
116
|
+
id: "openclaw-config-portal",
|
|
117
|
+
name: "OpenClaw Config Portal",
|
|
118
|
+
description: "Visual config page for Kimi/Qwen model switch and Feishu channel settings",
|
|
119
|
+
configSchema: emptyPluginConfigSchema(),
|
|
120
|
+
register(api: OpenClawPluginApi) {
|
|
121
|
+
const htmlPath = api.resolvePath("./web/index.html");
|
|
122
|
+
const html = readFileSync(htmlPath, "utf8");
|
|
123
|
+
const servePage = buildPageHandler(html);
|
|
124
|
+
|
|
125
|
+
const pageHandler = (req: IncomingMessage, res: ServerResponse) => {
|
|
126
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
127
|
+
sendMethodNotAllowed(res, "GET, HEAD");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (req.method === "HEAD") {
|
|
131
|
+
res.statusCode = 200;
|
|
132
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
133
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
134
|
+
res.end();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
servePage(req, res);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const apiHandler = async (req: IncomingMessage, res: ServerResponse) => {
|
|
141
|
+
if (req.method === "GET") {
|
|
142
|
+
await handleConfigRead(api, res);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (req.method === "POST") {
|
|
146
|
+
await handleConfigWrite(api, req, res);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
sendMethodNotAllowed(res, "GET, POST");
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
api.registerHttpRoute({ path: PORTAL_PATH, handler: pageHandler });
|
|
153
|
+
api.registerHttpRoute({ path: PORTAL_PATH_WITH_SLASH, handler: pageHandler });
|
|
154
|
+
api.registerHttpRoute({ path: PORTAL_API_PATH, handler: apiHandler });
|
|
155
|
+
api.registerHttpRoute({ path: PORTAL_API_PATH_WITH_SLASH, handler: apiHandler });
|
|
156
|
+
|
|
157
|
+
api.registerCommand({
|
|
158
|
+
name: "config-portal",
|
|
159
|
+
description: "Show the visual config portal URL.",
|
|
160
|
+
requireAuth: false,
|
|
161
|
+
handler: () => ({
|
|
162
|
+
text: `Open this URL in your browser: ${PORTAL_PATH}`,
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hzttt/config-portal",
|
|
3
|
+
"version": "2026.2.8",
|
|
4
|
+
"description": "OpenClaw visual config portal plugin (Kimi/Qwen/Feishu)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"devDependencies": {
|
|
7
|
+
"openclaw": "workspace:*"
|
|
8
|
+
},
|
|
9
|
+
"openclaw": {
|
|
10
|
+
"extensions": [
|
|
11
|
+
"./index.ts"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
|
+
import { applyPortalPayload, buildPortalSnapshot } from "./config-state.js";
|
|
4
|
+
|
|
5
|
+
describe("openclaw-config-portal config state", () => {
|
|
6
|
+
test("buildPortalSnapshot returns defaults for empty config", () => {
|
|
7
|
+
const snapshot = buildPortalSnapshot({} as OpenClawConfig);
|
|
8
|
+
|
|
9
|
+
expect(snapshot.activeModelProvider).toBe("kimi");
|
|
10
|
+
expect(snapshot.kimi.providerId).toBe("kimi-coding");
|
|
11
|
+
expect(snapshot.qwen.providerId).toBe("qwen");
|
|
12
|
+
expect(snapshot.feishu.webhookPath).toBe("/feishu/events");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("applyPortalPayload writes providers and switches default model", () => {
|
|
16
|
+
const baseConfig = {
|
|
17
|
+
agents: {
|
|
18
|
+
defaults: {
|
|
19
|
+
model: {
|
|
20
|
+
primary: "openai/gpt-5",
|
|
21
|
+
fallbacks: ["openai/gpt-4.1"],
|
|
22
|
+
},
|
|
23
|
+
models: {
|
|
24
|
+
"openai/gpt-5": { alias: "openai" },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
} as OpenClawConfig;
|
|
29
|
+
|
|
30
|
+
const next = applyPortalPayload(baseConfig, {
|
|
31
|
+
activeModelProvider: "qwen",
|
|
32
|
+
kimi: {
|
|
33
|
+
providerId: "kimi-coding",
|
|
34
|
+
modelId: "kimi-k2.5-coding-preview",
|
|
35
|
+
baseUrl: "api.moonshot.cn",
|
|
36
|
+
apiKey: "kimi-secret",
|
|
37
|
+
alias: "kimi",
|
|
38
|
+
},
|
|
39
|
+
qwen: {
|
|
40
|
+
providerId: "qwen",
|
|
41
|
+
modelId: "qwen-plus",
|
|
42
|
+
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode",
|
|
43
|
+
apiKey: "qwen-secret",
|
|
44
|
+
alias: "qwen",
|
|
45
|
+
},
|
|
46
|
+
feishu: {
|
|
47
|
+
enabled: true,
|
|
48
|
+
appId: "app-id",
|
|
49
|
+
appSecret: "app-secret",
|
|
50
|
+
encryptKey: "encrypt-key",
|
|
51
|
+
verificationToken: "verify-token",
|
|
52
|
+
domainMode: "feishu",
|
|
53
|
+
customDomain: "",
|
|
54
|
+
connectionMode: "webhook",
|
|
55
|
+
webhookPath: "/feishu/custom",
|
|
56
|
+
dmPolicy: "pairing",
|
|
57
|
+
allowFrom: ["user-1"],
|
|
58
|
+
groupPolicy: "allowlist",
|
|
59
|
+
groupAllowFrom: ["group-1"],
|
|
60
|
+
requireMention: true,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(next.models?.providers?.["kimi-coding"]?.baseUrl).toBe("https://api.moonshot.cn/v1");
|
|
65
|
+
expect(next.models?.providers?.qwen?.baseUrl).toBe(
|
|
66
|
+
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
67
|
+
);
|
|
68
|
+
expect(next.agents?.defaults?.model?.primary).toBe("qwen/qwen-plus");
|
|
69
|
+
expect(next.agents?.defaults?.model?.fallbacks).toEqual(["openai/gpt-4.1"]);
|
|
70
|
+
expect(next.channels?.feishu && (next.channels.feishu as { enabled?: boolean }).enabled).toBe(
|
|
71
|
+
true,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("applyPortalPayload auto-adds wildcard for feishu dmPolicy=open", () => {
|
|
76
|
+
const next = applyPortalPayload({} as OpenClawConfig, {
|
|
77
|
+
activeModelProvider: "kimi",
|
|
78
|
+
kimi: {
|
|
79
|
+
providerId: "kimi-coding",
|
|
80
|
+
modelId: "kimi-k2.5-coding-preview",
|
|
81
|
+
baseUrl: "https://api.moonshot.cn/v1",
|
|
82
|
+
apiKey: "",
|
|
83
|
+
alias: "kimi",
|
|
84
|
+
},
|
|
85
|
+
qwen: {
|
|
86
|
+
providerId: "qwen",
|
|
87
|
+
modelId: "qwen-plus",
|
|
88
|
+
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
89
|
+
apiKey: "",
|
|
90
|
+
alias: "qwen",
|
|
91
|
+
},
|
|
92
|
+
feishu: {
|
|
93
|
+
enabled: true,
|
|
94
|
+
appId: "",
|
|
95
|
+
appSecret: "",
|
|
96
|
+
encryptKey: "",
|
|
97
|
+
verificationToken: "",
|
|
98
|
+
domainMode: "feishu",
|
|
99
|
+
customDomain: "",
|
|
100
|
+
connectionMode: "websocket",
|
|
101
|
+
webhookPath: "/feishu/events",
|
|
102
|
+
dmPolicy: "open",
|
|
103
|
+
allowFrom: ["user-1"],
|
|
104
|
+
groupPolicy: "allowlist",
|
|
105
|
+
groupAllowFrom: [],
|
|
106
|
+
requireMention: true,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const feishu = next.channels?.feishu as { allowFrom?: string[] } | undefined;
|
|
111
|
+
expect(feishu?.allowFrom).toContain("*");
|
|
112
|
+
expect(feishu?.allowFrom).toContain("user-1");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("applyPortalPayload preserves existing secrets when payload leaves them empty", () => {
|
|
116
|
+
const next = applyPortalPayload(
|
|
117
|
+
{
|
|
118
|
+
models: {
|
|
119
|
+
providers: {
|
|
120
|
+
"kimi-coding": {
|
|
121
|
+
baseUrl: "https://api.moonshot.cn/v1",
|
|
122
|
+
api: "openai-completions",
|
|
123
|
+
apiKey: "old-kimi-key",
|
|
124
|
+
models: [],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
channels: {
|
|
129
|
+
feishu: {
|
|
130
|
+
appSecret: "old-secret",
|
|
131
|
+
encryptKey: "old-encrypt",
|
|
132
|
+
verificationToken: "old-token",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
} as OpenClawConfig,
|
|
136
|
+
{
|
|
137
|
+
activeModelProvider: "kimi",
|
|
138
|
+
kimi: {
|
|
139
|
+
providerId: "custom-kimi-id",
|
|
140
|
+
modelId: "kimi-k2.5-coding-preview",
|
|
141
|
+
baseUrl: "https://api.moonshot.cn/v1",
|
|
142
|
+
apiKey: "",
|
|
143
|
+
alias: "kimi",
|
|
144
|
+
},
|
|
145
|
+
qwen: {
|
|
146
|
+
providerId: "custom-qwen-id",
|
|
147
|
+
modelId: "qwen-plus",
|
|
148
|
+
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
149
|
+
apiKey: "",
|
|
150
|
+
alias: "qwen",
|
|
151
|
+
},
|
|
152
|
+
feishu: {
|
|
153
|
+
enabled: true,
|
|
154
|
+
appId: "app-id",
|
|
155
|
+
appSecret: "",
|
|
156
|
+
encryptKey: "",
|
|
157
|
+
verificationToken: "",
|
|
158
|
+
domainMode: "feishu",
|
|
159
|
+
customDomain: "",
|
|
160
|
+
connectionMode: "websocket",
|
|
161
|
+
webhookPath: "/feishu/events",
|
|
162
|
+
dmPolicy: "pairing",
|
|
163
|
+
allowFrom: [],
|
|
164
|
+
groupPolicy: "allowlist",
|
|
165
|
+
groupAllowFrom: [],
|
|
166
|
+
requireMention: true,
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(next.models?.providers?.["kimi-coding"]?.apiKey).toBe("old-kimi-key");
|
|
172
|
+
const feishu = next.channels?.feishu as
|
|
173
|
+
| { appSecret?: string; encryptKey?: string; verificationToken?: string }
|
|
174
|
+
| undefined;
|
|
175
|
+
expect(feishu?.appSecret).toBe("old-secret");
|
|
176
|
+
expect(feishu?.encryptKey).toBe("old-encrypt");
|
|
177
|
+
expect(feishu?.verificationToken).toBe("old-token");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import type { ModelDefinitionConfig, ModelProviderConfig, OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_KIMI_PROVIDER_ID = "kimi-coding";
|
|
4
|
+
const DEFAULT_QWEN_PROVIDER_ID = "qwen";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_KIMI_MODEL_ID = "kimi-k2.5-coding-preview";
|
|
7
|
+
const DEFAULT_QWEN_MODEL_ID = "qwen-plus";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.cn/v1";
|
|
10
|
+
const DEFAULT_QWEN_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_KIMI_ALIAS = "kimi";
|
|
13
|
+
const DEFAULT_QWEN_ALIAS = "qwen";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CONTEXT_WINDOW = 128000;
|
|
16
|
+
const DEFAULT_MAX_TOKENS = 8192;
|
|
17
|
+
|
|
18
|
+
const DEFAULT_FEISHU_WEBHOOK_PATH = "/feishu/events";
|
|
19
|
+
|
|
20
|
+
export type PortalProviderKey = "kimi" | "qwen";
|
|
21
|
+
export type FeishuDomainMode = "feishu" | "lark" | "custom";
|
|
22
|
+
export type FeishuConnectionMode = "websocket" | "webhook";
|
|
23
|
+
export type FeishuDmPolicy = "open" | "pairing" | "allowlist";
|
|
24
|
+
export type FeishuGroupPolicy = "open" | "allowlist" | "disabled";
|
|
25
|
+
|
|
26
|
+
export type PortalProviderConfigInput = {
|
|
27
|
+
providerId: string;
|
|
28
|
+
modelId: string;
|
|
29
|
+
baseUrl: string;
|
|
30
|
+
apiKey: string;
|
|
31
|
+
alias: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type PortalFeishuConfigInput = {
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
appId: string;
|
|
37
|
+
appSecret: string;
|
|
38
|
+
encryptKey: string;
|
|
39
|
+
verificationToken: string;
|
|
40
|
+
domainMode: FeishuDomainMode;
|
|
41
|
+
customDomain: string;
|
|
42
|
+
connectionMode: FeishuConnectionMode;
|
|
43
|
+
webhookPath: string;
|
|
44
|
+
dmPolicy: FeishuDmPolicy;
|
|
45
|
+
allowFrom: string[];
|
|
46
|
+
groupPolicy: FeishuGroupPolicy;
|
|
47
|
+
groupAllowFrom: string[];
|
|
48
|
+
requireMention: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type PortalConfigPayload = {
|
|
52
|
+
activeModelProvider: PortalProviderKey;
|
|
53
|
+
kimi: PortalProviderConfigInput;
|
|
54
|
+
qwen: PortalProviderConfigInput;
|
|
55
|
+
feishu: PortalFeishuConfigInput;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function asString(value: unknown, fallback = ""): string {
|
|
63
|
+
if (typeof value !== "string") {
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
return value.trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function asBoolean(value: unknown, fallback = false): boolean {
|
|
70
|
+
if (typeof value === "boolean") {
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
return fallback;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function asEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
|
77
|
+
if (typeof value !== "string") {
|
|
78
|
+
return fallback;
|
|
79
|
+
}
|
|
80
|
+
const normalized = value.trim();
|
|
81
|
+
return (allowed as readonly string[]).includes(normalized) ? (normalized as T) : fallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function asStringList(value: unknown): string[] {
|
|
85
|
+
if (!Array.isArray(value)) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
return value
|
|
89
|
+
.map((entry) => String(entry).trim())
|
|
90
|
+
.filter((entry) => entry.length > 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseModelRef(value: string | undefined): { providerId: string; modelId: string } | null {
|
|
94
|
+
if (!value) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const slashIndex = value.indexOf("/");
|
|
98
|
+
if (slashIndex <= 0 || slashIndex >= value.length - 1) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
providerId: value.slice(0, slashIndex),
|
|
103
|
+
modelId: value.slice(slashIndex + 1),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeBaseUrl(value: string, fallback: string): string {
|
|
108
|
+
const raw = value.trim() || fallback;
|
|
109
|
+
const withProtocol = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${raw}`;
|
|
110
|
+
const withoutTrailingSlash = withProtocol.replace(/\/+$/, "");
|
|
111
|
+
return withoutTrailingSlash.endsWith("/v1") ? withoutTrailingSlash : `${withoutTrailingSlash}/v1`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cleanStringList(values: string[]): string[] {
|
|
115
|
+
return values
|
|
116
|
+
.map((entry) => entry.trim())
|
|
117
|
+
.filter((entry) => entry.length > 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolvePrimaryModel(cfg: OpenClawConfig): string | undefined {
|
|
121
|
+
const modelConfig = cfg.agents?.defaults?.model;
|
|
122
|
+
if (!isRecord(modelConfig)) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const primary = modelConfig.primary;
|
|
126
|
+
return typeof primary === "string" && primary.trim() ? primary.trim() : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readProvider(cfg: OpenClawConfig, providerId: string): ModelProviderConfig | undefined {
|
|
130
|
+
const providers = cfg.models?.providers;
|
|
131
|
+
if (!providers) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
return providers[providerId];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function pickProviderModelId(params: {
|
|
138
|
+
cfg: OpenClawConfig;
|
|
139
|
+
providerId: string;
|
|
140
|
+
fallbackModelId: string;
|
|
141
|
+
}): string {
|
|
142
|
+
const primary = resolvePrimaryModel(params.cfg);
|
|
143
|
+
const primaryRef = parseModelRef(primary);
|
|
144
|
+
if (primaryRef?.providerId === params.providerId) {
|
|
145
|
+
return primaryRef.modelId;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const provider = readProvider(params.cfg, params.providerId);
|
|
149
|
+
const first = provider?.models?.[0]?.id;
|
|
150
|
+
return typeof first === "string" && first.trim() ? first.trim() : params.fallbackModelId;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveAlias(cfg: OpenClawConfig, modelRef: string, fallback: string): string {
|
|
154
|
+
const modelEntry = cfg.agents?.defaults?.models?.[modelRef];
|
|
155
|
+
const alias = modelEntry?.alias;
|
|
156
|
+
return typeof alias === "string" && alias.trim() ? alias.trim() : fallback;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolveFeishuDomainMode(domainValue: string): FeishuDomainMode {
|
|
160
|
+
if (domainValue === "feishu" || domainValue === "lark") {
|
|
161
|
+
return domainValue;
|
|
162
|
+
}
|
|
163
|
+
return "custom";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildModelDefinition(params: { id: string; name: string }): ModelDefinitionConfig {
|
|
167
|
+
return {
|
|
168
|
+
id: params.id,
|
|
169
|
+
name: params.name,
|
|
170
|
+
reasoning: false,
|
|
171
|
+
input: ["text"],
|
|
172
|
+
cost: {
|
|
173
|
+
input: 0,
|
|
174
|
+
output: 0,
|
|
175
|
+
cacheRead: 0,
|
|
176
|
+
cacheWrite: 0,
|
|
177
|
+
},
|
|
178
|
+
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
179
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function upsertModel(models: ModelDefinitionConfig[], next: ModelDefinitionConfig): ModelDefinitionConfig[] {
|
|
184
|
+
const index = models.findIndex((model) => model.id === next.id);
|
|
185
|
+
if (index === -1) {
|
|
186
|
+
return [...models, next];
|
|
187
|
+
}
|
|
188
|
+
const cloned = [...models];
|
|
189
|
+
cloned[index] = {
|
|
190
|
+
...cloned[index],
|
|
191
|
+
...next,
|
|
192
|
+
};
|
|
193
|
+
return cloned;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function withOptionalStringKey(target: Record<string, unknown>, key: string, value: string): void {
|
|
197
|
+
if (!value) {
|
|
198
|
+
delete target[key];
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
target[key] = value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function withSecretStringKey(target: Record<string, unknown>, key: string, value: string): void {
|
|
205
|
+
if (!value) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
target[key] = value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function buildProviderConfig(params: {
|
|
212
|
+
existing?: ModelProviderConfig;
|
|
213
|
+
modelId: string;
|
|
214
|
+
modelName: string;
|
|
215
|
+
baseUrl: string;
|
|
216
|
+
apiKey: string;
|
|
217
|
+
}): ModelProviderConfig {
|
|
218
|
+
const existing = params.existing;
|
|
219
|
+
const nextModel = buildModelDefinition({
|
|
220
|
+
id: params.modelId,
|
|
221
|
+
name: params.modelName,
|
|
222
|
+
});
|
|
223
|
+
const existingModels = Array.isArray(existing?.models) ? existing.models : [];
|
|
224
|
+
const models = upsertModel(existingModels, nextModel);
|
|
225
|
+
|
|
226
|
+
const next: ModelProviderConfig = {
|
|
227
|
+
...existing,
|
|
228
|
+
api: "openai-completions",
|
|
229
|
+
baseUrl: params.baseUrl,
|
|
230
|
+
models,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const apiKey = params.apiKey.trim();
|
|
234
|
+
if (apiKey) {
|
|
235
|
+
next.apiKey = apiKey;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return next;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function inferActiveProvider(params: {
|
|
242
|
+
cfg: OpenClawConfig;
|
|
243
|
+
kimiProviderId: string;
|
|
244
|
+
qwenProviderId: string;
|
|
245
|
+
}): PortalProviderKey {
|
|
246
|
+
const primaryRef = parseModelRef(resolvePrimaryModel(params.cfg));
|
|
247
|
+
if (primaryRef?.providerId === params.kimiProviderId) {
|
|
248
|
+
return "kimi";
|
|
249
|
+
}
|
|
250
|
+
if (primaryRef?.providerId === params.qwenProviderId) {
|
|
251
|
+
return "qwen";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const hasQwen = Boolean(readProvider(params.cfg, params.qwenProviderId));
|
|
255
|
+
if (hasQwen) {
|
|
256
|
+
return "qwen";
|
|
257
|
+
}
|
|
258
|
+
return "kimi";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function normalizePayload(input: unknown): PortalConfigPayload {
|
|
262
|
+
if (!isRecord(input)) {
|
|
263
|
+
throw new Error("Invalid payload: expected object.");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const activeModelProvider = asEnum(input.activeModelProvider, ["kimi", "qwen"], "kimi");
|
|
267
|
+
const kimiRaw = isRecord(input.kimi) ? input.kimi : {};
|
|
268
|
+
const qwenRaw = isRecord(input.qwen) ? input.qwen : {};
|
|
269
|
+
const feishuRaw = isRecord(input.feishu) ? input.feishu : {};
|
|
270
|
+
|
|
271
|
+
const normalized: PortalConfigPayload = {
|
|
272
|
+
activeModelProvider,
|
|
273
|
+
kimi: {
|
|
274
|
+
providerId: DEFAULT_KIMI_PROVIDER_ID,
|
|
275
|
+
modelId: asString(kimiRaw.modelId, DEFAULT_KIMI_MODEL_ID),
|
|
276
|
+
baseUrl: asString(kimiRaw.baseUrl, DEFAULT_KIMI_BASE_URL),
|
|
277
|
+
apiKey: asString(kimiRaw.apiKey),
|
|
278
|
+
alias: asString(kimiRaw.alias, DEFAULT_KIMI_ALIAS),
|
|
279
|
+
},
|
|
280
|
+
qwen: {
|
|
281
|
+
providerId: DEFAULT_QWEN_PROVIDER_ID,
|
|
282
|
+
modelId: asString(qwenRaw.modelId, DEFAULT_QWEN_MODEL_ID),
|
|
283
|
+
baseUrl: asString(qwenRaw.baseUrl, DEFAULT_QWEN_BASE_URL),
|
|
284
|
+
apiKey: asString(qwenRaw.apiKey),
|
|
285
|
+
alias: asString(qwenRaw.alias, DEFAULT_QWEN_ALIAS),
|
|
286
|
+
},
|
|
287
|
+
feishu: {
|
|
288
|
+
enabled: asBoolean(feishuRaw.enabled, false),
|
|
289
|
+
appId: asString(feishuRaw.appId),
|
|
290
|
+
appSecret: asString(feishuRaw.appSecret),
|
|
291
|
+
encryptKey: asString(feishuRaw.encryptKey),
|
|
292
|
+
verificationToken: asString(feishuRaw.verificationToken),
|
|
293
|
+
domainMode: asEnum(feishuRaw.domainMode, ["feishu", "lark", "custom"], "feishu"),
|
|
294
|
+
customDomain: asString(feishuRaw.customDomain),
|
|
295
|
+
connectionMode: asEnum(feishuRaw.connectionMode, ["websocket", "webhook"], "websocket"),
|
|
296
|
+
webhookPath: asString(feishuRaw.webhookPath, DEFAULT_FEISHU_WEBHOOK_PATH),
|
|
297
|
+
dmPolicy: asEnum(feishuRaw.dmPolicy, ["open", "pairing", "allowlist"], "pairing"),
|
|
298
|
+
allowFrom: asStringList(feishuRaw.allowFrom),
|
|
299
|
+
groupPolicy: asEnum(feishuRaw.groupPolicy, ["open", "allowlist", "disabled"], "allowlist"),
|
|
300
|
+
groupAllowFrom: asStringList(feishuRaw.groupAllowFrom),
|
|
301
|
+
requireMention: asBoolean(feishuRaw.requireMention, true),
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
if (!normalized.kimi.modelId) {
|
|
306
|
+
normalized.kimi.modelId = DEFAULT_KIMI_MODEL_ID;
|
|
307
|
+
}
|
|
308
|
+
if (!normalized.kimi.baseUrl) {
|
|
309
|
+
normalized.kimi.baseUrl = DEFAULT_KIMI_BASE_URL;
|
|
310
|
+
}
|
|
311
|
+
if (!normalized.kimi.alias) {
|
|
312
|
+
normalized.kimi.alias = DEFAULT_KIMI_ALIAS;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!normalized.qwen.modelId) {
|
|
316
|
+
normalized.qwen.modelId = DEFAULT_QWEN_MODEL_ID;
|
|
317
|
+
}
|
|
318
|
+
if (!normalized.qwen.baseUrl) {
|
|
319
|
+
normalized.qwen.baseUrl = DEFAULT_QWEN_BASE_URL;
|
|
320
|
+
}
|
|
321
|
+
if (!normalized.qwen.alias) {
|
|
322
|
+
normalized.qwen.alias = DEFAULT_QWEN_ALIAS;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!normalized.feishu.webhookPath) {
|
|
326
|
+
normalized.feishu.webhookPath = DEFAULT_FEISHU_WEBHOOK_PATH;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return normalized;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function buildPortalSnapshot(cfg: OpenClawConfig): PortalConfigPayload {
|
|
333
|
+
const kimiProvider = readProvider(cfg, DEFAULT_KIMI_PROVIDER_ID);
|
|
334
|
+
const qwenProvider = readProvider(cfg, DEFAULT_QWEN_PROVIDER_ID);
|
|
335
|
+
|
|
336
|
+
const kimiModelId = pickProviderModelId({
|
|
337
|
+
cfg,
|
|
338
|
+
providerId: DEFAULT_KIMI_PROVIDER_ID,
|
|
339
|
+
fallbackModelId: DEFAULT_KIMI_MODEL_ID,
|
|
340
|
+
});
|
|
341
|
+
const qwenModelId = pickProviderModelId({
|
|
342
|
+
cfg,
|
|
343
|
+
providerId: DEFAULT_QWEN_PROVIDER_ID,
|
|
344
|
+
fallbackModelId: DEFAULT_QWEN_MODEL_ID,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const kimiModelRef = `${DEFAULT_KIMI_PROVIDER_ID}/${kimiModelId}`;
|
|
348
|
+
const qwenModelRef = `${DEFAULT_QWEN_PROVIDER_ID}/${qwenModelId}`;
|
|
349
|
+
|
|
350
|
+
const feishuConfig = isRecord(cfg.channels?.feishu) ? cfg.channels?.feishu : {};
|
|
351
|
+
const feishuDomainRaw = asString(feishuConfig.domain, "feishu");
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
activeModelProvider: inferActiveProvider({
|
|
355
|
+
cfg,
|
|
356
|
+
kimiProviderId: DEFAULT_KIMI_PROVIDER_ID,
|
|
357
|
+
qwenProviderId: DEFAULT_QWEN_PROVIDER_ID,
|
|
358
|
+
}),
|
|
359
|
+
kimi: {
|
|
360
|
+
providerId: DEFAULT_KIMI_PROVIDER_ID,
|
|
361
|
+
modelId: kimiModelId,
|
|
362
|
+
baseUrl: kimiProvider?.baseUrl ?? DEFAULT_KIMI_BASE_URL,
|
|
363
|
+
apiKey: "",
|
|
364
|
+
alias: resolveAlias(cfg, kimiModelRef, DEFAULT_KIMI_ALIAS),
|
|
365
|
+
},
|
|
366
|
+
qwen: {
|
|
367
|
+
providerId: DEFAULT_QWEN_PROVIDER_ID,
|
|
368
|
+
modelId: qwenModelId,
|
|
369
|
+
baseUrl: qwenProvider?.baseUrl ?? DEFAULT_QWEN_BASE_URL,
|
|
370
|
+
apiKey: "",
|
|
371
|
+
alias: resolveAlias(cfg, qwenModelRef, DEFAULT_QWEN_ALIAS),
|
|
372
|
+
},
|
|
373
|
+
feishu: {
|
|
374
|
+
enabled: asBoolean(feishuConfig.enabled, false),
|
|
375
|
+
appId: asString(feishuConfig.appId),
|
|
376
|
+
appSecret: "",
|
|
377
|
+
encryptKey: "",
|
|
378
|
+
verificationToken: "",
|
|
379
|
+
domainMode: resolveFeishuDomainMode(feishuDomainRaw),
|
|
380
|
+
customDomain: resolveFeishuDomainMode(feishuDomainRaw) === "custom" ? feishuDomainRaw : "",
|
|
381
|
+
connectionMode: asEnum(feishuConfig.connectionMode, ["websocket", "webhook"], "websocket"),
|
|
382
|
+
webhookPath: asString(feishuConfig.webhookPath, DEFAULT_FEISHU_WEBHOOK_PATH),
|
|
383
|
+
dmPolicy: asEnum(feishuConfig.dmPolicy, ["open", "pairing", "allowlist"], "pairing"),
|
|
384
|
+
allowFrom: asStringList(feishuConfig.allowFrom),
|
|
385
|
+
groupPolicy: asEnum(feishuConfig.groupPolicy, ["open", "allowlist", "disabled"], "allowlist"),
|
|
386
|
+
groupAllowFrom: asStringList(feishuConfig.groupAllowFrom),
|
|
387
|
+
requireMention: asBoolean(feishuConfig.requireMention, true),
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function applyPortalPayload(cfg: OpenClawConfig, input: unknown): OpenClawConfig {
|
|
393
|
+
const payload = normalizePayload(input);
|
|
394
|
+
|
|
395
|
+
const kimiProviderId = payload.kimi.providerId;
|
|
396
|
+
const qwenProviderId = payload.qwen.providerId;
|
|
397
|
+
const kimiModelRef = `${kimiProviderId}/${payload.kimi.modelId}`;
|
|
398
|
+
const qwenModelRef = `${qwenProviderId}/${payload.qwen.modelId}`;
|
|
399
|
+
|
|
400
|
+
const nextProviders: Record<string, ModelProviderConfig> = {
|
|
401
|
+
...(cfg.models?.providers ?? {}),
|
|
402
|
+
[kimiProviderId]: buildProviderConfig({
|
|
403
|
+
existing: readProvider(cfg, kimiProviderId),
|
|
404
|
+
modelId: payload.kimi.modelId,
|
|
405
|
+
modelName: "Kimi Coding K2.5",
|
|
406
|
+
baseUrl: normalizeBaseUrl(payload.kimi.baseUrl, DEFAULT_KIMI_BASE_URL),
|
|
407
|
+
apiKey: payload.kimi.apiKey,
|
|
408
|
+
}),
|
|
409
|
+
[qwenProviderId]: buildProviderConfig({
|
|
410
|
+
existing: readProvider(cfg, qwenProviderId),
|
|
411
|
+
modelId: payload.qwen.modelId,
|
|
412
|
+
modelName: "Qwen",
|
|
413
|
+
baseUrl: normalizeBaseUrl(payload.qwen.baseUrl, DEFAULT_QWEN_BASE_URL),
|
|
414
|
+
apiKey: payload.qwen.apiKey,
|
|
415
|
+
}),
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const existingDefaults = cfg.agents?.defaults;
|
|
419
|
+
const existingModel = isRecord(existingDefaults?.model) ? existingDefaults.model : {};
|
|
420
|
+
const fallbacks = Array.isArray(existingModel.fallbacks)
|
|
421
|
+
? existingModel.fallbacks.filter((item): item is string => typeof item === "string")
|
|
422
|
+
: [];
|
|
423
|
+
|
|
424
|
+
const nextDefaultPrimary = payload.activeModelProvider === "qwen" ? qwenModelRef : kimiModelRef;
|
|
425
|
+
|
|
426
|
+
const nextModels = {
|
|
427
|
+
...(existingDefaults?.models ?? {}),
|
|
428
|
+
[kimiModelRef]: {
|
|
429
|
+
...(existingDefaults?.models?.[kimiModelRef] ?? {}),
|
|
430
|
+
alias: payload.kimi.alias,
|
|
431
|
+
},
|
|
432
|
+
[qwenModelRef]: {
|
|
433
|
+
...(existingDefaults?.models?.[qwenModelRef] ?? {}),
|
|
434
|
+
alias: payload.qwen.alias,
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const feishuDomain =
|
|
439
|
+
payload.feishu.domainMode === "custom"
|
|
440
|
+
? payload.feishu.customDomain || "feishu"
|
|
441
|
+
: payload.feishu.domainMode;
|
|
442
|
+
|
|
443
|
+
const allowFrom = cleanStringList(payload.feishu.allowFrom);
|
|
444
|
+
if (payload.feishu.dmPolicy === "open" && !allowFrom.includes("*")) {
|
|
445
|
+
allowFrom.push("*");
|
|
446
|
+
}
|
|
447
|
+
const groupAllowFrom = cleanStringList(payload.feishu.groupAllowFrom);
|
|
448
|
+
|
|
449
|
+
const existingFeishu = isRecord(cfg.channels?.feishu) ? { ...cfg.channels?.feishu } : {};
|
|
450
|
+
withOptionalStringKey(existingFeishu, "appId", payload.feishu.appId);
|
|
451
|
+
withSecretStringKey(existingFeishu, "appSecret", payload.feishu.appSecret);
|
|
452
|
+
withSecretStringKey(existingFeishu, "encryptKey", payload.feishu.encryptKey);
|
|
453
|
+
withSecretStringKey(existingFeishu, "verificationToken", payload.feishu.verificationToken);
|
|
454
|
+
|
|
455
|
+
existingFeishu.enabled = payload.feishu.enabled;
|
|
456
|
+
existingFeishu.domain = feishuDomain;
|
|
457
|
+
existingFeishu.connectionMode = payload.feishu.connectionMode;
|
|
458
|
+
existingFeishu.webhookPath = payload.feishu.webhookPath || DEFAULT_FEISHU_WEBHOOK_PATH;
|
|
459
|
+
existingFeishu.dmPolicy = payload.feishu.dmPolicy;
|
|
460
|
+
existingFeishu.groupPolicy = payload.feishu.groupPolicy;
|
|
461
|
+
existingFeishu.requireMention = payload.feishu.requireMention;
|
|
462
|
+
|
|
463
|
+
if (allowFrom.length > 0) {
|
|
464
|
+
existingFeishu.allowFrom = allowFrom;
|
|
465
|
+
} else {
|
|
466
|
+
delete existingFeishu.allowFrom;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (groupAllowFrom.length > 0) {
|
|
470
|
+
existingFeishu.groupAllowFrom = groupAllowFrom;
|
|
471
|
+
} else {
|
|
472
|
+
delete existingFeishu.groupAllowFrom;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const pluginsEntries = isRecord(cfg.plugins?.entries) ? { ...cfg.plugins?.entries } : {};
|
|
476
|
+
if (payload.feishu.enabled) {
|
|
477
|
+
const feishuPluginEntry = isRecord(pluginsEntries.feishu) ? { ...pluginsEntries.feishu } : {};
|
|
478
|
+
feishuPluginEntry.enabled = true;
|
|
479
|
+
pluginsEntries.feishu = feishuPluginEntry;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
...cfg,
|
|
484
|
+
models: {
|
|
485
|
+
...(cfg.models ?? {}),
|
|
486
|
+
providers: nextProviders,
|
|
487
|
+
},
|
|
488
|
+
agents: {
|
|
489
|
+
...(cfg.agents ?? {}),
|
|
490
|
+
defaults: {
|
|
491
|
+
...(existingDefaults ?? {}),
|
|
492
|
+
models: nextModels,
|
|
493
|
+
model: {
|
|
494
|
+
...(fallbacks.length > 0 ? { fallbacks } : {}),
|
|
495
|
+
primary: nextDefaultPrimary,
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
channels: {
|
|
500
|
+
...(cfg.channels ?? {}),
|
|
501
|
+
feishu: existingFeishu,
|
|
502
|
+
},
|
|
503
|
+
plugins: {
|
|
504
|
+
...(cfg.plugins ?? {}),
|
|
505
|
+
entries: pluginsEntries,
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
package/web/index.html
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>OpenClaw 配置门户</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #f6f8f5;
|
|
10
|
+
--panel: #ffffff;
|
|
11
|
+
--ink: #14211a;
|
|
12
|
+
--muted: #5f6d63;
|
|
13
|
+
--line: #d5dfd7;
|
|
14
|
+
--brand: #1c7a45;
|
|
15
|
+
--brand-soft: #e6f4eb;
|
|
16
|
+
--warn: #a93b2f;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
* {
|
|
20
|
+
box-sizing: border-box;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
body {
|
|
24
|
+
margin: 0;
|
|
25
|
+
font-family: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
|
26
|
+
color: var(--ink);
|
|
27
|
+
background:
|
|
28
|
+
radial-gradient(circle at 20% -10%, #dcefd9 0%, #f6f8f5 45%),
|
|
29
|
+
linear-gradient(180deg, #f6f8f5 0%, #eef4ef 100%);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.layout {
|
|
33
|
+
max-width: 980px;
|
|
34
|
+
margin: 0 auto;
|
|
35
|
+
padding: 28px 16px 48px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.hero {
|
|
39
|
+
margin-bottom: 16px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
h1 {
|
|
43
|
+
margin: 0 0 10px;
|
|
44
|
+
font-size: clamp(26px, 4.5vw, 40px);
|
|
45
|
+
letter-spacing: 0.02em;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.sub {
|
|
49
|
+
color: var(--muted);
|
|
50
|
+
margin: 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.panel {
|
|
54
|
+
background: var(--panel);
|
|
55
|
+
border: 1px solid var(--line);
|
|
56
|
+
border-radius: 16px;
|
|
57
|
+
padding: 16px;
|
|
58
|
+
margin-top: 16px;
|
|
59
|
+
box-shadow: 0 8px 24px rgba(16, 33, 24, 0.06);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.panel h2 {
|
|
63
|
+
margin: 0 0 14px;
|
|
64
|
+
font-size: 18px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.grid-2 {
|
|
68
|
+
display: grid;
|
|
69
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
70
|
+
gap: 12px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@media (max-width: 820px) {
|
|
74
|
+
.grid-2 {
|
|
75
|
+
grid-template-columns: 1fr;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
label {
|
|
80
|
+
display: block;
|
|
81
|
+
font-weight: 600;
|
|
82
|
+
font-size: 13px;
|
|
83
|
+
margin-bottom: 6px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.field {
|
|
87
|
+
margin-bottom: 10px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
input,
|
|
91
|
+
select,
|
|
92
|
+
textarea {
|
|
93
|
+
width: 100%;
|
|
94
|
+
border: 1px solid var(--line);
|
|
95
|
+
border-radius: 10px;
|
|
96
|
+
padding: 9px 10px;
|
|
97
|
+
font: inherit;
|
|
98
|
+
color: inherit;
|
|
99
|
+
background: #fff;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
textarea {
|
|
103
|
+
min-height: 84px;
|
|
104
|
+
resize: vertical;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.inline {
|
|
108
|
+
display: flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
gap: 8px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.inline label {
|
|
114
|
+
margin: 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.switches {
|
|
118
|
+
display: flex;
|
|
119
|
+
gap: 16px;
|
|
120
|
+
flex-wrap: wrap;
|
|
121
|
+
margin: 8px 0 4px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.switches .inline {
|
|
125
|
+
background: var(--brand-soft);
|
|
126
|
+
padding: 8px 10px;
|
|
127
|
+
border-radius: 10px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.actions {
|
|
131
|
+
display: flex;
|
|
132
|
+
gap: 10px;
|
|
133
|
+
align-items: center;
|
|
134
|
+
margin-top: 14px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
button {
|
|
138
|
+
border: none;
|
|
139
|
+
border-radius: 10px;
|
|
140
|
+
padding: 10px 14px;
|
|
141
|
+
font: inherit;
|
|
142
|
+
font-weight: 600;
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
background: var(--brand);
|
|
145
|
+
color: #fff;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
button:disabled {
|
|
149
|
+
opacity: 0.65;
|
|
150
|
+
cursor: not-allowed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.status {
|
|
154
|
+
font-size: 13px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.status[data-level="ok"] {
|
|
158
|
+
color: var(--brand);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.status[data-level="error"] {
|
|
162
|
+
color: var(--warn);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.hint {
|
|
166
|
+
color: var(--muted);
|
|
167
|
+
font-size: 12px;
|
|
168
|
+
margin-top: 6px;
|
|
169
|
+
}
|
|
170
|
+
</style>
|
|
171
|
+
</head>
|
|
172
|
+
<body>
|
|
173
|
+
<main class="layout">
|
|
174
|
+
<header class="hero">
|
|
175
|
+
<h1>OpenClaw 配置门户</h1>
|
|
176
|
+
<p class="sub">配置 Kimi Coding K2.5 / 千问模型并切换默认模型,同时设置飞书渠道。</p>
|
|
177
|
+
</header>
|
|
178
|
+
|
|
179
|
+
<section class="panel">
|
|
180
|
+
<h2>模型切换</h2>
|
|
181
|
+
<div class="switches">
|
|
182
|
+
<div class="inline">
|
|
183
|
+
<input id="active-kimi" type="radio" name="active-model" value="kimi" />
|
|
184
|
+
<label for="active-kimi">默认模型: Kimi</label>
|
|
185
|
+
</div>
|
|
186
|
+
<div class="inline">
|
|
187
|
+
<input id="active-qwen" type="radio" name="active-model" value="qwen" />
|
|
188
|
+
<label for="active-qwen">默认模型: 千问</label>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</section>
|
|
192
|
+
|
|
193
|
+
<section class="panel">
|
|
194
|
+
<h2>Kimi Coding K2.5</h2>
|
|
195
|
+
<div class="grid-2">
|
|
196
|
+
<div>
|
|
197
|
+
<div class="field">
|
|
198
|
+
<label for="kimi-model-id">Model ID</label>
|
|
199
|
+
<input id="kimi-model-id" />
|
|
200
|
+
</div>
|
|
201
|
+
<div class="field">
|
|
202
|
+
<label for="kimi-alias">Alias</label>
|
|
203
|
+
<input id="kimi-alias" />
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
<div>
|
|
207
|
+
<div class="field">
|
|
208
|
+
<label for="kimi-base-url">Base URL</label>
|
|
209
|
+
<input id="kimi-base-url" />
|
|
210
|
+
</div>
|
|
211
|
+
<div class="field">
|
|
212
|
+
<label for="kimi-api-key">API Key</label>
|
|
213
|
+
<input id="kimi-api-key" type="password" autocomplete="off" />
|
|
214
|
+
<div class="hint">留空表示保持当前 API Key。</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</section>
|
|
219
|
+
|
|
220
|
+
<section class="panel">
|
|
221
|
+
<h2>千问 (Qwen)</h2>
|
|
222
|
+
<div class="grid-2">
|
|
223
|
+
<div>
|
|
224
|
+
<div class="field">
|
|
225
|
+
<label for="qwen-model-id">Model ID</label>
|
|
226
|
+
<input id="qwen-model-id" />
|
|
227
|
+
</div>
|
|
228
|
+
<div class="field">
|
|
229
|
+
<label for="qwen-alias">Alias</label>
|
|
230
|
+
<input id="qwen-alias" />
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
<div>
|
|
234
|
+
<div class="field">
|
|
235
|
+
<label for="qwen-base-url">Base URL</label>
|
|
236
|
+
<input id="qwen-base-url" />
|
|
237
|
+
</div>
|
|
238
|
+
<div class="field">
|
|
239
|
+
<label for="qwen-api-key">API Key</label>
|
|
240
|
+
<input id="qwen-api-key" type="password" autocomplete="off" />
|
|
241
|
+
<div class="hint">留空表示保持当前 API Key。</div>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</section>
|
|
246
|
+
|
|
247
|
+
<section class="panel">
|
|
248
|
+
<h2>飞书渠道 (Feishu)</h2>
|
|
249
|
+
<div class="field inline">
|
|
250
|
+
<input id="feishu-enabled" type="checkbox" />
|
|
251
|
+
<label for="feishu-enabled">启用飞书渠道</label>
|
|
252
|
+
</div>
|
|
253
|
+
<div class="grid-2">
|
|
254
|
+
<div>
|
|
255
|
+
<div class="field">
|
|
256
|
+
<label for="feishu-app-id">App ID</label>
|
|
257
|
+
<input id="feishu-app-id" />
|
|
258
|
+
</div>
|
|
259
|
+
<div class="field">
|
|
260
|
+
<label for="feishu-app-secret">App Secret</label>
|
|
261
|
+
<input id="feishu-app-secret" type="password" autocomplete="off" />
|
|
262
|
+
<div class="hint">留空表示保持当前 App Secret。</div>
|
|
263
|
+
</div>
|
|
264
|
+
<div class="field">
|
|
265
|
+
<label for="feishu-encrypt-key">Encrypt Key</label>
|
|
266
|
+
<input id="feishu-encrypt-key" />
|
|
267
|
+
<div class="hint">留空表示保持当前 Encrypt Key。</div>
|
|
268
|
+
</div>
|
|
269
|
+
<div class="field">
|
|
270
|
+
<label for="feishu-verification-token">Verification Token</label>
|
|
271
|
+
<input id="feishu-verification-token" />
|
|
272
|
+
<div class="hint">留空表示保持当前 Verification Token。</div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
<div>
|
|
276
|
+
<div class="field">
|
|
277
|
+
<label for="feishu-domain-mode">Domain</label>
|
|
278
|
+
<select id="feishu-domain-mode">
|
|
279
|
+
<option value="feishu">feishu</option>
|
|
280
|
+
<option value="lark">lark</option>
|
|
281
|
+
<option value="custom">自定义 HTTPS 域名</option>
|
|
282
|
+
</select>
|
|
283
|
+
</div>
|
|
284
|
+
<div class="field">
|
|
285
|
+
<label for="feishu-custom-domain">Custom Domain</label>
|
|
286
|
+
<input id="feishu-custom-domain" placeholder="https://open.feishu.cn" />
|
|
287
|
+
</div>
|
|
288
|
+
<div class="field">
|
|
289
|
+
<label for="feishu-connection-mode">Connection Mode</label>
|
|
290
|
+
<select id="feishu-connection-mode">
|
|
291
|
+
<option value="websocket">websocket</option>
|
|
292
|
+
<option value="webhook">webhook</option>
|
|
293
|
+
</select>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="field">
|
|
296
|
+
<label for="feishu-webhook-path">Webhook Path</label>
|
|
297
|
+
<input id="feishu-webhook-path" />
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
<div class="grid-2">
|
|
303
|
+
<div>
|
|
304
|
+
<div class="field">
|
|
305
|
+
<label for="feishu-dm-policy">DM Policy</label>
|
|
306
|
+
<select id="feishu-dm-policy">
|
|
307
|
+
<option value="pairing">pairing</option>
|
|
308
|
+
<option value="allowlist">allowlist</option>
|
|
309
|
+
<option value="open">open</option>
|
|
310
|
+
</select>
|
|
311
|
+
</div>
|
|
312
|
+
<div class="field">
|
|
313
|
+
<label for="feishu-allow-from">allowFrom (逗号/换行分隔)</label>
|
|
314
|
+
<textarea id="feishu-allow-from"></textarea>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
<div>
|
|
318
|
+
<div class="field">
|
|
319
|
+
<label for="feishu-group-policy">Group Policy</label>
|
|
320
|
+
<select id="feishu-group-policy">
|
|
321
|
+
<option value="allowlist">allowlist</option>
|
|
322
|
+
<option value="open">open</option>
|
|
323
|
+
<option value="disabled">disabled</option>
|
|
324
|
+
</select>
|
|
325
|
+
</div>
|
|
326
|
+
<div class="field">
|
|
327
|
+
<label for="feishu-group-allow-from">groupAllowFrom (逗号/换行分隔)</label>
|
|
328
|
+
<textarea id="feishu-group-allow-from"></textarea>
|
|
329
|
+
</div>
|
|
330
|
+
<div class="field inline">
|
|
331
|
+
<input id="feishu-require-mention" type="checkbox" />
|
|
332
|
+
<label for="feishu-require-mention">群聊必须 @ 提及才响应</label>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
</section>
|
|
337
|
+
|
|
338
|
+
<section class="panel">
|
|
339
|
+
<div class="actions">
|
|
340
|
+
<button id="save-button">保存配置</button>
|
|
341
|
+
<span class="status" id="status"></span>
|
|
342
|
+
</div>
|
|
343
|
+
<p class="hint">保存后请重启 Gateway,模型与渠道运行时配置才会生效。</p>
|
|
344
|
+
</section>
|
|
345
|
+
</main>
|
|
346
|
+
|
|
347
|
+
<script>
|
|
348
|
+
const state = {
|
|
349
|
+
apiPath: "",
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const $ = (id) => document.getElementById(id);
|
|
353
|
+
const statusEl = $("status");
|
|
354
|
+
const saveButton = $("save-button");
|
|
355
|
+
|
|
356
|
+
function toList(text) {
|
|
357
|
+
return text
|
|
358
|
+
.split(/\n|,/)
|
|
359
|
+
.map((item) => item.trim())
|
|
360
|
+
.filter(Boolean);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function listToText(values) {
|
|
364
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
365
|
+
return "";
|
|
366
|
+
}
|
|
367
|
+
return values.join("\n");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function setStatus(message, level) {
|
|
371
|
+
statusEl.textContent = message || "";
|
|
372
|
+
statusEl.dataset.level = level || "";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function fillForm(config) {
|
|
376
|
+
$("active-kimi").checked = config.activeModelProvider === "kimi";
|
|
377
|
+
$("active-qwen").checked = config.activeModelProvider === "qwen";
|
|
378
|
+
|
|
379
|
+
$("kimi-model-id").value = config.kimi.modelId || "";
|
|
380
|
+
$("kimi-base-url").value = config.kimi.baseUrl || "";
|
|
381
|
+
$("kimi-api-key").value = config.kimi.apiKey || "";
|
|
382
|
+
$("kimi-alias").value = config.kimi.alias || "";
|
|
383
|
+
|
|
384
|
+
$("qwen-model-id").value = config.qwen.modelId || "";
|
|
385
|
+
$("qwen-base-url").value = config.qwen.baseUrl || "";
|
|
386
|
+
$("qwen-api-key").value = config.qwen.apiKey || "";
|
|
387
|
+
$("qwen-alias").value = config.qwen.alias || "";
|
|
388
|
+
|
|
389
|
+
$("feishu-enabled").checked = Boolean(config.feishu.enabled);
|
|
390
|
+
$("feishu-app-id").value = config.feishu.appId || "";
|
|
391
|
+
$("feishu-app-secret").value = config.feishu.appSecret || "";
|
|
392
|
+
$("feishu-encrypt-key").value = config.feishu.encryptKey || "";
|
|
393
|
+
$("feishu-verification-token").value = config.feishu.verificationToken || "";
|
|
394
|
+
$("feishu-domain-mode").value = config.feishu.domainMode || "feishu";
|
|
395
|
+
$("feishu-custom-domain").value = config.feishu.customDomain || "";
|
|
396
|
+
$("feishu-connection-mode").value = config.feishu.connectionMode || "websocket";
|
|
397
|
+
$("feishu-webhook-path").value = config.feishu.webhookPath || "/feishu/events";
|
|
398
|
+
$("feishu-dm-policy").value = config.feishu.dmPolicy || "pairing";
|
|
399
|
+
$("feishu-allow-from").value = listToText(config.feishu.allowFrom);
|
|
400
|
+
$("feishu-group-policy").value = config.feishu.groupPolicy || "allowlist";
|
|
401
|
+
$("feishu-group-allow-from").value = listToText(config.feishu.groupAllowFrom);
|
|
402
|
+
$("feishu-require-mention").checked =
|
|
403
|
+
config.feishu.requireMention === undefined ? true : Boolean(config.feishu.requireMention);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function buildPayload() {
|
|
407
|
+
return {
|
|
408
|
+
activeModelProvider: $("active-qwen").checked ? "qwen" : "kimi",
|
|
409
|
+
kimi: {
|
|
410
|
+
modelId: $("kimi-model-id").value.trim(),
|
|
411
|
+
baseUrl: $("kimi-base-url").value.trim(),
|
|
412
|
+
apiKey: $("kimi-api-key").value.trim(),
|
|
413
|
+
alias: $("kimi-alias").value.trim(),
|
|
414
|
+
},
|
|
415
|
+
qwen: {
|
|
416
|
+
modelId: $("qwen-model-id").value.trim(),
|
|
417
|
+
baseUrl: $("qwen-base-url").value.trim(),
|
|
418
|
+
apiKey: $("qwen-api-key").value.trim(),
|
|
419
|
+
alias: $("qwen-alias").value.trim(),
|
|
420
|
+
},
|
|
421
|
+
feishu: {
|
|
422
|
+
enabled: $("feishu-enabled").checked,
|
|
423
|
+
appId: $("feishu-app-id").value.trim(),
|
|
424
|
+
appSecret: $("feishu-app-secret").value.trim(),
|
|
425
|
+
encryptKey: $("feishu-encrypt-key").value.trim(),
|
|
426
|
+
verificationToken: $("feishu-verification-token").value.trim(),
|
|
427
|
+
domainMode: $("feishu-domain-mode").value,
|
|
428
|
+
customDomain: $("feishu-custom-domain").value.trim(),
|
|
429
|
+
connectionMode: $("feishu-connection-mode").value,
|
|
430
|
+
webhookPath: $("feishu-webhook-path").value.trim(),
|
|
431
|
+
dmPolicy: $("feishu-dm-policy").value,
|
|
432
|
+
allowFrom: toList($("feishu-allow-from").value),
|
|
433
|
+
groupPolicy: $("feishu-group-policy").value,
|
|
434
|
+
groupAllowFrom: toList($("feishu-group-allow-from").value),
|
|
435
|
+
requireMention: $("feishu-require-mention").checked,
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function loadConfig() {
|
|
441
|
+
setStatus("正在加载配置...", "");
|
|
442
|
+
const response = await fetch(state.apiPath, { method: "GET" });
|
|
443
|
+
const data = await response.json();
|
|
444
|
+
if (!response.ok || !data.ok) {
|
|
445
|
+
throw new Error(data.error || "读取配置失败");
|
|
446
|
+
}
|
|
447
|
+
fillForm(data.config);
|
|
448
|
+
setStatus("配置已加载", "ok");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function saveConfig() {
|
|
452
|
+
saveButton.disabled = true;
|
|
453
|
+
setStatus("正在保存...", "");
|
|
454
|
+
try {
|
|
455
|
+
const response = await fetch(state.apiPath, {
|
|
456
|
+
method: "POST",
|
|
457
|
+
headers: {
|
|
458
|
+
"Content-Type": "application/json",
|
|
459
|
+
},
|
|
460
|
+
body: JSON.stringify(buildPayload()),
|
|
461
|
+
});
|
|
462
|
+
const data = await response.json();
|
|
463
|
+
if (!response.ok || !data.ok) {
|
|
464
|
+
throw new Error(data.error || "保存失败");
|
|
465
|
+
}
|
|
466
|
+
fillForm(data.config);
|
|
467
|
+
setStatus(data.message || "保存成功", "ok");
|
|
468
|
+
} catch (error) {
|
|
469
|
+
setStatus(error instanceof Error ? error.message : String(error), "error");
|
|
470
|
+
} finally {
|
|
471
|
+
saveButton.disabled = false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function resolveApiPath() {
|
|
476
|
+
const pathname = location.pathname.replace(/\/+$/, "");
|
|
477
|
+
return `${pathname}/api/config`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
saveButton.addEventListener("click", () => {
|
|
481
|
+
void saveConfig();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
state.apiPath = resolveApiPath();
|
|
485
|
+
void loadConfig();
|
|
486
|
+
</script>
|
|
487
|
+
</body>
|
|
488
|
+
</html>
|