agent-loft 0.2.29 → 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.
- package/package.json +1 -1
- package/src/channels/web/server.ts +1 -1
- package/web/app.js +8 -5560
- package/web/css/base.css +291 -0
- package/web/css/composer.css +506 -0
- package/web/css/loader.css +196 -0
- package/web/css/messages.css +734 -0
- package/web/css/mobile.css +100 -0
- package/web/css/overlay.css +437 -0
- package/web/css/schedule.css +93 -0
- package/web/css/settings.css +335 -0
- package/web/css/sidebar.css +593 -0
- package/web/css/topbar.css +205 -0
- package/web/index.html +11 -1
- package/web/lib/api.js +26 -0
- package/web/lib/dom.js +101 -0
- package/web/lib/markdown.js +52 -0
- package/web/lib/overlay.js +144 -0
- package/web/lib/push.js +70 -0
- package/web/lib/time.js +60 -0
- package/web/lib/ws.js +232 -0
- package/web/settings/agents.js +210 -0
- package/web/settings/commands.js +411 -0
- package/web/settings/common.js +42 -0
- package/web/settings/feishu.js +181 -0
- package/web/settings/general.js +184 -0
- package/web/settings/index.js +63 -0
- package/web/settings/memory.js +366 -0
- package/web/settings/notify.js +80 -0
- package/web/settings/security.js +188 -0
- package/web/settings/users.js +189 -0
- package/web/state.js +44 -0
- package/web/views/auth.js +61 -0
- package/web/views/composer.js +324 -0
- package/web/views/conv-actions.js +404 -0
- package/web/views/interaction.js +140 -0
- package/web/views/layout.js +186 -0
- package/web/views/messages.js +510 -0
- package/web/views/quota.js +145 -0
- package/web/views/schedule.js +120 -0
- package/web/views/sidebar.js +455 -0
- package/web/views/slash.js +185 -0
- package/web/views/topbar.js +394 -0
- package/web/views/voice.js +189 -0
- package/web/style.css +0 -3500
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { agentIcon, fill, h, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { relTime } from "../lib/time.js";
|
|
4
|
+
import { S, ui } from "../state.js";
|
|
5
|
+
import { agentLabel, convTitle, renderSidebar } from "./sidebar.js";
|
|
6
|
+
import { agentCatalog, baseName, catalogDefaults, draftPick, modelName, renderContext, renderTopbar, saveDraftPick, visibleModels } from "./topbar.js";
|
|
7
|
+
import { closeMenu, confirmDialog, modal, placePopover, showPopover } from "../lib/overlay.js";
|
|
8
|
+
import { newDraft, openConversation, renderEmpty } from "./messages.js";
|
|
9
|
+
|
|
10
|
+
// ---------- 会话操作 ----------
|
|
11
|
+
|
|
12
|
+
export async function doAction(action, value, extra = {}) {
|
|
13
|
+
if (!S.current) return;
|
|
14
|
+
try {
|
|
15
|
+
const res = await api(`/api/conversations/${encodeURIComponent(S.current)}/action`, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
body: { action, value, ...extra },
|
|
18
|
+
});
|
|
19
|
+
S.convs.set(res.info.key, res.info);
|
|
20
|
+
renderTopbar();
|
|
21
|
+
toast(res.text);
|
|
22
|
+
if (["new", "fork", "resume", "agent", "cd"].includes(action)) await openConversation(S.current);
|
|
23
|
+
return res;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
toast(err.message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 分叉成一条新对话(anchor 给了就到那条回复为止),然后切过去。原对话不动 */
|
|
30
|
+
export async function forkConversation(anchor, key = S.current) {
|
|
31
|
+
if (!key) return;
|
|
32
|
+
try {
|
|
33
|
+
const res = await api(`/api/conversations/${encodeURIComponent(key)}/fork`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
body: anchor ? { anchor } : {},
|
|
36
|
+
});
|
|
37
|
+
S.convs.set(res.info.key, res.info);
|
|
38
|
+
toast("已分叉成新对话,原对话保留");
|
|
39
|
+
await openConversation(res.info.key);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
toast(err.message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function switchAgent(id) {
|
|
46
|
+
if (!S.current) {
|
|
47
|
+
S.draftAgent = id;
|
|
48
|
+
localStorage.setItem("aim.agent", id);
|
|
49
|
+
renderTopbar();
|
|
50
|
+
renderEmpty();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
await doAction("agent", id);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 模型 / 思考程度选择:已有会话直接切,新对话先记下,发第一条消息时带过去 */
|
|
57
|
+
export async function openModelPicker(anchor = ui.modelSlot) {
|
|
58
|
+
if (!anchor) return;
|
|
59
|
+
if (ui.menu?.dataset.for === "model") return closeMenu();
|
|
60
|
+
const pop = showPopover(anchor);
|
|
61
|
+
pop.dataset.for = "model";
|
|
62
|
+
pop.classList.add("model-pop");
|
|
63
|
+
fill(pop, h("div", { class: "pop-loading" }, h("span", { class: "spinner" })));
|
|
64
|
+
placePopover(pop, anchor);
|
|
65
|
+
|
|
66
|
+
const conv = S.current ? S.convs.get(S.current) : null;
|
|
67
|
+
let models;
|
|
68
|
+
let current;
|
|
69
|
+
let currentEffort;
|
|
70
|
+
let effortSupported = true;
|
|
71
|
+
try {
|
|
72
|
+
if (conv) {
|
|
73
|
+
// 顺便把这个 agent 的默认模型 / 显示名准备好(缓存过就不会再请求)
|
|
74
|
+
const [data] = await Promise.all([
|
|
75
|
+
api(`/api/conversations/${encodeURIComponent(S.current)}/models`),
|
|
76
|
+
agentCatalog(conv.agent).catch(() => []),
|
|
77
|
+
]);
|
|
78
|
+
({ models, current, currentEffort, effortSupported } = data);
|
|
79
|
+
} else {
|
|
80
|
+
models = await agentCatalog(S.draftAgent);
|
|
81
|
+
const pick = draftPick();
|
|
82
|
+
current = pick.model ?? (models.find((m) => m.isDefault) ?? models.find((m) => m.id === "default"))?.id;
|
|
83
|
+
currentEffort = pick.effort;
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (ui.menu === pop) fill(pop, h("div", { class: "pop-error" }, err.message));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (ui.menu !== pop) return;
|
|
90
|
+
|
|
91
|
+
const choose = async (kind, value) => {
|
|
92
|
+
if (kind === "model") current = value;
|
|
93
|
+
else currentEffort = value;
|
|
94
|
+
if (conv) {
|
|
95
|
+
await doAction(kind, value);
|
|
96
|
+
} else {
|
|
97
|
+
draftPick()[kind] = value;
|
|
98
|
+
saveDraftPick();
|
|
99
|
+
renderContext();
|
|
100
|
+
}
|
|
101
|
+
if (kind === "effort") closeMenu();
|
|
102
|
+
else if (ui.menu === pop) render();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const agent = conv ? conv.agent : S.draftAgent;
|
|
106
|
+
// 当前值可能是别名、完整名或空(默认):先按 id 对,对不上再按显示名对,只勾一项
|
|
107
|
+
const currentItem = (list) => {
|
|
108
|
+
const byId = list.find((m) => m.id === current);
|
|
109
|
+
if (byId) return byId;
|
|
110
|
+
const name = modelName(agent, current);
|
|
111
|
+
return list.find((m) => m.id !== "default" && (m.label || m.id) === name) ?? null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function render() {
|
|
115
|
+
const shown = visibleModels(agent, models);
|
|
116
|
+
const on = currentItem(shown);
|
|
117
|
+
const efforts = (on ?? currentItem(models))?.efforts ?? models.find((m) => m.efforts?.length)?.efforts ?? [];
|
|
118
|
+
if (!currentEffort) currentEffort = catalogDefaults.get(agent)?.effort;
|
|
119
|
+
fill(
|
|
120
|
+
pop,
|
|
121
|
+
h("div", { class: "pop-head" }, "模型"),
|
|
122
|
+
shown.map((m) =>
|
|
123
|
+
h(
|
|
124
|
+
"button",
|
|
125
|
+
{ class: `pop-item${m === on ? " on" : ""}`, onclick: () => void choose("model", m.id) },
|
|
126
|
+
h("span", { class: "grow" }, h("span", { class: "t" }, m.label || m.id), m.description ? h("span", { class: "s" }, m.description) : null),
|
|
127
|
+
m === on ? h("span", { class: "check" }, "✓") : null,
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
effortSupported && efforts.length
|
|
131
|
+
? [
|
|
132
|
+
h("div", { class: "sep" }),
|
|
133
|
+
h("div", { class: "pop-head" }, "思考程度"),
|
|
134
|
+
h(
|
|
135
|
+
"div",
|
|
136
|
+
{ class: "effort-seg" },
|
|
137
|
+
efforts.map((e) => h("button", { class: e === currentEffort ? "on" : "", onclick: () => void choose("effort", e) }, e)),
|
|
138
|
+
),
|
|
139
|
+
]
|
|
140
|
+
: null,
|
|
141
|
+
);
|
|
142
|
+
placePopover(pop, anchor);
|
|
143
|
+
}
|
|
144
|
+
render();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function openSessions(all) {
|
|
148
|
+
const body = h("div", { class: "modal-body" }, h("span", { class: "spinner" }));
|
|
149
|
+
const close = modal({ title: "历史会话", body });
|
|
150
|
+
try {
|
|
151
|
+
const data = await api(`/api/conversations/${encodeURIComponent(S.current)}/sessions${all ? "?all=1" : ""}`);
|
|
152
|
+
fill(body,
|
|
153
|
+
h(
|
|
154
|
+
"div",
|
|
155
|
+
{ class: "muted" },
|
|
156
|
+
data.allDirs ? (data.fellBack ? `${data.cwd} 下没有历史会话,已列出所有目录的` : "所有目录的最近会话") : `目录 ${data.cwd}`,
|
|
157
|
+
!data.allDirs
|
|
158
|
+
? h(
|
|
159
|
+
"button",
|
|
160
|
+
{
|
|
161
|
+
class: "btn small",
|
|
162
|
+
style: { marginLeft: "8px" },
|
|
163
|
+
onclick: () => {
|
|
164
|
+
close();
|
|
165
|
+
void openSessions(true);
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
"看所有目录",
|
|
169
|
+
)
|
|
170
|
+
: null,
|
|
171
|
+
),
|
|
172
|
+
...(data.sessions.length === 0
|
|
173
|
+
? [h("div", { class: "muted" }, "还没有历史会话")]
|
|
174
|
+
: data.sessions.map((s) =>
|
|
175
|
+
h(
|
|
176
|
+
"button",
|
|
177
|
+
{
|
|
178
|
+
class: `list-item${s.id === data.current ? " on" : ""}`,
|
|
179
|
+
onclick: async () => {
|
|
180
|
+
if (s.id === data.current) return;
|
|
181
|
+
close();
|
|
182
|
+
await doAction("resume", s.id, s.cwd ? { cwd: s.cwd } : {});
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
h(
|
|
186
|
+
"div",
|
|
187
|
+
{ class: "grow" },
|
|
188
|
+
h("div", { class: "t" }, s.title),
|
|
189
|
+
h("div", { class: "s" }, `${relTime(s.updatedAt)}${data.allDirs && s.cwd ? ` · ${s.cwd}` : ""}`),
|
|
190
|
+
),
|
|
191
|
+
s.id === data.current ? "当前" : null,
|
|
192
|
+
),
|
|
193
|
+
)),
|
|
194
|
+
);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
fill(body, h("div", { class: "error-text" }, err.message));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 目录选择器:一层层点进去选,也能手动输入。只能在 workspaceRoot 之内 */
|
|
201
|
+
export async function changeCwd() {
|
|
202
|
+
// 还没发消息的新对话:只记下选择,创建会话时带过去
|
|
203
|
+
const draft = !S.current;
|
|
204
|
+
const conv = draft ? null : S.convs.get(S.current);
|
|
205
|
+
const current = draft ? (S.draftCwd ?? S.workspaceRoot ?? "") : (conv?.cwd ?? "");
|
|
206
|
+
const goLabel = draft ? "使用此目录" : "切换到此目录";
|
|
207
|
+
let showHidden = localStorage.getItem("aim.cwdHidden") === "1";
|
|
208
|
+
let listing = null;
|
|
209
|
+
let seq = 0;
|
|
210
|
+
|
|
211
|
+
const crumbs = h("div", { class: "dir-crumbs" });
|
|
212
|
+
const list = h("div", { class: "dir-list" });
|
|
213
|
+
const hiddenBox = h("input", { type: "checkbox", checked: showHidden });
|
|
214
|
+
hiddenBox.onchange = () => {
|
|
215
|
+
showHidden = hiddenBox.checked;
|
|
216
|
+
localStorage.setItem("aim.cwdHidden", showHidden ? "1" : "0");
|
|
217
|
+
renderList();
|
|
218
|
+
};
|
|
219
|
+
const manual = h("input", { class: "dir-manual", placeholder: "或直接输入路径(相对工作区,回车跳转)" });
|
|
220
|
+
manual.onkeydown = (e) => {
|
|
221
|
+
if (e.key === "Enter" && manual.value.trim()) {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
void load(manual.value.trim());
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const body = h(
|
|
227
|
+
"div",
|
|
228
|
+
{ class: "modal-body" },
|
|
229
|
+
crumbs,
|
|
230
|
+
h("div", { class: "row", style: { gap: "8px", alignItems: "center" } }, manual, h("label", { class: "check" }, hiddenBox, "显示隐藏")),
|
|
231
|
+
list,
|
|
232
|
+
);
|
|
233
|
+
const go = h("button", { class: "btn primary", disabled: true }, goLabel);
|
|
234
|
+
const note = h("span", { class: "muted grow" }, draft ? "发送第一条消息时在这个目录开会话" : "切换后开新会话");
|
|
235
|
+
const close = modal({
|
|
236
|
+
title: draft ? "选择工作目录" : "切换工作目录",
|
|
237
|
+
body,
|
|
238
|
+
foot: h("div", { class: "modal-foot", style: { alignItems: "center" } }, note, go),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
go.onclick = async () => {
|
|
242
|
+
if (!listing) return;
|
|
243
|
+
close();
|
|
244
|
+
if (draft) {
|
|
245
|
+
S.draftCwd = listing.path === listing.root ? null : listing.path;
|
|
246
|
+
if (!S.current) renderEmpty();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
await doAction("cd", listing.path);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
function renderCrumbs() {
|
|
253
|
+
const parts = listing.rel ? listing.rel.split(/[\\/]/) : [];
|
|
254
|
+
const rootName = listing.root.split(/[\\/]/).filter(Boolean).pop() || listing.root;
|
|
255
|
+
const items = [h("button", { class: "crumb", title: listing.root, onclick: () => load("") }, rootName)];
|
|
256
|
+
parts.forEach((name, i) => {
|
|
257
|
+
items.push(h("span", { class: "crumb-sep" }, "/"));
|
|
258
|
+
const rel = parts.slice(0, i + 1).join("/");
|
|
259
|
+
items.push(h("button", { class: "crumb", onclick: () => load(rel) }, name));
|
|
260
|
+
});
|
|
261
|
+
fill(crumbs, items);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function renderList() {
|
|
265
|
+
if (!listing) return;
|
|
266
|
+
const join = (name) => (listing.rel ? `${listing.rel}/${name}` : name);
|
|
267
|
+
const dirs = listing.dirs.filter((d) => showHidden || !d.hidden);
|
|
268
|
+
const hiddenCount = listing.dirs.length - dirs.length;
|
|
269
|
+
fill(
|
|
270
|
+
list,
|
|
271
|
+
listing.rel
|
|
272
|
+
? h(
|
|
273
|
+
"button",
|
|
274
|
+
{ class: "list-item dir-item", onclick: () => load(listing.rel.split(/[\\/]/).slice(0, -1).join("/")) },
|
|
275
|
+
h("span", { class: "dir-icon" }, "↩"),
|
|
276
|
+
h("div", { class: "grow" }, h("div", { class: "t" }, "..")),
|
|
277
|
+
)
|
|
278
|
+
: null,
|
|
279
|
+
dirs.length === 0 ? h("div", { class: "muted" }, hiddenCount ? `没有子目录(${hiddenCount} 个隐藏目录未显示)` : "没有子目录") : null,
|
|
280
|
+
dirs.map((d) => {
|
|
281
|
+
const abs = `${listing.path.replace(/[\\/]+$/, "")}/${d.name}`;
|
|
282
|
+
const isCurrent = abs === current;
|
|
283
|
+
return h(
|
|
284
|
+
"button",
|
|
285
|
+
{ class: `list-item dir-item${isCurrent ? " on" : ""}`, onclick: () => load(join(d.name)) },
|
|
286
|
+
h("span", { class: "dir-icon" }, "📁"),
|
|
287
|
+
h("div", { class: "grow" }, h("div", { class: "t" }, d.name)),
|
|
288
|
+
isCurrent ? h("span", { class: "muted" }, "当前") : current.startsWith(`${abs}/`) ? h("span", { class: "muted" }, "当前在内") : null,
|
|
289
|
+
h("span", { class: "dir-chev" }, "›"),
|
|
290
|
+
);
|
|
291
|
+
}),
|
|
292
|
+
listing.truncated ? h("div", { class: "muted" }, "子目录太多,只列出了一部分,可以手动输入路径") : null,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function load(rel) {
|
|
297
|
+
const my = ++seq;
|
|
298
|
+
go.disabled = true;
|
|
299
|
+
fill(list, h("span", { class: "spinner" }));
|
|
300
|
+
try {
|
|
301
|
+
const data = await api(`/api/dirs?path=${encodeURIComponent(rel)}`);
|
|
302
|
+
if (my !== seq) return;
|
|
303
|
+
listing = data;
|
|
304
|
+
manual.value = "";
|
|
305
|
+
go.disabled = false;
|
|
306
|
+
go.textContent = data.path === current ? "已是当前目录" : goLabel;
|
|
307
|
+
if (data.path === current) go.disabled = true;
|
|
308
|
+
renderCrumbs();
|
|
309
|
+
renderList();
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (my !== seq) return;
|
|
312
|
+
if (!listing) {
|
|
313
|
+
fill(list, h("div", { class: "error-text" }, err.message));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// 进不去就留在原地
|
|
317
|
+
toast(err.message);
|
|
318
|
+
go.disabled = listing.path === current;
|
|
319
|
+
renderList();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 从当前目录开始(在工作区之内的话),否则从根目录开始
|
|
324
|
+
const root = S.workspaceRoot ?? "";
|
|
325
|
+
const startRel = root && (current === root || current.startsWith(`${root.replace(/[\\/]+$/, "")}/`)) ? current.slice(root.replace(/[\\/]+$/, "").length + 1) : "";
|
|
326
|
+
await load(startRel);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 重命名:小弹窗,回车保存、Esc 取消,打开时全选原名 */
|
|
330
|
+
export function renameConv(key = S.current) {
|
|
331
|
+
const c = S.convs.get(key);
|
|
332
|
+
if (!c) return;
|
|
333
|
+
const input = h("input", { class: "rename-input", value: convTitle(c), maxlength: "120", spellcheck: "false" });
|
|
334
|
+
const save = h("button", { class: "btn primary" }, "保存");
|
|
335
|
+
const cancel = h("button", { class: "btn ghost" }, "取消");
|
|
336
|
+
const close = () => {
|
|
337
|
+
overlay.classList.add("leaving");
|
|
338
|
+
setTimeout(() => overlay.remove(), 120);
|
|
339
|
+
document.removeEventListener("keydown", onKey, true);
|
|
340
|
+
};
|
|
341
|
+
const submit = async () => {
|
|
342
|
+
const title = input.value.trim();
|
|
343
|
+
if (!title || title === convTitle(c)) return close();
|
|
344
|
+
save.disabled = true;
|
|
345
|
+
try {
|
|
346
|
+
const info = await api(`/api/conversations/${encodeURIComponent(key)}`, { method: "PATCH", body: { title } });
|
|
347
|
+
S.convs.set(info.key, info);
|
|
348
|
+
renderSidebar();
|
|
349
|
+
if (key === S.current) renderTopbar();
|
|
350
|
+
close();
|
|
351
|
+
} catch (err) {
|
|
352
|
+
save.disabled = false;
|
|
353
|
+
toast(err.message);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const onKey = (e) => {
|
|
357
|
+
if (e.key === "Escape") {
|
|
358
|
+
e.preventDefault();
|
|
359
|
+
close();
|
|
360
|
+
} else if (e.key === "Enter" && !e.isComposing && document.activeElement === input) {
|
|
361
|
+
e.preventDefault();
|
|
362
|
+
void submit();
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
save.onclick = () => void submit();
|
|
366
|
+
cancel.onclick = close;
|
|
367
|
+
const overlay = h(
|
|
368
|
+
"div",
|
|
369
|
+
{ class: "overlay dialog-overlay", onclick: (e) => e.target === overlay && close() },
|
|
370
|
+
h(
|
|
371
|
+
"div",
|
|
372
|
+
{ class: "dialog" },
|
|
373
|
+
h("h3", {}, "重命名会话"),
|
|
374
|
+
h("p", { class: "dialog-sub" }, agentIcon(c.agent), h("span", {}, `${agentLabel(c.agent)} · ${baseName(c.cwd)}`)),
|
|
375
|
+
input,
|
|
376
|
+
h("div", { class: "dialog-foot" }, cancel, save),
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
document.body.append(overlay);
|
|
380
|
+
document.addEventListener("keydown", onKey, true);
|
|
381
|
+
input.focus();
|
|
382
|
+
input.select();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function deleteConv(key = S.current) {
|
|
386
|
+
if (!key) return;
|
|
387
|
+
const c = S.convs.get(key);
|
|
388
|
+
const sure = await confirmDialog({
|
|
389
|
+
title: "删除会话?",
|
|
390
|
+
sub: c ? [agentIcon(c.agent), h("span", {}, `${convTitle(c)} · ${baseName(c.cwd)}`)] : null,
|
|
391
|
+
message: "只从列表里移除,agent 那边的会话记录还在。",
|
|
392
|
+
confirmText: "删除",
|
|
393
|
+
danger: true,
|
|
394
|
+
});
|
|
395
|
+
if (!sure) return;
|
|
396
|
+
try {
|
|
397
|
+
await api(`/api/conversations/${encodeURIComponent(key)}`, { method: "DELETE" });
|
|
398
|
+
if (c) c.hidden = true;
|
|
399
|
+
if (key === S.current) newDraft();
|
|
400
|
+
else renderSidebar();
|
|
401
|
+
} catch (err) {
|
|
402
|
+
toast(err.message);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { h, toast } from "../lib/dom.js";
|
|
2
|
+
import { renderMarkdown } from "../lib/markdown.js";
|
|
3
|
+
import { desktop, isAdmin, S } from "../state.js";
|
|
4
|
+
import { WS } from "../lib/ws.js";
|
|
5
|
+
import { convTitle, scheduleSidebar } from "./sidebar.js";
|
|
6
|
+
import { addItem, updateItem } from "./messages.js";
|
|
7
|
+
|
|
8
|
+
// ---------- 交互(授权 / 提问) ----------
|
|
9
|
+
|
|
10
|
+
export function upsertInteraction(ix) {
|
|
11
|
+
// 只进不退:本地已经知道它处理完了,晚到的旧快照(还写着待处理)不能把它复活
|
|
12
|
+
const known = S.pending.get(ix.id);
|
|
13
|
+
if (known?.settled && !ix.settled) return;
|
|
14
|
+
S.pending.set(ix.id, ix);
|
|
15
|
+
if (ix.settled) setTimeout(() => S.pending.get(ix.id)?.settled && S.pending.delete(ix.id), 60000);
|
|
16
|
+
scheduleSidebar();
|
|
17
|
+
if (ix.convKey !== S.current) {
|
|
18
|
+
if (!ix.settled && document.visibilityState === "visible") {
|
|
19
|
+
const c = S.convs.get(ix.convKey);
|
|
20
|
+
toast(`「${convTitle(c)}」在等你处理:${ix.spec.title}`, 4000);
|
|
21
|
+
}
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const existing = S.items.find((i) => i.type === "ix" && i.id === ix.id);
|
|
25
|
+
if (existing) {
|
|
26
|
+
existing.ix = ix;
|
|
27
|
+
updateItem(existing);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (ix.settled) return;
|
|
31
|
+
// 卡片插在这一轮中间:之前的输出先封口,之后的输出接在卡片下面,顺序才对
|
|
32
|
+
if (S.turnAi && !S.turnAi.done) {
|
|
33
|
+
S.turnAi.done = true;
|
|
34
|
+
S.turnAi.segment = true;
|
|
35
|
+
updateItem(S.turnAi);
|
|
36
|
+
S.turnAi = null;
|
|
37
|
+
}
|
|
38
|
+
addItem({ type: "ix", id: ix.id, ix });
|
|
39
|
+
notifyIfHidden(ix);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function notifyIfHidden(ix) {
|
|
43
|
+
// 桌面端由服务端的 notice 统一弹,这里再弹就重复了
|
|
44
|
+
if (desktop || document.visibilityState === "visible" || !("Notification" in window)) return;
|
|
45
|
+
if (Notification.permission === "granted") {
|
|
46
|
+
try {
|
|
47
|
+
new Notification("Agent Loft 需要你处理", { body: ix.spec.title, tag: ix.id });
|
|
48
|
+
} catch {
|
|
49
|
+
// 某些浏览器只允许在 SW 里发
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function ixOp(id, op, extra = {}) {
|
|
55
|
+
try {
|
|
56
|
+
await WS.request({ t: "ix", id, op, ...extra });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
toast(err.message);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const SETTLE_LABEL = {
|
|
63
|
+
"allow-once": "已允许(本次)",
|
|
64
|
+
"allow-always": "已允许(总是)",
|
|
65
|
+
"allow-session": "已允许(本会话全部放行)",
|
|
66
|
+
deny: "已拒绝",
|
|
67
|
+
timeout: "已超时,按拒绝处理",
|
|
68
|
+
cancelled: "已取消",
|
|
69
|
+
answered: "已回答",
|
|
70
|
+
skipped: "已跳过",
|
|
71
|
+
gone: "已处理",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function renderInteraction(item) {
|
|
75
|
+
const ix = item.ix;
|
|
76
|
+
const spec = ix.spec;
|
|
77
|
+
const settled = ix.settled;
|
|
78
|
+
if (spec.kind === "approval") {
|
|
79
|
+
return h(
|
|
80
|
+
"div",
|
|
81
|
+
{ class: `ix-card${settled ? " settled" : ""}` },
|
|
82
|
+
h("div", { class: "ix-title" }, settled ? "" : "⚠️", spec.title),
|
|
83
|
+
h("div", { class: "ix-detail" }, renderMarkdown(spec.detail)),
|
|
84
|
+
settled
|
|
85
|
+
? h("div", { class: "ix-meta" }, `${SETTLE_LABEL[settled.settlement] ?? settled.settlement} · ${settled.by}`)
|
|
86
|
+
: h(
|
|
87
|
+
"div",
|
|
88
|
+
{ class: "ix-buttons" },
|
|
89
|
+
spec.allowSession
|
|
90
|
+
? h("button", { class: "btn accent", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
|
|
91
|
+
: null,
|
|
92
|
+
h("button", { class: `btn${spec.allowSession ? "" : " accent"}`, onclick: () => ixOp(ix.id, "decide", { decision: "allow-once" }) }, "允许一次"),
|
|
93
|
+
// 会落盘、对所有人生效的「总是允许」只给管理员
|
|
94
|
+
spec.alwaysLabel && (!spec.alwaysGlobal || isAdmin())
|
|
95
|
+
? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
|
|
96
|
+
: null,
|
|
97
|
+
h("button", { class: "btn danger", onclick: () => ixOp(ix.id, "decide", { decision: "deny" }) }, "拒绝"),
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
// 提问
|
|
102
|
+
const needsSubmit = spec.questions.some((q) => q.multiSelect);
|
|
103
|
+
return h(
|
|
104
|
+
"div",
|
|
105
|
+
{ class: `ix-card ask${settled ? " settled" : ""}` },
|
|
106
|
+
h("div", { class: "ix-title" }, settled ? "" : "❓", spec.title),
|
|
107
|
+
spec.questions.map((q, qi) => {
|
|
108
|
+
const picked = ix.selections[qi] ?? [];
|
|
109
|
+
const free = ix.freeText?.[qi];
|
|
110
|
+
return h(
|
|
111
|
+
"div",
|
|
112
|
+
{ class: "ix-q" },
|
|
113
|
+
h("div", { class: "q-head" }, h("b", {}, q.header), q.question, q.multiSelect ? "(可多选)" : ""),
|
|
114
|
+
q.options.map((opt, oi) => {
|
|
115
|
+
const on = picked.includes(oi);
|
|
116
|
+
return h(
|
|
117
|
+
"button",
|
|
118
|
+
{
|
|
119
|
+
class: `opt${on ? " on" : ""}`,
|
|
120
|
+
disabled: !!settled,
|
|
121
|
+
onclick: () => ixOp(ix.id, "select", { q: qi, o: oi, v: q.multiSelect ? !on : true }),
|
|
122
|
+
},
|
|
123
|
+
`${on ? "✓ " : ""}${opt.label}`,
|
|
124
|
+
opt.description ? h("small", {}, opt.description) : null,
|
|
125
|
+
);
|
|
126
|
+
}),
|
|
127
|
+
free ? h("div", { class: "ix-meta" }, `→ ${free}`) : null,
|
|
128
|
+
);
|
|
129
|
+
}),
|
|
130
|
+
settled
|
|
131
|
+
? h("div", { class: "ix-meta" }, `${SETTLE_LABEL[settled.settlement] ?? settled.settlement} · ${settled.by}`)
|
|
132
|
+
: h(
|
|
133
|
+
"div",
|
|
134
|
+
{ class: "ix-buttons" },
|
|
135
|
+
needsSubmit ? h("button", { class: "btn accent", onclick: () => ixOp(ix.id, "submit") }, "提交") : null,
|
|
136
|
+
h("button", { class: "btn", onclick: () => ixOp(ix.id, "skip") }, "跳过"),
|
|
137
|
+
h("span", { class: "ix-meta" }, "都不合适?直接在下面输入框里回答"),
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
}
|