agent-loft 0.2.37 → 0.2.38
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 +1 -1
- package/package.json +1 -1
- package/src/channels/web/api.ts +11 -0
- package/src/core/personal.ts +2 -0
- package/src/core/ui-prefs.ts +65 -0
- package/web/css/mobile.css +29 -0
- package/web/views/layout.js +1 -2
- package/web/views/sidebar.js +56 -10
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
|
|
51
|
+
- 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶;排好的顺序跟着账号走、各设备一致,新消息不会打乱),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
|
|
52
52
|
- 回复过程中接着发的消息排队,标着「排队中」,上一条回复完自动接着处理;点「打断并发送」停掉正在进行的回复、马上处理这条;点停止则连排着的一起取消(标「没有发出」)。
|
|
53
53
|
- 太长的消息先收起,点「展开显示」看全文;对话里有 3 条以上消息时,右边有一列快速定位,鼠标移上去列出每条消息的开头,点一下跳过去。
|
|
54
54
|
- 侧栏按页加载:「最近」先显示 10 条、每个项目目录先显示 5 条,点「展开显示」每次再拉 10 条。
|
package/package.json
CHANGED
package/src/channels/web/api.ts
CHANGED
|
@@ -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)) });
|
package/src/core/personal.ts
CHANGED
|
@@ -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
|
+
}
|
package/web/css/mobile.css
CHANGED
|
@@ -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;
|
package/web/views/layout.js
CHANGED
|
@@ -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 =
|
|
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();
|
package/web/views/sidebar.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = () => {
|