agent-loft 0.2.28 → 0.2.30

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/channels/web/server.ts +1 -1
  3. package/web/app.js +8 -5555
  4. package/web/css/base.css +291 -0
  5. package/web/css/composer.css +506 -0
  6. package/web/css/loader.css +196 -0
  7. package/web/css/messages.css +734 -0
  8. package/web/css/mobile.css +100 -0
  9. package/web/css/overlay.css +437 -0
  10. package/web/css/schedule.css +93 -0
  11. package/web/css/settings.css +335 -0
  12. package/web/css/sidebar.css +593 -0
  13. package/web/css/topbar.css +205 -0
  14. package/web/index.html +11 -1
  15. package/web/lib/api.js +26 -0
  16. package/web/lib/dom.js +101 -0
  17. package/web/lib/markdown.js +52 -0
  18. package/web/lib/overlay.js +144 -0
  19. package/web/lib/push.js +70 -0
  20. package/web/lib/time.js +60 -0
  21. package/web/lib/ws.js +232 -0
  22. package/web/settings/agents.js +210 -0
  23. package/web/settings/commands.js +411 -0
  24. package/web/settings/common.js +42 -0
  25. package/web/settings/feishu.js +181 -0
  26. package/web/settings/general.js +184 -0
  27. package/web/settings/index.js +63 -0
  28. package/web/settings/memory.js +366 -0
  29. package/web/settings/notify.js +80 -0
  30. package/web/settings/security.js +188 -0
  31. package/web/settings/users.js +189 -0
  32. package/web/state.js +44 -0
  33. package/web/views/auth.js +61 -0
  34. package/web/views/composer.js +324 -0
  35. package/web/views/conv-actions.js +404 -0
  36. package/web/views/interaction.js +140 -0
  37. package/web/views/layout.js +186 -0
  38. package/web/views/messages.js +510 -0
  39. package/web/views/quota.js +145 -0
  40. package/web/views/schedule.js +120 -0
  41. package/web/views/sidebar.js +455 -0
  42. package/web/views/slash.js +185 -0
  43. package/web/views/topbar.js +394 -0
  44. package/web/views/voice.js +189 -0
  45. package/web/style.css +0 -3500
@@ -0,0 +1,324 @@
1
+ import { fill, h, icon, ICON, isTouch, toast } from "../lib/dom.js";
2
+ import { api } from "../lib/api.js";
3
+ import { desktop, S, ui } from "../state.js";
4
+ import { WS } from "../lib/ws.js";
5
+ import { agentLabel, renderSidebar } from "./sidebar.js";
6
+ import { draftPick, renderContext, renderTopbar, sentKeys, updateCtxVisibility } from "./topbar.js";
7
+ import { addItem, openConversation, openLightbox, renderEmpty, scrollToBottom } from "./messages.js";
8
+ import { renderScheduled } from "./schedule.js";
9
+ import { cancelVoice, SpeechRec, toggleVoice } from "./voice.js";
10
+ import { closeSlashMenu, commandUsage, oneLine, slashKeydown, updateSlashMenu } from "./slash.js";
11
+ import { doAction, openModelPicker, openSessions } from "./conv-actions.js";
12
+
13
+ // ---------- 输入框 ----------
14
+
15
+ export function renderComposer() {
16
+ cancelVoice();
17
+ ui.input = h("textarea", {
18
+ rows: 1,
19
+ placeholder: "随心输入",
20
+ oninput: () => {
21
+ // 识别过程中用户自己动了输入框,以用户为准,停掉识别
22
+ cancelVoice();
23
+ autosize();
24
+ void updateSlashMenu();
25
+ },
26
+ onblur: () => setTimeout(closeSlashMenu, 150),
27
+ onkeydown: (e) => {
28
+ if (slashKeydown(e)) {
29
+ e.preventDefault();
30
+ return;
31
+ }
32
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing && !isTouch) {
33
+ e.preventDefault();
34
+ void sendMessage();
35
+ }
36
+ },
37
+ onpaste: (e) => {
38
+ const files = [...(e.clipboardData?.files ?? [])];
39
+ if (files.length) {
40
+ e.preventDefault();
41
+ void addFiles(files);
42
+ }
43
+ },
44
+ });
45
+ ui.fileInput = h("input", {
46
+ type: "file",
47
+ multiple: true,
48
+ style: { display: "none" },
49
+ onchange: (e) => {
50
+ void addFiles([...e.target.files]);
51
+ e.target.value = "";
52
+ },
53
+ });
54
+ ui.sendBtn = h("button", { class: "send-btn", title: "发送", onclick: () => (S.busy && !hasDraft() ? stopTurn() : sendMessage()) });
55
+ // 桌面端(Electron)里不放语音输入
56
+ ui.micBtn = SpeechRec && !desktop
57
+ ? h("button", { class: "icon-btn mic-btn", title: "语音输入", onclick: toggleVoice }, icon("mic"))
58
+ : null;
59
+ ui.ctxBar = h("div", { class: "ctx-bar" });
60
+ ui.modelSlot = h("span", { class: "model-slot" });
61
+ ui.ctxRing = h("span", { class: "ctx-ring-slot" });
62
+ ui.chips = h("div", { class: "chips" });
63
+ ui.schedBar = h("div", { class: "sched-bar", hidden: true });
64
+ ui.slashMenu = h("div", { class: "slash-menu", hidden: true });
65
+ ui.composer = h(
66
+ "div",
67
+ {
68
+ class: "composer",
69
+ ondragover: (e) => {
70
+ e.preventDefault();
71
+ ui.composer.classList.add("drag");
72
+ },
73
+ ondragleave: () => ui.composer.classList.remove("drag"),
74
+ ondrop: (e) => {
75
+ e.preventDefault();
76
+ ui.composer.classList.remove("drag");
77
+ void addFiles([...(e.dataTransfer?.files ?? [])]);
78
+ },
79
+ },
80
+ ui.slashMenu,
81
+ ui.chips,
82
+ ui.input,
83
+ h(
84
+ "div",
85
+ { class: "composer-row" },
86
+ h("button", { class: "icon-btn add-btn", title: "添加附件", onclick: () => ui.fileInput.click() }, icon("plus")),
87
+ ui.fileInput,
88
+ h("span", { class: "spacer" }),
89
+ ui.ctxRing,
90
+ ui.modelSlot,
91
+ ui.micBtn,
92
+ ui.sendBtn,
93
+ ),
94
+ );
95
+ fill(ui.composerWrap,
96
+ ui.toBottom,
97
+ h("div", { class: "composer-box" }, ui.schedBar, ui.ctxBar, ui.composer),
98
+ );
99
+ ui.input.addEventListener("input", updateSendBtn);
100
+ updateSendBtn();
101
+ renderContext();
102
+ renderScheduled();
103
+ }
104
+
105
+ export function autosize() {
106
+ ui.input.style.height = "auto";
107
+ ui.input.style.height = `${Math.min(ui.input.scrollHeight, innerHeight * 0.4)}px`;
108
+ }
109
+
110
+ function hasDraft() {
111
+ return !!ui.input?.value.trim() || S.uploads.length > 0;
112
+ }
113
+
114
+ export function updateSendBtn() {
115
+ if (!ui.sendBtn) return;
116
+ // 已经点了发送、还在等服务器确认:转圈,不能再点,避免以为没点上又点一次
117
+ if (S.sending) {
118
+ ui.sendBtn.innerHTML = '<span class="send-spinner"></span>';
119
+ ui.sendBtn.title = "发送中…";
120
+ ui.sendBtn.disabled = true;
121
+ return;
122
+ }
123
+ const stop = S.busy && !hasDraft();
124
+ ui.sendBtn.innerHTML = stop ? ICON.stop : ICON.send;
125
+ ui.sendBtn.title = stop ? "停止" : "发送";
126
+ ui.sendBtn.disabled = !stop && !hasDraft();
127
+ }
128
+
129
+ export function setBusy(busy) {
130
+ S.busy = !!busy;
131
+ updateSendBtn();
132
+ }
133
+
134
+ export function renderChips() {
135
+ if (!ui.chips) return;
136
+ const remove = (u) => {
137
+ S.uploads = S.uploads.filter((x) => x !== u);
138
+ if (u.url) URL.revokeObjectURL(u.url);
139
+ renderChips();
140
+ };
141
+ fill(ui.chips,
142
+ ...S.uploads.map((u) =>
143
+ u.url
144
+ ? h(
145
+ "span",
146
+ { class: `chip-img${u.uploading ? " uploading" : ""}`, title: u.name },
147
+ h("img", { src: u.url, alt: u.name, onclick: () => openLightbox(u.url, u.name) }),
148
+ u.uploading ? h("span", { class: "spinner" }) : null,
149
+ h("button", { class: "chip-x", title: "移除", onclick: () => remove(u) }, "×"),
150
+ )
151
+ : h(
152
+ "span",
153
+ { class: "chip" },
154
+ u.uploading ? h("span", { class: "spinner" }) : "📄",
155
+ h("span", {}, u.name),
156
+ h("button", { title: "移除", onclick: () => remove(u) }, "×"),
157
+ ),
158
+ ),
159
+ );
160
+ updateSendBtn();
161
+ }
162
+
163
+ export async function ensureConversation() {
164
+ if (S.current) return S.current;
165
+ if (!S.draftAgent) throw new Error("没有可用的 agent");
166
+ let info;
167
+ try {
168
+ info = await api("/api/conversations", {
169
+ method: "POST",
170
+ body: { agent: S.draftAgent, cwd: S.draftCwd ?? undefined, mode: S.draftMode },
171
+ });
172
+ } catch (err) {
173
+ // 选的目录没了(被删、或工作区改了):退回根目录,让用户重新确认
174
+ if (err.status === 400 && S.draftCwd) {
175
+ S.draftCwd = null;
176
+ renderEmpty();
177
+ }
178
+ throw err;
179
+ }
180
+ // 新对话里选过模型 / 思考程度:建好会话后先切过去再发消息
181
+ const pick = { ...draftPick() };
182
+ for (const [action, value] of [["model", pick.model], ["effort", pick.effort]]) {
183
+ if (!value) continue;
184
+ try {
185
+ const res = await api(`/api/conversations/${encodeURIComponent(info.key)}/action`, { method: "POST", body: { action, value } });
186
+ info = res.info;
187
+ } catch (err) {
188
+ toast(`${action === "model" ? "切换模型" : "切换思考程度"}失败:${err.message}`);
189
+ }
190
+ }
191
+ S.convs.set(info.key, info);
192
+ S.current = info.key;
193
+ S.allowedAgents = S.agents.filter((a) => a.enabled).map((a) => a.id);
194
+ S.seq.set(info.key, 0);
195
+ history.replaceState(null, "", `#${encodeURIComponent(info.key)}`);
196
+ WS.presence();
197
+ fill(ui.msgInner, );
198
+ WS.subscribe(info.key, 0);
199
+ renderTopbar();
200
+ renderSidebar();
201
+ return info.key;
202
+ }
203
+
204
+ async function addFiles(files) {
205
+ if (!files.length) return;
206
+ let key;
207
+ try {
208
+ key = await ensureConversation();
209
+ } catch (err) {
210
+ toast(err.message);
211
+ return;
212
+ }
213
+ for (const file of files) {
214
+ const u = { name: file.name || "粘贴的图片.png", kind: file.type.startsWith("image/") ? "image" : "file", uploading: true };
215
+ // 图片用本地文件直接做缩略图,不用等上传完、也不用再从服务器取
216
+ if (u.kind === "image") u.url = URL.createObjectURL(file);
217
+ S.uploads.push(u);
218
+ renderChips();
219
+ try {
220
+ const res = await api(`/api/conversations/${encodeURIComponent(key)}/upload?name=${encodeURIComponent(u.name)}`, {
221
+ method: "POST",
222
+ body: file,
223
+ headers: { "Content-Type": file.type || "application/octet-stream" },
224
+ });
225
+ Object.assign(u, { id: res.id, kind: res.kind, uploading: false, serverName: res.name });
226
+ if (u.kind !== "image" && u.url) (URL.revokeObjectURL(u.url), delete u.url);
227
+ } catch (err) {
228
+ S.uploads = S.uploads.filter((x) => x !== u);
229
+ if (u.url) URL.revokeObjectURL(u.url);
230
+ toast(`${u.name} 上传失败:${err.message}`);
231
+ }
232
+ renderChips();
233
+ }
234
+ }
235
+
236
+ /** 发送中:按钮转圈、输入框只读(确认后要清空它,期间再打的字会丢) */
237
+ function setSending(on) {
238
+ S.sending = on;
239
+ if (ui.input) {
240
+ ui.input.readOnly = on;
241
+ ui.input.classList.toggle("sending", on);
242
+ }
243
+ updateSendBtn();
244
+ }
245
+
246
+ export async function sendMessage() {
247
+ if (S.sending) return;
248
+ cancelVoice();
249
+ const text = ui.input.value;
250
+ if (!text.trim() && S.uploads.length === 0) return;
251
+ if (S.uploads.some((u) => u.uploading)) {
252
+ toast("附件还在上传,稍等");
253
+ return;
254
+ }
255
+ closeSlashMenu();
256
+ let key;
257
+ let res;
258
+ setSending(true);
259
+ // 发出去之后恢复自动滚动;回显的那条用户消息到了就顶到最上面(见 applyEvent)
260
+ S.pinNext = true;
261
+ try {
262
+ key = await ensureConversation();
263
+ if ("Notification" in window && Notification.permission === "default") void Notification.requestPermission();
264
+ // 命令(比如 /status 要现读额度)可能比普通发送慢,多给点时间
265
+ res = await WS.request({ t: "send", key, text, uploads: S.uploads.map((u) => u.id) }, 45000);
266
+ } catch (err) {
267
+ S.pinNext = false;
268
+ toast(err.message);
269
+ return;
270
+ } finally {
271
+ setSending(false);
272
+ }
273
+ if (!res?.reply) {
274
+ sentKeys.add(key);
275
+ updateCtxVisibility();
276
+ }
277
+ ui.input.value = "";
278
+ autosize();
279
+ for (const u of S.uploads) if (u.url) URL.revokeObjectURL(u.url);
280
+ S.uploads = [];
281
+ renderChips();
282
+ updateSendBtn();
283
+ if (res?.reply) {
284
+ // 命令不进会话记录,没有回显:直接贴到底看结果
285
+ S.pinNext = false;
286
+ await showCommandReply(text.trim(), res.reply, res.info);
287
+ scrollToBottom();
288
+ } else if (!ui.pinEl) ui.follow = true;
289
+ }
290
+
291
+ /** 会改变当前会话(换会话、换目录、换 agent)的命令,执行完要重拉一遍 */
292
+ const RELOADING_COMMANDS = new Set(["new", "fork", "resume", "agent", "claude", "codex", "cd"]);
293
+
294
+ /** agent-loft 命令的结果:只显示给发命令的这个页面,不进会话记录 */
295
+ async function showCommandReply(text, reply, info) {
296
+ if (info) S.convs.set(info.key, info);
297
+ renderTopbar();
298
+ const head = text.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
299
+ if (RELOADING_COMMANDS.has(head) && S.current) await openConversation(S.current);
300
+ const note = (md) => addItem({ type: "notice", text: md });
301
+ switch (reply.type) {
302
+ case "text":
303
+ note(`\`${text}\`\n\n${reply.text}`);
304
+ return;
305
+ case "sessions":
306
+ await openSessions(reply.allDirs);
307
+ return;
308
+ case "models":
309
+ await openModelPicker();
310
+ return;
311
+ case "agents":
312
+ note(
313
+ `当前 agent:**${agentLabel(reply.current)}**\n\n可切换:${reply.agents.map((a) => `\`/${a.id}\` ${a.label}`).join(" ")}`,
314
+ );
315
+ return;
316
+ case "commands":
317
+ note(reply.commands.map((c) => `\`${commandUsage(c)}\` ${oneLine(c.description)}`).join("\n\n") || "没有可用命令");
318
+ return;
319
+ }
320
+ }
321
+
322
+ async function stopTurn() {
323
+ await doAction("stop");
324
+ }