agent-loft 0.2.29 → 0.2.31
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 +5 -0
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +3 -2
- package/src/agents/claude/session.ts +11 -4
- package/src/agents/codex/session.ts +9 -3
- package/src/channels/feishu/bot.ts +8 -4
- package/src/channels/feishu/turn-stream.ts +3 -2
- package/src/channels/web/api.ts +4 -0
- package/src/channels/web/server.ts +14 -2
- package/src/channels/web/tasks-api.ts +94 -0
- package/src/core/chat-mode.ts +14 -14
- package/src/core/hub.ts +95 -16
- package/src/core/memory-mcp.ts +1 -1
- package/src/core/personal.ts +3 -1
- package/src/core/store.ts +2 -0
- package/src/core/task-prompt.ts +38 -0
- package/src/core/task-runner.ts +232 -0
- package/src/core/task-schedule.ts +250 -0
- package/src/core/tasks-bridge.ts +252 -0
- package/src/core/tasks-mcp.ts +55 -0
- package/src/core/tasks.ts +281 -0
- package/src/core/types.ts +2 -0
- package/src/main.ts +8 -0
- package/src/mcp/tasks-server.ts +206 -0
- package/web/app.js +8 -5560
- package/web/css/base.css +291 -0
- package/web/css/composer.css +506 -0
- package/web/css/loader.css +196 -0
- package/web/css/messages.css +734 -0
- package/web/css/mobile.css +100 -0
- package/web/css/overlay.css +437 -0
- package/web/css/schedule.css +93 -0
- package/web/css/settings.css +335 -0
- package/web/css/sidebar.css +593 -0
- package/web/css/tasks.css +455 -0
- package/web/css/topbar.css +205 -0
- package/web/index.html +12 -1
- package/web/lib/api.js +26 -0
- package/web/lib/dom.js +101 -0
- package/web/lib/markdown.js +52 -0
- package/web/lib/overlay.js +144 -0
- package/web/lib/push.js +70 -0
- package/web/lib/time.js +60 -0
- package/web/lib/ws.js +236 -0
- package/web/settings/agents.js +210 -0
- package/web/settings/commands.js +411 -0
- package/web/settings/common.js +42 -0
- package/web/settings/feishu.js +181 -0
- package/web/settings/general.js +184 -0
- package/web/settings/index.js +63 -0
- package/web/settings/memory.js +366 -0
- package/web/settings/notify.js +80 -0
- package/web/settings/security.js +188 -0
- package/web/settings/users.js +189 -0
- package/web/state.js +46 -0
- package/web/views/auth.js +61 -0
- package/web/views/composer.js +324 -0
- package/web/views/conv-actions.js +412 -0
- package/web/views/interaction.js +140 -0
- package/web/views/layout.js +197 -0
- package/web/views/messages.js +531 -0
- package/web/views/quota.js +145 -0
- package/web/views/schedule.js +120 -0
- package/web/views/sidebar.js +458 -0
- package/web/views/slash.js +185 -0
- package/web/views/tasks.js +460 -0
- package/web/views/topbar.js +400 -0
- package/web/views/voice.js +189 -0
- package/web/style.css +0 -3500
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { fill, h, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { S } from "../state.js";
|
|
4
|
+
import { confirmDialog, modal } from "../lib/overlay.js";
|
|
5
|
+
import { field, saveConfig } from "./common.js";
|
|
6
|
+
|
|
7
|
+
export function feishuTab(cfg, panel) {
|
|
8
|
+
const bots = structuredClone(cfg.feishu.bots);
|
|
9
|
+
const statusBox = new Map();
|
|
10
|
+
const render = () => {
|
|
11
|
+
fill(panel,
|
|
12
|
+
h(
|
|
13
|
+
"div",
|
|
14
|
+
{ class: "row" },
|
|
15
|
+
h("button", { class: "btn accent", onclick: () => registerBot() }, "扫码创建新机器人"),
|
|
16
|
+
h(
|
|
17
|
+
"button",
|
|
18
|
+
{
|
|
19
|
+
class: "btn",
|
|
20
|
+
onclick: () => {
|
|
21
|
+
bots.push({ id: "", name: "新机器人", enabled: true, appId: "", appSecret: "", domain: "feishu", allowFrom: [], agents: ["claude"], defaultAgent: "claude", _new: true });
|
|
22
|
+
render();
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
"手动添加",
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
...bots.map((b, i) => botSection(b, i)),
|
|
29
|
+
bots.length === 0 ? h("div", { class: "muted" }, "还没有飞书机器人。") : null,
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
const botSection = (b, i) => {
|
|
33
|
+
const name = h("input", { value: b.name });
|
|
34
|
+
const appId = h("input", { value: b.appId, placeholder: "cli_xxx" });
|
|
35
|
+
const secret = h("input", { value: b.appSecret, placeholder: "App Secret", type: "password", autocomplete: "off" });
|
|
36
|
+
const domain = h("select", {}, h("option", { value: "feishu", selected: b.domain !== "lark" }, "飞书"), h("option", { value: "lark", selected: b.domain === "lark" }, "Lark"));
|
|
37
|
+
const allow = h("textarea", { rows: 2, placeholder: "ou_xxx,一行一个" });
|
|
38
|
+
allow.value = b.allowFrom.join("\n");
|
|
39
|
+
const enabled = h("input", { type: "checkbox", checked: b.enabled });
|
|
40
|
+
const agentChecks = S.agents.map((a) => {
|
|
41
|
+
const box = h("input", { type: "checkbox", checked: b.agents.includes(a.id), value: a.id });
|
|
42
|
+
return { id: a.id, box, el: h("label", { class: "check" }, box, a.label) };
|
|
43
|
+
});
|
|
44
|
+
const def = h("select", {}, S.agents.map((a) => h("option", { value: a.id, selected: a.id === b.defaultAgent }, a.label)));
|
|
45
|
+
const status = h("span", { class: "status-tag" }, "…");
|
|
46
|
+
statusBox.set(b.id, status);
|
|
47
|
+
const collect = () => ({
|
|
48
|
+
...b,
|
|
49
|
+
name: name.value.trim(),
|
|
50
|
+
appId: appId.value.trim(),
|
|
51
|
+
appSecret: secret.value.trim(),
|
|
52
|
+
domain: domain.value,
|
|
53
|
+
allowFrom: allow.value.split(/[\s,]+/).filter(Boolean),
|
|
54
|
+
enabled: enabled.checked,
|
|
55
|
+
agents: agentChecks.filter((c) => c.box.checked).map((c) => c.id),
|
|
56
|
+
defaultAgent: def.value,
|
|
57
|
+
});
|
|
58
|
+
return h(
|
|
59
|
+
"div",
|
|
60
|
+
{ class: "section" },
|
|
61
|
+
h("h4", {}, b.name || "机器人", b._new ? null : status, h("span", { class: "grow" }), h("label", { class: "check" }, enabled, "启用")),
|
|
62
|
+
field("名称", name),
|
|
63
|
+
h("div", { class: "row" }, h("div", { style: { flex: 1 } }, field("App ID", appId)), h("div", { style: { flex: 1 } }, field("App Secret", secret))),
|
|
64
|
+
field("域名", domain),
|
|
65
|
+
field("可用的 Agent", h("div", { class: "row" }, agentChecks.map((c) => c.el)), "勾一个 = 一个机器人对应一个 agent;勾多个可在会话里用 /agent 切换"),
|
|
66
|
+
field("默认 Agent", def),
|
|
67
|
+
field("Owner 白名单(open_id)", allow, "陌生人发消息会拿到配对码,在「授权」里批准即可,不必手填"),
|
|
68
|
+
h(
|
|
69
|
+
"div",
|
|
70
|
+
{ class: "row" },
|
|
71
|
+
h(
|
|
72
|
+
"button",
|
|
73
|
+
{
|
|
74
|
+
class: "btn primary",
|
|
75
|
+
onclick: async () => {
|
|
76
|
+
bots[i] = collect();
|
|
77
|
+
delete bots[i]._new;
|
|
78
|
+
await saveConfig(cfg, { feishu: { bots } });
|
|
79
|
+
feishuTab(cfg, (fill(panel, ), panel));
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
"保存",
|
|
83
|
+
),
|
|
84
|
+
h(
|
|
85
|
+
"button",
|
|
86
|
+
{
|
|
87
|
+
class: "btn",
|
|
88
|
+
onclick: async () => {
|
|
89
|
+
const v = collect();
|
|
90
|
+
try {
|
|
91
|
+
const r = await api("/api/feishu/test", { method: "POST", body: { id: b.id, appId: v.appId, appSecret: v.appSecret, domain: v.domain } });
|
|
92
|
+
toast(r.ok ? "✅ 凭据有效" : `❌ ${r.message}`);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
toast(err.message);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
"测试凭据",
|
|
99
|
+
),
|
|
100
|
+
h(
|
|
101
|
+
"button",
|
|
102
|
+
{
|
|
103
|
+
class: "btn danger",
|
|
104
|
+
onclick: async () => {
|
|
105
|
+
if (!(await confirmDialog({ title: `删除机器人「${b.name}」?`, confirmText: "删除", danger: true }))) return;
|
|
106
|
+
bots.splice(i, 1);
|
|
107
|
+
if (!b._new) await saveConfig(cfg, { feishu: { bots } });
|
|
108
|
+
render();
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
"删除",
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
const registerBot = async () => {
|
|
117
|
+
const nameInput = h("input", { value: "Agent 助手" });
|
|
118
|
+
const checks = S.agents.filter((a) => a.enabled).map((a) => ({ id: a.id, box: h("input", { type: "checkbox", checked: true }), label: a.label }));
|
|
119
|
+
const body = h(
|
|
120
|
+
"div",
|
|
121
|
+
{ class: "modal-body" },
|
|
122
|
+
h("div", { class: "muted" }, "用飞书扫码,会自动创建一个飞书应用(机器人),并把扫码的你设为 owner。"),
|
|
123
|
+
field("机器人名称", nameInput),
|
|
124
|
+
field("可用的 Agent", h("div", { class: "row" }, checks.map((c) => h("label", { class: "check" }, c.box, c.label)))),
|
|
125
|
+
);
|
|
126
|
+
const go = h("button", { class: "btn primary" }, "生成二维码");
|
|
127
|
+
const closeReg = modal({ title: "扫码创建飞书机器人", body, foot: h("div", { class: "modal-foot" }, go) });
|
|
128
|
+
go.onclick = async () => {
|
|
129
|
+
go.disabled = true;
|
|
130
|
+
try {
|
|
131
|
+
const reg = await api("/api/feishu/register", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
body: { name: nameInput.value, agents: checks.filter((c) => c.box.checked).map((c) => c.id) },
|
|
134
|
+
});
|
|
135
|
+
const state = h("div", { class: "muted", style: { textAlign: "center" } }, "等待扫码确认…");
|
|
136
|
+
fill(body,
|
|
137
|
+
h("div", { class: "qr", html: reg.qrSvg }),
|
|
138
|
+
h("div", { class: "row", style: { justifyContent: "center" } }, h("a", { href: reg.qrUrl, target: "_blank", rel: "noopener" }, "手机上直接打开链接")),
|
|
139
|
+
state,
|
|
140
|
+
);
|
|
141
|
+
go.remove();
|
|
142
|
+
const poll = async () => {
|
|
143
|
+
if (!body.isConnected) return;
|
|
144
|
+
try {
|
|
145
|
+
const r = await api(`/api/feishu/register/${reg.id}`);
|
|
146
|
+
if (r.status === "pending") return setTimeout(poll, 3000);
|
|
147
|
+
if (r.status === "success") {
|
|
148
|
+
toast("✅ 机器人已创建并启动");
|
|
149
|
+
closeReg();
|
|
150
|
+
const fresh = await api("/api/config");
|
|
151
|
+
Object.assign(cfg, fresh);
|
|
152
|
+
await saveConfig(cfg, {});
|
|
153
|
+
feishuTab(cfg, (fill(panel, ), panel));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
state.textContent = { access_denied: "已拒绝授权", expired: "二维码已过期", timeout: "超时了" }[r.status] ?? `出错:${r.message ?? r.status}`;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
state.textContent = err.message;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
setTimeout(poll, 3000);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
toast(err.message);
|
|
164
|
+
go.disabled = false;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
render();
|
|
169
|
+
api("/api/feishu/status")
|
|
170
|
+
.then((list) => {
|
|
171
|
+
for (const s of list) {
|
|
172
|
+
const el = statusBox.get(s.id);
|
|
173
|
+
if (!el) continue;
|
|
174
|
+
const label = { connected: "已连接", connecting: "连接中", reconnecting: "重连中", failed: "连接失败", stopped: "已停止" }[s.state] ?? s.state;
|
|
175
|
+
el.textContent = label;
|
|
176
|
+
el.className = `status-tag ${s.state === "connected" ? "ok" : s.state === "failed" ? "bad" : ""}`;
|
|
177
|
+
if (s.error) el.title = s.error;
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
.catch(() => {});
|
|
181
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { fill, h, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { desktop, desktopQuota } from "../state.js";
|
|
4
|
+
import { field, saveConfig } from "./common.js";
|
|
5
|
+
|
|
6
|
+
/** 服务端版本:有新版就能一键升级(npm install -g,系统服务下自动重启) */
|
|
7
|
+
export function serverVersionSection() {
|
|
8
|
+
const box = h("div", { class: "section" });
|
|
9
|
+
const load = async (refresh) => {
|
|
10
|
+
fill(box, h("h4", {}, "服务端版本", h("span", { class: "grow" }), h("span", { class: "spinner" })));
|
|
11
|
+
let v;
|
|
12
|
+
try {
|
|
13
|
+
v = await api(`/api/server/version${refresh ? "?refresh=1" : ""}`);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
fill(box, h("h4", {}, "服务端版本"), h("div", { class: "error-text" }, err.message));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const recheck = h("button", { class: "btn small", onclick: () => load(true) }, "检查更新");
|
|
19
|
+
const status = v.updateAvailable
|
|
20
|
+
? `有新版本 ${v.latest}`
|
|
21
|
+
: v.latest
|
|
22
|
+
? "已是最新版本"
|
|
23
|
+
: `查不到最新版本${v.latestError ? `:${v.latestError}` : ""}`;
|
|
24
|
+
const upgradeBtn =
|
|
25
|
+
v.updateAvailable && v.canUpgrade
|
|
26
|
+
? h(
|
|
27
|
+
"button",
|
|
28
|
+
{
|
|
29
|
+
class: "btn small primary",
|
|
30
|
+
onclick: async () => {
|
|
31
|
+
upgradeBtn.disabled = true;
|
|
32
|
+
upgradeBtn.textContent = "正在升级…";
|
|
33
|
+
try {
|
|
34
|
+
const r = await api("/api/server/upgrade", { method: "POST" });
|
|
35
|
+
toast(r.restarting ? `已升级到 ${r.to},服务正在重启,页面会自动重连` : `已升级到 ${r.to},重启 agent-loft 后生效`, 6000);
|
|
36
|
+
void load(false);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
toast(`升级失败:${err.message}`, 6000);
|
|
39
|
+
upgradeBtn.disabled = false;
|
|
40
|
+
upgradeBtn.textContent = "升级";
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
"升级",
|
|
45
|
+
)
|
|
46
|
+
: null;
|
|
47
|
+
fill(
|
|
48
|
+
box,
|
|
49
|
+
h("h4", {}, "服务端版本", h("span", { class: "grow" }), upgradeBtn, recheck),
|
|
50
|
+
h("div", { class: "health-meta" }, `当前 ${v.version} · ${status}`),
|
|
51
|
+
v.updateAvailable && !v.canUpgrade && v.reason ? h("div", { class: "health-meta" }, v.reason) : null,
|
|
52
|
+
v.updateAvailable && v.canUpgrade && !v.service ? h("div", { class: "health-meta" }, "agent-loft 是在前台手动运行的,升级后要自己重启一下。") : null,
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
void load(false);
|
|
56
|
+
return box;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function generalTab(cfg, panel) {
|
|
60
|
+
const root = h("input", { value: cfg.workspaceRoot });
|
|
61
|
+
const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
|
|
62
|
+
const ttl = h("input", { type: "number", min: 0, value: Math.round(cfg.sessionTtlMs / 3600000) });
|
|
63
|
+
const quotaMin = h("input", { type: "number", min: 0, max: 1440, value: cfg.web?.quotaRefreshMin ?? 10 });
|
|
64
|
+
const quotaOn = h("input", { type: "checkbox", checked: cfg.web?.quotaEnabled !== false });
|
|
65
|
+
panel.append(
|
|
66
|
+
h(
|
|
67
|
+
"div",
|
|
68
|
+
{ class: "section" },
|
|
69
|
+
h("h4", {}, "通用"),
|
|
70
|
+
field("工作目录(workspace)", root, "所有会话的默认目录,/cd 只能在它之内"),
|
|
71
|
+
field("授权等待超时(分钟)", approval, "超时按拒绝处理"),
|
|
72
|
+
field("会话闲置多久自动开新会话(小时)", ttl, "0 表示永不过期"),
|
|
73
|
+
h("label", { class: "check" }, quotaOn, "查询账号额度"),
|
|
74
|
+
h("div", { class: "muted" }, "关掉后侧栏、/status、桌面端都不再去问 agent 的额度(Claude 每次要临时起一个 CLI 进程才查得到)"),
|
|
75
|
+
field(
|
|
76
|
+
"额度自动刷新间隔(分钟)",
|
|
77
|
+
quotaMin,
|
|
78
|
+
desktopQuota
|
|
79
|
+
? "在桌面端里额度由桌面端统一刷新,以桌面端设置里的间隔为准;这里只对浏览器打开的页面生效"
|
|
80
|
+
: "侧栏底部的额度,空闲时多久刷新一次;0 表示不自动刷新(跑完一轮、点击额度区域仍会刷新)",
|
|
81
|
+
),
|
|
82
|
+
h(
|
|
83
|
+
"div",
|
|
84
|
+
{ class: "row" },
|
|
85
|
+
h(
|
|
86
|
+
"button",
|
|
87
|
+
{
|
|
88
|
+
class: "btn primary",
|
|
89
|
+
onclick: () =>
|
|
90
|
+
saveConfig(cfg, {
|
|
91
|
+
workspaceRoot: root.value,
|
|
92
|
+
approvalTimeoutMs: Number(approval.value) * 60000,
|
|
93
|
+
sessionTtlMs: Number(ttl.value) * 3600000,
|
|
94
|
+
web: { quotaRefreshMin: Number(quotaMin.value), quotaEnabled: quotaOn.checked },
|
|
95
|
+
}),
|
|
96
|
+
},
|
|
97
|
+
"保存",
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 只有本机能连的监听地址(和服务端 listen.ts 的判断一致) */
|
|
105
|
+
function loopbackHost(host) {
|
|
106
|
+
const v = String(host ?? "").trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
107
|
+
return v === "localhost" || v === "::1" || /^127\./.test(v);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 网络访问:要不要让局域网里的其他设备打开、监听哪个端口。保存后服务端当场换过去,不用重启;
|
|
112
|
+
* 端口被占用之类的失败会直接报出来,原来的照常工作。
|
|
113
|
+
*/
|
|
114
|
+
export function networkSection(cfg) {
|
|
115
|
+
const net = cfg.network ?? { lan: [], local: true, listening: null };
|
|
116
|
+
const box = h("div", { class: "section" });
|
|
117
|
+
const lanOn = h("input", { type: "checkbox", checked: !loopbackHost(cfg.web.host) });
|
|
118
|
+
const port = h("input", { type: "number", min: 1, max: 65535, value: cfg.web.port });
|
|
119
|
+
const hint = h("small", {});
|
|
120
|
+
// 现在这个页面是不是直接连的这个端口(不是经由反代 / 隧道)
|
|
121
|
+
const pagePort = Number(location.port || (location.protocol === "https:" ? 443 : 80));
|
|
122
|
+
const direct = pagePort === cfg.web.port;
|
|
123
|
+
|
|
124
|
+
const update = () => {
|
|
125
|
+
const p = Number(port.value) || cfg.web.port;
|
|
126
|
+
const lines = [];
|
|
127
|
+
if (lanOn.checked) {
|
|
128
|
+
lines.push(
|
|
129
|
+
net.lan.length
|
|
130
|
+
? `局域网里的其他设备用 ${net.lan.map((ip) => `http://${ip}:${p}`).join(" 或 ")} 打开`
|
|
131
|
+
: "这台机器现在没连局域网,连上后其他设备可以用它的局域网地址打开",
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
lines.push("只有这台机器自己能打开。这台机器上的反向代理、Cloudflare Tunnel 之类不受影响");
|
|
135
|
+
}
|
|
136
|
+
if (!net.local && !lanOn.checked && !loopbackHost(cfg.web.host)) {
|
|
137
|
+
lines.push("⚠️ 你现在是从局域网里的另一台设备访问的,不能在这里关掉,请到运行 agent-loft 的机器上操作");
|
|
138
|
+
}
|
|
139
|
+
if (!direct && p !== cfg.web.port) lines.push(`⚠️ 你是经由反向代理 / 隧道访问的,改端口后要把它指向新端口 ${p},否则这个页面会打不开`);
|
|
140
|
+
fill(hint, lines.map((l, i) => [i ? h("br") : null, l]));
|
|
141
|
+
};
|
|
142
|
+
lanOn.addEventListener("change", update);
|
|
143
|
+
port.addEventListener("input", update);
|
|
144
|
+
update();
|
|
145
|
+
|
|
146
|
+
const save = async () => {
|
|
147
|
+
const p = Number(port.value);
|
|
148
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) return toast("端口要是 1 到 65535 之间的整数");
|
|
149
|
+
// 打开:原来是本机地址就改成所有网卡;原来就是某个具体地址(手改过配置)就保留
|
|
150
|
+
const host = lanOn.checked ? (loopbackHost(cfg.web.host) ? "0.0.0.0" : cfg.web.host) : "127.0.0.1";
|
|
151
|
+
if (host === cfg.web.host && p === cfg.web.port) return toast("没有改动");
|
|
152
|
+
const oldPort = cfg.web.port;
|
|
153
|
+
let next;
|
|
154
|
+
try {
|
|
155
|
+
next = await api("/api/config", { method: "PUT", body: { web: { host, port: p } } });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
toast(err.message, 5000);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
Object.assign(cfg, next);
|
|
161
|
+
if (p !== oldPort && direct) {
|
|
162
|
+
// 旧端口已经不再接新连接:带着当前会话跳到新端口
|
|
163
|
+
const url = new URL(location.href);
|
|
164
|
+
url.port = String(p);
|
|
165
|
+
toast(`已改为端口 ${p},正在跳转…`);
|
|
166
|
+
// 桌面端里由它改掉存着的服务器地址再重新打开(直接跳过去会被当成外部链接)
|
|
167
|
+
if (typeof desktop?.serverMoved === "function") desktop.serverMoved(url.origin);
|
|
168
|
+
else setTimeout(() => (location.href = url.href), 600);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
toast(p !== oldPort ? `已改为端口 ${p},记得把反向代理 / 隧道也指向它` : "已保存", p !== oldPort ? 6000 : 2600);
|
|
172
|
+
box.replaceWith(networkSection(cfg));
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
fill(
|
|
176
|
+
box,
|
|
177
|
+
h("h4", {}, "网络访问"),
|
|
178
|
+
h("label", { class: "check" }, lanOn, "允许局域网内的其他设备访问"),
|
|
179
|
+
hint,
|
|
180
|
+
field("端口", port, "改了立即生效,不用重启"),
|
|
181
|
+
h("div", { class: "row" }, h("button", { class: "btn primary", onclick: () => void save() }, "保存")),
|
|
182
|
+
);
|
|
183
|
+
return box;
|
|
184
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { fill, h } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { isAdmin } from "../state.js";
|
|
4
|
+
import { modal } from "../lib/overlay.js";
|
|
5
|
+
import { notifyTab } from "./notify.js";
|
|
6
|
+
import { generalTab } from "./general.js";
|
|
7
|
+
import { commandsTab } from "./commands.js";
|
|
8
|
+
import { agentsTab } from "./agents.js";
|
|
9
|
+
import { feishuTab } from "./feishu.js";
|
|
10
|
+
import { authTab, securityTab } from "./security.js";
|
|
11
|
+
import { memoryTab } from "./memory.js";
|
|
12
|
+
import { systemTab, usersTab } from "./users.js";
|
|
13
|
+
|
|
14
|
+
// ---------- 设置 ----------
|
|
15
|
+
|
|
16
|
+
export async function openSettings() {
|
|
17
|
+
const nav = h("div", { class: "settings-nav" });
|
|
18
|
+
const panel = h("div", { class: "settings-body" });
|
|
19
|
+
// 设置弹窗高度固定,切标签页时不跟着内容一伸一缩
|
|
20
|
+
modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel) });
|
|
21
|
+
// 普通成员只能管自己的通知、密码和登录设备;其余设置都是管理员的
|
|
22
|
+
if (!isAdmin()) {
|
|
23
|
+
const tabs = [
|
|
24
|
+
["记忆", () => memoryTab(panel)],
|
|
25
|
+
["通知", () => notifyTab(panel)],
|
|
26
|
+
["账号", () => securityTab(panel)],
|
|
27
|
+
];
|
|
28
|
+
const select = (i) => {
|
|
29
|
+
nav.querySelectorAll("button").forEach((b, j) => b.classList.toggle("on", i === j));
|
|
30
|
+
fill(panel);
|
|
31
|
+
tabs[i][1]();
|
|
32
|
+
};
|
|
33
|
+
nav.append(...tabs.map(([name], i) => h("button", { onclick: () => select(i) }, name)));
|
|
34
|
+
select(0);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
let cfg;
|
|
38
|
+
try {
|
|
39
|
+
cfg = await api("/api/config");
|
|
40
|
+
} catch (err) {
|
|
41
|
+
panel.append(h("div", { class: "error-text" }, err.message));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const tabs = [
|
|
45
|
+
["通用", () => generalTab(cfg, panel)],
|
|
46
|
+
["Agent", () => agentsTab(cfg, panel)],
|
|
47
|
+
["命令", () => commandsTab(cfg, panel)],
|
|
48
|
+
["飞书机器人", () => feishuTab(cfg, panel)],
|
|
49
|
+
["授权", () => authTab(panel)],
|
|
50
|
+
["记忆", () => memoryTab(panel)],
|
|
51
|
+
["通知", () => notifyTab(panel)],
|
|
52
|
+
["账号", () => securityTab(panel)],
|
|
53
|
+
["用户", () => usersTab(panel)],
|
|
54
|
+
["系统", () => systemTab(cfg, panel)],
|
|
55
|
+
];
|
|
56
|
+
const select = (i) => {
|
|
57
|
+
nav.querySelectorAll("button").forEach((b, j) => b.classList.toggle("on", i === j));
|
|
58
|
+
fill(panel, );
|
|
59
|
+
tabs[i][1]();
|
|
60
|
+
};
|
|
61
|
+
nav.append(...tabs.map(([name], i) => h("button", { onclick: () => select(i) }, name)));
|
|
62
|
+
select(0);
|
|
63
|
+
}
|