agent-loft 0.2.11 → 0.2.13
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 +2 -0
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +12 -0
- package/src/channels/feishu/cards.ts +6 -1
- package/src/channels/web/api.ts +11 -1
- package/src/channels/web/server.ts +2 -2
- package/src/core/command-catalog.ts +91 -4
- package/src/core/config.ts +9 -2
- package/src/core/hub.ts +5 -1
- package/web/app.js +192 -15
- package/web/index.html +1 -1
- package/web/style.css +229 -0
package/README.md
CHANGED
|
@@ -73,6 +73,8 @@ agent-loft service install
|
|
|
73
73
|
|
|
74
74
|
聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/approve <id> allow-once|allow-always|allow-session|deny` `/help`
|
|
75
75
|
|
|
76
|
+
自定义命令:设置 → 命令 → 「添加命令」,填命令名、说明和一段提示词,之后发 `/命令名 参数` 就会把这段提示词发给 agent(提示词里的 `$ARGUMENTS` 换成参数,没写就把参数接在后面)。按 agent 分开存,可以编辑、删除,也会同步到飞书指令面板。
|
|
77
|
+
|
|
76
78
|
## 安全
|
|
77
79
|
|
|
78
80
|
这个服务能让 agent 在你的机器上执行命令,等于一个远程 shell。
|
package/package.json
CHANGED
|
@@ -136,12 +136,24 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
136
136
|
// 运行时带着 timestamp(ISO 字符串),类型声明里没有
|
|
137
137
|
const ts = Date.parse((msg as { timestamp?: string }).timestamp ?? "");
|
|
138
138
|
if (msg.type === "user") {
|
|
139
|
+
// Claude Code 自己塞进记录的「用户消息」不是人发的:后台命令跑完的 <task-notification> 等,
|
|
140
|
+
// 记录里 origin.kind 不是 human。老记录可能没有 origin,再按正文开头兜底
|
|
141
|
+
const origin = (msg as { origin?: { kind?: string } }).origin;
|
|
142
|
+
// 它触发的那轮回复仍单独成一条(和实时看到的一样),不并进上一条回复
|
|
143
|
+
if (origin?.kind && origin.kind !== "human") {
|
|
144
|
+
flush();
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
139
147
|
const texts = blocks.filter((b) => b.type === "text" && b.text).map((b) => b.text!);
|
|
140
148
|
if (texts.length === 0) continue; // 纯 tool_result
|
|
141
149
|
// 我们自己拼进去的附件清单:从正文里拆出来,单独作为附件给前端展示
|
|
142
150
|
const { text, attachments } = splitAttachmentNote(texts.join("\n"));
|
|
143
151
|
if (!text && attachments.length === 0) continue;
|
|
144
152
|
if (text.startsWith("<command-") || text.startsWith("<local-command")) continue;
|
|
153
|
+
if (text.startsWith("<task-notification>")) {
|
|
154
|
+
flush();
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
145
157
|
flush();
|
|
146
158
|
out.push({
|
|
147
159
|
role: "user",
|
|
@@ -255,7 +255,12 @@ export function commandListCard(params: {
|
|
|
255
255
|
{ tag: "hr" },
|
|
256
256
|
];
|
|
257
257
|
for (const c of shown) {
|
|
258
|
-
const tag =
|
|
258
|
+
const tag =
|
|
259
|
+
c.source === "agent"
|
|
260
|
+
? ` <font color='grey'>· ${params.agentLabel}</font>`
|
|
261
|
+
: c.source === "custom"
|
|
262
|
+
? " <font color='grey'>· 自定义</font>"
|
|
263
|
+
: "";
|
|
259
264
|
const text = `**${usageOf(c)}**${tag}\n<font color='grey'>${oneLine(c.description) || "(无说明)"}</font>`;
|
|
260
265
|
elements.push({
|
|
261
266
|
tag: "column_set",
|
package/src/channels/web/api.ts
CHANGED
|
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
6
6
|
import { AGENT_IDS, config, listWorkspaceDirs, resolveWorkspacePath, saveConfig, type AppConfig } from "../../core/config.js";
|
|
7
|
+
import { cleanCustomCommands } from "../../core/command-catalog.js";
|
|
7
8
|
import { hub } from "../../core/hub.js";
|
|
8
9
|
import { interactions } from "../../core/interactions.js";
|
|
9
10
|
import { approve, listApproved, listPending, revoke } from "../../core/pairing.js";
|
|
@@ -183,11 +184,20 @@ function applyConfig(input: Partial<AppConfig>): void {
|
|
|
183
184
|
const names = (v: unknown) =>
|
|
184
185
|
Array.isArray(v) ? [...new Set(v.filter((s): s is string => typeof s === "string" && s.length > 0 && s.length < 128))] : null;
|
|
185
186
|
for (const id of AGENT_IDS) {
|
|
186
|
-
const prefs = (input.commands as Record<string, { order?: unknown; hidden?: unknown } | undefined>)[id];
|
|
187
|
+
const prefs = (input.commands as Record<string, { order?: unknown; hidden?: unknown; custom?: unknown } | undefined>)[id];
|
|
187
188
|
if (!prefs) continue;
|
|
189
|
+
let custom = next.commands[id].custom;
|
|
190
|
+
if (prefs.custom !== undefined) {
|
|
191
|
+
try {
|
|
192
|
+
custom = cleanCustomCommands(prefs.custom, true);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
throw new HttpError(400, err instanceof Error ? err.message : String(err));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
188
197
|
next.commands[id] = {
|
|
189
198
|
order: names(prefs.order) ?? next.commands[id].order,
|
|
190
199
|
hidden: names(prefs.hidden) ?? next.commands[id].hidden,
|
|
200
|
+
custom,
|
|
191
201
|
};
|
|
192
202
|
}
|
|
193
203
|
}
|
|
@@ -162,8 +162,8 @@ function watching(key: string): boolean {
|
|
|
162
162
|
return false;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
/** 通知正文用:去掉 markdown
|
|
166
|
-
export function plainPreview(text: string, max =
|
|
165
|
+
/** 通知正文用:去掉 markdown 记号,压成一行。40 字在 iPhone 通知收起时能完整显示 */
|
|
166
|
+
export function plainPreview(text: string, max = 40): string {
|
|
167
167
|
const plain = text
|
|
168
168
|
.replace(/```[\s\S]*?```/g, " [代码] ")
|
|
169
169
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, " [图片] ")
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// 命令目录:输入「/」时给用户看的那张表。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 三种来源:
|
|
4
4
|
// - agent-loft 自己的命令(/new /status …),由 hub 拦下来执行,所有 agent 通用;
|
|
5
|
+
// - 用户自定义命令(设置 → 命令里添加):一段预设的提示词,发送时展开成这段话交给 agent;
|
|
5
6
|
// - agent 原生命令(Claude 的 skills、/compact 之类),agent-loft 不认识,原样转给 agent。
|
|
6
7
|
// 顺序和隐藏按 agent 分开存在配置里(设置 → 命令),用户自己排。
|
|
7
8
|
|
|
@@ -30,17 +31,88 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
|
|
|
30
31
|
/** agent 自己报上来的命令 */
|
|
31
32
|
export type AgentCommand = { name: string; description: string; args?: string };
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* 用户自定义命令。prompt 里的 $ARGUMENTS 换成命令后面跟的参数;
|
|
36
|
+
* 没写 $ARGUMENTS 又带了参数,参数接在提示词后面另起一段。
|
|
37
|
+
*/
|
|
38
|
+
export type CustomCommand = { name: string; description: string; prompt: string };
|
|
39
|
+
|
|
40
|
+
/** 自定义命令名:和飞书指令同样的规则(小写字母、数字、- _,最长 32),这样能原样同步到飞书 */
|
|
41
|
+
export const CUSTOM_NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
42
|
+
|
|
33
43
|
export type CommandEntry = {
|
|
34
44
|
name: string;
|
|
35
45
|
args?: string;
|
|
36
46
|
description: string;
|
|
37
|
-
source: "agent-loft" | "agent";
|
|
47
|
+
source: "agent-loft" | "custom" | "agent";
|
|
48
|
+
/** 自定义命令的提示词(设置页编辑用) */
|
|
49
|
+
prompt?: string;
|
|
38
50
|
/** 不带参数就能直接执行(飞书卡片上给按钮、Web 里回车直接发) */
|
|
39
51
|
runnable: boolean;
|
|
40
52
|
hidden: boolean;
|
|
41
53
|
};
|
|
42
54
|
|
|
43
|
-
export type CommandPrefs = { order: string[]; hidden: string[] };
|
|
55
|
+
export type CommandPrefs = { order: string[]; hidden: string[]; custom?: CustomCommand[] };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 整理自定义命令列表。strict(Web 保存时)遇到不合规的直接报错,提示具体哪条;
|
|
59
|
+
* 否则(读配置文件时,可能是手改的)悄悄丢掉不合规的。
|
|
60
|
+
*/
|
|
61
|
+
export function cleanCustomCommands(list: unknown, strict = false): CustomCommand[] {
|
|
62
|
+
const fail = (msg: string) => {
|
|
63
|
+
if (strict) throw new Error(msg);
|
|
64
|
+
};
|
|
65
|
+
if (!Array.isArray(list)) {
|
|
66
|
+
fail("自定义命令格式不对");
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
const builtin = new Set(BUILTIN_COMMANDS.map((c) => c.name));
|
|
70
|
+
const out: CustomCommand[] = [];
|
|
71
|
+
for (const raw of list as Partial<CustomCommand>[]) {
|
|
72
|
+
const name = typeof raw?.name === "string" ? raw.name.trim().replace(/^[//]/, "").toLowerCase() : "";
|
|
73
|
+
const prompt = typeof raw?.prompt === "string" ? raw.prompt.trim() : "";
|
|
74
|
+
const description = typeof raw?.description === "string" ? raw.description.trim().slice(0, 200) : "";
|
|
75
|
+
if (!CUSTOM_NAME.test(name)) {
|
|
76
|
+
fail(`命令名「${name || "(空)"}」不合规:只能用小写字母、数字、- 和 _,以字母或数字开头,最长 32 个字符`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (builtin.has(name)) {
|
|
80
|
+
fail(`/${name} 是 agent-loft 内置命令,换个名字`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (out.some((c) => c.name === name)) {
|
|
84
|
+
fail(`/${name} 重复了`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!prompt) {
|
|
88
|
+
fail(`/${name} 的提示词不能为空`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (prompt.length > 20000) {
|
|
92
|
+
fail(`/${name} 的提示词太长(最多 20000 字)`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
out.push({ name, description, prompt });
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 整条消息是一条自定义命令时,展开成它的提示词;不是就返回 null(照原样处理)。
|
|
102
|
+
* 和内置命令一样,只认「整条消息就是命令」:多行的、不认识的名字都不展开。
|
|
103
|
+
*/
|
|
104
|
+
export function expandCustomCommand(raw: string, custom: CustomCommand[] | undefined): string | null {
|
|
105
|
+
if (!custom?.length) return null;
|
|
106
|
+
const text = raw.trim().replace(/^//, "/");
|
|
107
|
+
if (!text.startsWith("/") || text.includes("\n")) return null;
|
|
108
|
+
const m = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(text);
|
|
109
|
+
if (!m) return null;
|
|
110
|
+
const cmd = custom.find((c) => c.name === m[1]!.toLowerCase());
|
|
111
|
+
if (!cmd) return null;
|
|
112
|
+
const args = (m[2] ?? "").trim();
|
|
113
|
+
if (cmd.prompt.includes("$ARGUMENTS")) return cmd.prompt.split("$ARGUMENTS").join(args).trim();
|
|
114
|
+
return args ? `${cmd.prompt.trim()}\n\n${args}` : cmd.prompt.trim();
|
|
115
|
+
}
|
|
44
116
|
|
|
45
117
|
/** 参数里有必填项(尖括号)就不能一键执行 */
|
|
46
118
|
function needsArgs(args: string | undefined): boolean {
|
|
@@ -73,6 +145,21 @@ export function buildCatalog(params: {
|
|
|
73
145
|
hidden: false,
|
|
74
146
|
}));
|
|
75
147
|
const taken = new Set(BUILTIN_COMMANDS.map((c) => c.name));
|
|
148
|
+
// 自定义命令:和内置重名的不收(保存时已经拦过,这里兜底手改配置的情况);原生命令和它重名时让给它
|
|
149
|
+
const custom: CommandEntry[] = [];
|
|
150
|
+
for (const c of params.prefs?.custom ?? []) {
|
|
151
|
+
if (!CUSTOM_NAME.test(c.name) || taken.has(c.name)) continue;
|
|
152
|
+
taken.add(c.name);
|
|
153
|
+
custom.push({
|
|
154
|
+
name: c.name,
|
|
155
|
+
...(c.prompt.includes("$ARGUMENTS") ? { args: "[参数]" } : {}),
|
|
156
|
+
description: c.description,
|
|
157
|
+
source: "custom",
|
|
158
|
+
prompt: c.prompt,
|
|
159
|
+
runnable: true,
|
|
160
|
+
hidden: false,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
76
163
|
const seen = new Set<string>();
|
|
77
164
|
const native: CommandEntry[] = [];
|
|
78
165
|
for (const c of params.native) {
|
|
@@ -89,7 +176,7 @@ export function buildCatalog(params: {
|
|
|
89
176
|
});
|
|
90
177
|
}
|
|
91
178
|
|
|
92
|
-
const all = [...builtin, ...native];
|
|
179
|
+
const all = [...builtin, ...custom, ...native];
|
|
93
180
|
const order = params.prefs?.order ?? [];
|
|
94
181
|
const hidden = new Set(params.prefs?.hidden ?? []);
|
|
95
182
|
const rank = new Map(order.map((name, i) => [name, i]));
|
package/src/core/config.ts
CHANGED
|
@@ -6,7 +6,7 @@ import fs from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import crypto from "node:crypto";
|
|
8
8
|
import { configPath, ensureDirs, readJson, writeJsonAtomic } from "../home.js";
|
|
9
|
-
import type
|
|
9
|
+
import { cleanCustomCommands, type CommandPrefs } from "./command-catalog.js";
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
|
@@ -97,7 +97,7 @@ export function defaultConfig(): AppConfig {
|
|
|
97
97
|
},
|
|
98
98
|
},
|
|
99
99
|
web: { enabled: true, host: "0.0.0.0", port: 8800, passwordHash: "", trustedProxies: ["127.0.0.1", "::1"], quotaRefreshMin: 10 },
|
|
100
|
-
commands: { claude: { order: [], hidden: [] }, codex: { order: [], hidden: [] } },
|
|
100
|
+
commands: { claude: { order: [], hidden: [], custom: [] }, codex: { order: [], hidden: [], custom: [] } },
|
|
101
101
|
feishu: { watchdogMs: 10 * 60 * 1000, bots: [] },
|
|
102
102
|
};
|
|
103
103
|
}
|
|
@@ -132,6 +132,11 @@ function normalizeBot(raw: Partial<FeishuBotConfig>): FeishuBotConfig {
|
|
|
132
132
|
};
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** 自定义命令可能是手改进配置文件的:不合规的丢掉,别让一条坏数据把命令列表搞崩 */
|
|
136
|
+
function normalizeCommands(cfg: AppConfig): void {
|
|
137
|
+
for (const id of AGENT_IDS) cfg.commands[id].custom = cleanCustomCommands(cfg.commands[id].custom);
|
|
138
|
+
}
|
|
139
|
+
|
|
135
140
|
let current: AppConfig | null = null;
|
|
136
141
|
const listeners = new Set<(next: AppConfig, prev: AppConfig) => void>();
|
|
137
142
|
|
|
@@ -142,6 +147,7 @@ export function loadConfig(): AppConfig {
|
|
|
142
147
|
const merged = mergeDefaults(defaultConfig(), raw);
|
|
143
148
|
merged.workspaceRoot = path.resolve(merged.workspaceRoot);
|
|
144
149
|
merged.feishu.bots = merged.feishu.bots.map(normalizeBot);
|
|
150
|
+
normalizeCommands(merged);
|
|
145
151
|
current = merged;
|
|
146
152
|
if (!raw) writeJsonAtomic(configPath, merged);
|
|
147
153
|
watchExternalEdits();
|
|
@@ -167,6 +173,7 @@ function watchExternalEdits(): void {
|
|
|
167
173
|
const next = mergeDefaults(defaultConfig(), raw);
|
|
168
174
|
next.workspaceRoot = path.resolve(next.workspaceRoot);
|
|
169
175
|
next.feishu.bots = next.feishu.bots.map(normalizeBot);
|
|
176
|
+
normalizeCommands(next);
|
|
170
177
|
if (JSON.stringify(next) === JSON.stringify(current)) return;
|
|
171
178
|
const prev = current;
|
|
172
179
|
current = next;
|
package/src/core/hub.ts
CHANGED
|
@@ -38,7 +38,7 @@ import type {
|
|
|
38
38
|
StepKind,
|
|
39
39
|
UserInput,
|
|
40
40
|
} from "./types.js";
|
|
41
|
-
import { buildCatalog, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
|
|
41
|
+
import { buildCatalog, expandCustomCommand, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
|
|
42
42
|
import { helpText, type ParsedCommand } from "./commands.js";
|
|
43
43
|
|
|
44
44
|
// ---------- 事件 ----------
|
|
@@ -655,6 +655,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
|
|
|
655
655
|
send(key: string, input: UserInput, origin: Origin, gen?: InputGen): void {
|
|
656
656
|
const conv = getConversation(key);
|
|
657
657
|
if (!conv) throw new Error(`会话不存在:${key}`);
|
|
658
|
+
// 用户自定义命令(设置 → 命令):展开成预设的提示词再发,界面上和 agent 收到的都是展开后的内容
|
|
659
|
+
const custom = (config().commands as Record<string, CommandPrefs | undefined>)[conv.agent]?.custom;
|
|
660
|
+
const expanded = expandCustomCommand(input.text, custom);
|
|
661
|
+
if (expanded !== null) input = { ...input, text: expanded };
|
|
658
662
|
const g = gen ?? this.generation(key);
|
|
659
663
|
const cancelled = () => {
|
|
660
664
|
if (this.stopGeneration(key) !== g.stop) {
|
package/web/app.js
CHANGED
|
@@ -45,6 +45,7 @@ const ICON = {
|
|
|
45
45
|
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
|
|
46
46
|
x: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
|
|
47
47
|
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
|
|
48
|
+
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>',
|
|
48
49
|
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5A2.5 2.5 0 0 1 5.5 5h3.6l2 2.2h7.4A2.5 2.5 0 0 1 21 9.7v7.8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5Z"/><path d="M3 10h18"/></svg>',
|
|
49
50
|
folderOpen: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17.5V7.5A2.5 2.5 0 0 1 5.5 5h3.6l2 2.2h6.4A2.5 2.5 0 0 1 20 9.7V11"/><path d="M3 17.5 5.6 12a2 2 0 0 1 1.8-1.1h13a1 1 0 0 1 .9 1.4l-2.4 6.2a2.2 2.2 0 0 1-2 1.5H5.5A2.5 2.5 0 0 1 3 17.5Z"/></svg>',
|
|
50
51
|
laptop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/></svg>',
|
|
@@ -68,6 +69,27 @@ ICON.claude = (() => {
|
|
|
68
69
|
})();
|
|
69
70
|
ICON.codex = ICON.logo;
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* 星星加载动画(回复末尾、打开会话时用)。开屏那个带台面的完整 logo 动画直接写在 index.html 里。
|
|
74
|
+
* 每个实例的渐变 id 唯一,避免页面里有隐藏的同 id 渐变时画不出来。
|
|
75
|
+
* 回复流式输出时整条消息会反复重建,动画按全局时钟设负的起始延迟,重建后接着播、不会一抖一抖地重来。
|
|
76
|
+
*/
|
|
77
|
+
let loaderSeq = 0;
|
|
78
|
+
|
|
79
|
+
/** 只有 logo 中间那颗大星,一边旋转一边放大缩小 */
|
|
80
|
+
const STARS_MS = 1800;
|
|
81
|
+
function starsLoader(cls = "") {
|
|
82
|
+
const id = `loft-g${++loaderSeq}`;
|
|
83
|
+
const el = h("span", {
|
|
84
|
+
class: `loft-stars${cls ? ` ${cls}` : ""}`,
|
|
85
|
+
role: "img",
|
|
86
|
+
"aria-label": "回复中",
|
|
87
|
+
html: `<svg viewBox="180 82 152 152" aria-hidden="true"><defs><linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="180" y1="82" x2="332" y2="234"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#06b6d4"/></linearGradient></defs><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(#${id})"/></svg>`,
|
|
88
|
+
});
|
|
89
|
+
el.style.setProperty("--t", `${-(performance.now() % STARS_MS).toFixed(0)}ms`);
|
|
90
|
+
return el;
|
|
91
|
+
}
|
|
92
|
+
|
|
71
93
|
function agentIcon(id, cls = "agent-icon") {
|
|
72
94
|
return h("span", { class: cls, html: ICON[id] ?? ICON.laptop });
|
|
73
95
|
}
|
|
@@ -120,6 +142,28 @@ function formatDuration(ms) {
|
|
|
120
142
|
return `${m}分${s ? `${s}秒` : ""}`;
|
|
121
143
|
}
|
|
122
144
|
|
|
145
|
+
/** 回复进行中的实时用时:整秒跳动,12秒 / 3分05秒 / 1小时05分 */
|
|
146
|
+
function liveDuration(ms) {
|
|
147
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
148
|
+
const hr = Math.floor(total / 3600);
|
|
149
|
+
const m = Math.floor((total % 3600) / 60);
|
|
150
|
+
const s = total % 60;
|
|
151
|
+
if (hr > 0) return `${hr}小时${String(m).padStart(2, "0")}分`;
|
|
152
|
+
if (m > 0) return `${m}分${String(s).padStart(2, "0")}秒`;
|
|
153
|
+
return `${s}秒`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 服务端时间 - 本机时间。事件里的 ts 是服务端时钟,算用时要换算过来,免得两边时钟不准 */
|
|
157
|
+
let clockSkew = 0;
|
|
158
|
+
const serverNow = () => Date.now() + clockSkew;
|
|
159
|
+
|
|
160
|
+
/** 所有进行中的用时一秒刷一次,只改文字,不重建消息 */
|
|
161
|
+
setInterval(() => {
|
|
162
|
+
for (const el of document.querySelectorAll(".ai-elapsed[data-start]")) {
|
|
163
|
+
el.textContent = liveDuration(serverNow() - Number(el.dataset.start));
|
|
164
|
+
}
|
|
165
|
+
}, 1000);
|
|
166
|
+
|
|
123
167
|
/** 老版本记下的脚注是「用时 1234.5s」,显示时也换成时 / 分 / 秒 */
|
|
124
168
|
function tidyFooter(footer) {
|
|
125
169
|
return footer?.replace(/用时 ([\d.]+)s/, (_, s) => `用时 ${formatDuration(Number(s) * 1000)}`) ?? "";
|
|
@@ -478,7 +522,10 @@ async function boot() {
|
|
|
478
522
|
WS.connect();
|
|
479
523
|
void loadQuota();
|
|
480
524
|
const fromHash = decodeURIComponent(location.hash.slice(1));
|
|
525
|
+
// 地址里带了会话就打开它;否则打开「最近」里最近活跃的那个对话,一个都没有才进新对话页
|
|
526
|
+
const latest = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt)[0];
|
|
481
527
|
if (fromHash && S.convs.has(fromHash)) await openConversation(fromHash);
|
|
528
|
+
else if (latest) await openConversation(latest.key);
|
|
482
529
|
else newDraft();
|
|
483
530
|
if ("serviceWorker" in navigator) {
|
|
484
531
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
|
@@ -1348,6 +1395,7 @@ function newDraft() {
|
|
|
1348
1395
|
S.current = null;
|
|
1349
1396
|
S.items = [];
|
|
1350
1397
|
S.turnAi = null;
|
|
1398
|
+
turnStartAt = null;
|
|
1351
1399
|
S.uploads = [];
|
|
1352
1400
|
setBusy(false);
|
|
1353
1401
|
history.replaceState(null, "", location.pathname);
|
|
@@ -1387,13 +1435,14 @@ async function openConversation(key) {
|
|
|
1387
1435
|
S.current = key;
|
|
1388
1436
|
S.items = [];
|
|
1389
1437
|
S.turnAi = null;
|
|
1438
|
+
turnStartAt = null;
|
|
1390
1439
|
S.uploads = [];
|
|
1391
1440
|
renderChips();
|
|
1392
1441
|
history.replaceState(null, "", `#${encodeURIComponent(key)}`);
|
|
1393
1442
|
WS.presence();
|
|
1394
1443
|
renderTopbar();
|
|
1395
1444
|
renderSidebar();
|
|
1396
|
-
fill(ui.msgInner, h("div", { class: "empty" },
|
|
1445
|
+
fill(ui.msgInner, h("div", { class: "empty" }, starsLoader("lg")));
|
|
1397
1446
|
let data;
|
|
1398
1447
|
for (let attempt = 0; ; attempt++) {
|
|
1399
1448
|
try {
|
|
@@ -1414,6 +1463,7 @@ async function openConversation(key) {
|
|
|
1414
1463
|
if (token !== S.loadToken) return; // 期间又切到别的会话了
|
|
1415
1464
|
S.convs.set(key, data.info);
|
|
1416
1465
|
S.allowedAgents = data.allowedAgents;
|
|
1466
|
+
if (Number.isFinite(data.serverTime)) clockSkew = data.serverTime - Date.now();
|
|
1417
1467
|
fill(ui.msgInner, );
|
|
1418
1468
|
for (const m of data.messages) {
|
|
1419
1469
|
if (m.role === "user") addItem({ type: "user", text: m.text, files: m.attachments ?? [], origin: null });
|
|
@@ -1629,7 +1679,19 @@ function renderAi(item) {
|
|
|
1629
1679
|
"div",
|
|
1630
1680
|
{ class: "msg-ai" },
|
|
1631
1681
|
steps,
|
|
1632
|
-
body ? renderMarkdown(body) :
|
|
1682
|
+
body ? renderMarkdown(body) : null,
|
|
1683
|
+
// 还在回复:末尾挂上星星动画和实时用时;什么都还没出来时旁边带一句「思考中…」
|
|
1684
|
+
working
|
|
1685
|
+
? h(
|
|
1686
|
+
"div",
|
|
1687
|
+
{ class: "thinking" },
|
|
1688
|
+
starsLoader(),
|
|
1689
|
+
!body && item.steps.length === 0 ? h("span", { class: "shimmer" }, "思考中…") : null,
|
|
1690
|
+
item.startAt
|
|
1691
|
+
? h("span", { class: "ai-elapsed", "data-start": item.startAt }, liveDuration(serverNow() - item.startAt))
|
|
1692
|
+
: null,
|
|
1693
|
+
)
|
|
1694
|
+
: null,
|
|
1633
1695
|
item.error ? h("div", { class: "error-box" }, item.error) : null,
|
|
1634
1696
|
item.done && !item.segment && (body || item.footer)
|
|
1635
1697
|
? h(
|
|
@@ -1647,9 +1709,17 @@ function renderAi(item) {
|
|
|
1647
1709
|
);
|
|
1648
1710
|
}
|
|
1649
1711
|
|
|
1650
|
-
|
|
1712
|
+
/**
|
|
1713
|
+
* 这一轮的开始时间(服务端时钟):从用户发出那条消息算起。中途插了授权 / 提问卡片、
|
|
1714
|
+
* 回复拆成好几段时,后面的段接着这个时间算,显示的是整轮的总用时。
|
|
1715
|
+
* 没有用户消息就开始的一轮(比如后台任务跑完 agent 自己接着干),从第一个事件算起。
|
|
1716
|
+
*/
|
|
1717
|
+
let turnStartAt = null;
|
|
1718
|
+
|
|
1719
|
+
function currentAi(ts) {
|
|
1651
1720
|
if (!S.turnAi || S.turnAi.done) {
|
|
1652
|
-
|
|
1721
|
+
turnStartAt ??= ts ?? serverNow();
|
|
1722
|
+
S.turnAi = addItem({ type: "ai", steps: [], committed: "", partial: "", done: false, startAt: turnStartAt });
|
|
1653
1723
|
}
|
|
1654
1724
|
return S.turnAi;
|
|
1655
1725
|
}
|
|
@@ -1668,31 +1738,33 @@ function applyEvent(env) {
|
|
|
1668
1738
|
S.items = S.items.filter((i) => i !== S.turnAi);
|
|
1669
1739
|
S.turnAi = null;
|
|
1670
1740
|
}
|
|
1741
|
+
// 上一轮还没结束时发的消息会排队,不改正在进行那一轮的开始时间
|
|
1742
|
+
if (!S.turnAi || S.turnAi.done) turnStartAt = env.ts;
|
|
1671
1743
|
addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
|
|
1672
|
-
currentAi();
|
|
1744
|
+
currentAi(env.ts);
|
|
1673
1745
|
return;
|
|
1674
1746
|
}
|
|
1675
1747
|
case "step": {
|
|
1676
|
-
const ai = currentAi();
|
|
1748
|
+
const ai = currentAi(env.ts);
|
|
1677
1749
|
ai.steps.push(ev.text);
|
|
1678
1750
|
updateItem(ai);
|
|
1679
1751
|
return;
|
|
1680
1752
|
}
|
|
1681
1753
|
case "delta": {
|
|
1682
|
-
const ai = currentAi();
|
|
1754
|
+
const ai = currentAi(env.ts);
|
|
1683
1755
|
ai.partial += ev.text;
|
|
1684
1756
|
updateItem(ai);
|
|
1685
1757
|
return;
|
|
1686
1758
|
}
|
|
1687
1759
|
case "text": {
|
|
1688
|
-
const ai = currentAi();
|
|
1760
|
+
const ai = currentAi(env.ts);
|
|
1689
1761
|
ai.committed += (ai.committed ? "\n\n" : "") + ev.text;
|
|
1690
1762
|
ai.partial = "";
|
|
1691
1763
|
updateItem(ai);
|
|
1692
1764
|
return;
|
|
1693
1765
|
}
|
|
1694
1766
|
case "result": {
|
|
1695
|
-
const ai = currentAi();
|
|
1767
|
+
const ai = currentAi(env.ts);
|
|
1696
1768
|
if (!ai.committed && ev.text) ai.committed = ev.text;
|
|
1697
1769
|
ai.partial = "";
|
|
1698
1770
|
ai.done = true;
|
|
@@ -1701,6 +1773,7 @@ function applyEvent(env) {
|
|
|
1701
1773
|
ai.anchor = ev.anchor;
|
|
1702
1774
|
updateItem(ai);
|
|
1703
1775
|
S.turnAi = null;
|
|
1776
|
+
turnStartAt = null;
|
|
1704
1777
|
return;
|
|
1705
1778
|
}
|
|
1706
1779
|
case "error": {
|
|
@@ -1710,6 +1783,7 @@ function applyEvent(env) {
|
|
|
1710
1783
|
updateItem(S.turnAi);
|
|
1711
1784
|
S.turnAi = null;
|
|
1712
1785
|
} else addItem({ type: "error", text: ev.message });
|
|
1786
|
+
turnStartAt = null;
|
|
1713
1787
|
return;
|
|
1714
1788
|
}
|
|
1715
1789
|
case "notice":
|
|
@@ -1722,6 +1796,7 @@ function applyEvent(env) {
|
|
|
1722
1796
|
updateItem(S.turnAi);
|
|
1723
1797
|
S.turnAi = null;
|
|
1724
1798
|
}
|
|
1799
|
+
if (!ev.busy) turnStartAt = null;
|
|
1725
1800
|
return;
|
|
1726
1801
|
case "meta":
|
|
1727
1802
|
return;
|
|
@@ -2364,6 +2439,7 @@ function renderSlashMenu(items, error) {
|
|
|
2364
2439
|
h("span", { class: "slash-name" }, `/${c.name}`),
|
|
2365
2440
|
c.args ? h("span", { class: "slash-args" }, c.args) : null,
|
|
2366
2441
|
c.source === "agent" ? h("span", { class: "badge" }, agentLabel(currentAgentId())) : null,
|
|
2442
|
+
c.source === "custom" ? h("span", { class: "badge" }, "自定义") : null,
|
|
2367
2443
|
),
|
|
2368
2444
|
h("div", { class: "slash-desc" }, oneLine(c.description, 120) || "(无说明)"),
|
|
2369
2445
|
),
|
|
@@ -3110,8 +3186,10 @@ async function saveConfig(cfg, patch) {
|
|
|
3110
3186
|
renderTopbar();
|
|
3111
3187
|
renderSidebar();
|
|
3112
3188
|
renderContext();
|
|
3189
|
+
return true;
|
|
3113
3190
|
} catch (err) {
|
|
3114
3191
|
toast(err.message);
|
|
3192
|
+
return false;
|
|
3115
3193
|
}
|
|
3116
3194
|
}
|
|
3117
3195
|
|
|
@@ -3224,9 +3302,60 @@ function modelPicker(agent, model, effort, defaultLabel) {
|
|
|
3224
3302
|
return { modelEl: modelSlot, effortEl: effortSlot, model: () => getModel(), effort: () => getEffort() };
|
|
3225
3303
|
}
|
|
3226
3304
|
|
|
3305
|
+
/** agent-loft 内置命令名(和服务端 BUILTIN_COMMANDS 一致),自定义命令不能用 */
|
|
3306
|
+
const BUILTIN_NAMES = ["new", "status", "stop", "model", "effort", "sessions", "resume", "fork", "agent", "claude", "codex", "cd", "pwd", "autoedit", "approve", "help"];
|
|
3307
|
+
|
|
3308
|
+
/**
|
|
3309
|
+
* 添加 / 编辑一条自定义命令。onSave 返回 true 才关窗;失败(比如服务端校验没过)留着让用户改。
|
|
3310
|
+
* 规则和服务端一致:名字只能小写字母、数字、- _(飞书指令同款规则),不能和其它命令重名。
|
|
3311
|
+
*/
|
|
3312
|
+
function customDialog({ agent, entry, taken }, onSave) {
|
|
3313
|
+
const name = h("input", { value: entry?.name ?? "", placeholder: "比如 review", autocomplete: "off", spellcheck: "false" });
|
|
3314
|
+
const desc = h("input", { value: entry?.description ?? "", placeholder: "在命令列表里显示的一句话说明(可选)", maxlength: 200 });
|
|
3315
|
+
const prompt = h("textarea", { rows: 8, placeholder: "发送这条命令时,实际发给 agent 的内容" });
|
|
3316
|
+
prompt.value = entry?.prompt ?? "";
|
|
3317
|
+
const err = h("div", { class: "error-text" });
|
|
3318
|
+
const ok = h("button", { class: "btn primary" }, entry ? "保存" : "添加");
|
|
3319
|
+
const builtin = new Set(BUILTIN_NAMES);
|
|
3320
|
+
const submit = async () => {
|
|
3321
|
+
const n = name.value.trim().replace(/^[//]/, "").toLowerCase();
|
|
3322
|
+
const p = prompt.value.trim();
|
|
3323
|
+
let problem = "";
|
|
3324
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(n)) problem = "命令名只能用小写字母、数字、- 和 _,以字母或数字开头,最长 32 个字符";
|
|
3325
|
+
else if (builtin.has(n)) problem = `/${n} 是 agent-loft 内置命令,换个名字`;
|
|
3326
|
+
else if (taken.includes(n)) problem = `已经有 /${n} 这条命令了,换个名字`;
|
|
3327
|
+
else if (!p) problem = "提示词不能为空";
|
|
3328
|
+
err.textContent = problem;
|
|
3329
|
+
if (problem) return;
|
|
3330
|
+
ok.disabled = true;
|
|
3331
|
+
const saved = await onSave({ name: n, description: desc.value.trim(), prompt: p });
|
|
3332
|
+
ok.disabled = false;
|
|
3333
|
+
if (saved) close();
|
|
3334
|
+
};
|
|
3335
|
+
ok.onclick = () => void submit();
|
|
3336
|
+
const close = modal({
|
|
3337
|
+
title: entry ? `编辑 /${entry.name}` : `添加命令 · ${agentLabel(agent)}`,
|
|
3338
|
+
body: h(
|
|
3339
|
+
"div",
|
|
3340
|
+
{ class: "modal-body" },
|
|
3341
|
+
field("命令名", h("div", { class: "cmd-name-input" }, h("span", {}, "/"), name), "小写字母、数字、- 和 _,最长 32 个字符(和飞书指令规则一致,才能同步到飞书)"),
|
|
3342
|
+
field("说明", desc),
|
|
3343
|
+
field(
|
|
3344
|
+
"提示词",
|
|
3345
|
+
prompt,
|
|
3346
|
+
"发送「/命令名 参数」时,把这段话发给 agent。提示词里写 $ARGUMENTS 会换成参数;不写的话,参数接在提示词后面。",
|
|
3347
|
+
),
|
|
3348
|
+
err,
|
|
3349
|
+
),
|
|
3350
|
+
foot: h("div", { class: "modal-foot" }, ok),
|
|
3351
|
+
});
|
|
3352
|
+
setTimeout(() => (entry ? prompt : name).focus(), 0);
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3227
3355
|
/**
|
|
3228
3356
|
* 设置 → 命令:每个 agent 一张表,拖动或用箭头排序,取消勾选就不在「/」列表里出现。
|
|
3229
3357
|
* 保存时把整张表的顺序存下来;以后新装的 skill 不在表里,会自动接在最后。
|
|
3358
|
+
* 用户可以自己添加命令(一段预设的提示词),自己加的可以编辑、删除,增删改后立即保存。
|
|
3230
3359
|
*/
|
|
3231
3360
|
function commandsTab(cfg, panel) {
|
|
3232
3361
|
const agentIds = Object.keys(cfg.agents);
|
|
@@ -3313,9 +3442,16 @@ function commandsTab(cfg, panel) {
|
|
|
3313
3442
|
{ class: "t" },
|
|
3314
3443
|
h("code", {}, commandUsage(c)),
|
|
3315
3444
|
c.source === "agent" ? h("span", { class: "badge" }, agentLabel(agent)) : null,
|
|
3445
|
+
c.source === "custom" ? h("span", { class: "badge custom" }, "自定义") : null,
|
|
3316
3446
|
),
|
|
3317
|
-
h("div", { class: "s" }, oneLine(c.description, 100) || "(无说明)"),
|
|
3447
|
+
h("div", { class: "s" }, oneLine(c.description || c.prompt || "", 100) || "(无说明)"),
|
|
3318
3448
|
),
|
|
3449
|
+
c.source === "custom"
|
|
3450
|
+
? [
|
|
3451
|
+
h("button", { class: "icon-btn", title: "编辑", onclick: () => editCustom(c) }, icon("edit")),
|
|
3452
|
+
h("button", { class: "icon-btn danger", title: "删除", onclick: () => deleteCustom(c) }, icon("trash")),
|
|
3453
|
+
]
|
|
3454
|
+
: null,
|
|
3319
3455
|
h("button", { class: "icon-btn", title: "置顶", onclick: () => move(i, 0) }, "⤒"),
|
|
3320
3456
|
h("button", { class: "icon-btn", title: "上移", onclick: () => move(i, i - 1) }, "↑"),
|
|
3321
3457
|
h("button", { class: "icon-btn", title: "下移", onclick: () => move(i, i + 1) }, "↓"),
|
|
@@ -3326,10 +3462,46 @@ function commandsTab(cfg, panel) {
|
|
|
3326
3462
|
};
|
|
3327
3463
|
|
|
3328
3464
|
const save = async (prefs) => {
|
|
3329
|
-
await saveConfig(cfg, { commands: { [agent]: prefs } });
|
|
3465
|
+
const ok = await saveConfig(cfg, { commands: { [agent]: prefs } });
|
|
3330
3466
|
slash.cache.clear();
|
|
3331
3467
|
// 服务端 3 秒防抖后才开始同步,稍等再看状态
|
|
3332
|
-
setTimeout(() => void refreshFeishu(), 3500);
|
|
3468
|
+
if (ok) setTimeout(() => void refreshFeishu(), 3500);
|
|
3469
|
+
return ok;
|
|
3470
|
+
};
|
|
3471
|
+
const prefsOf = (rows) => ({
|
|
3472
|
+
order: rows.map((c) => c.name),
|
|
3473
|
+
hidden: rows.filter((c) => c.hidden).map((c) => c.name),
|
|
3474
|
+
custom: rows.filter((c) => c.source === "custom").map(({ name, description, prompt }) => ({ name, description, prompt })),
|
|
3475
|
+
});
|
|
3476
|
+
|
|
3477
|
+
// ---- 自定义命令:增删改都立即保存(连同当前的排序和显示),保存失败就不动列表 ----
|
|
3478
|
+
const commit = async (rows) => {
|
|
3479
|
+
if (!(await save(prefsOf(rows)))) return false;
|
|
3480
|
+
list = rows;
|
|
3481
|
+
renderList();
|
|
3482
|
+
return true;
|
|
3483
|
+
};
|
|
3484
|
+
const addCustom = () =>
|
|
3485
|
+
customDialog({ agent, taken: list.map((c) => c.name) }, async (entry) => {
|
|
3486
|
+
// 新命令放在已有的自定义命令后面;还没有的话放在 agent-loft 内置命令后面
|
|
3487
|
+
const rows = [...list];
|
|
3488
|
+
const lastCustom = rows.findLastIndex((c) => c.source === "custom");
|
|
3489
|
+
const at = lastCustom >= 0 ? lastCustom + 1 : rows.findLastIndex((c) => c.source === "agent-loft") + 1;
|
|
3490
|
+
rows.splice(at, 0, { ...entry, source: "custom", runnable: true, hidden: false });
|
|
3491
|
+
return commit(rows);
|
|
3492
|
+
});
|
|
3493
|
+
const editCustom = (c) =>
|
|
3494
|
+
customDialog({ agent, entry: c, taken: list.filter((x) => x !== c).map((x) => x.name) }, (entry) =>
|
|
3495
|
+
commit(list.map((x) => (x === c ? { ...x, ...entry } : x))),
|
|
3496
|
+
);
|
|
3497
|
+
const deleteCustom = async (c) => {
|
|
3498
|
+
const sure = await confirmDialog({
|
|
3499
|
+
title: `删除 /${c.name}?`,
|
|
3500
|
+
message: "删除后在「/」列表和飞书指令面板里都不再出现。",
|
|
3501
|
+
confirmText: "删除",
|
|
3502
|
+
danger: true,
|
|
3503
|
+
});
|
|
3504
|
+
if (sure) await commit(list.filter((x) => x !== c));
|
|
3333
3505
|
};
|
|
3334
3506
|
|
|
3335
3507
|
// ---- 飞书指令面板同步状态 ----
|
|
@@ -3397,7 +3569,12 @@ function commandsTab(cfg, panel) {
|
|
|
3397
3569
|
"在输入框里打「/」时弹出的命令,按这里的顺序排列;保存后会自动同步到飞书机器人的指令面板。取消勾选的不会出现在列表里,但照样可以手动输入。带 agent 名标记的是 agent 自己的命令(skills 等),会原样转给 agent。",
|
|
3398
3570
|
),
|
|
3399
3571
|
tabs,
|
|
3400
|
-
|
|
3572
|
+
h(
|
|
3573
|
+
"div",
|
|
3574
|
+
{ class: "row cmd-tools" },
|
|
3575
|
+
search,
|
|
3576
|
+
h("button", { class: "btn", onclick: () => addCustom() }, icon("plus"), "添加命令"),
|
|
3577
|
+
),
|
|
3401
3578
|
listBox,
|
|
3402
3579
|
h(
|
|
3403
3580
|
"div",
|
|
@@ -3406,7 +3583,7 @@ function commandsTab(cfg, panel) {
|
|
|
3406
3583
|
"button",
|
|
3407
3584
|
{
|
|
3408
3585
|
class: "btn primary",
|
|
3409
|
-
onclick: () => save(
|
|
3586
|
+
onclick: () => save(prefsOf(list)),
|
|
3410
3587
|
},
|
|
3411
3588
|
"保存",
|
|
3412
3589
|
),
|
|
@@ -3415,7 +3592,7 @@ function commandsTab(cfg, panel) {
|
|
|
3415
3592
|
{
|
|
3416
3593
|
class: "btn",
|
|
3417
3594
|
onclick: async () => {
|
|
3418
|
-
if (!(await confirmDialog({ title: "恢复默认?", message: `把 ${agentLabel(agent)}
|
|
3595
|
+
if (!(await confirmDialog({ title: "恢复默认?", message: `把 ${agentLabel(agent)} 的命令顺序和显示恢复成默认(自定义命令保留)。`, confirmText: "恢复默认" }))) return;
|
|
3419
3596
|
await save({ order: [], hidden: [] });
|
|
3420
3597
|
await load(agent);
|
|
3421
3598
|
},
|
package/web/index.html
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<link rel="stylesheet" href="/style.css" />
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
|
20
|
-
<div id="app"><div class="boot"
|
|
20
|
+
<div id="app"><div class="boot"><span class="loft-loader xl" role="img" aria-label="加载中"><svg viewBox="96 56 320 388" aria-hidden="true"><defs><linearGradient id="boot-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><path class="ll-shadow" d="M256 296L404 364L256 432L108 364Z" fill="url(#boot-g)"/><path class="ll-plate" d="M256 252L404 320L256 388L108 320Z" fill="url(#boot-g)"/><path class="ll-star ll-l" 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(#boot-g)"/><path class="ll-star ll-c" 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(#boot-g)"/><path class="ll-star ll-r" 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(#boot-g)"/></svg></span></div></div>
|
|
21
21
|
<script type="module" src="/app.js"></script>
|
|
22
22
|
</body>
|
|
23
23
|
</html>
|
package/web/style.css
CHANGED
|
@@ -1246,6 +1246,182 @@ a.att-file:hover {
|
|
|
1246
1246
|
font-size: 14px;
|
|
1247
1247
|
}
|
|
1248
1248
|
|
|
1249
|
+
/* 回复进行中的实时用时,跟在星星动画后面 */
|
|
1250
|
+
.ai-elapsed {
|
|
1251
|
+
font-size: 13px;
|
|
1252
|
+
color: var(--fg-faint);
|
|
1253
|
+
font-variant-numeric: tabular-nums;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/* ---------- logo 加载动画:三颗星依次跳起,台面跟着呼吸 ---------- */
|
|
1257
|
+
.loft-loader {
|
|
1258
|
+
--size: 26px;
|
|
1259
|
+
display: inline-block;
|
|
1260
|
+
flex-shrink: 0;
|
|
1261
|
+
width: var(--size);
|
|
1262
|
+
height: var(--size);
|
|
1263
|
+
line-height: 0;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
.loft-loader.xl {
|
|
1267
|
+
--size: 72px;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
.loft-loader svg {
|
|
1271
|
+
display: block;
|
|
1272
|
+
width: 100%;
|
|
1273
|
+
height: 100%;
|
|
1274
|
+
overflow: visible;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
.loft-loader path {
|
|
1278
|
+
transform-box: fill-box;
|
|
1279
|
+
transform-origin: 50% 50%;
|
|
1280
|
+
animation-duration: 1.6s;
|
|
1281
|
+
animation-iteration-count: infinite;
|
|
1282
|
+
animation-timing-function: cubic-bezier(0.45, 0, 0.25, 1);
|
|
1283
|
+
animation-fill-mode: both;
|
|
1284
|
+
animation-delay: calc(var(--t, 0ms) + var(--d, 0ms));
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
.loft-loader .ll-star {
|
|
1288
|
+
animation-name: loft-star;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
.loft-loader .ll-l {
|
|
1292
|
+
--d: 0ms;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
.loft-loader .ll-c {
|
|
1296
|
+
--d: 180ms;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
.loft-loader .ll-r {
|
|
1300
|
+
--d: 360ms;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
.loft-loader .ll-plate {
|
|
1304
|
+
animation-name: loft-plate;
|
|
1305
|
+
--d: 260ms;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
.loft-loader .ll-shadow {
|
|
1309
|
+
animation-name: loft-shadow;
|
|
1310
|
+
--d: 320ms;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
@keyframes loft-star {
|
|
1314
|
+
0%,
|
|
1315
|
+
70%,
|
|
1316
|
+
100% {
|
|
1317
|
+
transform: translateY(0) scale(0.72);
|
|
1318
|
+
opacity: 0.4;
|
|
1319
|
+
}
|
|
1320
|
+
28% {
|
|
1321
|
+
transform: translateY(-22px) scale(1.06);
|
|
1322
|
+
opacity: 1;
|
|
1323
|
+
}
|
|
1324
|
+
48% {
|
|
1325
|
+
transform: translateY(0) scale(0.92);
|
|
1326
|
+
opacity: 0.85;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
@keyframes loft-plate {
|
|
1331
|
+
0%,
|
|
1332
|
+
100% {
|
|
1333
|
+
opacity: 0.75;
|
|
1334
|
+
}
|
|
1335
|
+
45% {
|
|
1336
|
+
opacity: 1;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
@keyframes loft-shadow {
|
|
1341
|
+
0%,
|
|
1342
|
+
100% {
|
|
1343
|
+
transform: translateY(-6px);
|
|
1344
|
+
opacity: 0.32;
|
|
1345
|
+
}
|
|
1346
|
+
50% {
|
|
1347
|
+
transform: translateY(6px);
|
|
1348
|
+
opacity: 0.12;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1353
|
+
.loft-loader .ll-star {
|
|
1354
|
+
animation-name: loft-star-calm;
|
|
1355
|
+
animation-duration: 2.4s;
|
|
1356
|
+
}
|
|
1357
|
+
.loft-loader .ll-shadow {
|
|
1358
|
+
animation: none;
|
|
1359
|
+
opacity: 0.3;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/* 回复末尾的精简版:只有一颗星,旋转 + 放大缩小 */
|
|
1364
|
+
.loft-stars {
|
|
1365
|
+
display: inline-block;
|
|
1366
|
+
flex-shrink: 0;
|
|
1367
|
+
width: 18px;
|
|
1368
|
+
height: 18px;
|
|
1369
|
+
line-height: 0;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/* 打开会话、读历史时用的大一号 */
|
|
1373
|
+
.loft-stars.lg {
|
|
1374
|
+
width: 32px;
|
|
1375
|
+
height: 32px;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
.loft-stars svg {
|
|
1379
|
+
display: block;
|
|
1380
|
+
width: 100%;
|
|
1381
|
+
height: 100%;
|
|
1382
|
+
overflow: visible;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
.loft-stars path {
|
|
1386
|
+
transform-box: fill-box;
|
|
1387
|
+
transform-origin: 50% 50%;
|
|
1388
|
+
animation: loft-twirl 1.8s cubic-bezier(0.45, 0, 0.25, 1) infinite both;
|
|
1389
|
+
animation-delay: var(--t, 0ms);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/* 四角星转 90° 正好回到原样,所以每轮转 180° 能无缝接上 */
|
|
1393
|
+
@keyframes loft-twirl {
|
|
1394
|
+
0% {
|
|
1395
|
+
transform: rotate(0deg) scale(0.6);
|
|
1396
|
+
opacity: 0.55;
|
|
1397
|
+
}
|
|
1398
|
+
45% {
|
|
1399
|
+
transform: rotate(110deg) scale(1.12);
|
|
1400
|
+
opacity: 1;
|
|
1401
|
+
}
|
|
1402
|
+
100% {
|
|
1403
|
+
transform: rotate(180deg) scale(0.6);
|
|
1404
|
+
opacity: 0.55;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1409
|
+
.loft-stars path {
|
|
1410
|
+
animation-name: loft-star-calm;
|
|
1411
|
+
animation-duration: 2.4s;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
@keyframes loft-star-calm {
|
|
1416
|
+
0%,
|
|
1417
|
+
100% {
|
|
1418
|
+
opacity: 0.4;
|
|
1419
|
+
}
|
|
1420
|
+
40% {
|
|
1421
|
+
opacity: 1;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1249
1425
|
.footer-note {
|
|
1250
1426
|
font-size: 12px;
|
|
1251
1427
|
color: var(--fg-faint);
|
|
@@ -2736,6 +2912,59 @@ button.list-item:hover {
|
|
|
2736
2912
|
width: auto;
|
|
2737
2913
|
}
|
|
2738
2914
|
|
|
2915
|
+
.cmd-row .icon-btn svg {
|
|
2916
|
+
width: 16px;
|
|
2917
|
+
height: 16px;
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
.cmd-row .icon-btn.danger:hover {
|
|
2921
|
+
color: var(--danger);
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
.cmd-row .badge.custom {
|
|
2925
|
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
2926
|
+
color: var(--accent);
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
/* 筛选框 + 「添加命令」按钮一行 */
|
|
2930
|
+
.cmd-tools {
|
|
2931
|
+
flex-wrap: nowrap;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
.cmd-tools .input {
|
|
2935
|
+
flex: 1;
|
|
2936
|
+
min-width: 0;
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
.cmd-tools .btn {
|
|
2940
|
+
flex-shrink: 0;
|
|
2941
|
+
display: inline-flex;
|
|
2942
|
+
align-items: center;
|
|
2943
|
+
gap: 4px;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
.cmd-tools .btn svg {
|
|
2947
|
+
width: 16px;
|
|
2948
|
+
height: 16px;
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
/* 添加 / 编辑命令:命令名前面固定一个「/」 */
|
|
2952
|
+
.cmd-name-input {
|
|
2953
|
+
display: flex;
|
|
2954
|
+
align-items: center;
|
|
2955
|
+
gap: 6px;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
.cmd-name-input > span {
|
|
2959
|
+
color: var(--fg-faint);
|
|
2960
|
+
font-family: var(--mono);
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
.field .cmd-name-input input {
|
|
2964
|
+
flex: 1;
|
|
2965
|
+
font-family: var(--mono);
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2739
2968
|
.brand-icon {
|
|
2740
2969
|
display: inline-grid;
|
|
2741
2970
|
width: 18px;
|