agent-loft 0.2.37 → 0.2.39

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 CHANGED
@@ -48,7 +48,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
48
48
  - 输入框里的小圆环显示上下文窗口占用;模型按钮显示实际的模型和思考程度(如「Opus 5.5 high」)。
49
49
  - 滚动和 ChatGPT 一样:发出的问题顶到最上面、下面留白,回复长到屏幕底部才跟着往下滚;回复过程中自己滚动了就不再自动滚,下次发消息再恢复。
50
50
  - 定时发送:右上角「更多」→「定时发送」,默认排在 5 小时额度重置后 5 分钟(弹窗里提示 5 小时 / 7 天额度的重置时间),由服务端到点发出,关掉页面也会发。适合额度用完、要等刷新才能接着干活的时候。
51
- - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
51
+ - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶;排好的顺序跟着账号走、各设备一致,新消息不会打乱),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
52
52
  - 回复过程中接着发的消息排队,标着「排队中」,上一条回复完自动接着处理;点「打断并发送」停掉正在进行的回复、马上处理这条;点停止则连排着的一起取消(标「没有发出」)。
53
53
  - 太长的消息先收起,点「展开显示」看全文;对话里有 3 条以上消息时,右边有一列快速定位,鼠标移上去列出每条消息的开头,点一下跳过去。
54
54
  - 侧栏按页加载:「最近」先显示 10 条、每个项目目录先显示 5 条,点「展开显示」每次再拉 10 条。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.37",
3
+ "version": "0.2.39",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -26,6 +26,7 @@ import {
26
26
  } from "../../core/users.js";
27
27
  import type { SavedAttachment } from "../../core/types.js";
28
28
  import { dailyUsage } from "../../core/usage.js";
29
+ import { appendDirOrder, getDirOrder, setDirOrder } from "../../core/ui-prefs.js";
29
30
  import {
30
31
  addMemoryItem,
31
32
  clearMemory,
@@ -558,6 +559,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
558
559
  // 展开着的目录带上翻页游标(没带 next 的目录前端还没拉过)
559
560
  dirs: dirsOf(all).map((d) => (dirPages.has(d.dir) ? { ...d, next: dirPages.get(d.dir)!.next } : d)),
560
561
  },
562
+ // 「项目」目录的顺序(拖拽排的),跟着账号走;null = 还没存过
563
+ dirOrder: getDirOrder(user.id) ?? null,
561
564
  pending,
562
565
  user: publicMe(user),
563
566
  remote: remoteStatus(user),
@@ -566,6 +569,14 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
566
569
  return;
567
570
  }
568
571
 
572
+ // ----- 侧栏「项目」目录顺序:order 整份换掉,append 只把新目录接在最后 -----
573
+ if (p === "/api/ui/dir-order" && method === "PUT") {
574
+ const body = await readJsonBody<{ order?: unknown; append?: unknown }>(req);
575
+ const order = body.order !== undefined ? setDirOrder(user.id, body.order) : appendDirOrder(user.id, body.append);
576
+ sendJson(res, 200, { order });
577
+ return;
578
+ }
579
+
569
580
  // ----- 系统通知(Web Push) -----
570
581
  if (p === "/api/push" && method === "GET") {
571
582
  sendJson(res, 200, { publicKey: vapidPublicKey(), subscriptions: listSubscriptions(pushOwnedBy(user.id)) });
@@ -6,6 +6,7 @@ import { deleteUserTasks } from "./tasks.js";
6
6
  import { purgeUserSkills } from "./skills.js";
7
7
  import type { Conversation } from "./store.js";
8
8
  import { getUser, legacyOwnerId } from "./users.js";
9
+ import { deleteUiPrefs } from "./ui-prefs.js";
9
10
 
10
11
  /**
11
12
  * 这条对话该用谁的记忆。比会话列表的归属规则(users.ts 的 ownedBy)更严:
@@ -45,4 +46,5 @@ export function purgeUserData(userId: string): void {
45
46
  unlinkUser(userId);
46
47
  deleteUserTasks(userId);
47
48
  purgeUserSkills(userId);
49
+ deleteUiPrefs(userId);
48
50
  }
@@ -0,0 +1,65 @@
1
+ // 每个用户的界面偏好,跟着账号走(换手机、换浏览器、桌面端都一样)。
2
+ // 现在只有侧栏「项目」目录的顺序:用户拖过的顺序是固定的,新消息不会把目录顶到前面。
3
+ import path from "node:path";
4
+ import { dataDir, readJson, writeJsonAtomic } from "../home.js";
5
+
6
+ type Prefs = { dirOrder?: string[] };
7
+
8
+ const prefsPath = path.join(dataDir, "ui-prefs.json");
9
+ let cache: Record<string, Prefs> | null = null;
10
+
11
+ function load(): Record<string, Prefs> {
12
+ if (!cache) {
13
+ const raw = readJson<unknown>(prefsPath, {});
14
+ cache = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, Prefs>) : {};
15
+ }
16
+ return cache;
17
+ }
18
+
19
+ function save(): void {
20
+ writeJsonAtomic(prefsPath, cache ?? {});
21
+ }
22
+
23
+ const MAX_DIRS = 2000;
24
+
25
+ function cleanDirs(list: unknown): string[] {
26
+ if (!Array.isArray(list)) return [];
27
+ return [...new Set(list.filter((d): d is string => typeof d === "string" && d.length > 0 && d.length < 4096))].slice(0, MAX_DIRS);
28
+ }
29
+
30
+ /** 目录顺序;undefined = 这个用户还没在服务端存过(前端可以把本地的迁上来) */
31
+ export function getDirOrder(userId: string): string[] | undefined {
32
+ return load()[userId]?.dirOrder;
33
+ }
34
+
35
+ /** 整份换掉(拖拽、置顶、按活跃重排) */
36
+ export function setDirOrder(userId: string, order: unknown): string[] {
37
+ const all = load();
38
+ const dirOrder = cleanDirs(order);
39
+ all[userId] = { ...all[userId], dirOrder };
40
+ save();
41
+ return dirOrder;
42
+ }
43
+
44
+ /** 新出现的目录接在最后。只追加没有的,不动已有的名次 —— 别的设备同时在拖也不会被盖掉 */
45
+ export function appendDirOrder(userId: string, dirs: unknown): string[] {
46
+ const all = load();
47
+ const cur = all[userId]?.dirOrder ?? [];
48
+ const add = cleanDirs(dirs).filter((d) => !cur.includes(d));
49
+ if (add.length === 0 && all[userId]?.dirOrder) return cur;
50
+ const dirOrder = [...cur, ...add].slice(0, MAX_DIRS);
51
+ all[userId] = { ...all[userId], dirOrder };
52
+ save();
53
+ return dirOrder;
54
+ }
55
+
56
+ export function deleteUiPrefs(userId: string): void {
57
+ const all = load();
58
+ if (!(userId in all)) return;
59
+ delete all[userId];
60
+ save();
61
+ }
62
+
63
+ export function resetUiPrefsForTest(): void {
64
+ cache = null;
65
+ }
@@ -49,6 +49,35 @@
49
49
  display: none;
50
50
  }
51
51
 
52
+ /* 「/」「@」补全:键盘弹出后屏幕只剩一半,菜单改成一行一条、最多露出五条多一点,
53
+ 露出半条好让人知道还能往下滑 */
54
+ .slash-menu {
55
+ bottom: calc(100% + 4px);
56
+ max-height: min(196px, 30vh);
57
+ padding: 4px;
58
+ }
59
+
60
+ .slash-item {
61
+ display: flex;
62
+ align-items: baseline;
63
+ gap: 8px;
64
+ padding: 7px 8px;
65
+ }
66
+
67
+ .slash-head {
68
+ flex: none;
69
+ max-width: 60%;
70
+ }
71
+
72
+ .slash-args {
73
+ display: none;
74
+ }
75
+
76
+ .slash-desc {
77
+ flex: 1;
78
+ min-width: 0;
79
+ }
80
+
52
81
  .ctx-bar {
53
82
  margin: 0 10px -22px;
54
83
  font-size: 13px;
@@ -14,7 +14,6 @@ import {
14
14
  reportDesktop,
15
15
  resetProjectOrder,
16
16
  setSideWidth,
17
- sideState,
18
17
  startMarquee,
19
18
  startSideResize,
20
19
  stopMarquee,
@@ -197,7 +196,7 @@ function renderLayout() {
197
196
  ...(i > 0 ? [{ label: "置顶", onclick: () => moveProjectDir(dir, null) }, { label: "上移", onclick: () => moveProjectDir(dir, projDirs[i - 1]) }] : []),
198
197
  ...(i >= 0 && i < projDirs.length - 1 ? [{ label: "下移", onclick: () => moveProjectDir(dir, projDirs[i + 1], true) }] : []),
199
198
  ];
200
- const reset = sideState.order.length ? [{ label: "恢复按最近活跃排序", onclick: resetProjectOrder }] : [];
199
+ const reset = projDirs.length > 1 ? [{ label: "按最近活跃重排", onclick: resetProjectOrder }] : [];
201
200
  const items = moves.length && reset.length ? [...moves, "sep", ...reset] : [...moves, ...reset];
202
201
  if (!items.length) return;
203
202
  e.preventDefault();
@@ -111,6 +111,13 @@ export async function openConversation(key) {
111
111
  if (!known) renderSidebar();
112
112
  S.allowedAgents = data.allowedAgents;
113
113
  if (Number.isFinite(data.serverTime)) setServerTime(data.serverTime);
114
+ // 等历史的时候 WS 刚连上(刚打开页面),订阅回放的进行中这一轮可能已经先画进来了:
115
+ // 下面整页重画、live 会再重放一遍,这里得一起清掉,不然 S.turnAi 还指着被清掉的那个占位,
116
+ // 回复全写进一个不在页面上的元素里
117
+ S.items = [];
118
+ S.turnAi = null;
119
+ turnStartAt = null;
120
+ ui.pinEl = null;
114
121
  fill(ui.msgInner, );
115
122
  // 历史一次性加进来,最后再统一排版,免得每加一条就强制排版一次
116
123
  bulk = true;
@@ -195,17 +195,47 @@ function agentShort(id) {
195
195
  export const sideState = { open: [], more: [], folded: [], order: [], ...JSON.parse(localStorage.getItem("aim.side") || "{}") };
196
196
 
197
197
  /**
198
- * 「项目」目录的顺序:没手动排过时按最近活跃;拖过之后按 sideState.order,
199
- * 之后新出现的目录排在已排好的后面(同样按活跃),不会挤掉用户置顶的目录
198
+ * 「项目」目录的顺序固定按 sideState.order,新消息不会把目录顶到前面。
199
+ * 顺序存在服务端、跟着账号走(换设备也一样);新出现的目录接在最后(几个一起出现时按活跃排),
200
+ * 接上之后名次也就定了
200
201
  */
201
202
  function orderProjectDirs(dirs) {
202
203
  const rank = new Map(sideState.order.map((d, i) => [d, i]));
203
204
  const ranked = dirs.filter((d) => rank.has(d)).sort((a, b) => rank.get(a) - rank.get(b));
204
- return [...ranked, ...dirs.filter((d) => !rank.has(d))];
205
+ const fresh = dirs.filter((d) => !rank.has(d));
206
+ if (fresh.length && orderSynced) {
207
+ sideState.order = [...sideState.order, ...fresh];
208
+ saveSideState();
209
+ void putDirOrder({ append: fresh });
210
+ }
211
+ return [...ranked, ...fresh];
212
+ }
213
+
214
+ /** 拿到服务端存的顺序之前不往上写,免得这台设备本地的旧顺序盖掉别处排好的 */
215
+ let orderSynced = false;
216
+
217
+ function saveSideState() {
218
+ localStorage.setItem("aim.side", JSON.stringify(sideState));
219
+ }
220
+
221
+ async function putDirOrder(body) {
222
+ try {
223
+ const data = await api("/api/ui/dir-order", { method: "PUT", body });
224
+ // 追加时以服务端的为准(别的设备可能刚排过)
225
+ if (body.append && data?.order && data.order.join("\n") !== sideState.order.join("\n")) {
226
+ sideState.order = data.order;
227
+ saveSideState();
228
+ scheduleSidebar();
229
+ }
230
+ } catch (err) {
231
+ toast(`保存目录顺序失败:${err.message}`);
232
+ }
205
233
  }
206
234
 
207
235
  /** 当前侧栏里显示的目录顺序(renderSidebar 时记下,拖拽 / 右键菜单用) */
208
236
  export let projDirs = [];
237
+ /** 同一批目录按最近活跃的顺序(「按最近活跃重排」用) */
238
+ let projDirsByActivity = [];
209
239
 
210
240
  /** 把目录 dir 挪到 target 前面(after 为 true 时挪到后面);target 为 null 表示挪到最前 */
211
241
  export function moveProjectDir(dir, target, after = false) {
@@ -215,14 +245,18 @@ export function moveProjectDir(dir, target, after = false) {
215
245
  if (idx < 0) return;
216
246
  list.splice(idx + (after ? 1 : 0), 0, dir);
217
247
  // 暂时没显示出来的目录(会话都被隐藏了之类)保留原来的名次,排在后面
218
- sideState.order = [...list, ...sideState.order.filter((d) => !list.includes(d))];
219
- localStorage.setItem("aim.side", JSON.stringify(sideState));
220
- renderSidebar();
248
+ setProjectOrder([...list, ...sideState.order.filter((d) => !list.includes(d))]);
221
249
  }
222
250
 
251
+ /** 按最近活跃重排一次,之后照样固定 */
223
252
  export function resetProjectOrder() {
224
- sideState.order = [];
225
- localStorage.setItem("aim.side", JSON.stringify(sideState));
253
+ setProjectOrder([...projDirsByActivity, ...sideState.order.filter((d) => !projDirsByActivity.includes(d))]);
254
+ }
255
+
256
+ function setProjectOrder(order) {
257
+ sideState.order = order;
258
+ saveSideState();
259
+ if (orderSynced) void putDirOrder({ order });
226
260
  renderSidebar();
227
261
  }
228
262
 
@@ -288,6 +322,17 @@ export function applyBootstrap(data) {
288
322
  st.next = pages.full ? null : sideState.open.includes(dir) ? (fetched.get(dir) ?? null) : undefined;
289
323
  }
290
324
  for (const [dir, next] of fetched) dirState(dir).next = next;
325
+ // 老版本服务端没有这个字段:顺序只存在本地
326
+ if ("dirOrder" in data) {
327
+ if (data.dirOrder) {
328
+ sideState.order = data.dirOrder;
329
+ saveSideState();
330
+ } else if (sideState.order.length) {
331
+ // 以前只存在这台设备上的顺序,第一次搬到服务端
332
+ void putDirOrder({ order: sideState.order });
333
+ }
334
+ orderSynced = true;
335
+ }
291
336
  }
292
337
 
293
338
  /** 拉下一页,合进 S.convs。失败了不自动重试(否则每次重绘都去拉),点「展开显示」再试 */
@@ -433,7 +478,7 @@ export function renderSidebar() {
433
478
  if (btn) nodes.push(btn);
434
479
  }
435
480
 
436
- // ---- 项目:Web 会话按工作目录分组,目录按最近活跃排序 ----
481
+ // ---- 项目:Web 会话按工作目录分组,目录按用户排的顺序(见 orderProjectDirs) ----
437
482
  const byDir = new Map();
438
483
  for (const c of visible.filter((c) => c.channel === "web")) {
439
484
  if (!byDir.has(c.cwd)) byDir.set(c.cwd, []);
@@ -452,7 +497,8 @@ export function renderSidebar() {
452
497
  for (const [dir, convs] of byDir) dirAt.set(dir, Math.max(dirAt.get(dir) ?? 0, convs[0].activeAt));
453
498
  const projFolded = sideState.folded.includes("@projects");
454
499
  if (dirAt.size) nodes.push(groupHead("项目", "@projects", projFolded));
455
- projDirs = orderProjectDirs([...dirAt.keys()].sort((a, b) => dirAt.get(b) - dirAt.get(a)));
500
+ projDirsByActivity = [...dirAt.keys()].sort((a, b) => dirAt.get(b) - dirAt.get(a));
501
+ projDirs = orderProjectDirs(projDirsByActivity);
456
502
  if (dirAt.size && !projFolded) {
457
503
  const clearDrop = () => ui.convList.querySelectorAll(".drop-before, .drop-after").forEach((el) => el.classList.remove("drop-before", "drop-after"));
458
504
  const endDrag = () => {