agent-loft 0.2.18 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -13,6 +13,7 @@ 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";
15
15
  import { getConversation } from "../../core/store.js";
16
+ import { RUNNING_VERSION } from "../../core/upgrade.js";
16
17
  import type { Settlement } from "../../core/types.js";
17
18
  import { authed, handleApi, takeUploads } from "./api.js";
18
19
  import { authEvents, ensureSetupToken, validateSession } from "./auth.js";
@@ -60,18 +61,33 @@ const MIME: Record<string, string> = {
60
61
  ".webmanifest": "application/manifest+json",
61
62
  };
62
63
 
63
- function serveFile(res: http.ServerResponse, file: string, cache: string): void {
64
+ /**
65
+ * 静态文件一律 no-cache + Last-Modified:每次都向服务端确认一下,没变就回 304。
66
+ * 升级后 app.js、style.css、vendor 都会变,但地址不变,带 max-age 的话刷新页面还会拿到旧的。
67
+ */
68
+ function serveFile(req: http.IncomingMessage, res: http.ServerResponse, file: string): void {
64
69
  fs.stat(file, (err, stat) => {
65
70
  if (err || !stat.isFile()) {
66
71
  res.writeHead(404, SECURITY_HEADERS);
67
72
  res.end("not found");
68
73
  return;
69
74
  }
70
- res.writeHead(200, {
75
+ const lastModified = new Date(Math.floor(stat.mtimeMs / 1000) * 1000);
76
+ const since = Date.parse(req.headers["if-modified-since"] ?? "");
77
+ const headers = {
71
78
  ...SECURITY_HEADERS,
79
+ "Cache-Control": "no-cache",
80
+ "Last-Modified": lastModified.toUTCString(),
81
+ };
82
+ if (!Number.isNaN(since) && lastModified.getTime() <= since) {
83
+ res.writeHead(304, headers);
84
+ res.end();
85
+ return;
86
+ }
87
+ res.writeHead(200, {
88
+ ...headers,
72
89
  "Content-Type": MIME[path.extname(file)] ?? "application/octet-stream",
73
90
  "Content-Length": stat.size,
74
- "Cache-Control": cache,
75
91
  });
76
92
  fs.createReadStream(file).pipe(res);
77
93
  });
@@ -81,7 +97,7 @@ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: U
81
97
  const vendor = VENDOR[url.pathname];
82
98
  if (vendor) {
83
99
  try {
84
- serveFile(res, vendor(), "public, max-age=86400");
100
+ serveFile(req, res, vendor());
85
101
  } catch {
86
102
  res.writeHead(404);
87
103
  res.end();
@@ -108,9 +124,7 @@ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: U
108
124
  res.end();
109
125
  return;
110
126
  }
111
- // 页面本身别缓存:升级后要立刻生效
112
- serveFile(res, file, rel === "/index.html" || rel === "/sw.js" ? "no-cache" : "public, max-age=300");
113
- void req;
127
+ serveFile(req, res, file);
114
128
  }
115
129
 
116
130
  // ---------- WebSocket ----------
@@ -397,7 +411,7 @@ export async function startWeb(): Promise<void> {
397
411
  });
398
412
  ws.on("close", () => clients.delete(client));
399
413
  ws.on("error", () => clients.delete(client));
400
- send(client, { t: "hello", seq: hub.currentSeq(), epoch: EPOCH });
414
+ send(client, { t: "hello", seq: hub.currentSeq(), epoch: EPOCH, version: RUNNING_VERSION });
401
415
  });
402
416
  } catch (err) {
403
417
  console.error("[web] WebSocket 握手出错:", err);
package/web/app.js CHANGED
@@ -38,7 +38,6 @@ const ICON = {
38
38
  stop: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1.5"/></svg>',
39
39
  clip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.6-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>',
40
40
  mic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
41
- more: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>',
42
41
  down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg>',
43
42
  gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/></svg>',
44
43
  fork: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10"/><path d="M18 9c0 5-6 4-11.2 8.6"/></svg>',
@@ -269,6 +268,12 @@ const desktop = window.agentLoftDesktop ?? null;
269
268
  const desktopQuota = typeof desktop?.onQuota === "function";
270
269
  // 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 style.css 的 .in-desktop)
271
270
  if (desktop) document.documentElement.classList.add("in-desktop");
271
+ /**
272
+ * 新版桌面端顶栏的「更多」里能打开每台服务器的设置,额度也在它的菜单栏里:侧栏底部的额度和设置就不显示了。
273
+ * 老版本桌面端没有这个入口,照旧显示,免得设置打不开。
274
+ */
275
+ const desktopSettings = typeof desktop?.onOpenSettings === "function";
276
+ if (desktopSettings) document.documentElement.classList.add("desktop-settings");
272
277
 
273
278
  // ---------- WebSocket ----------
274
279
 
@@ -402,6 +407,7 @@ function markGone(ix) {
402
407
  function onWsMessage(msg) {
403
408
  switch (msg.t) {
404
409
  case "hello": {
410
+ if (checkServerVersion(msg.version)) return;
405
411
  const restarted = WS.epoch && WS.epoch !== msg.epoch;
406
412
  WS.epoch = msg.epoch;
407
413
  WS.hellos += 1;
@@ -464,14 +470,31 @@ function onWsMessage(msg) {
464
470
  }
465
471
  }
466
472
 
467
- function setConnBanner(text) {
473
+ function setConnBanner(text, onclick) {
468
474
  ui.banner?.remove();
469
475
  ui.banner = null;
470
476
  if (!text || !ui.main) return;
471
- ui.banner = h("div", { class: "conn-banner" }, text);
477
+ ui.banner = onclick ? h("button", { class: "conn-banner clickable", onclick }, text) : h("div", { class: "conn-banner" }, text);
472
478
  ui.main.append(ui.banner);
473
479
  }
474
480
 
481
+ /** 页面加载时连上的服务端版本。服务端升级重启后版本变了,页面上跑的还是旧前端,得刷新 */
482
+ let loadedServerVersion = null;
483
+
484
+ /** 服务端换了版本:刷新页面换上新前端。返回 true 表示已经在刷新了,后面不用再处理 */
485
+ function checkServerVersion(version) {
486
+ if (!version) return false;
487
+ if (!loadedServerVersion) loadedServerVersion = version;
488
+ if (version === loadedServerVersion) return false;
489
+ // 输入框里有没发出去的字:别直接刷掉,提示一下让用户自己点
490
+ if (ui.input?.value.trim()) {
491
+ setConnBanner(`服务端已升级到 ${version},点这里刷新页面`, () => location.reload());
492
+ return false;
493
+ }
494
+ location.reload();
495
+ return true;
496
+ }
497
+
475
498
  // ---------- 登录 ----------
476
499
 
477
500
  async function showAuth() {
@@ -549,6 +572,12 @@ async function boot() {
549
572
  desktop?.onOpen((key) => {
550
573
  if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
551
574
  });
575
+ // 桌面端「更多」里点了这台服务器的设置;已经开着就不再叠一层
576
+ if (desktopSettings) {
577
+ desktop.onOpenSettings(() => {
578
+ if (!document.querySelector(".overlay .settings")) void openSettings();
579
+ });
580
+ }
552
581
  if ("serviceWorker" in navigator) {
553
582
  navigator.serviceWorker.register("/sw.js").catch(() => {});
554
583
  clearAppBadge();
@@ -579,6 +608,13 @@ function renderLayout() {
579
608
  lastDown = { key, at: now };
580
609
  });
581
610
  ui.convList.addEventListener("contextmenu", (e) => {
611
+ // 飞书机器人那一行:右键看这个机器人的所有聊天
612
+ const bot = e.target.closest?.("[data-bot]")?.dataset.bot;
613
+ if (bot) {
614
+ e.preventDefault();
615
+ showMenu({ x: e.clientX, y: e.clientY }, [{ label: "历史会话…", onclick: () => openBotConversations(bot) }]);
616
+ return;
617
+ }
582
618
  const key = e.target.closest?.("[data-key]")?.dataset.key;
583
619
  if (!key || !S.convs.has(key)) return;
584
620
  e.preventDefault();
@@ -1025,6 +1061,7 @@ function renderSidebar() {
1025
1061
  "button",
1026
1062
  {
1027
1063
  class: `conv-item${cur?.channel === "feishu" && cur.channelId === bot.id ? " active" : ""}`,
1064
+ "data-bot": bot.id,
1028
1065
  title: latest ? `飞书 · ${bot.name}\n${agentLabel(latest.agent)} · ${latest.cwd}` : `飞书 · ${bot.name}\n还没有会话`,
1029
1066
  onclick: () => (latest ? open(latest.key) : toast("这个机器人还没有会话,先在飞书里给它发条消息")),
1030
1067
  },
@@ -1117,13 +1154,9 @@ function renderSidebar() {
1117
1154
 
1118
1155
  function renderTopbar() {
1119
1156
  const c = S.current ? S.convs.get(S.current) : null;
1120
- const more = h("button", { class: "icon-btn", title: "更多", onclick: (e) => openMoreMenu(e.currentTarget) }, icon("more"));
1121
1157
  fill(ui.topbar,
1122
- ...[
1123
- h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
1124
- h("div", { class: "top-title" }, c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null),
1125
- c ? more : null,
1126
- ].filter(Boolean),
1158
+ h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
1159
+ h("div", { class: "top-title" }, c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null),
1127
1160
  );
1128
1161
  renderContext();
1129
1162
  }
@@ -1380,24 +1413,6 @@ function closeMenu() {
1380
1413
  removeEventListener("blur", closeMenu);
1381
1414
  }
1382
1415
 
1383
- function openMoreMenu(anchor) {
1384
- const c = S.convs.get(S.current);
1385
- if (!c) return;
1386
- showMenu(anchor, [
1387
- // 飞书机器人在侧栏只占一行(点开最近活跃的那个聊天),它的其他聊天从这里找
1388
- ...(c.channel === "feishu" ? [{ label: "历史会话…", onclick: () => openBotConversations(c.channelId) }] : []),
1389
- { label: "开新会话(清空上下文)", onclick: () => doAction("new") },
1390
- c.canFork
1391
- ? { label: "分叉成新对话", onclick: () => forkConversation() }
1392
- : { label: "分叉当前会话", onclick: () => doAction("fork") },
1393
- { label: "切换工作目录…", onclick: () => changeCwd() },
1394
- { label: "查看状态", onclick: () => showStatus() },
1395
- "sep",
1396
- { label: "重命名", onclick: () => renameConv(S.current) },
1397
- ...(c.channel === "web" ? [{ label: "从列表删除", danger: true, onclick: () => deleteConv() }] : []),
1398
- ]);
1399
- }
1400
-
1401
1416
  /** 侧栏会话的右键菜单:分叉 / 重命名 / 删除 */
1402
1417
  function openConvContextMenu(key, at) {
1403
1418
  const c = S.convs.get(key);
@@ -2041,7 +2056,8 @@ function renderComposer() {
2041
2056
  },
2042
2057
  });
2043
2058
  ui.sendBtn = h("button", { class: "send-btn", title: "发送", onclick: () => (S.busy && !hasDraft() ? stopTurn() : sendMessage()) });
2044
- ui.micBtn = SpeechRec
2059
+ // 桌面端(Electron)里不放语音输入
2060
+ ui.micBtn = SpeechRec && !desktop
2045
2061
  ? h("button", { class: "icon-btn mic-btn", title: "语音输入", onclick: toggleVoice }, icon("mic"))
2046
2062
  : null;
2047
2063
  ui.ctxBar = h("div", { class: "ctx-bar" });
@@ -2945,15 +2961,6 @@ async function changeCwd() {
2945
2961
  await load(startRel);
2946
2962
  }
2947
2963
 
2948
- async function showStatus() {
2949
- try {
2950
- const data = await api(`/api/conversations/${encodeURIComponent(S.current)}/status`);
2951
- modal({ title: "状态", body: h("div", { class: "modal-body" }, renderMarkdown("```\n" + data.text + "\n```")) });
2952
- } catch (err) {
2953
- toast(err.message);
2954
- }
2955
- }
2956
-
2957
2964
  /** 重命名:小弹窗,回车保存、Esc 取消,打开时全选原名 */
2958
2965
  function renameConv(key = S.current) {
2959
2966
  const c = S.convs.get(key);
package/web/style.css CHANGED
@@ -550,6 +550,11 @@ button.conv-group:hover {
550
550
  margin-left: auto;
551
551
  }
552
552
 
553
+ /* 桌面端里:额度在菜单栏、设置在顶栏「更多」里,侧栏底部整条不要 */
554
+ .desktop-settings .side-bottom {
555
+ display: none;
556
+ }
557
+
553
558
  /* ---- 侧栏底部的额度 ---- */
554
559
  .quota {
555
560
  position: relative;
@@ -2139,6 +2144,13 @@ button.ctx-item:hover {
2139
2144
  z-index: 5;
2140
2145
  }
2141
2146
 
2147
+ .conn-banner.clickable {
2148
+ border: 0;
2149
+ font: inherit;
2150
+ font-size: 12px;
2151
+ cursor: pointer;
2152
+ }
2153
+
2142
2154
  /* ---------- 弹层 ---------- */
2143
2155
 
2144
2156
  .overlay {