agent-loft 0.2.16 → 0.2.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -116,6 +116,13 @@ export class ClaudeDriver implements AgentDriver {
116
116
  }
117
117
  }
118
118
 
119
+ async sessionTitle(id: string, cwd: string): Promise<string | undefined> {
120
+ if (!SESSION_ID.test(id)) return undefined;
121
+ // SDK 的 customTitle 已经是「手动改的名 || 自动生成的 aiTitle」,不含首条消息兜底
122
+ const info = await getSessionInfo(id, { dir: cwd });
123
+ return info?.customTitle?.replace(/\s+/g, " ").trim() || undefined;
124
+ }
125
+
119
126
  /**
120
127
  * 把会话记录整理成「一问一答」:用户一条,助手把这一轮所有文本拼成一条,
121
128
  * 中间的工具调用作为过程摘要挂在助手那条上。工具结果(type=user 的 tool_result)
@@ -102,6 +102,11 @@ export class CodexDriver implements AgentDriver {
102
102
  }
103
103
  }
104
104
 
105
+ async sessionTitle(id: string): Promise<string | undefined> {
106
+ const res = await appServer.call<{ thread: Thread }>("thread/read", { threadId: id, includeTurns: false });
107
+ return res.thread.name?.replace(/\s+/g, " ").trim() || undefined;
108
+ }
109
+
105
110
  async readHistory(id: string): Promise<HistoryMessage[]> {
106
111
  const res = await appServer.call<{
107
112
  thread: { turns?: { id?: string; items?: HistoryItem[]; startedAt?: number | null; completedAt?: number | null }[] };
@@ -61,8 +61,8 @@ export function approvalCard(interaction: Interaction, ctx: CardCtx) {
61
61
  const id = interaction.id;
62
62
  const buttons = [
63
63
  button("允许一次", { a: "approval", r: id, d: "allow-once" }, ctx, "primary"),
64
- ...(spec.alwaysLabel ? [button(spec.alwaysLabel, { a: "approval", r: id, d: "allow-always" }, ctx)] : []),
65
64
  ...(spec.allowSession ? [button("本会话全部允许", { a: "approval", r: id, d: "allow-session" }, ctx)] : []),
65
+ ...(spec.alwaysLabel ? [button(spec.alwaysLabel, { a: "approval", r: id, d: "allow-always" }, ctx)] : []),
66
66
  button("拒绝", { a: "approval", r: id, d: "deny" }, ctx, "danger"),
67
67
  ];
68
68
  return card(
@@ -15,6 +15,7 @@ import { listAllowlist, setAllowlist } from "../../agents/claude/permissions.js"
15
15
  import { agentAvailability, agentHealth, invalidateHealth } from "../../agents/health.js";
16
16
  import { installCodex, upgrade, useInstalledCodex, versionInfo } from "../../core/upgrade.js";
17
17
  import { feishuBots } from "../feishu/index.js";
18
+ import { fromThisMachine, isLoopbackHost, lanAddresses, listening, rebind } from "./listen.js";
18
19
  import { beginRegistration, initRegistration, pollRegistration, qrSvg } from "../feishu/register.js";
19
20
  import { FeishuClient } from "../feishu/client.js";
20
21
  import {
@@ -176,8 +177,11 @@ function applyConfig(input: Partial<AppConfig>): void {
176
177
  }
177
178
  if (input.web) {
178
179
  // 密码只能走专门的接口改
179
- const { passwordHash: _ignored, ...web } = input.web as AppConfig["web"];
180
+ // 监听地址、端口已经在 applyListen 里校验并换好了,这里只收形状对的
181
+ const { passwordHash: _ignored, host, port, ...web } = input.web as AppConfig["web"];
180
182
  next.web = { ...next.web, ...web, passwordHash: current.web.passwordHash };
183
+ if (typeof host === "string" && host.trim()) next.web.host = host.trim();
184
+ if (Number.isInteger(Number(port)) && Number(port) >= 1 && Number(port) <= 65535) next.web.port = Number(port);
181
185
  const q = Number(next.web.quotaRefreshMin);
182
186
  next.web.quotaRefreshMin = Number.isFinite(q) ? Math.max(0, Math.min(1440, Math.round(q))) : 10;
183
187
  }
@@ -217,6 +221,38 @@ function applyConfig(input: Partial<AppConfig>): void {
217
221
  saveConfig(next);
218
222
  }
219
223
 
224
+ // ---------- 监听地址(局域网访问、端口) ----------
225
+
226
+ /** 设置页「网络访问」要显示的:这台机器的局域网地址、当前请求是不是从本机来的 */
227
+ function networkInfo(req: IncomingMessage) {
228
+ return { lan: lanAddresses(), local: fromThisMachine(req.socket.remoteAddress), listening: listening() };
229
+ }
230
+
231
+ /**
232
+ * 改了监听地址 / 端口:先真的换过去,成功了配置才保存(applyConfig 里只是照抄)。
233
+ * 端口被占用之类的失败直接报给页面,旧地址照常工作。
234
+ */
235
+ async function applyListen(req: IncomingMessage, web: Partial<AppConfig["web"]> | undefined): Promise<void> {
236
+ if (!web || (web.host === undefined && web.port === undefined)) return;
237
+ const current = config().web;
238
+ const host = web.host === undefined ? current.host : String(web.host).trim();
239
+ const port = web.port === undefined ? current.port : Number(web.port);
240
+ if (!host) throw new HttpError(400, "监听地址不能为空");
241
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new HttpError(400, "端口要是 1 到 65535 之间的整数");
242
+ if (host === current.host && port === current.port) return;
243
+ // 从局域网里的另一台设备上关掉局域网访问:这个页面马上就连不上了,而且没法在这台设备上改回来
244
+ if (isLoopbackHost(host) && !isLoopbackHost(current.host) && !fromThisMachine(req.socket.remoteAddress)) {
245
+ throw new HttpError(400, "你现在是从局域网里的另一台设备访问的,关掉局域网访问后这个页面会立刻断开、也没法再改回来。请在运行 agent-loft 的这台机器上操作。");
246
+ }
247
+ if (config().web.enabled) {
248
+ try {
249
+ await rebind(host, port);
250
+ } catch (err) {
251
+ throw new HttpError(400, err instanceof Error ? err.message : String(err));
252
+ }
253
+ }
254
+ }
255
+
220
256
  // ---------- 鉴权 ----------
221
257
 
222
258
  function setCookie(req: IncomingMessage, res: ServerResponse, token: string, maxAgeSec: number): void {
@@ -398,7 +434,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
398
434
  if (!sub && method === "PATCH") {
399
435
  const body = await readJsonBody<{ title?: string; hidden?: boolean }>(req);
400
436
  updateConversation(key, {
401
- ...(typeof body.title === "string" ? { title: body.title.trim().slice(0, 80) } : {}),
437
+ // 手动改过名,就不再拿 agent 生成的标题覆盖
438
+ ...(typeof body.title === "string" ? { title: body.title.trim().slice(0, 80), titleAuto: false } : {}),
402
439
  ...(typeof body.hidden === "boolean" ? { hidden: body.hidden } : {}),
403
440
  });
404
441
  hub.emitEvent(key, { type: "meta" });
@@ -534,12 +571,14 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
534
571
 
535
572
  // ----- 设置 -----
536
573
  if (p === "/api/config" && method === "GET") {
537
- sendJson(res, 200, publicConfig());
574
+ sendJson(res, 200, { ...(publicConfig() as object), network: networkInfo(req) });
538
575
  return;
539
576
  }
540
577
  if (p === "/api/config" && method === "PUT") {
541
- applyConfig(await readJsonBody<Partial<AppConfig>>(req));
542
- sendJson(res, 200, publicConfig());
578
+ const input = await readJsonBody<Partial<AppConfig>>(req);
579
+ await applyListen(req, input.web);
580
+ applyConfig(input);
581
+ sendJson(res, 200, { ...(publicConfig() as object), network: networkInfo(req) });
543
582
  return;
544
583
  }
545
584
  // 侧栏的账号额度:各 agent 自己缓存(60 秒),这里并发问一遍,单个失败 / 超时不影响别的
@@ -0,0 +1,120 @@
1
+ // Web 服务监听在哪个地址、哪个端口,以及不重启进程就换地址。
2
+ //
3
+ // http.Server 自己不 listen,由一个 net.Server 收连接再交给它(emit "connection")。
4
+ // 这样换地址时可以先在新地址上监听成功、再关掉旧的监听:
5
+ // - 新端口被占用 / 没权限:直接报错,旧的照常工作,配置也不改;
6
+ // - 已经连着的页面、WebSocket 属于 http.Server,关掉旧监听不影响它们(只是不再接新连接)。
7
+ import net from "node:net";
8
+ import os from "node:os";
9
+ import type http from "node:http";
10
+
11
+ let target: http.Server | null = null;
12
+ let listener: net.Server | null = null;
13
+ let bound: { host: string; port: number } | null = null;
14
+
15
+ /** 只有本机能连的地址 */
16
+ export function isLoopbackHost(host: string): boolean {
17
+ const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
18
+ return h === "localhost" || h === "::1" || /^127\./.test(h);
19
+ }
20
+
21
+ /** 这条连接是不是从本机来的(直连本机,或者经由本机上的反代 / 隧道) */
22
+ export function fromThisMachine(remote: string | undefined): boolean {
23
+ if (!remote) return false;
24
+ return isLoopbackHost(remote.replace(/^::ffff:/, ""));
25
+ }
26
+
27
+ function open(host: string, port: number): Promise<net.Server> {
28
+ return new Promise((resolve, reject) => {
29
+ const srv = net.createServer((socket) => target?.emit("connection", socket));
30
+ const onError = (err: NodeJS.ErrnoException) => {
31
+ srv.close();
32
+ if (err.code === "EADDRINUSE") reject(new Error(`端口 ${port} 已经被别的程序占用了`));
33
+ else if (err.code === "EACCES") reject(new Error(`没有权限监听端口 ${port}(1024 以下的端口一般需要管理员权限)`));
34
+ else if (err.code === "EADDRNOTAVAIL") reject(new Error(`这台机器上没有地址 ${host}`));
35
+ else reject(err);
36
+ };
37
+ srv.once("error", onError);
38
+ srv.listen(port, host, () => {
39
+ srv.off("error", onError);
40
+ srv.on("error", (err) => console.error("[web] 监听出错:", err));
41
+ resolve(srv);
42
+ });
43
+ });
44
+ }
45
+
46
+ /**
47
+ * 关掉监听。不等 close 的回调:它要等这个监听接过的所有连接都断开才回调,
48
+ * 而连接已经交给 http.Server 了(包括正在请求改地址的这一条),等它会卡死。
49
+ */
50
+ function shut(srv: net.Server): void {
51
+ srv.close();
52
+ }
53
+
54
+ /** 刚关掉的端口要等一下才真正放出来:被占用就稍等重试几次 */
55
+ async function openRetry(host: string, port: number): Promise<net.Server> {
56
+ for (let i = 0; ; i++) {
57
+ try {
58
+ return await open(host, port);
59
+ } catch (err) {
60
+ if (i >= 20 || !String((err as Error).message).includes("占用")) throw err;
61
+ await new Promise((r) => setTimeout(r, 50));
62
+ }
63
+ }
64
+ }
65
+
66
+ /** 启动时第一次监听 */
67
+ export async function listen(server: http.Server, host: string, port: number): Promise<void> {
68
+ target = server;
69
+ listener = await open(host, port);
70
+ bound = { host, port };
71
+ }
72
+
73
+ /** 换到新的地址 / 端口:新的监听成功了才关旧的;失败抛出能直接给人看的错误 */
74
+ export async function rebind(host: string, port: number): Promise<void> {
75
+ if (!target) throw new Error("Web 服务没有在运行");
76
+ if (bound && bound.host === host && bound.port === port) return;
77
+ const old = listener;
78
+ // 同一个端口只换地址(比如 0.0.0.0 → 127.0.0.1):两个监听会冲突,只能先关旧的;
79
+ // 新的失败了再把旧的开回来
80
+ if (old && bound?.port === port) {
81
+ const prev = bound;
82
+ shut(old);
83
+ try {
84
+ listener = await openRetry(host, port);
85
+ } catch (err) {
86
+ listener = await openRetry(prev.host, prev.port);
87
+ throw err;
88
+ }
89
+ } else {
90
+ listener = await open(host, port);
91
+ if (old) shut(old);
92
+ }
93
+ bound = { host, port };
94
+ console.log(`[web] 已改为监听 http://${host}:${port}`);
95
+ }
96
+
97
+ export function listening(): { host: string; port: number } | null {
98
+ return bound;
99
+ }
100
+
101
+ export function closeListener(): void {
102
+ const l = listener;
103
+ listener = null;
104
+ bound = null;
105
+ target = null;
106
+ if (l) shut(l);
107
+ }
108
+
109
+ /** 这台机器在局域网里的 IPv4 地址(给设置页显示「其他设备用这个地址访问」) */
110
+ export function lanAddresses(): string[] {
111
+ const out: string[] = [];
112
+ for (const list of Object.values(os.networkInterfaces())) {
113
+ for (const a of list ?? []) {
114
+ // 169.254:没拿到地址时的自分配地址;198.18/15:Clash、Surge 等代理软件虚拟网卡用的,都不是真的局域网地址
115
+ if (a.family !== "IPv4" || a.internal || /^169\.254\.|^198\.1[89]\./.test(a.address)) continue;
116
+ out.push(a.address);
117
+ }
118
+ }
119
+ return [...new Set(out)];
120
+ }
@@ -8,7 +8,7 @@ import path from "node:path";
8
8
  import { createRequire } from "node:module";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { WebSocketServer, type WebSocket } from "ws";
11
- import { config } from "../../core/config.js";
11
+ import { config, onConfigChange } from "../../core/config.js";
12
12
  import { parseCommand } from "../../core/commands.js";
13
13
  import { hub, type EventEnvelope } from "../../core/hub.js";
14
14
  import { interactions, type Interaction } from "../../core/interactions.js";
@@ -20,6 +20,7 @@ import { cookies } from "./http.js";
20
20
  import { COOKIE } from "./api.js";
21
21
  import { HttpError, sameOrigin, SECURITY_HEADERS, sendJson } from "./http.js";
22
22
  import { hasSubscriptions, pushToAll } from "./push.js";
23
+ import { closeListener, listen, listening, rebind } from "./listen.js";
23
24
 
24
25
  const require = createRequire(import.meta.url);
25
26
  const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../web");
@@ -336,6 +337,7 @@ function handleClientMessage(client: Client, raw: string): void {
336
337
  let server: http.Server | null = null;
337
338
  let wss: WebSocketServer | null = null;
338
339
  let heartbeat: NodeJS.Timeout | null = null;
340
+ let stopWatchingListen: (() => void) | null = null;
339
341
 
340
342
  export async function startWeb(): Promise<void> {
341
343
  const { host, port } = config().web;
@@ -426,11 +428,14 @@ export async function startWeb(): Promise<void> {
426
428
  interactions.on("updated", broadcastInteraction);
427
429
  interactions.on("settled", broadcastInteraction);
428
430
 
429
- await new Promise<void>((resolve, reject) => {
430
- server!.once("error", reject);
431
- server!.listen(port, host, () => resolve());
432
- });
431
+ await listen(server, host, port);
433
432
  console.log(`[web] 已监听 http://${host}:${port}`);
433
+ // 配置文件被手改了地址 / 端口:跟着换(设置页保存时已经先换好了,这里会直接跳过)
434
+ stopWatchingListen = onConfigChange((next) => {
435
+ const now = listening();
436
+ if (!now || (now.host === next.web.host && now.port === next.web.port)) return;
437
+ rebind(next.web.host, next.web.port).catch((err) => console.error("[web] 换监听地址失败:", err instanceof Error ? err.message : err));
438
+ });
434
439
  const token = ensureSetupToken();
435
440
  if (token) {
436
441
  console.log("=".repeat(56));
@@ -455,6 +460,11 @@ export async function stopWeb(): Promise<void> {
455
460
  for (const client of clients) client.ws.close(1001, "服务重启");
456
461
  clients.clear();
457
462
  wss?.close();
463
+ stopWatchingListen?.();
464
+ stopWatchingListen = null;
465
+ closeListener();
466
+ // http.Server 自己没有 listen(连接是 listen.ts 交过来的),close 只为断开还连着的
458
467
  await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
468
+ server?.closeAllConnections();
459
469
  server = null;
460
470
  }
package/src/core/hub.ts CHANGED
@@ -159,6 +159,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
159
159
  private locks = new Map<string, Promise<unknown>>();
160
160
  /** 最近一次点停止的时间:紧接着冒出来的「被打断」错误是预期内的,不当错误显示 */
161
161
  private stoppedAt = new Map<string, number>();
162
+ /** 正在等 agent 标题的对话,避免一轮轮叠加重试 */
163
+ private titleSyncing = new Set<string>();
162
164
  /**
163
165
  * 每条会话的「生命周期代次」:开新会话、分叉、恢复、切 agent、切目录时加一。
164
166
  * 渠道收到消息后还要先做异步的事(飞书下载附件),期间会话被换掉了的话,
@@ -497,6 +499,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
497
499
  recordFooter(holder.session?.sessionId ?? (conv ? agentBinding(conv, agent).sessionId : undefined), result.text, footer);
498
500
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
499
501
  this.refreshRuntimeOnce(key, liveId);
502
+ this.syncTitle(key, agent);
500
503
  },
501
504
  onError: (err) => {
502
505
  if (!alive()) return;
@@ -555,6 +558,47 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
555
558
  return session;
556
559
  }
557
560
 
561
+ /**
562
+ * 自动标题换成 agent 自己起的。agent 的标题是异步生成的,一轮结束时未必已经有了,
563
+ * 所以隔一会儿再问几次。每轮结束都会再同步一次:agent 后来改了标题(比如在终端里 /rename)也能跟上。
564
+ */
565
+ private syncTitle(key: string, agent: string, attempt = 0): void {
566
+ const delays = [1_000, 5_000, 15_000, 45_000];
567
+ const driver = this.drivers.get(agent);
568
+ if (!driver?.sessionTitle) return;
569
+ if (attempt === 0) {
570
+ if (this.titleSyncing.has(key)) return; // 上一轮的重试还在跑
571
+ this.titleSyncing.add(key);
572
+ }
573
+ if (attempt >= delays.length) {
574
+ this.titleSyncing.delete(key);
575
+ return;
576
+ }
577
+ setTimeout(() => {
578
+ void (async () => {
579
+ const conv = getConversation(key);
580
+ const sessionId = conv && conv.agent === agent ? agentBinding(conv, agent).sessionId : undefined;
581
+ if (!conv?.titleAuto || !sessionId) return this.titleSyncing.delete(key);
582
+ const title = await driver.sessionTitle!(sessionId, conv.cwd).catch((err) => {
583
+ console.error(`[${agent}] 读取会话标题失败:`, err instanceof Error ? err.message : err);
584
+ return undefined;
585
+ });
586
+ // 等待期间用户可能改了名、切了会话
587
+ const now = getConversation(key);
588
+ if (!now?.titleAuto || now.agent !== agent || agentBinding(now, agent).sessionId !== sessionId) {
589
+ return this.titleSyncing.delete(key);
590
+ }
591
+ if (!title) return this.syncTitle(key, agent, attempt + 1);
592
+ this.titleSyncing.delete(key);
593
+ const clipped = title.slice(0, 80);
594
+ if (clipped !== now.title) {
595
+ updateConversation(key, { title: clipped });
596
+ this.emitEvent(key, { type: "meta" });
597
+ }
598
+ })();
599
+ }, delays[attempt]);
600
+ }
601
+
558
602
  /** 丢掉活着的会话(不动绑定)。挂着的审批一并取消,否则阻塞的那一轮永远走不完。 */
559
603
  async drop(key: string, note: string): Promise<void> {
560
604
  interactions.cancelAll(key, note);
@@ -713,7 +757,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
713
757
  const lastActive = conv.activeAt;
714
758
  touchConversation(key);
715
759
  if (!conv.title && conv.channel === "web" && input.text.trim()) {
716
- updateConversation(key, { title: input.text.trim().replace(/\s+/g, " ").slice(0, 40) });
760
+ // 先用首条消息占位,第一轮结束后换成 agent 自己生成的短标题(syncTitle)
761
+ updateConversation(key, { title: input.text.trim().replace(/\s+/g, " ").slice(0, 40), titleAuto: true });
717
762
  this.emitEvent(key, { type: "meta" });
718
763
  }
719
764
  this.emitEvent(key, shown);
package/src/core/store.ts CHANGED
@@ -28,6 +28,11 @@ export type Conversation = {
28
28
  /** 渠道实例:飞书是机器人 id,Web 固定 web */
29
29
  channelId: string;
30
30
  title?: string;
31
+ /**
32
+ * 标题是自动起的(先用首条消息占位,之后换成 agent 生成的标题),可以继续被 agent 的标题覆盖。
33
+ * 用户手动改过名就清掉,之后不再动它。
34
+ */
35
+ titleAuto?: boolean;
31
36
  agent: string;
32
37
  cwd: string;
33
38
  /** cwd 是否用户显式指定。没指定过的跟随 workspaceRoot 变化 */
package/src/core/types.ts CHANGED
@@ -201,6 +201,8 @@ export interface AgentDriver {
201
201
  /** cwd 为 undefined 时列全部目录 */
202
202
  listSessions(cwd: string | undefined, limit?: number): Promise<SessionSummary[]>;
203
203
  sessionExists(id: string, cwd: string): Promise<boolean>;
204
+ /** agent 自己给会话起的标题(模型总结的或在别处改过的);还没生成就返回 undefined,不拿首条消息凑数 */
205
+ sessionTitle?(id: string, cwd: string): Promise<string | undefined>;
204
206
  readHistory(id: string, cwd: string): Promise<HistoryMessage[]>;
205
207
  /**
206
208
  * 把会话复制出一条新的、返回新会话 id。anchor(HistoryMessage / TurnResult 里的)给了就只保留到
package/web/app.js CHANGED
@@ -1948,12 +1948,12 @@ function renderInteraction(item) {
1948
1948
  "div",
1949
1949
  { class: "ix-buttons" },
1950
1950
  h("button", { class: "btn accent", onclick: () => ixOp(ix.id, "decide", { decision: "allow-once" }) }, "允许一次"),
1951
- spec.alwaysLabel
1952
- ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
1953
- : null,
1954
1951
  spec.allowSession
1955
1952
  ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
1956
1953
  : null,
1954
+ spec.alwaysLabel
1955
+ ? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
1956
+ : null,
1957
1957
  h("button", { class: "btn danger", onclick: () => ixOp(ix.id, "decide", { decision: "deny" }) }, "拒绝"),
1958
1958
  ),
1959
1959
  );
@@ -3357,6 +3357,89 @@ function generalTab(cfg, panel) {
3357
3357
  ),
3358
3358
  ),
3359
3359
  );
3360
+ panel.append(networkSection(cfg));
3361
+ }
3362
+
3363
+ /** 只有本机能连的监听地址(和服务端 listen.ts 的判断一致) */
3364
+ function loopbackHost(host) {
3365
+ const v = String(host ?? "").trim().toLowerCase().replace(/^\[|\]$/g, "");
3366
+ return v === "localhost" || v === "::1" || /^127\./.test(v);
3367
+ }
3368
+
3369
+ /**
3370
+ * 网络访问:要不要让局域网里的其他设备打开、监听哪个端口。保存后服务端当场换过去,不用重启;
3371
+ * 端口被占用之类的失败会直接报出来,原来的照常工作。
3372
+ */
3373
+ function networkSection(cfg) {
3374
+ const net = cfg.network ?? { lan: [], local: true, listening: null };
3375
+ const box = h("div", { class: "section" });
3376
+ const lanOn = h("input", { type: "checkbox", checked: !loopbackHost(cfg.web.host) });
3377
+ const port = h("input", { type: "number", min: 1, max: 65535, value: cfg.web.port });
3378
+ const hint = h("small", {});
3379
+ // 现在这个页面是不是直接连的这个端口(不是经由反代 / 隧道)
3380
+ const pagePort = Number(location.port || (location.protocol === "https:" ? 443 : 80));
3381
+ const direct = pagePort === cfg.web.port;
3382
+
3383
+ const update = () => {
3384
+ const p = Number(port.value) || cfg.web.port;
3385
+ const lines = [];
3386
+ if (lanOn.checked) {
3387
+ lines.push(
3388
+ net.lan.length
3389
+ ? `局域网里的其他设备用 ${net.lan.map((ip) => `http://${ip}:${p}`).join(" 或 ")} 打开`
3390
+ : "这台机器现在没连局域网,连上后其他设备可以用它的局域网地址打开",
3391
+ );
3392
+ } else {
3393
+ lines.push("只有这台机器自己能打开。这台机器上的反向代理、Cloudflare Tunnel 之类不受影响");
3394
+ }
3395
+ if (!net.local && !lanOn.checked && !loopbackHost(cfg.web.host)) {
3396
+ lines.push("⚠️ 你现在是从局域网里的另一台设备访问的,不能在这里关掉,请到运行 agent-loft 的机器上操作");
3397
+ }
3398
+ if (!direct && p !== cfg.web.port) lines.push(`⚠️ 你是经由反向代理 / 隧道访问的,改端口后要把它指向新端口 ${p},否则这个页面会打不开`);
3399
+ fill(hint, lines.map((l, i) => [i ? h("br") : null, l]));
3400
+ };
3401
+ lanOn.addEventListener("change", update);
3402
+ port.addEventListener("input", update);
3403
+ update();
3404
+
3405
+ const save = async () => {
3406
+ const p = Number(port.value);
3407
+ if (!Number.isInteger(p) || p < 1 || p > 65535) return toast("端口要是 1 到 65535 之间的整数");
3408
+ // 打开:原来是本机地址就改成所有网卡;原来就是某个具体地址(手改过配置)就保留
3409
+ const host = lanOn.checked ? (loopbackHost(cfg.web.host) ? "0.0.0.0" : cfg.web.host) : "127.0.0.1";
3410
+ if (host === cfg.web.host && p === cfg.web.port) return toast("没有改动");
3411
+ const oldPort = cfg.web.port;
3412
+ let next;
3413
+ try {
3414
+ next = await api("/api/config", { method: "PUT", body: { web: { host, port: p } } });
3415
+ } catch (err) {
3416
+ toast(err.message, 5000);
3417
+ return;
3418
+ }
3419
+ Object.assign(cfg, next);
3420
+ if (p !== oldPort && direct) {
3421
+ // 旧端口已经不再接新连接:带着当前会话跳到新端口
3422
+ const url = new URL(location.href);
3423
+ url.port = String(p);
3424
+ toast(`已改为端口 ${p},正在跳转…`);
3425
+ // 桌面端里由它改掉存着的服务器地址再重新打开(直接跳过去会被当成外部链接)
3426
+ if (typeof desktop?.serverMoved === "function") desktop.serverMoved(url.origin);
3427
+ else setTimeout(() => (location.href = url.href), 600);
3428
+ return;
3429
+ }
3430
+ toast(p !== oldPort ? `已改为端口 ${p},记得把反向代理 / 隧道也指向它` : "已保存", p !== oldPort ? 6000 : 2600);
3431
+ box.replaceWith(networkSection(cfg));
3432
+ };
3433
+
3434
+ fill(
3435
+ box,
3436
+ h("h4", {}, "网络访问"),
3437
+ h("label", { class: "check" }, lanOn, "允许局域网内的其他设备访问"),
3438
+ hint,
3439
+ field("端口", port, "改了立即生效,不用重启"),
3440
+ h("div", { class: "row" }, h("button", { class: "btn primary", onclick: () => void save() }, "保存")),
3441
+ );
3442
+ return box;
3360
3443
  }
3361
3444
 
3362
3445
  /**