agent-loft 0.2.9 → 0.2.11
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 +1 -1
- package/src/agents/claude/driver.ts +11 -12
- package/src/agents/codex/driver.ts +12 -4
- package/src/channels/web/api.ts +39 -2
- package/src/core/footers.ts +12 -0
- package/src/core/hub.ts +6 -4
- package/src/core/types.ts +5 -0
- package/src/core/uploads.ts +50 -1
- package/web/app.js +281 -49
- package/web/style.css +339 -18
package/package.json
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
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({
|
|
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
|
|
118
|
-
.map((c) => (c.type === "text" ? (c.text ?? "") :
|
|
118
|
+
const raw = (item.content ?? [])
|
|
119
|
+
.map((c) => (c.type === "text" ? (c.text ?? "") : ""))
|
|
119
120
|
.filter(Boolean)
|
|
120
121
|
.join("\n");
|
|
121
|
-
|
|
122
|
-
|
|
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: [] };
|
package/src/channels/web/api.ts
CHANGED
|
@@ -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, {
|
package/src/core/footers.ts
CHANGED
|
@@ -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
|
|
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 = {
|
package/src/core/uploads.ts
CHANGED
|
@@ -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: "
|
|
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 ? "
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
1418
|
-
item.text,
|
|
1419
|
-
|
|
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
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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: "
|
|
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.
|
|
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
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
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 (!
|
|
2464
|
+
async function forkConversation(anchor, key = S.current) {
|
|
2465
|
+
if (!key) return;
|
|
2286
2466
|
try {
|
|
2287
|
-
const res = await api(`/api/conversations/${encodeURIComponent(
|
|
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
|
-
|
|
2694
|
-
|
|
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(
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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("正在重启…");
|
package/web/style.css
CHANGED
|
@@ -516,9 +516,15 @@ button.conv-group:hover {
|
|
|
516
516
|
flex-shrink: 0;
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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、纯色不透明」的元素时跳过它,
|
|
@@ -944,7 +949,7 @@ button.conv-group:hover {
|
|
|
944
949
|
.messages-inner {
|
|
945
950
|
max-width: 820px;
|
|
946
951
|
margin: 0 auto;
|
|
947
|
-
padding: 16px 20px
|
|
952
|
+
padding: 16px 20px 40px;
|
|
948
953
|
display: flex;
|
|
949
954
|
flex-direction: column;
|
|
950
955
|
gap: 18px;
|
|
@@ -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:
|
|
1153
|
+
display: flex;
|
|
1050
1154
|
align-items: center;
|
|
1051
|
-
gap:
|
|
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
|
|
1057
|
-
|
|
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
|
|
1061
|
-
|
|
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
|
|
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;
|
|
@@ -1457,6 +1597,26 @@ button.conv-group:hover {
|
|
|
1457
1597
|
|
|
1458
1598
|
.composer-wrap {
|
|
1459
1599
|
padding: 0 16px calc(12px + env(safe-area-inset-bottom));
|
|
1600
|
+
position: relative;
|
|
1601
|
+
z-index: 2;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/* 输入框上沿的渐变:聊天内容滚到底部时柔和地淡出,不是被一刀切开(和顶栏下沿对称)。
|
|
1605
|
+
右边让出滚动条的宽度,免得把滚动条也盖淡 */
|
|
1606
|
+
.composer-wrap::before {
|
|
1607
|
+
content: "";
|
|
1608
|
+
position: absolute;
|
|
1609
|
+
left: 0;
|
|
1610
|
+
right: 10px;
|
|
1611
|
+
bottom: 100%;
|
|
1612
|
+
height: 32px;
|
|
1613
|
+
pointer-events: none;
|
|
1614
|
+
background: linear-gradient(
|
|
1615
|
+
to top,
|
|
1616
|
+
var(--bg),
|
|
1617
|
+
color-mix(in srgb, var(--bg) 70%, transparent) 40%,
|
|
1618
|
+
color-mix(in srgb, var(--bg) 0%, transparent)
|
|
1619
|
+
);
|
|
1460
1620
|
}
|
|
1461
1621
|
|
|
1462
1622
|
.composer-box {
|
|
@@ -1901,6 +2061,34 @@ button.ctx-item:hover {
|
|
|
1901
2061
|
background: var(--bg-hover);
|
|
1902
2062
|
}
|
|
1903
2063
|
|
|
2064
|
+
.confirm-dialog .dialog-sub {
|
|
2065
|
+
margin-bottom: 0;
|
|
2066
|
+
min-width: 0;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
.confirm-dialog .dialog-sub > span:last-child {
|
|
2070
|
+
overflow: hidden;
|
|
2071
|
+
text-overflow: ellipsis;
|
|
2072
|
+
white-space: nowrap;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
.dialog-msg {
|
|
2076
|
+
margin: 12px 0 0;
|
|
2077
|
+
font-size: 14px;
|
|
2078
|
+
line-height: 1.6;
|
|
2079
|
+
color: var(--fg-soft);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
.dialog-foot .btn.danger-fill {
|
|
2083
|
+
background: var(--danger);
|
|
2084
|
+
border-color: var(--danger);
|
|
2085
|
+
color: #fff;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
.dialog-foot .btn.danger-fill:hover {
|
|
2089
|
+
background: color-mix(in srgb, var(--danger) 88%, #000);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
1904
2092
|
.dialog-foot .btn.primary {
|
|
1905
2093
|
background: var(--accent);
|
|
1906
2094
|
border-color: var(--accent);
|
|
@@ -2100,6 +2288,10 @@ button.list-item:hover {
|
|
|
2100
2288
|
background: var(--bg-hover);
|
|
2101
2289
|
}
|
|
2102
2290
|
|
|
2291
|
+
.menu button.danger {
|
|
2292
|
+
color: var(--danger);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2103
2295
|
/* 贴着按钮弹出的选择面板(模型 / agent) */
|
|
2104
2296
|
.popover {
|
|
2105
2297
|
min-width: 220px;
|
|
@@ -2338,7 +2530,7 @@ button.list-item:hover {
|
|
|
2338
2530
|
}
|
|
2339
2531
|
|
|
2340
2532
|
.messages-inner {
|
|
2341
|
-
padding: 12px 14px
|
|
2533
|
+
padding: 12px 14px 36px;
|
|
2342
2534
|
}
|
|
2343
2535
|
|
|
2344
2536
|
.msg-user {
|
|
@@ -2602,3 +2794,132 @@ button.list-item:hover {
|
|
|
2602
2794
|
text-overflow: ellipsis;
|
|
2603
2795
|
white-space: nowrap;
|
|
2604
2796
|
}
|
|
2797
|
+
|
|
2798
|
+
/* ---------- 滚动条:细、无边框、无轨道,像 Codex ---------- */
|
|
2799
|
+
::-webkit-scrollbar {
|
|
2800
|
+
width: 8px;
|
|
2801
|
+
height: 8px;
|
|
2802
|
+
}
|
|
2803
|
+
::-webkit-scrollbar-track,
|
|
2804
|
+
::-webkit-scrollbar-corner {
|
|
2805
|
+
background: transparent;
|
|
2806
|
+
}
|
|
2807
|
+
::-webkit-scrollbar-thumb {
|
|
2808
|
+
background: color-mix(in srgb, var(--fg-faint) 32%, transparent);
|
|
2809
|
+
border-radius: 999px;
|
|
2810
|
+
border: 2px solid transparent;
|
|
2811
|
+
background-clip: content-box;
|
|
2812
|
+
}
|
|
2813
|
+
::-webkit-scrollbar-thumb:hover {
|
|
2814
|
+
background-color: color-mix(in srgb, var(--fg-faint) 55%, transparent);
|
|
2815
|
+
}
|
|
2816
|
+
::-webkit-scrollbar-button {
|
|
2817
|
+
display: none;
|
|
2818
|
+
}
|
|
2819
|
+
/* 不认 ::-webkit-scrollbar 的浏览器(Firefox)用标准属性;Chrome 里设了标准属性会盖掉上面的,所以分开 */
|
|
2820
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
2821
|
+
* {
|
|
2822
|
+
scrollbar-width: thin;
|
|
2823
|
+
scrollbar-color: color-mix(in srgb, var(--fg-faint) 40%, transparent) transparent;
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
/* ---------- 图片附件:缩略图 + 点开看大图 ---------- */
|
|
2828
|
+
.chip-img {
|
|
2829
|
+
position: relative;
|
|
2830
|
+
width: 56px;
|
|
2831
|
+
height: 56px;
|
|
2832
|
+
border-radius: 10px;
|
|
2833
|
+
overflow: hidden;
|
|
2834
|
+
background: var(--bg-soft);
|
|
2835
|
+
flex-shrink: 0;
|
|
2836
|
+
}
|
|
2837
|
+
.chip-img img {
|
|
2838
|
+
width: 100%;
|
|
2839
|
+
height: 100%;
|
|
2840
|
+
object-fit: cover;
|
|
2841
|
+
display: block;
|
|
2842
|
+
cursor: zoom-in;
|
|
2843
|
+
}
|
|
2844
|
+
.chip-img.uploading img {
|
|
2845
|
+
opacity: 0.5;
|
|
2846
|
+
}
|
|
2847
|
+
.chip-img .spinner {
|
|
2848
|
+
position: absolute;
|
|
2849
|
+
left: 50%;
|
|
2850
|
+
top: 50%;
|
|
2851
|
+
margin: -6px 0 0 -6px;
|
|
2852
|
+
pointer-events: none;
|
|
2853
|
+
}
|
|
2854
|
+
.chip-img .chip-x {
|
|
2855
|
+
position: absolute;
|
|
2856
|
+
top: 3px;
|
|
2857
|
+
right: 3px;
|
|
2858
|
+
width: 18px;
|
|
2859
|
+
height: 18px;
|
|
2860
|
+
border-radius: 50%;
|
|
2861
|
+
background: rgba(0, 0, 0, 0.6);
|
|
2862
|
+
color: #fff;
|
|
2863
|
+
font-size: 13px;
|
|
2864
|
+
line-height: 18px;
|
|
2865
|
+
text-align: center;
|
|
2866
|
+
padding: 0;
|
|
2867
|
+
opacity: 0;
|
|
2868
|
+
transition: opacity 0.12s;
|
|
2869
|
+
}
|
|
2870
|
+
.chip-img:hover .chip-x {
|
|
2871
|
+
opacity: 1;
|
|
2872
|
+
}
|
|
2873
|
+
@media (hover: none) {
|
|
2874
|
+
.chip-img .chip-x {
|
|
2875
|
+
opacity: 1;
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
|
|
2880
|
+
.lightbox {
|
|
2881
|
+
position: fixed;
|
|
2882
|
+
inset: 0;
|
|
2883
|
+
z-index: 60;
|
|
2884
|
+
background: rgba(0, 0, 0, 0.82);
|
|
2885
|
+
display: flex;
|
|
2886
|
+
flex-direction: column;
|
|
2887
|
+
align-items: center;
|
|
2888
|
+
justify-content: center;
|
|
2889
|
+
gap: 10px;
|
|
2890
|
+
padding: 24px;
|
|
2891
|
+
cursor: zoom-out;
|
|
2892
|
+
animation: fade-in 0.12s ease-out;
|
|
2893
|
+
}
|
|
2894
|
+
.lightbox img {
|
|
2895
|
+
max-width: 100%;
|
|
2896
|
+
max-height: calc(100% - 32px);
|
|
2897
|
+
object-fit: contain;
|
|
2898
|
+
border-radius: 6px;
|
|
2899
|
+
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
.lightbox-bar {
|
|
2903
|
+
display: flex;
|
|
2904
|
+
align-items: center;
|
|
2905
|
+
gap: 14px;
|
|
2906
|
+
cursor: default;
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
.lightbox-name {
|
|
2910
|
+
color: rgba(255, 255, 255, 0.75);
|
|
2911
|
+
font-size: 13px;
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
.lightbox-dl {
|
|
2915
|
+
color: #fff;
|
|
2916
|
+
font-size: 13px;
|
|
2917
|
+
text-decoration: none;
|
|
2918
|
+
padding: 4px 14px;
|
|
2919
|
+
border-radius: 999px;
|
|
2920
|
+
background: rgba(255, 255, 255, 0.16);
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
.lightbox-dl:hover {
|
|
2924
|
+
background: rgba(255, 255, 255, 0.26);
|
|
2925
|
+
}
|