agent-loft 0.2.29 → 0.2.31
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 +5 -0
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +3 -2
- package/src/agents/claude/session.ts +11 -4
- package/src/agents/codex/session.ts +9 -3
- package/src/channels/feishu/bot.ts +8 -4
- package/src/channels/feishu/turn-stream.ts +3 -2
- package/src/channels/web/api.ts +4 -0
- package/src/channels/web/server.ts +14 -2
- package/src/channels/web/tasks-api.ts +94 -0
- package/src/core/chat-mode.ts +14 -14
- package/src/core/hub.ts +95 -16
- package/src/core/memory-mcp.ts +1 -1
- package/src/core/personal.ts +3 -1
- package/src/core/store.ts +2 -0
- package/src/core/task-prompt.ts +38 -0
- package/src/core/task-runner.ts +232 -0
- package/src/core/task-schedule.ts +250 -0
- package/src/core/tasks-bridge.ts +252 -0
- package/src/core/tasks-mcp.ts +55 -0
- package/src/core/tasks.ts +281 -0
- package/src/core/types.ts +2 -0
- package/src/main.ts +8 -0
- package/src/mcp/tasks-server.ts +206 -0
- 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/tasks.css +455 -0
- package/web/css/topbar.css +205 -0
- package/web/index.html +12 -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 +236 -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 +46 -0
- package/web/views/auth.js +61 -0
- package/web/views/composer.js +324 -0
- package/web/views/conv-actions.js +412 -0
- package/web/views/interaction.js +140 -0
- package/web/views/layout.js +197 -0
- package/web/views/messages.js +531 -0
- package/web/views/quota.js +145 -0
- package/web/views/schedule.js +120 -0
- package/web/views/sidebar.js +458 -0
- package/web/views/slash.js +185 -0
- package/web/views/tasks.js +460 -0
- package/web/views/topbar.js +400 -0
- package/web/views/voice.js +189 -0
- package/web/style.css +0 -3500
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { fill, h, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { relTime } from "../lib/time.js";
|
|
4
|
+
import { confirmDialog } from "../lib/overlay.js";
|
|
5
|
+
import { field } from "./common.js";
|
|
6
|
+
import { networkSection, serverVersionSection } from "./general.js";
|
|
7
|
+
|
|
8
|
+
// ---------- 用户(管理员) ----------
|
|
9
|
+
|
|
10
|
+
export async function usersTab(panel) {
|
|
11
|
+
const box = h("div", { class: "section" }, h("h4", {}, "用户"), h("span", { class: "spinner" }));
|
|
12
|
+
const addBox = h("div", { class: "section" });
|
|
13
|
+
fill(panel, box, addBox);
|
|
14
|
+
const reload = () => {
|
|
15
|
+
fill(panel);
|
|
16
|
+
void usersTab(panel);
|
|
17
|
+
};
|
|
18
|
+
let data;
|
|
19
|
+
try {
|
|
20
|
+
data = await api("/api/users");
|
|
21
|
+
} catch (err) {
|
|
22
|
+
fill(box, h("h4", {}, "用户"), h("div", { class: "error-text" }, err.message));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const act = (btn, fn) => async () => {
|
|
26
|
+
btn.disabled = true;
|
|
27
|
+
try {
|
|
28
|
+
if ((await fn()) !== false) reload();
|
|
29
|
+
} catch (err) {
|
|
30
|
+
toast(err.message);
|
|
31
|
+
}
|
|
32
|
+
btn.disabled = false;
|
|
33
|
+
};
|
|
34
|
+
const patch = (u, body) => api(`/api/users/${encodeURIComponent(u.id)}`, { method: "PATCH", body });
|
|
35
|
+
|
|
36
|
+
/** 重置密码:就地展开一个输入框 */
|
|
37
|
+
const resetRow = (u, holder) => {
|
|
38
|
+
const pw = h("input", { type: "password", autocomplete: "new-password", placeholder: "新密码(至少 8 位)" });
|
|
39
|
+
const ok = h("button", { class: "btn small primary" }, "确定");
|
|
40
|
+
ok.onclick = act(ok, async () => {
|
|
41
|
+
await patch(u, { password: pw.value });
|
|
42
|
+
toast(`已重置「${u.name}」的密码,他的设备已下线`);
|
|
43
|
+
});
|
|
44
|
+
fill(holder, h("div", { class: "row" }, pw, ok));
|
|
45
|
+
pw.focus();
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
fill(
|
|
49
|
+
box,
|
|
50
|
+
h("h4", {}, "用户"),
|
|
51
|
+
h(
|
|
52
|
+
"div",
|
|
53
|
+
{ class: "muted" },
|
|
54
|
+
"每个人只看得到自己的对话、授权和通知。管理员能改所有设置,还能看到飞书机器人的对话。",
|
|
55
|
+
),
|
|
56
|
+
h(
|
|
57
|
+
"div",
|
|
58
|
+
{ class: "error-text" },
|
|
59
|
+
"注意:账号不是安全隔离。所有 agent 都以服务器上的同一个系统用户运行,任何用户都能让 agent 读到这台机器上的文件(包括别人的对话记录和配置里的密钥),用的也是同一个 Claude / Codex 订阅。只把账号给你信任的人。",
|
|
60
|
+
),
|
|
61
|
+
data.users.map((u) => {
|
|
62
|
+
const self = u.id === data.me;
|
|
63
|
+
const extra = h("div");
|
|
64
|
+
const pwBtn = h("button", { class: "btn small" }, "重置密码");
|
|
65
|
+
pwBtn.onclick = () => resetRow(u, extra);
|
|
66
|
+
const roleBtn = h("button", { class: "btn small", disabled: self }, u.role === "admin" ? "设为成员" : "设为管理员");
|
|
67
|
+
roleBtn.onclick = act(roleBtn, () => patch(u, { role: u.role === "admin" ? "member" : "admin" }));
|
|
68
|
+
const disBtn = h("button", { class: "btn small", disabled: self }, u.disabled ? "启用" : "停用");
|
|
69
|
+
disBtn.onclick = act(disBtn, () => patch(u, { disabled: !u.disabled }));
|
|
70
|
+
const memBtn = h("button", { class: "btn small", title: "删除他的「关于我」和所有记住的事。管理员看不到记忆的内容" }, "清空记忆");
|
|
71
|
+
memBtn.onclick = act(memBtn, async () => {
|
|
72
|
+
const sure = await confirmDialog({
|
|
73
|
+
title: `清空「${u.name}」的记忆?`,
|
|
74
|
+
message: "他的「关于我」和所有记住的事都会删除,不能恢复。",
|
|
75
|
+
confirmText: "清空",
|
|
76
|
+
danger: true,
|
|
77
|
+
});
|
|
78
|
+
if (!sure) return false;
|
|
79
|
+
await api(`/api/users/${encodeURIComponent(u.id)}/memory`, { method: "DELETE" });
|
|
80
|
+
toast("已清空");
|
|
81
|
+
});
|
|
82
|
+
const linkBtn = u.feishuLinks ? h("button", { class: "btn small" }, "解除飞书绑定") : null;
|
|
83
|
+
if (linkBtn) {
|
|
84
|
+
linkBtn.onclick = act(linkBtn, async () => {
|
|
85
|
+
await api(`/api/users/${encodeURIComponent(u.id)}/feishu`, { method: "DELETE" });
|
|
86
|
+
toast("已解除,他和机器人的私聊交还给管理员");
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const rmBtn = h("button", { class: "btn small danger", disabled: self }, "删除");
|
|
90
|
+
rmBtn.onclick = act(rmBtn, async () => {
|
|
91
|
+
const sure = await confirmDialog({
|
|
92
|
+
title: `删除用户「${u.name}」?`,
|
|
93
|
+
message: "他的设备会立刻下线,记忆和飞书绑定一起删除。他的对话不会删除,之后归管理员。",
|
|
94
|
+
confirmText: "删除",
|
|
95
|
+
danger: true,
|
|
96
|
+
});
|
|
97
|
+
if (!sure) return false;
|
|
98
|
+
await api(`/api/users/${encodeURIComponent(u.id)}`, { method: "DELETE" });
|
|
99
|
+
toast("已删除");
|
|
100
|
+
});
|
|
101
|
+
return h(
|
|
102
|
+
"div",
|
|
103
|
+
{ class: "list-item", style: { flexWrap: "wrap" } },
|
|
104
|
+
h(
|
|
105
|
+
"div",
|
|
106
|
+
{ class: "grow" },
|
|
107
|
+
h(
|
|
108
|
+
"div",
|
|
109
|
+
{ class: "t" },
|
|
110
|
+
u.name,
|
|
111
|
+
self ? "(你)" : "",
|
|
112
|
+
" ",
|
|
113
|
+
h("span", { class: "status-tag" }, u.role === "admin" ? "管理员" : "成员"),
|
|
114
|
+
u.disabled ? h("span", { class: "status-tag bad" }, "已停用") : null,
|
|
115
|
+
),
|
|
116
|
+
h("div", { class: "s" }, `创建于 ${relTime(u.createdAt)} · ${u.devices} 台设备已登录${u.feishuLinks ? ` · 绑定了 ${u.feishuLinks} 个飞书账号` : ""}`),
|
|
117
|
+
extra,
|
|
118
|
+
),
|
|
119
|
+
pwBtn,
|
|
120
|
+
roleBtn,
|
|
121
|
+
disBtn,
|
|
122
|
+
memBtn,
|
|
123
|
+
linkBtn,
|
|
124
|
+
rmBtn,
|
|
125
|
+
);
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const name = h("input", { autocomplete: "off", autocapitalize: "off", spellcheck: "false", placeholder: "登录用的名字" });
|
|
130
|
+
const pw = h("input", { type: "password", autocomplete: "new-password", placeholder: "至少 8 位" });
|
|
131
|
+
const role = h("select", {}, h("option", { value: "member" }, "成员"), h("option", { value: "admin" }, "管理员"));
|
|
132
|
+
const add = h("button", { class: "btn primary" }, "添加");
|
|
133
|
+
add.onclick = act(add, async () => {
|
|
134
|
+
await api("/api/users", { method: "POST", body: { name: name.value.trim(), password: pw.value, role: role.value } });
|
|
135
|
+
toast(`已添加「${name.value.trim()}」`);
|
|
136
|
+
});
|
|
137
|
+
fill(
|
|
138
|
+
addBox,
|
|
139
|
+
h("h4", {}, "添加用户"),
|
|
140
|
+
field("用户名", name),
|
|
141
|
+
field("密码", pw, "告诉对方,让他登录后在 设置 → 账号 里自己改掉"),
|
|
142
|
+
field("角色", role, "成员只能用自己的对话;管理员能改所有设置、管理用户"),
|
|
143
|
+
h("div", { class: "row" }, add),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function systemTab(cfg, panel) {
|
|
148
|
+
// 系统信息要等接口返回,先占好位置,版本和网络访问两节不用等它
|
|
149
|
+
const sysBox = h("div", { class: "section" }, h("h4", {}, "系统", h("span", { class: "grow" }), h("span", { class: "spinner" })));
|
|
150
|
+
panel.append(sysBox, serverVersionSection(), networkSection(cfg));
|
|
151
|
+
let sys;
|
|
152
|
+
try {
|
|
153
|
+
sys = await api("/api/system");
|
|
154
|
+
} catch (err) {
|
|
155
|
+
fill(sysBox, h("h4", {}, "系统"), h("div", { class: "error-text" }, err.message));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
fill(
|
|
159
|
+
sysBox,
|
|
160
|
+
h("h4", {}, "系统"),
|
|
161
|
+
h("div", {}, `版本 ${sys.version} · Node ${sys.node} · 已运行 ${Math.round(sys.uptimeMs / 60000)} 分钟`),
|
|
162
|
+
h("div", { class: "muted" }, sys.underSystemd ? "以系统服务运行" : "前台运行(重启需要在终端里操作)"),
|
|
163
|
+
h(
|
|
164
|
+
"div",
|
|
165
|
+
{ class: "row" },
|
|
166
|
+
h(
|
|
167
|
+
"button",
|
|
168
|
+
{
|
|
169
|
+
class: "btn",
|
|
170
|
+
disabled: !sys.underSystemd,
|
|
171
|
+
onclick: async () => {
|
|
172
|
+
if (!(await confirmDialog({ title: "重启 agent-loft?", message: "进行中的对话会被中断。", confirmText: "重启", danger: true }))) return;
|
|
173
|
+
try {
|
|
174
|
+
await api("/api/system/restart", { method: "POST" });
|
|
175
|
+
toast("正在重启…");
|
|
176
|
+
setTimeout(() => location.reload(), 5000);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
toast(err.message);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
"重启服务",
|
|
183
|
+
),
|
|
184
|
+
"Notification" in window && Notification.permission !== "granted"
|
|
185
|
+
? h("button", { class: "btn", onclick: () => Notification.requestPermission() }, "开启浏览器通知")
|
|
186
|
+
: null,
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
}
|
package/web/state.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ---------- 状态 ----------
|
|
2
|
+
|
|
3
|
+
export const S = {
|
|
4
|
+
agents: [],
|
|
5
|
+
bots: [],
|
|
6
|
+
convs: new Map(),
|
|
7
|
+
pending: new Map(),
|
|
8
|
+
current: null,
|
|
9
|
+
draftAgent: localStorage.getItem("aim.agent") || null,
|
|
10
|
+
/** 新对话用聊天模式还是工作模式,记住上次的选择 */
|
|
11
|
+
draftMode: localStorage.getItem("aim.mode") === "chat" ? "chat" : "work",
|
|
12
|
+
/** 新对话(还没发消息)选的工作目录,null 表示工作区根目录。开新对话时沿用上次选的 */
|
|
13
|
+
draftCwd: null,
|
|
14
|
+
items: [],
|
|
15
|
+
turnAi: null,
|
|
16
|
+
seq: new Map(),
|
|
17
|
+
uploads: [],
|
|
18
|
+
busy: false,
|
|
19
|
+
allowedAgents: [],
|
|
20
|
+
search: "",
|
|
21
|
+
loadToken: 0,
|
|
22
|
+
/** 登录的用户 { id, name, role } */
|
|
23
|
+
user: null,
|
|
24
|
+
/** 主区域在显示什么:null 是对话(或新对话),"tasks" 是定时任务页 */
|
|
25
|
+
view: null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const isAdmin = () => S.user?.role === "admin";
|
|
29
|
+
|
|
30
|
+
export const ui = {};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 运行在 macOS 桌面端(agent-loft-desktop)里时,由它的 preload 注入。
|
|
34
|
+
* 桌面端收不到 Web Push,系统通知、Dock 角标、菜单栏的待处理数都交给它。
|
|
35
|
+
*/
|
|
36
|
+
export const desktop = window.agentLoftDesktop ?? null;
|
|
37
|
+
/** 新版桌面端:额度由它统一查好推过来,页面自己不查(老版本桌面端没有 onQuota,照旧自己查) */
|
|
38
|
+
export const desktopQuota = typeof desktop?.onQuota === "function";
|
|
39
|
+
// 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 css/topbar.css 的 .in-desktop)
|
|
40
|
+
if (desktop) document.documentElement.classList.add("in-desktop");
|
|
41
|
+
/**
|
|
42
|
+
* 新版桌面端顶栏的「更多」里能打开每台服务器的设置:侧栏底部的设置按钮就不显示了(额度照常显示,数据由桌面端推)。
|
|
43
|
+
* 老版本桌面端没有这个入口,照旧显示,免得设置打不开。
|
|
44
|
+
*/
|
|
45
|
+
export const desktopSettings = typeof desktop?.onOpenSettings === "function";
|
|
46
|
+
if (desktopSettings) document.documentElement.classList.add("desktop-settings");
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { $app, fill, h } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { WS } from "../lib/ws.js";
|
|
4
|
+
import { boot } from "./layout.js";
|
|
5
|
+
|
|
6
|
+
// ---------- 登录 ----------
|
|
7
|
+
|
|
8
|
+
export async function showAuth() {
|
|
9
|
+
WS.stopped = true;
|
|
10
|
+
WS.sock?.close();
|
|
11
|
+
let me = { needsSetup: false };
|
|
12
|
+
try {
|
|
13
|
+
me = await api("/api/me");
|
|
14
|
+
} catch {
|
|
15
|
+
// 网络问题时按登录处理
|
|
16
|
+
}
|
|
17
|
+
const err = h("div", { class: "error-text" });
|
|
18
|
+
const pw = h("input", { type: "password", placeholder: "密码", autocomplete: me.needsSetup ? "new-password" : "current-password" });
|
|
19
|
+
const token = h("input", { placeholder: "设置码(在启动日志里)", autocomplete: "off" });
|
|
20
|
+
// 只有一个用户时和以前一样只要密码;多个用户才要填用户名
|
|
21
|
+
const askName = me.needsSetup || me.multiUser;
|
|
22
|
+
const name = h("input", {
|
|
23
|
+
placeholder: me.needsSetup ? "admin" : "用户名",
|
|
24
|
+
autocomplete: "username",
|
|
25
|
+
autocapitalize: "off",
|
|
26
|
+
spellcheck: "false",
|
|
27
|
+
});
|
|
28
|
+
const btn = h("button", { class: "btn primary", type: "submit" }, me.needsSetup ? "设置密码并进入" : "登录");
|
|
29
|
+
const form = h(
|
|
30
|
+
"form",
|
|
31
|
+
{
|
|
32
|
+
class: "auth-card",
|
|
33
|
+
onsubmit: async (e) => {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
err.textContent = "";
|
|
36
|
+
btn.disabled = true;
|
|
37
|
+
try {
|
|
38
|
+
if (me.needsSetup) {
|
|
39
|
+
await api("/api/setup", { method: "POST", body: { token: token.value, name: name.value.trim() || "admin", password: pw.value } });
|
|
40
|
+
} else {
|
|
41
|
+
await api("/api/login", { method: "POST", body: { ...(askName ? { name: name.value.trim() } : {}), password: pw.value } });
|
|
42
|
+
}
|
|
43
|
+
await boot();
|
|
44
|
+
} catch (e2) {
|
|
45
|
+
err.textContent = e2.message;
|
|
46
|
+
} finally {
|
|
47
|
+
btn.disabled = false;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
h("h1", {}, "Agent Loft"),
|
|
52
|
+
h("p", {}, me.needsSetup ? "首次使用:输入启动日志里的设置码,建一个管理员账号(密码至少 8 位)" : "登录以继续"),
|
|
53
|
+
me.needsSetup ? h("label", { class: "field" }, h("span", {}, "设置码"), token) : null,
|
|
54
|
+
askName ? h("label", { class: "field" }, h("span", {}, me.needsSetup ? "管理员用户名" : "用户名"), name) : null,
|
|
55
|
+
h("label", { class: "field" }, h("span", {}, me.needsSetup ? "新密码" : "密码"), pw),
|
|
56
|
+
btn,
|
|
57
|
+
err,
|
|
58
|
+
);
|
|
59
|
+
fill($app, h("div", { class: "auth" }, form));
|
|
60
|
+
(me.needsSetup ? token : askName ? name : pw).focus();
|
|
61
|
+
}
|
|
@@ -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
|
+
}
|