agent-loft 0.2.8 → 0.2.10

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
@@ -1,9 +1,22 @@
1
+ <p align="center"><img src="web/icon.svg" width="120" alt="agent-loft"></p>
2
+
1
3
  # agent-loft
2
4
 
3
- 用飞书(以后还有 Telegram 等)和 Web 页面,驱动本机的 **Claude Code** 和 **Codex**。
5
+ **多个 agent 的工作台。** 把本机的 **Claude Code**、**Codex**(以后还有更多)放到同一张台面上统一管理,用飞书(以后还有 Telegram 等)和 Web 页面随时随地驱动它们。
4
6
 
5
7
  由 `feishu-claude-bridge` 和 `feishu-codex-bridge` 合并而来:一个进程、一份配置,飞书和网页共用同一套会话、审批和模型切换。
6
8
 
9
+ ## 理念
10
+
11
+ 这里的 Loft 不是阁楼,是**工作台**:agent 是干活的人,agent-loft 是它们共用的那张台面。
12
+
13
+ - **多 agent,一张台面**:Claude Code、Codex 各有所长,不该各开一个窗口、各记一套会话。它们在这里共用工作目录、会话列表、审批和模型切换,同一条对话里随时换人接手,各自的上下文都还在。
14
+ - **统一管理,而不是再造一个 agent**:agent-loft 不自己做模型、不改 agent 的行为,只负责把官方的 agent 接进来、管起来。新的 agent 接一个驱动就能上台。
15
+ - **人在哪,台面就在哪**:电脑上用网页,手机上用 PWA 或飞书。同一个会话、同一个授权请求,在哪个渠道处理都行,其他地方自动同步。
16
+ - **跑在你自己的机器上**:代码、凭据和会话都留在本机,agent-loft 只是一层安全的遥控。
17
+
18
+ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张台面上。
19
+
7
20
  ## 能做什么
8
21
 
9
22
  - **多个 agent**:Claude Code(官方 Claude Agent SDK)、Codex(官方 `codex app-server`)。在同一条会话里 `/agent` 切换,各 agent 的上下文各自保留。
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "type": "module",
5
- "description": "把飞书(以及以后的 Telegram 等)和 Web 页面桥接到本机 Claude Code / Codex:聊天驱动、卡片审批、会话与模型切换",
5
+ "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
7
7
  "feishu",
8
8
  "lark",
@@ -10,6 +10,7 @@ import {
10
10
  } from "@anthropic-ai/claude-agent-sdk";
11
11
  import os from "node:os";
12
12
  import type { AgentCommand } from "../../core/command-catalog.js";
13
+ import { splitAttachmentNote } from "../../core/uploads.js";
13
14
  import { config } from "../../core/config.js";
14
15
  import type {
15
16
  AgentDriver,
@@ -78,15 +79,6 @@ function blocksOf(message: unknown): Block[] {
78
79
  return Array.isArray(content) ? (content as Block[]) : [];
79
80
  }
80
81
 
81
- /** 用户消息里我们自己拼进去的附件说明,历史里只留一行简述 */
82
- function cleanUserText(text: string): string {
83
- const cut = [text.indexOf("[用户通过飞书发来了以下附件"), text.indexOf("[用户发来了以下附件")]
84
- .filter((i) => i >= 0)
85
- .sort((a, b) => a - b)[0];
86
- if (cut === undefined) return text;
87
- const files = (text.slice(cut).match(/^- /gm) ?? []).length;
88
- return `${text.slice(0, cut).trim()}${files ? `\n\n📎 ${files} 个附件` : ""}`.trim();
89
- }
90
82
 
91
83
  export class ClaudeDriver implements AgentDriver {
92
84
  readonly id = "claude";
@@ -146,10 +138,17 @@ export class ClaudeDriver implements AgentDriver {
146
138
  if (msg.type === "user") {
147
139
  const texts = blocks.filter((b) => b.type === "text" && b.text).map((b) => b.text!);
148
140
  if (texts.length === 0) continue; // 纯 tool_result
149
- const text = cleanUserText(texts.join("\n"));
150
- if (!text || text.startsWith("<command-") || text.startsWith("<local-command")) continue;
141
+ // 我们自己拼进去的附件清单:从正文里拆出来,单独作为附件给前端展示
142
+ const { text, attachments } = splitAttachmentNote(texts.join("\n"));
143
+ if (!text && attachments.length === 0) continue;
144
+ if (text.startsWith("<command-") || text.startsWith("<local-command")) continue;
151
145
  flush();
152
- out.push({ role: "user", text, ...(Number.isFinite(ts) ? { ts } : {}) });
146
+ out.push({
147
+ role: "user",
148
+ text,
149
+ ...(attachments.length ? { attachments } : {}),
150
+ ...(Number.isFinite(ts) ? { ts } : {}),
151
+ });
153
152
  } else if (msg.type === "assistant") {
154
153
  assistant ??= { role: "assistant", text: "", steps: [] };
155
154
  // 这一轮最后一条助手消息:分叉到这里为止,时间也取它的
@@ -6,6 +6,7 @@ import { describeRateLimits, readRateLimits, toQuota } from "./account.js";
6
6
  import { diagnose } from "./errors.js";
7
7
  import { CodexSession, summarize } from "./session.js";
8
8
  import { config, onConfigChange } from "../../core/config.js";
9
+ import { splitAttachmentNote } from "../../core/uploads.js";
9
10
  import type {
10
11
  AgentDriver,
11
12
  AgentEvents,
@@ -114,12 +115,19 @@ export class CodexDriver implements AgentDriver {
114
115
  const ts = turn.startedAt ? turn.startedAt * 1000 : undefined;
115
116
  for (const item of turn.items ?? []) {
116
117
  if (item.type === "userMessage") {
117
- const text = (item.content ?? [])
118
- .map((c) => (c.type === "text" ? (c.text ?? "") : c.type === "localImage" ? "📎 图片" : ""))
118
+ const raw = (item.content ?? [])
119
+ .map((c) => (c.type === "text" ? (c.text ?? "") : ""))
119
120
  .filter(Boolean)
120
121
  .join("\n");
121
- const cut = text.indexOf("[用户");
122
- out.push({ role: "user", text: cut > 0 ? text.slice(0, cut).trim() || text : text, ...(ts ? { ts } : {}) });
122
+ // 附件清单拆出来单独展示;没有清单的图片(别处发的)只能留个占位
123
+ const { text, attachments } = splitAttachmentNote(raw);
124
+ const bareImages = attachments.length ? 0 : (item.content ?? []).filter((c) => c.type === "localImage").length;
125
+ out.push({
126
+ role: "user",
127
+ text: bareImages ? [text, ...Array(bareImages).fill("📎 图片")].filter(Boolean).join("\n") : text,
128
+ ...(attachments.length ? { attachments } : {}),
129
+ ...(ts ? { ts } : {}),
130
+ });
123
131
  continue;
124
132
  }
125
133
  assistant ??= { role: "assistant", text: "", steps: [] };
@@ -1,13 +1,14 @@
1
1
  // Web 的 REST 接口。实时的东西(事件流、发消息、点授权)走 WebSocket,见 server.ts。
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
+ import path from "node:path";
4
5
  import type { IncomingMessage, ServerResponse } from "node:http";
5
6
  import { AGENT_IDS, config, listWorkspaceDirs, resolveWorkspacePath, saveConfig, type AppConfig } from "../../core/config.js";
6
7
  import { hub } from "../../core/hub.js";
7
8
  import { interactions } from "../../core/interactions.js";
8
9
  import { approve, listApproved, listPending, revoke } from "../../core/pairing.js";
9
10
  import { getConversation, updateConversation } from "../../core/store.js";
10
- import { saveBuffer } from "../../core/uploads.js";
11
+ import { resolveUploadFile, saveBuffer, sniff } from "../../core/uploads.js";
11
12
  import type { SavedAttachment } from "../../core/types.js";
12
13
  import { listAllowlist, setAllowlist } from "../../agents/claude/permissions.js";
13
14
  import { feishuBots } from "../feishu/index.js";
@@ -26,7 +27,7 @@ import {
26
27
  validateSession,
27
28
  verifyPassword,
28
29
  } from "./auth.js";
29
- import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, sendJson } from "./http.js";
30
+ import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, SECURITY_HEADERS, sendJson } from "./http.js";
30
31
  import { addSubscription, listSubscriptions, pushToAll, removeSubscription, vapidPublicKey } from "./push.js";
31
32
 
32
33
  export const COOKIE = "aim_session";
@@ -56,6 +57,36 @@ setInterval(() => {
56
57
  for (const [id, entry] of uploads) if (now - entry.at > 60 * 60 * 1000) uploads.delete(id);
57
58
  }, 10 * 60 * 1000).unref();
58
59
 
60
+ /**
61
+ * 附件文件。只有按文件头确认是常见图片的才 inline 显示(缩略图 / 大图要用),其余一律当下载,
62
+ * 免得用户传上来的 HTML / SVG 在本站域名下被浏览器当网页执行
63
+ */
64
+ const INLINE_IMAGE = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
65
+
66
+ function serveUpload(res: ServerResponse, requested: string, download: boolean): void {
67
+ const file = requested ? resolveUploadFile(requested) : undefined;
68
+ if (!file) throw new HttpError(404, "文件不存在(可能会话重置时已清理)");
69
+ const head = Buffer.alloc(12);
70
+ const fd = fs.openSync(file, "r");
71
+ try {
72
+ fs.readSync(fd, head, 0, 12, 0);
73
+ } finally {
74
+ fs.closeSync(fd);
75
+ }
76
+ const type = sniff(head)?.mediaType;
77
+ const inline = !download && !!type && INLINE_IMAGE.has(type);
78
+ const name = path.basename(file);
79
+ res.writeHead(200, {
80
+ ...SECURITY_HEADERS,
81
+ "Content-Type": inline ? type! : "application/octet-stream",
82
+ "Content-Length": fs.statSync(file).size,
83
+ "Content-Disposition": `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`,
84
+ "Content-Security-Policy": "default-src 'none'; img-src 'self'; sandbox",
85
+ "Cache-Control": "private, max-age=3600",
86
+ });
87
+ fs.createReadStream(file).pipe(res);
88
+ }
89
+
59
90
  // ---------- 飞书扫码创建应用 ----------
60
91
 
61
92
  type Registration = {
@@ -256,6 +287,12 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
256
287
 
257
288
  if (!authed(req)) throw new HttpError(401, "请先登录");
258
289
 
290
+ // ----- 附件预览 / 下载:只给上传目录里的文件 -----
291
+ if (p === "/api/file" && method === "GET") {
292
+ serveUpload(res, url.searchParams.get("path") ?? "", url.searchParams.get("download") === "1");
293
+ return;
294
+ }
295
+
259
296
  // ----- 总览 -----
260
297
  if (p === "/api/bootstrap" && method === "GET") {
261
298
  sendJson(res, 200, {
@@ -17,6 +17,18 @@ const MAX_PER_SESSION = 1000;
17
17
  /** 存下来比对用的正文结尾长度 */
18
18
  const TAIL = 80;
19
19
 
20
+ /** 用时按大小换单位:12.3秒 / 3分12秒 / 1小时5分 */
21
+ export function formatDuration(ms: number): string {
22
+ const sec = ms / 1000;
23
+ if (sec < 60) return `${sec.toFixed(1)}秒`;
24
+ const total = Math.round(sec);
25
+ const h = Math.floor(total / 3600);
26
+ const m = Math.floor((total % 3600) / 60);
27
+ const s = total % 60;
28
+ if (h > 0) return `${h}小时${m ? `${m}分` : ""}`;
29
+ return `${m}分${s ? `${s}秒` : ""}`;
30
+ }
31
+
20
32
  let store: Store | null = null;
21
33
  let timer: NodeJS.Timeout | null = null;
22
34
 
package/src/core/hub.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import crypto from "node:crypto";
11
11
  import { EventEmitter } from "node:events";
12
12
  import { config, onConfigChange, resolveWorkspacePath } from "./config.js";
13
- import { attachFooters, copyFooters, recordFooter } from "./footers.js";
13
+ import { attachFooters, copyFooters, formatDuration, recordFooter } from "./footers.js";
14
14
  import { interactions, SETTLEMENT_LABEL } from "./interactions.js";
15
15
  import {
16
16
  agentBinding,
@@ -23,9 +23,10 @@ import {
23
23
  updateConversation,
24
24
  type Conversation,
25
25
  } from "./store.js";
26
- import { clearUploads, ensureUploadsDir, KIND_LABEL } from "./uploads.js";
26
+ import { attachmentInfo, clearUploads, ensureUploadsDir, KIND_LABEL } from "./uploads.js";
27
27
  import type {
28
28
  AgentDriver,
29
+ AttachmentInfo,
29
30
  AgentSession,
30
31
  ContextUsage,
31
32
  ApprovalDecision,
@@ -52,7 +53,7 @@ export type Origin = {
52
53
  };
53
54
 
54
55
  export type ConvEvent =
55
- | { type: "user"; text: string; attachments: string[]; origin: Origin }
56
+ | { type: "user"; text: string; attachments: string[]; files?: AttachmentInfo[]; origin: Origin }
56
57
  | { type: "turn_start" }
57
58
  | { type: "step"; kind: StepKind; text: string }
58
59
  | { type: "delta"; text: string }
@@ -481,7 +482,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
481
482
  onText: (text) => alive() && this.emitEvent(key, { type: "text", text }),
482
483
  onResult: (result) => {
483
484
  if (!alive()) return;
484
- const parts = [`用时 ${(result.durationMs / 1000).toFixed(1)}s`];
485
+ const parts = [`用时 ${formatDuration(result.durationMs)}`];
485
486
  if (result.costUsd !== undefined) parts.push(`约 $${result.costUsd.toFixed(4)}`);
486
487
  if (result.tokens !== undefined) parts.push(`${result.tokens} tokens`);
487
488
  const footer = parts.join(" ");
@@ -677,6 +678,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
677
678
  type: "user" as const,
678
679
  text: input.text,
679
680
  attachments: input.attachments.map((a) => `${KIND_LABEL[a.kind]} ${a.name}`),
681
+ files: input.attachments.map(attachmentInfo),
680
682
  origin,
681
683
  };
682
684
  const by = `${origin.name ?? "用户"}以消息作答`;
package/src/core/types.ts CHANGED
@@ -18,6 +18,9 @@ export type SavedAttachment = {
18
18
 
19
19
  export type UserInput = { text: string; attachments: SavedAttachment[] };
20
20
 
21
+ /** 给前端展示用的附件:能预览 / 下载(path 只在上传目录里有效,见 uploads.resolveUploadFile) */
22
+ export type AttachmentInfo = { kind: AttachmentKind; name: string; path: string; size?: number };
23
+
21
24
  // ---------- 交互(审批 / 提问) ----------
22
25
 
23
26
  /**
@@ -145,6 +148,8 @@ export type HistoryMessage = {
145
148
  anchor?: string;
146
149
  /** 助手消息:这一轮的用时 / 费用 / tokens(agent-loft 自己记的,agent 记录里没有) */
147
150
  footer?: string;
151
+ /** 用户消息:带的附件(从消息里的附件清单解析出来) */
152
+ attachments?: AttachmentInfo[];
148
153
  };
149
154
 
150
155
  export type SessionOptions = {
@@ -5,7 +5,7 @@
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { dataDir } from "../home.js";
8
- import type { AttachmentKind, SavedAttachment } from "./types.js";
8
+ import type { AttachmentInfo, AttachmentKind, SavedAttachment } from "./types.js";
9
9
 
10
10
  const uploadsRoot = path.join(dataDir, "uploads");
11
11
 
@@ -181,6 +181,55 @@ export function formatSize(bytes: number): string {
181
181
  return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
182
182
  }
183
183
 
184
+ /** 附件清单的开头(新旧两种写法),历史里靠它把清单从用户正文里切出去 */
185
+ const NOTE_HEADS = ["[用户发来了以下附件", "[用户通过飞书发来了以下附件"];
186
+ const LABEL_KIND = Object.fromEntries(Object.entries(KIND_LABEL).map(([k, v]) => [v, k])) as Record<string, AttachmentKind>;
187
+
188
+ /**
189
+ * 从发给 agent 的消息里拆出用户原文和附件清单(describeAttachments 写的那段)。
190
+ * 清单每行形如「- 图片1:/路径/a.png(12.3KB,…)」
191
+ */
192
+ export function splitAttachmentNote(text: string): { text: string; attachments: AttachmentInfo[] } {
193
+ const cut = NOTE_HEADS.map((h) => text.indexOf(h))
194
+ .filter((i) => i >= 0)
195
+ .sort((a, b) => a - b)[0];
196
+ if (cut === undefined) return { text, attachments: [] };
197
+ const attachments: AttachmentInfo[] = [];
198
+ for (const line of text.slice(cut).split("\n")) {
199
+ const m = /^- (图片|文件|语音|视频)\d+:(.+)(([^()]*))\s*$/.exec(line.trim());
200
+ if (!m) continue;
201
+ const file = m[2]!.trim();
202
+ const size = parseSize(m[3]!.split(",")[0]!);
203
+ attachments.push({ kind: LABEL_KIND[m[1]!] ?? "file", name: path.basename(file), path: file, ...(size ? { size } : {}) });
204
+ }
205
+ return { text: text.slice(0, cut).trim(), attachments };
206
+ }
207
+
208
+ function parseSize(s: string): number | undefined {
209
+ const m = /^([\d.]+)(B|KB|MB)$/.exec(s.trim());
210
+ if (!m) return undefined;
211
+ return Math.round(Number(m[1]) * (m[2] === "MB" ? 1024 * 1024 : m[2] === "KB" ? 1024 : 1));
212
+ }
213
+
214
+ export function attachmentInfo(a: SavedAttachment): AttachmentInfo {
215
+ return { kind: a.kind, name: a.name, path: a.path, size: a.size };
216
+ }
217
+
218
+ /**
219
+ * 前端要预览 / 下载的附件:只认上传目录里真实存在的普通文件(解析符号链接后再判断,
220
+ * 防止用 ../ 或链接读到别处)。不合法返回 undefined
221
+ */
222
+ export function resolveUploadFile(p: string): string | undefined {
223
+ try {
224
+ const root = fs.realpathSync(uploadsRoot);
225
+ const real = fs.realpathSync(path.resolve(p));
226
+ if (!real.startsWith(root + path.sep)) return undefined;
227
+ return fs.statSync(real).isFile() ? real : undefined;
228
+ } catch {
229
+ return undefined;
230
+ }
231
+ }
232
+
184
233
  /** 附件清单的文字说明,两个 agent 都用它告诉模型文件在哪。 */
185
234
  export function describeAttachments(
186
235
  saved: SavedAttachment[],
package/web/app.js CHANGED
@@ -108,6 +108,23 @@ async function api(path, opts = {}) {
108
108
  }
109
109
 
110
110
  /** 回复下面的时间:今天只显示时分,今年显示月日,再早带上年份 */
111
+ /** 用时按大小换单位:12.3秒 / 3分12秒 / 1小时5分(和服务端 formatDuration 一致) */
112
+ function formatDuration(ms) {
113
+ const sec = ms / 1000;
114
+ if (sec < 60) return `${sec.toFixed(1)}秒`;
115
+ const total = Math.round(sec);
116
+ const hr = Math.floor(total / 3600);
117
+ const m = Math.floor((total % 3600) / 60);
118
+ const s = total % 60;
119
+ if (hr > 0) return `${hr}小时${m ? `${m}分` : ""}`;
120
+ return `${m}分${s ? `${s}秒` : ""}`;
121
+ }
122
+
123
+ /** 老版本记下的脚注是「用时 1234.5s」,显示时也换成时 / 分 / 秒 */
124
+ function tidyFooter(footer) {
125
+ return footer?.replace(/用时 ([\d.]+)s/, (_, s) => `用时 ${formatDuration(Number(s) * 1000)}`) ?? "";
126
+ }
127
+
111
128
  function clockTime(ts) {
112
129
  const d = new Date(ts);
113
130
  const now = new Date();
@@ -492,6 +509,12 @@ function renderLayout() {
492
509
  }
493
510
  lastDown = { key, at: now };
494
511
  });
512
+ ui.convList.addEventListener("contextmenu", (e) => {
513
+ const key = e.target.closest?.("[data-key]")?.dataset.key;
514
+ if (!key || !S.convs.has(key)) return;
515
+ e.preventDefault();
516
+ openConvContextMenu(key, { x: e.clientX, y: e.clientY });
517
+ });
495
518
  ui.convList.addEventListener("pointerover", (e) => {
496
519
  const title = e.target.closest?.(".conv-item")?.querySelector(".title");
497
520
  if (title && title !== marquee.el) startMarquee(title);
@@ -862,7 +885,7 @@ function renderSidebar() {
862
885
  keys.some((k) => askKeys.has(k))
863
886
  ? h("span", { class: "dot ask", title: "等你处理" })
864
887
  : keys.some((k) => S.convs.get(k)?.busy)
865
- ? h("span", { class: "dot busy" })
888
+ ? h("span", { class: "conv-spin", title: "进行中" })
866
889
  : null;
867
890
  const open = (key) => {
868
891
  closeSide();
@@ -878,7 +901,7 @@ function renderSidebar() {
878
901
  {
879
902
  class: `conv-item${cls}${c.key === S.current ? " active" : ""}`,
880
903
  "data-key": c.key,
881
- title: `${convTitle(c)}${forkNote(c)}\n${detail}\n双击重命名`,
904
+ title: `${convTitle(c)}${forkNote(c)}\n${detail}\n双击重命名,右键更多操作`,
882
905
  onclick: () => open(c.key),
883
906
  },
884
907
  orphan ? h("span", { class: "brand-icon lead fork-mark", html: ICON.fork }) : null,
@@ -1206,7 +1229,7 @@ function showMenu(anchor, items) {
1206
1229
  : h(
1207
1230
  "button",
1208
1231
  {
1209
- class: it.danger ? "btn danger" : "",
1232
+ class: it.danger ? "danger" : "",
1210
1233
  onclick: () => {
1211
1234
  closeMenu();
1212
1235
  it.onclick();
@@ -1217,12 +1240,21 @@ function showMenu(anchor, items) {
1217
1240
  ),
1218
1241
  );
1219
1242
  document.body.append(menu);
1220
- const r = anchor.getBoundingClientRect();
1243
+ // anchor 可以是元素(贴着它下沿弹出),也可以是 {x, y}(右键菜单,贴着鼠标弹出)
1244
+ const r = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, bottom: anchor.y };
1221
1245
  const w = menu.offsetWidth;
1222
- menu.style.top = `${Math.min(r.bottom + 4, innerHeight - menu.offsetHeight - 8)}px`;
1246
+ menu.style.top = `${Math.max(8, Math.min(r.bottom + (anchor instanceof Element ? 4 : 0), innerHeight - menu.offsetHeight - 8))}px`;
1223
1247
  menu.style.left = `${Math.max(8, Math.min(r.left, innerWidth - w - 8))}px`;
1224
1248
  ui.menu = menu;
1225
- setTimeout(() => document.addEventListener("pointerdown", onOutsideMenu), 0);
1249
+ setTimeout(() => {
1250
+ document.addEventListener("pointerdown", onOutsideMenu);
1251
+ document.addEventListener("keydown", onMenuKey);
1252
+ addEventListener("blur", closeMenu);
1253
+ }, 0);
1254
+ }
1255
+
1256
+ function onMenuKey(e) {
1257
+ if (e.key === "Escape") closeMenu();
1226
1258
  }
1227
1259
 
1228
1260
  function onOutsideMenu(e) {
@@ -1234,13 +1266,16 @@ function closeMenu() {
1234
1266
  ui.menu?.remove();
1235
1267
  ui.menu = null;
1236
1268
  document.removeEventListener("pointerdown", onOutsideMenu);
1269
+ document.removeEventListener("keydown", onMenuKey);
1270
+ removeEventListener("blur", closeMenu);
1237
1271
  }
1238
1272
 
1239
1273
  function openMoreMenu(anchor) {
1240
1274
  const c = S.convs.get(S.current);
1241
1275
  if (!c) return;
1242
1276
  showMenu(anchor, [
1243
- { label: "历史会话…", onclick: () => openSessions(false) },
1277
+ // 飞书机器人在侧栏只占一行(点开最近活跃的那个聊天),它的其他聊天从这里找
1278
+ ...(c.channel === "feishu" ? [{ label: "历史会话…", onclick: () => openBotConversations(c.channelId) }] : []),
1244
1279
  { label: "开新会话(清空上下文)", onclick: () => doAction("new") },
1245
1280
  c.canFork
1246
1281
  ? { label: "分叉成新对话", onclick: () => forkConversation() }
@@ -1253,6 +1288,59 @@ function openMoreMenu(anchor) {
1253
1288
  ]);
1254
1289
  }
1255
1290
 
1291
+ /** 侧栏会话的右键菜单:分叉 / 重命名 / 删除 */
1292
+ function openConvContextMenu(key, at) {
1293
+ const c = S.convs.get(key);
1294
+ if (!c) return;
1295
+ showMenu(at, [
1296
+ {
1297
+ label: "分叉",
1298
+ onclick: async () => {
1299
+ if (c.canFork) return forkConversation(undefined, key);
1300
+ // 不支持另开一条的:只能在原对话里分叉,先切过去
1301
+ if (key !== S.current) await openConversation(key);
1302
+ await doAction("fork");
1303
+ },
1304
+ },
1305
+ { label: "重命名", onclick: () => renameConv(key) },
1306
+ ...(c.channel === "web" ? [{ label: "删除", danger: true, onclick: () => deleteConv(key) }] : []),
1307
+ ]);
1308
+ }
1309
+
1310
+ /** 飞书机器人的历史会话:这个机器人的所有聊天,按活跃时间排,点一下打开 */
1311
+ function openBotConversations(botId) {
1312
+ const bot = S.bots.find((b) => b.id === botId);
1313
+ const convs = [...S.convs.values()]
1314
+ .filter((c) => c.channel === "feishu" && c.channelId === botId && !c.hidden)
1315
+ .sort((a, b) => b.activeAt - a.activeAt);
1316
+ const body = h("div", { class: "modal-body" });
1317
+ const close = modal({ title: `历史会话${bot ? ` · ${bot.name}` : ""}`, body });
1318
+ fill(
1319
+ body,
1320
+ ...(convs.length === 0
1321
+ ? [h("div", { class: "muted" }, "还没有会话")]
1322
+ : convs.map((c) =>
1323
+ h(
1324
+ "button",
1325
+ {
1326
+ class: `list-item${c.key === S.current ? " on" : ""}`,
1327
+ onclick: () => {
1328
+ close();
1329
+ if (c.key !== S.current) void openConversation(c.key);
1330
+ },
1331
+ },
1332
+ h(
1333
+ "div",
1334
+ { class: "grow" },
1335
+ h("div", { class: "t" }, convTitle(c)),
1336
+ h("div", { class: "s" }, `${relTime(c.activeAt)} · ${agentLabel(c.agent)} · ${c.cwd}`),
1337
+ ),
1338
+ c.key === S.current ? "当前" : c.busy ? h("span", { class: "conv-spin", title: "进行中" }) : null,
1339
+ ),
1340
+ )),
1341
+ );
1342
+ }
1343
+
1256
1344
  // ---------- 打开会话 ----------
1257
1345
 
1258
1346
  function newDraft() {
@@ -1328,7 +1416,7 @@ async function openConversation(key) {
1328
1416
  S.allowedAgents = data.allowedAgents;
1329
1417
  fill(ui.msgInner, );
1330
1418
  for (const m of data.messages) {
1331
- if (m.role === "user") addItem({ type: "user", text: m.text, files: [], origin: null });
1419
+ if (m.role === "user") addItem({ type: "user", text: m.text, files: m.attachments ?? [], origin: null });
1332
1420
  else addItem({ type: "ai", steps: m.steps ?? [], committed: m.text, partial: "", done: true, footer: m.footer, at: m.ts, anchor: m.anchor });
1333
1421
  }
1334
1422
  S.seq.set(key, 0);
@@ -1408,16 +1496,96 @@ function updateItem(item) {
1408
1496
  });
1409
1497
  }
1410
1498
 
1499
+ const fileUrl = (f, download) => `/api/file?path=${encodeURIComponent(f.path)}${download ? "&download=1" : ""}`;
1500
+
1501
+ function fmtSize(n) {
1502
+ if (!n) return "";
1503
+ if (n < 1024) return `${n} B`;
1504
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
1505
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
1506
+ }
1507
+
1508
+ /** 非图片附件:卡片(图标 + 文件名 + 大小),点一下下载 */
1509
+ function fileCard(f, gone) {
1510
+ const ext = (f.name.match(/\.([^.]+)$/)?.[1] ?? "").slice(0, 4).toUpperCase();
1511
+ return h(
1512
+ gone ? "span" : "a",
1513
+ {
1514
+ class: `att-file${gone ? " gone" : ""}`,
1515
+ ...(gone ? { title: "文件已清理(开新会话 / 切换目录时会清掉附件)" } : { href: fileUrl(f, true), download: f.name, title: `下载 ${f.name}` }),
1516
+ },
1517
+ h("span", { class: "att-ext" }, ext || "FILE"),
1518
+ h(
1519
+ "span",
1520
+ { class: "att-meta" },
1521
+ h("span", { class: "att-name" }, f.name),
1522
+ h("span", { class: "att-size" }, gone ? "已清理" : fmtSize(f.size) || "下载"),
1523
+ ),
1524
+ );
1525
+ }
1526
+
1527
+ /**
1528
+ * 用户消息带的附件,排在气泡上面,顺序和发送时一致。
1529
+ * files:新的是 {kind, name, path, size} 对象;老事件里只有「图片 a.png」这样的标签,只能显示名字
1530
+ */
1531
+ function renderUserFiles(files) {
1532
+ if (!files?.length) return null;
1533
+ return h(
1534
+ "div",
1535
+ { class: "msg-atts" },
1536
+ files.map((f) => {
1537
+ if (typeof f === "string") return h("span", { class: "att-file gone" }, h("span", { class: "att-meta" }, h("span", { class: "att-name" }, f)));
1538
+ if (f.kind !== "image") return fileCard(f);
1539
+ const box = h("button", { class: "att-img", title: f.name, onclick: () => openLightbox(fileUrl(f), f.name, fileUrl(f, true)) });
1540
+ const img = h("img", { src: fileUrl(f), alt: f.name, loading: "lazy" });
1541
+ // 文件已经被清理:退回成「已清理」的文件卡片
1542
+ img.onerror = () => box.replaceWith(fileCard(f, true));
1543
+ box.append(img);
1544
+ return box;
1545
+ }),
1546
+ );
1547
+ }
1548
+
1549
+ /** 点缩略图看大图:点任意处或按 Esc 关闭 */
1550
+ function openLightbox(src, name, download) {
1551
+ const close = () => {
1552
+ overlay.remove();
1553
+ document.removeEventListener("keydown", onKey, true);
1554
+ };
1555
+ const onKey = (e) => {
1556
+ if (e.key === "Escape") {
1557
+ e.preventDefault();
1558
+ close();
1559
+ }
1560
+ };
1561
+ const overlay = h(
1562
+ "div",
1563
+ { class: "lightbox", onclick: close },
1564
+ h("img", { src, alt: name ?? "" }),
1565
+ h(
1566
+ "div",
1567
+ { class: "lightbox-bar", onclick: (e) => e.stopPropagation() },
1568
+ name ? h("span", { class: "lightbox-name" }, name) : null,
1569
+ download ? h("a", { class: "lightbox-dl", href: download, download: name ?? "" }, "下载") : null,
1570
+ ),
1571
+ );
1572
+ document.body.append(overlay);
1573
+ document.addEventListener("keydown", onKey, true);
1574
+ }
1575
+
1411
1576
  function renderItem(item) {
1412
1577
  switch (item.type) {
1413
- case "user":
1578
+ case "user": {
1579
+ const origin = item.origin && item.origin.channel !== "web" ? `来自${item.origin.name ?? item.origin.channel}` : "";
1414
1580
  return h(
1415
1581
  "div",
1416
- { class: "msg-user" },
1417
- item.origin && item.origin.channel !== "web" ? h("span", { class: "origin" }, `来自${item.origin.name ?? item.origin.channel}`) : null,
1418
- item.text,
1419
- item.files?.length ? h("div", { class: "files" }, `📎 ${item.files.join("、")}`) : null,
1582
+ { class: "msg-user-group" },
1583
+ // 只发了附件、没有文字:「来自飞书」挂在附件上面
1584
+ !item.text && origin ? h("span", { class: "origin" }, origin) : null,
1585
+ renderUserFiles(item.files),
1586
+ item.text ? h("div", { class: "msg-user" }, origin ? h("span", { class: "origin" }, origin) : null, item.text) : null,
1420
1587
  );
1588
+ }
1421
1589
  case "ai":
1422
1590
  return renderAi(item);
1423
1591
  case "notice":
@@ -1445,10 +1613,14 @@ function renderAi(item) {
1445
1613
  h(
1446
1614
  "summary",
1447
1615
  {},
1448
- working ? h("span", { class: "spinner" }) : null,
1449
- working
1450
- ? `正在处理… ${item.steps[item.steps.length - 1]}`
1451
- : `${item.segment ? "" : "已完成 "}${item.steps.length} 步${item.segment ? "过程" : ""}`,
1616
+ h(
1617
+ "span",
1618
+ { class: `steps-text${working ? " shimmer" : ""}` },
1619
+ working
1620
+ ? item.steps[item.steps.length - 1].split("\n")[0]
1621
+ : `${item.segment ? "" : "已完成 "}${item.steps.length} 步${item.segment ? "过程" : ""}`,
1622
+ ),
1623
+ h("span", { class: "chev" }, "›"),
1452
1624
  ),
1453
1625
  h("ol", {}, item.steps.map((s) => h("li", {}, s))),
1454
1626
  )
@@ -1457,7 +1629,7 @@ function renderAi(item) {
1457
1629
  "div",
1458
1630
  { class: "msg-ai" },
1459
1631
  steps,
1460
- body ? renderMarkdown(body) : working && item.steps.length === 0 ? h("div", { class: "thinking" }, h("span", { class: "spinner" }), "思考中…") : null,
1632
+ body ? renderMarkdown(body) : working && item.steps.length === 0 ? h("div", { class: "thinking" }, h("span", { class: "shimmer" }, "思考中…")) : null,
1461
1633
  item.error ? h("div", { class: "error-box" }, item.error) : null,
1462
1634
  item.done && !item.segment && (body || item.footer)
1463
1635
  ? h(
@@ -1468,7 +1640,7 @@ function renderAi(item) {
1468
1640
  ? h("button", { class: "icon-btn", title: "从这里分叉成新对话", onclick: () => forkConversation(item.anchor) }, icon("fork"))
1469
1641
  : null,
1470
1642
  item.footer || item.at
1471
- ? h("span", { class: "footer-note" }, [item.footer, item.at ? clockTime(item.at) : ""].filter(Boolean).join(" "))
1643
+ ? h("span", { class: "footer-note" }, [item.at ? clockTime(item.at) : "", tidyFooter(item.footer)].filter(Boolean).join(" "))
1472
1644
  : null,
1473
1645
  )
1474
1646
  : null,
@@ -1496,7 +1668,7 @@ function applyEvent(env) {
1496
1668
  S.items = S.items.filter((i) => i !== S.turnAi);
1497
1669
  S.turnAi = null;
1498
1670
  }
1499
- addItem({ type: "user", text: ev.text, files: ev.attachments, origin: ev.origin });
1671
+ addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
1500
1672
  currentAi();
1501
1673
  return;
1502
1674
  }
@@ -1924,25 +2096,28 @@ function setBusy(busy) {
1924
2096
 
1925
2097
  function renderChips() {
1926
2098
  if (!ui.chips) return;
2099
+ const remove = (u) => {
2100
+ S.uploads = S.uploads.filter((x) => x !== u);
2101
+ if (u.url) URL.revokeObjectURL(u.url);
2102
+ renderChips();
2103
+ };
1927
2104
  fill(ui.chips,
1928
2105
  ...S.uploads.map((u) =>
1929
- h(
1930
- "span",
1931
- { class: "chip" },
1932
- u.uploading ? h("span", { class: "spinner" }) : u.kind === "image" ? "🖼" : "📄",
1933
- h("span", {}, u.name),
1934
- h(
1935
- "button",
1936
- {
1937
- title: "移除",
1938
- onclick: () => {
1939
- S.uploads = S.uploads.filter((x) => x !== u);
1940
- renderChips();
1941
- },
1942
- },
1943
- "×",
1944
- ),
1945
- ),
2106
+ u.url
2107
+ ? h(
2108
+ "span",
2109
+ { class: `chip-img${u.uploading ? " uploading" : ""}`, title: u.name },
2110
+ h("img", { src: u.url, alt: u.name, onclick: () => openLightbox(u.url, u.name) }),
2111
+ u.uploading ? h("span", { class: "spinner" }) : null,
2112
+ h("button", { class: "chip-x", title: "移除", onclick: () => remove(u) }, "×"),
2113
+ )
2114
+ : h(
2115
+ "span",
2116
+ { class: "chip" },
2117
+ u.uploading ? h("span", { class: "spinner" }) : "📄",
2118
+ h("span", {}, u.name),
2119
+ h("button", { title: "移除", onclick: () => remove(u) }, "×"),
2120
+ ),
1946
2121
  ),
1947
2122
  );
1948
2123
  updateSendBtn();
@@ -1997,6 +2172,8 @@ async function addFiles(files) {
1997
2172
  }
1998
2173
  for (const file of files) {
1999
2174
  const u = { name: file.name || "粘贴的图片.png", kind: file.type.startsWith("image/") ? "image" : "file", uploading: true };
2175
+ // 图片用本地文件直接做缩略图,不用等上传完、也不用再从服务器取
2176
+ if (u.kind === "image") u.url = URL.createObjectURL(file);
2000
2177
  S.uploads.push(u);
2001
2178
  renderChips();
2002
2179
  try {
@@ -2005,9 +2182,11 @@ async function addFiles(files) {
2005
2182
  body: file,
2006
2183
  headers: { "Content-Type": file.type || "application/octet-stream" },
2007
2184
  });
2008
- Object.assign(u, { id: res.id, kind: res.kind, uploading: false });
2185
+ Object.assign(u, { id: res.id, kind: res.kind, uploading: false, serverName: res.name });
2186
+ if (u.kind !== "image" && u.url) (URL.revokeObjectURL(u.url), delete u.url);
2009
2187
  } catch (err) {
2010
2188
  S.uploads = S.uploads.filter((x) => x !== u);
2189
+ if (u.url) URL.revokeObjectURL(u.url);
2011
2190
  toast(`${u.name} 上传失败:${err.message}`);
2012
2191
  }
2013
2192
  renderChips();
@@ -2040,6 +2219,7 @@ async function sendMessage() {
2040
2219
  }
2041
2220
  ui.input.value = "";
2042
2221
  autosize();
2222
+ for (const u of S.uploads) if (u.url) URL.revokeObjectURL(u.url);
2043
2223
  S.uploads = [];
2044
2224
  renderChips();
2045
2225
  updateSendBtn();
@@ -2281,10 +2461,10 @@ async function doAction(action, value, extra = {}) {
2281
2461
  }
2282
2462
 
2283
2463
  /** 分叉成一条新对话(anchor 给了就到那条回复为止),然后切过去。原对话不动 */
2284
- async function forkConversation(anchor) {
2285
- if (!S.current) return;
2464
+ async function forkConversation(anchor, key = S.current) {
2465
+ if (!key) return;
2286
2466
  try {
2287
- const res = await api(`/api/conversations/${encodeURIComponent(S.current)}/fork`, {
2467
+ const res = await api(`/api/conversations/${encodeURIComponent(key)}/fork`, {
2288
2468
  method: "POST",
2289
2469
  body: anchor ? { anchor } : {},
2290
2470
  });
@@ -2690,13 +2870,65 @@ function renameConv(key = S.current) {
2690
2870
  input.select();
2691
2871
  }
2692
2872
 
2693
- async function deleteConv() {
2694
- if (!confirm("从列表里删除这条会话?(agent 那边的记录还在,之后能从「历史会话」恢复)")) return;
2873
+ /**
2874
+ * 确认弹窗(替代浏览器自带的 confirm),样式和重命名弹窗一致。
2875
+ * 回车确认、Esc / 点外面取消;resolve 成 true / false
2876
+ */
2877
+ function confirmDialog({ title, message, sub, confirmText = "确定", danger = false }) {
2878
+ return new Promise((resolve) => {
2879
+ const ok = h("button", { class: `btn ${danger ? "danger-fill" : "primary"}` }, confirmText);
2880
+ const cancel = h("button", { class: "btn ghost" }, "取消");
2881
+ const done = (v) => {
2882
+ overlay.classList.add("leaving");
2883
+ setTimeout(() => overlay.remove(), 120);
2884
+ document.removeEventListener("keydown", onKey, true);
2885
+ resolve(v);
2886
+ };
2887
+ const onKey = (e) => {
2888
+ if (e.key === "Escape") {
2889
+ e.preventDefault();
2890
+ done(false);
2891
+ } else if (e.key === "Enter" && !e.isComposing) {
2892
+ e.preventDefault();
2893
+ done(true);
2894
+ }
2895
+ };
2896
+ ok.onclick = () => done(true);
2897
+ cancel.onclick = () => done(false);
2898
+ const overlay = h(
2899
+ "div",
2900
+ { class: "overlay dialog-overlay", onclick: (e) => e.target === overlay && done(false) },
2901
+ h(
2902
+ "div",
2903
+ { class: "dialog confirm-dialog" },
2904
+ h("h3", {}, title),
2905
+ sub ? h("p", { class: "dialog-sub" }, sub) : null,
2906
+ message ? h("p", { class: "dialog-msg" }, message) : null,
2907
+ h("div", { class: "dialog-foot" }, cancel, ok),
2908
+ ),
2909
+ );
2910
+ document.body.append(overlay);
2911
+ document.addEventListener("keydown", onKey, true);
2912
+ ok.focus();
2913
+ });
2914
+ }
2915
+
2916
+ async function deleteConv(key = S.current) {
2917
+ if (!key) return;
2918
+ const c = S.convs.get(key);
2919
+ const sure = await confirmDialog({
2920
+ title: "删除会话?",
2921
+ sub: c ? [agentIcon(c.agent), h("span", {}, `${convTitle(c)} · ${baseName(c.cwd)}`)] : null,
2922
+ message: "只从列表里移除,agent 那边的会话记录还在。",
2923
+ confirmText: "删除",
2924
+ danger: true,
2925
+ });
2926
+ if (!sure) return;
2695
2927
  try {
2696
- await api(`/api/conversations/${encodeURIComponent(S.current)}`, { method: "DELETE" });
2697
- const c = S.convs.get(S.current);
2928
+ await api(`/api/conversations/${encodeURIComponent(key)}`, { method: "DELETE" });
2698
2929
  if (c) c.hidden = true;
2699
- newDraft();
2930
+ if (key === S.current) newDraft();
2931
+ else renderSidebar();
2700
2932
  } catch (err) {
2701
2933
  toast(err.message);
2702
2934
  }
@@ -3183,7 +3415,7 @@ function commandsTab(cfg, panel) {
3183
3415
  {
3184
3416
  class: "btn",
3185
3417
  onclick: async () => {
3186
- if (!confirm(`把 ${agentLabel(agent)} 的命令顺序和显示恢复成默认?`)) return;
3418
+ if (!(await confirmDialog({ title: "恢复默认?", message: `把 ${agentLabel(agent)} 的命令顺序和显示恢复成默认。`, confirmText: "恢复默认" }))) return;
3187
3419
  await save({ order: [], hidden: [] });
3188
3420
  await load(agent);
3189
3421
  },
@@ -3442,7 +3674,7 @@ function feishuTab(cfg, panel) {
3442
3674
  {
3443
3675
  class: "btn danger",
3444
3676
  onclick: async () => {
3445
- if (!confirm(`删除机器人「${b.name}」?`)) return;
3677
+ if (!(await confirmDialog({ title: `删除机器人「${b.name}」?`, confirmText: "删除", danger: true }))) return;
3446
3678
  bots.splice(i, 1);
3447
3679
  if (!b._new) await saveConfig(cfg, { feishu: { bots } });
3448
3680
  render();
@@ -3700,7 +3932,7 @@ async function systemTab(panel) {
3700
3932
  class: "btn",
3701
3933
  disabled: !sys.underSystemd,
3702
3934
  onclick: async () => {
3703
- if (!confirm("重启 agent-loft?进行中的对话会被中断。")) return;
3935
+ if (!(await confirmDialog({ title: "重启 agent-loft?", message: "进行中的对话会被中断。", confirmText: "重启", danger: true }))) return;
3704
3936
  try {
3705
3937
  await api("/api/system/restart", { method: "POST" });
3706
3938
  toast("正在重启…");
Binary file
@@ -1,13 +1,12 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
- <defs>
3
- <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
- <stop offset="0" stop-color="#3b82f6"/>
5
- <stop offset="1" stop-color="#06b6d4"/>
6
- </linearGradient>
7
- </defs>
2
+ <!-- Agent Loft:agent 工作台 —— 一大两小几颗星(多个 agent)站在同一张台面上,统一管理 -->
3
+ <defs><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="104" y1="104" x2="408" y2="408"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#06b6d4"/></linearGradient></defs>
8
4
  <rect width="512" height="512" fill="#fff"/>
9
- <g transform="translate(256 262) scale(1.2) translate(-256 -262)">
10
- <path d="M196 140H316A64 64 0 0 1 380 204V276A64 64 0 0 1 316 340H236L180 380C173 385 164 380 166 372L173 336A64 64 0 0 1 132 276V204A64 64 0 0 1 196 140Z" fill="none" stroke="url(#g)" stroke-width="26" stroke-linejoin="round"/>
11
- <path d="M256 178C262 222 274 234 318 240C274 246 262 258 256 302C250 258 238 246 194 240C238 234 250 222 256 178Z" fill="url(#g)"/>
5
+ <g transform="translate(256 257) scale(0.8) translate(-256 -257)">
6
+ <path d="M256 296L404 364L256 432L108 364Z" fill="url(#g)" fill-opacity=".3"/>
7
+ <path d="M256 252L404 320L256 388L108 320Z" fill="url(#g)"/>
8
+ <path d="M256 82C268.92 145.08 268.92 145.08 332 158C268.92 170.92 268.92 170.92 256 234C243.08 170.92 243.08 170.92 180 158C243.08 145.08 243.08 145.08 256 82Z" fill="url(#g)"/>
9
+ <path d="M152 186C158.46 217.54 158.46 217.54 190 224C158.46 230.46 158.46 230.46 152 262C145.54 230.46 145.54 230.46 114 224C145.54 217.54 145.54 217.54 152 186Z" fill="url(#g)" fill-opacity=".85"/>
10
+ <path d="M360 186C366.46 217.54 366.46 217.54 398 224C366.46 230.46 366.46 230.46 360 262C353.54 230.46 353.54 230.46 322 224C353.54 217.54 353.54 217.54 360 186Z" fill="url(#g)" fill-opacity=".85"/>
12
11
  </g>
13
12
  </svg>
package/web/icon.svg CHANGED
@@ -1,12 +1,12 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
- <defs>
3
- <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
- <stop offset="0" stop-color="#3b82f6"/>
5
- <stop offset="1" stop-color="#06b6d4"/>
6
- </linearGradient>
7
- </defs>
8
- <g transform="translate(256 262) scale(1.4) translate(-256 -262)">
9
- <path d="M196 140H316A64 64 0 0 1 380 204V276A64 64 0 0 1 316 340H236L180 380C173 385 164 380 166 372L173 336A64 64 0 0 1 132 276V204A64 64 0 0 1 196 140Z" fill="none" stroke="url(#g)" stroke-width="26" stroke-linejoin="round"/>
10
- <path d="M256 178C262 222 274 234 318 240C274 246 262 258 256 302C250 258 238 246 194 240C238 234 250 222 256 178Z" fill="url(#g)"/>
2
+ <!-- Agent Loft:agent 工作台 —— 一大两小几颗星(多个 agent)站在同一张台面上,统一管理 -->
3
+ <defs><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="104" y1="104" x2="408" y2="408"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#06b6d4"/></linearGradient></defs>
4
+ <rect width="512" height="512" rx="116" fill="#fff"/>
5
+ <g transform="translate(256 257) scale(1.0) translate(-256 -257)">
6
+ <path d="M256 296L404 364L256 432L108 364Z" fill="url(#g)" fill-opacity=".3"/>
7
+ <path d="M256 252L404 320L256 388L108 320Z" fill="url(#g)"/>
8
+ <path d="M256 82C268.92 145.08 268.92 145.08 332 158C268.92 170.92 268.92 170.92 256 234C243.08 170.92 243.08 170.92 180 158C243.08 145.08 243.08 145.08 256 82Z" fill="url(#g)"/>
9
+ <path d="M152 186C158.46 217.54 158.46 217.54 190 224C158.46 230.46 158.46 230.46 152 262C145.54 230.46 145.54 230.46 114 224C145.54 217.54 145.54 217.54 152 186Z" fill="url(#g)" fill-opacity=".85"/>
10
+ <path d="M360 186C366.46 217.54 366.46 217.54 398 224C366.46 230.46 366.46 230.46 360 262C353.54 230.46 353.54 230.46 322 224C353.54 217.54 353.54 217.54 360 186Z" fill="url(#g)" fill-opacity=".85"/>
11
11
  </g>
12
12
  </svg>
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "Agent Loft",
3
3
  "short_name": "Agent Loft",
4
- "description": "在手机和电脑上驱动本机的 Claude Code / Codex",
4
+ "description": "多个 agent 的工作台:在手机和电脑上统一管理本机的 Claude Code / Codex",
5
5
  "start_url": "/",
6
6
  "scope": "/",
7
7
  "display": "standalone",
package/web/style.css CHANGED
@@ -516,9 +516,15 @@ button.conv-group:hover {
516
516
  flex-shrink: 0;
517
517
  }
518
518
 
519
- .dot.busy {
520
- background: var(--accent);
521
- animation: pulse 1.2s infinite;
519
+ /* 进行中:Codex 那种细圈转圈,一段弧在淡色圆环上转 */
520
+ .conv-spin {
521
+ width: 13px;
522
+ height: 13px;
523
+ flex-shrink: 0;
524
+ border-radius: 50%;
525
+ border: 1.5px solid color-mix(in srgb, var(--fg-faint) 28%, transparent);
526
+ border-top-color: var(--fg-soft);
527
+ animation: spin 0.75s linear infinite;
522
528
  }
523
529
 
524
530
  .dot.ask {
@@ -749,7 +755,6 @@ button.conv-group:hover {
749
755
  gap: 6px;
750
756
  padding: calc(8px + env(safe-area-inset-top)) 12px 8px;
751
757
  min-height: 52px;
752
- border-bottom: 1px solid transparent;
753
758
  /*
754
759
  * iOS 26+ 的主屏 PWA 会在页面顶端叠一层系统的模糊渐变(Liquid Glass 边缘效果),CSS 关不掉。
755
760
  * WebKit 只在顶端有「fixed / sticky、铺满宽度、高于 10px、纯色不透明」的元素时跳过它,
@@ -1008,6 +1013,109 @@ button.conv-group:hover {
1008
1013
  border-bottom-color: var(--fg);
1009
1014
  }
1010
1015
 
1016
+ /* 用户消息:附件在上、气泡在下,整组靠右 */
1017
+ .msg-user-group {
1018
+ display: flex;
1019
+ flex-direction: column;
1020
+ align-items: flex-end;
1021
+ gap: 6px;
1022
+ min-width: 0;
1023
+ }
1024
+
1025
+ .msg-user-group > .origin {
1026
+ font-size: 12px;
1027
+ color: var(--fg-faint);
1028
+ }
1029
+
1030
+ .msg-atts {
1031
+ display: flex;
1032
+ flex-wrap: wrap;
1033
+ justify-content: flex-end;
1034
+ align-items: flex-end;
1035
+ gap: 6px;
1036
+ max-width: 85%;
1037
+ }
1038
+
1039
+ .att-img {
1040
+ width: 128px;
1041
+ height: 128px;
1042
+ padding: 0;
1043
+ border: 1px solid var(--border);
1044
+ border-radius: 12px;
1045
+ overflow: hidden;
1046
+ background: var(--bg-soft);
1047
+ cursor: zoom-in;
1048
+ }
1049
+
1050
+ .att-img img {
1051
+ width: 100%;
1052
+ height: 100%;
1053
+ object-fit: cover;
1054
+ display: block;
1055
+ }
1056
+
1057
+ .att-file {
1058
+ display: flex;
1059
+ align-items: center;
1060
+ gap: 10px;
1061
+ width: 240px;
1062
+ max-width: 100%;
1063
+ padding: 8px 12px 8px 8px;
1064
+ border: 1px solid var(--border);
1065
+ border-radius: 12px;
1066
+ background: var(--bg);
1067
+ color: var(--fg);
1068
+ text-decoration: none;
1069
+ transition: background 0.12s;
1070
+ }
1071
+
1072
+ a.att-file:hover {
1073
+ background: var(--bg-soft);
1074
+ }
1075
+
1076
+ .att-file.gone {
1077
+ opacity: 0.6;
1078
+ }
1079
+
1080
+ .att-ext {
1081
+ flex-shrink: 0;
1082
+ width: 36px;
1083
+ height: 36px;
1084
+ border-radius: 8px;
1085
+ display: grid;
1086
+ place-items: center;
1087
+ font-size: 10px;
1088
+ font-weight: 600;
1089
+ letter-spacing: 0.02em;
1090
+ color: #fff;
1091
+ background: var(--accent);
1092
+ }
1093
+
1094
+ .att-meta {
1095
+ display: flex;
1096
+ flex-direction: column;
1097
+ min-width: 0;
1098
+ }
1099
+
1100
+ .att-name {
1101
+ font-size: 13px;
1102
+ overflow: hidden;
1103
+ text-overflow: ellipsis;
1104
+ white-space: nowrap;
1105
+ }
1106
+
1107
+ .att-size {
1108
+ font-size: 12px;
1109
+ color: var(--fg-faint);
1110
+ }
1111
+
1112
+ @media (max-width: 768px) {
1113
+ .att-img {
1114
+ width: 96px;
1115
+ height: 96px;
1116
+ }
1117
+ }
1118
+
1011
1119
  .msg-user {
1012
1120
  align-self: flex-end;
1013
1121
  max-width: 85%;
@@ -1025,11 +1133,6 @@ button.conv-group:hover {
1025
1133
  margin-bottom: 2px;
1026
1134
  }
1027
1135
 
1028
- .msg-user .files {
1029
- margin-top: 6px;
1030
- font-size: 13px;
1031
- color: var(--fg-soft);
1032
- }
1033
1136
 
1034
1137
  .msg-ai {
1035
1138
  display: flex;
@@ -1043,32 +1146,69 @@ button.conv-group:hover {
1043
1146
  color: var(--fg-soft);
1044
1147
  }
1045
1148
 
1149
+ /* 过程摘要:一行,太长就省略;折叠箭头在文字后面 */
1046
1150
  .steps summary {
1047
1151
  cursor: pointer;
1048
1152
  list-style: none;
1049
- display: inline-flex;
1153
+ display: flex;
1050
1154
  align-items: center;
1051
- gap: 6px;
1155
+ gap: 4px;
1156
+ width: fit-content;
1157
+ max-width: 100%;
1052
1158
  padding: 2px 0;
1053
1159
  user-select: none;
1054
1160
  }
1055
1161
 
1056
- .steps summary::-webkit-details-marker {
1057
- display: none;
1162
+ .steps summary .steps-text {
1163
+ min-width: 0;
1164
+ overflow: hidden;
1165
+ text-overflow: ellipsis;
1166
+ white-space: nowrap;
1058
1167
  }
1059
1168
 
1060
- .steps summary::before {
1061
- content: "›";
1169
+ .steps summary .chev {
1170
+ flex-shrink: 0;
1062
1171
  display: inline-block;
1063
1172
  transition: transform 0.15s;
1064
1173
  font-size: 16px;
1065
1174
  line-height: 1;
1066
1175
  }
1067
1176
 
1068
- .steps[open] summary::before {
1177
+ .steps[open] summary .chev {
1069
1178
  transform: rotate(90deg);
1070
1179
  }
1071
1180
 
1181
+ /* 还在跑命令 / 工具:一道光从左扫到右 */
1182
+ .shimmer {
1183
+ color: transparent;
1184
+ background: linear-gradient(
1185
+ 90deg,
1186
+ var(--fg-faint) 0%,
1187
+ var(--fg-faint) 40%,
1188
+ var(--fg) 50%,
1189
+ var(--fg-faint) 60%,
1190
+ var(--fg-faint) 100%
1191
+ )
1192
+ 0 0 / 250% 100%;
1193
+ -webkit-background-clip: text;
1194
+ background-clip: text;
1195
+ animation: shimmer 1.8s linear infinite;
1196
+ }
1197
+
1198
+ @keyframes shimmer {
1199
+ from {
1200
+ background-position: 100% 0;
1201
+ }
1202
+ to {
1203
+ background-position: 0% 0;
1204
+ }
1205
+ }
1206
+
1207
+ .steps summary::-webkit-details-marker {
1208
+ display: none;
1209
+ }
1210
+
1211
+
1072
1212
  .steps ol {
1073
1213
  margin: 4px 0 0;
1074
1214
  padding: 6px 0 6px 14px;
@@ -1901,6 +2041,34 @@ button.ctx-item:hover {
1901
2041
  background: var(--bg-hover);
1902
2042
  }
1903
2043
 
2044
+ .confirm-dialog .dialog-sub {
2045
+ margin-bottom: 0;
2046
+ min-width: 0;
2047
+ }
2048
+
2049
+ .confirm-dialog .dialog-sub > span:last-child {
2050
+ overflow: hidden;
2051
+ text-overflow: ellipsis;
2052
+ white-space: nowrap;
2053
+ }
2054
+
2055
+ .dialog-msg {
2056
+ margin: 12px 0 0;
2057
+ font-size: 14px;
2058
+ line-height: 1.6;
2059
+ color: var(--fg-soft);
2060
+ }
2061
+
2062
+ .dialog-foot .btn.danger-fill {
2063
+ background: var(--danger);
2064
+ border-color: var(--danger);
2065
+ color: #fff;
2066
+ }
2067
+
2068
+ .dialog-foot .btn.danger-fill:hover {
2069
+ background: color-mix(in srgb, var(--danger) 88%, #000);
2070
+ }
2071
+
1904
2072
  .dialog-foot .btn.primary {
1905
2073
  background: var(--accent);
1906
2074
  border-color: var(--accent);
@@ -2100,6 +2268,10 @@ button.list-item:hover {
2100
2268
  background: var(--bg-hover);
2101
2269
  }
2102
2270
 
2271
+ .menu button.danger {
2272
+ color: var(--danger);
2273
+ }
2274
+
2103
2275
  /* 贴着按钮弹出的选择面板(模型 / agent) */
2104
2276
  .popover {
2105
2277
  min-width: 220px;
@@ -2602,3 +2774,132 @@ button.list-item:hover {
2602
2774
  text-overflow: ellipsis;
2603
2775
  white-space: nowrap;
2604
2776
  }
2777
+
2778
+ /* ---------- 滚动条:细、无边框、无轨道,像 Codex ---------- */
2779
+ ::-webkit-scrollbar {
2780
+ width: 8px;
2781
+ height: 8px;
2782
+ }
2783
+ ::-webkit-scrollbar-track,
2784
+ ::-webkit-scrollbar-corner {
2785
+ background: transparent;
2786
+ }
2787
+ ::-webkit-scrollbar-thumb {
2788
+ background: color-mix(in srgb, var(--fg-faint) 32%, transparent);
2789
+ border-radius: 999px;
2790
+ border: 2px solid transparent;
2791
+ background-clip: content-box;
2792
+ }
2793
+ ::-webkit-scrollbar-thumb:hover {
2794
+ background-color: color-mix(in srgb, var(--fg-faint) 55%, transparent);
2795
+ }
2796
+ ::-webkit-scrollbar-button {
2797
+ display: none;
2798
+ }
2799
+ /* 不认 ::-webkit-scrollbar 的浏览器(Firefox)用标准属性;Chrome 里设了标准属性会盖掉上面的,所以分开 */
2800
+ @supports not selector(::-webkit-scrollbar) {
2801
+ * {
2802
+ scrollbar-width: thin;
2803
+ scrollbar-color: color-mix(in srgb, var(--fg-faint) 40%, transparent) transparent;
2804
+ }
2805
+ }
2806
+
2807
+ /* ---------- 图片附件:缩略图 + 点开看大图 ---------- */
2808
+ .chip-img {
2809
+ position: relative;
2810
+ width: 56px;
2811
+ height: 56px;
2812
+ border-radius: 10px;
2813
+ overflow: hidden;
2814
+ background: var(--bg-soft);
2815
+ flex-shrink: 0;
2816
+ }
2817
+ .chip-img img {
2818
+ width: 100%;
2819
+ height: 100%;
2820
+ object-fit: cover;
2821
+ display: block;
2822
+ cursor: zoom-in;
2823
+ }
2824
+ .chip-img.uploading img {
2825
+ opacity: 0.5;
2826
+ }
2827
+ .chip-img .spinner {
2828
+ position: absolute;
2829
+ left: 50%;
2830
+ top: 50%;
2831
+ margin: -6px 0 0 -6px;
2832
+ pointer-events: none;
2833
+ }
2834
+ .chip-img .chip-x {
2835
+ position: absolute;
2836
+ top: 3px;
2837
+ right: 3px;
2838
+ width: 18px;
2839
+ height: 18px;
2840
+ border-radius: 50%;
2841
+ background: rgba(0, 0, 0, 0.6);
2842
+ color: #fff;
2843
+ font-size: 13px;
2844
+ line-height: 18px;
2845
+ text-align: center;
2846
+ padding: 0;
2847
+ opacity: 0;
2848
+ transition: opacity 0.12s;
2849
+ }
2850
+ .chip-img:hover .chip-x {
2851
+ opacity: 1;
2852
+ }
2853
+ @media (hover: none) {
2854
+ .chip-img .chip-x {
2855
+ opacity: 1;
2856
+ }
2857
+ }
2858
+
2859
+
2860
+ .lightbox {
2861
+ position: fixed;
2862
+ inset: 0;
2863
+ z-index: 60;
2864
+ background: rgba(0, 0, 0, 0.82);
2865
+ display: flex;
2866
+ flex-direction: column;
2867
+ align-items: center;
2868
+ justify-content: center;
2869
+ gap: 10px;
2870
+ padding: 24px;
2871
+ cursor: zoom-out;
2872
+ animation: fade-in 0.12s ease-out;
2873
+ }
2874
+ .lightbox img {
2875
+ max-width: 100%;
2876
+ max-height: calc(100% - 32px);
2877
+ object-fit: contain;
2878
+ border-radius: 6px;
2879
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
2880
+ }
2881
+
2882
+ .lightbox-bar {
2883
+ display: flex;
2884
+ align-items: center;
2885
+ gap: 14px;
2886
+ cursor: default;
2887
+ }
2888
+
2889
+ .lightbox-name {
2890
+ color: rgba(255, 255, 255, 0.75);
2891
+ font-size: 13px;
2892
+ }
2893
+
2894
+ .lightbox-dl {
2895
+ color: #fff;
2896
+ font-size: 13px;
2897
+ text-decoration: none;
2898
+ padding: 4px 14px;
2899
+ border-radius: 999px;
2900
+ background: rgba(255, 255, 255, 0.16);
2901
+ }
2902
+
2903
+ .lightbox-dl:hover {
2904
+ background: rgba(255, 255, 255, 0.26);
2905
+ }