agent-loft 0.2.32 → 0.2.33
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 +7 -3
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +80 -2
- package/src/agents/claude/session.ts +51 -7
- package/src/agents/codex/session.ts +43 -7
- package/src/channels/feishu/bot.ts +2 -2
- package/src/channels/web/api.ts +68 -2
- package/src/channels/web/conv-pages.ts +68 -0
- package/src/channels/web/server.ts +13 -4
- package/src/core/hub.ts +120 -19
- package/src/core/skill-runtime.ts +1 -1
- package/src/core/skills.ts +2 -1
- package/src/core/types.ts +24 -2
- package/src/core/usage.ts +70 -0
- package/web/css/composer.css +0 -25
- package/web/css/messages.css +169 -0
- package/web/css/sidebar.css +5 -5
- package/web/css/skills.css +18 -1
- package/web/css/topbar.css +131 -0
- package/web/lib/dom.js +2 -1
- package/web/lib/markdown.js +84 -14
- package/web/lib/ws.js +46 -6
- package/web/settings/index.js +7 -3
- package/web/{views → settings}/skills.js +86 -172
- package/web/state.js +5 -4
- package/web/views/composer.js +2 -1
- package/web/views/interaction.js +12 -4
- package/web/views/layout.js +22 -25
- package/web/views/mention.js +2 -2
- package/web/views/messages.js +239 -81
- package/web/views/msg-nav.js +120 -0
- package/web/views/quota.js +1 -0
- package/web/views/sidebar.js +114 -17
- package/web/views/topbar.js +169 -41
|
@@ -2,54 +2,41 @@ import { fill, h, icon, starsLoader, toast } from "../lib/dom.js";
|
|
|
2
2
|
import { api } from "../lib/api.js";
|
|
3
3
|
import { renderMarkdown } from "../lib/markdown.js";
|
|
4
4
|
import { confirmDialog, modal, showMenu } from "../lib/overlay.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (S.current) WS.raw({ t: "unsub", key: S.current });
|
|
21
|
-
S.current = null;
|
|
22
|
-
S.view = "skills";
|
|
23
|
-
S.items = [];
|
|
24
|
-
S.turnAi = null;
|
|
25
|
-
history.replaceState(null, "", "#skills");
|
|
26
|
-
WS.presence();
|
|
27
|
-
ui.spacer.style.height = "0px";
|
|
28
|
-
ui.follow = false;
|
|
29
|
-
// 和定时任务页一样:藏起输入框(css/tasks.css 的 .tasks-view)
|
|
30
|
-
ui.main.classList.add("tasks-view");
|
|
31
|
-
closeSide();
|
|
32
|
-
renderTopbar();
|
|
33
|
-
renderSidebar();
|
|
34
|
-
fill(ui.msgInner, h("div", { class: "empty" }, starsLoader("lg")));
|
|
5
|
+
|
|
6
|
+
// ---------- 技能(设置里的一页) ----------
|
|
7
|
+
// 我的技能:自己建的,加上以前从别人那装的(可以停用、填变量、卸载);管理员另有「本机技能」(机器上原有的)。
|
|
8
|
+
// 技能有变化时服务端推 { t: "skills" }(lib/ws.js 转成 aim:skills 事件),这一页开着就重新拉。
|
|
9
|
+
|
|
10
|
+
const K = { data: null, tab: "mine", query: "", token: 0, reloadTimer: 0, box: null };
|
|
11
|
+
|
|
12
|
+
/** 以前分享出去的技能(现在不能再设了):卡片上标一下还有谁看得到 */
|
|
13
|
+
const SHARED = { users: "分享给了指定的人", all: "所有人可见" };
|
|
14
|
+
|
|
15
|
+
export async function skillsTab(panel) {
|
|
16
|
+
K.tab = "mine";
|
|
17
|
+
K.query = "";
|
|
18
|
+
K.box = h("div", { class: "skills-page" }, h("div", { class: "section" }, h("h4", {}, "技能"), h("span", { class: "spinner" })));
|
|
19
|
+
fill(panel, K.box);
|
|
35
20
|
await reload();
|
|
36
|
-
ui.messages.scrollTop = 0;
|
|
37
21
|
}
|
|
38
22
|
|
|
23
|
+
/** 这一页还开着(没关设置、没切到别的标签页) */
|
|
24
|
+
const showing = () => !!K.box?.isConnected;
|
|
25
|
+
|
|
39
26
|
async function reload() {
|
|
40
27
|
const my = ++K.token;
|
|
41
28
|
try {
|
|
42
29
|
const data = await api("/api/skills");
|
|
43
|
-
if (my !== K.token ||
|
|
30
|
+
if (my !== K.token || !showing()) return;
|
|
44
31
|
K.data = data;
|
|
45
32
|
render();
|
|
46
33
|
} catch (err) {
|
|
47
|
-
if (my === K.token &&
|
|
34
|
+
if (my === K.token && showing()) fill(K.box, h("div", { class: "error-text" }, err.message));
|
|
48
35
|
}
|
|
49
36
|
}
|
|
50
37
|
|
|
51
38
|
document.addEventListener("aim:skills", () => {
|
|
52
|
-
if (
|
|
39
|
+
if (!showing()) return;
|
|
53
40
|
clearTimeout(K.reloadTimer);
|
|
54
41
|
K.reloadTimer = setTimeout(() => void reload(), 300);
|
|
55
42
|
});
|
|
@@ -58,76 +45,66 @@ function mine() {
|
|
|
58
45
|
return K.data.skills.filter((s) => s.installed).sort((a, b) => a.name.localeCompare(b.name));
|
|
59
46
|
}
|
|
60
47
|
|
|
61
|
-
function plaza() {
|
|
62
|
-
return K.data.skills.filter((s) => !s.mine).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
48
|
function matches(s) {
|
|
66
49
|
const q = K.query.trim().toLowerCase();
|
|
67
50
|
return !q || s.name.includes(q) || s.description.toLowerCase().includes(q) || (s.ownerName ?? "").toLowerCase().includes(q);
|
|
68
51
|
}
|
|
69
52
|
|
|
70
53
|
function render() {
|
|
71
|
-
const tabs =
|
|
72
|
-
[
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
54
|
+
const tabs = K.data.machine
|
|
55
|
+
? [
|
|
56
|
+
["mine", `我的技能 ${mine().length}`],
|
|
57
|
+
["machine", `本机技能 ${K.data.machine.length}`],
|
|
58
|
+
]
|
|
59
|
+
: [];
|
|
60
|
+
const search = h("input", { class: "skill-search", type: "search", placeholder: "搜索名字、说明", value: K.query });
|
|
77
61
|
search.oninput = () => {
|
|
78
62
|
K.query = search.value;
|
|
79
63
|
fill(listBox, renderList());
|
|
80
64
|
};
|
|
81
65
|
const listBox = h("div", { class: "skill-list-box" }, renderList());
|
|
82
66
|
fill(
|
|
83
|
-
|
|
67
|
+
K.box,
|
|
84
68
|
h(
|
|
85
69
|
"div",
|
|
86
|
-
{ class: "
|
|
70
|
+
{ class: "skills-head" },
|
|
87
71
|
h(
|
|
88
|
-
"
|
|
89
|
-
{ class: "
|
|
90
|
-
|
|
91
|
-
"div",
|
|
92
|
-
{ class: "grow" },
|
|
93
|
-
h("h2", {}, "技能"),
|
|
94
|
-
h(
|
|
95
|
-
"p",
|
|
96
|
-
{},
|
|
97
|
-
"技能是一份写给 agent 的做事方法(SKILL.md),可以带脚本和资料,Claude 和 Codex 都能用。工作模式下 agent 遇到对得上的事会自己用;在输入框或定时任务里输入 @ 可以点名用哪个。",
|
|
98
|
-
),
|
|
99
|
-
),
|
|
100
|
-
h(
|
|
101
|
-
"div",
|
|
102
|
-
{ class: "skill-head-btns" },
|
|
103
|
-
h("button", { class: "btn", onclick: () => pickUpload() }, "上传"),
|
|
104
|
-
h("button", { class: "btn primary", onclick: () => openEditor() }, icon("plus"), "新建"),
|
|
105
|
-
),
|
|
72
|
+
"p",
|
|
73
|
+
{ class: "skill-note" },
|
|
74
|
+
"技能是一份写给 agent 的做事方法(SKILL.md),可以带脚本和资料,Claude 和 Codex 都能用。工作模式下 agent 遇到对得上的事会自己用;在输入框或定时任务里输入 @ 可以点名用哪个。",
|
|
106
75
|
),
|
|
107
76
|
h(
|
|
108
77
|
"div",
|
|
109
|
-
{ class: "skill-
|
|
110
|
-
h(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
78
|
+
{ class: "skill-head-btns" },
|
|
79
|
+
h("button", { class: "btn", onclick: () => pickUpload() }, "上传"),
|
|
80
|
+
h("button", { class: "btn primary", onclick: () => openEditor() }, icon("plus"), "新建"),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
h(
|
|
84
|
+
"div",
|
|
85
|
+
{ class: "skill-tabs" },
|
|
86
|
+
tabs.length
|
|
87
|
+
? h(
|
|
88
|
+
"div",
|
|
89
|
+
{ class: "seg" },
|
|
90
|
+
tabs.map(([id, label]) =>
|
|
91
|
+
h(
|
|
92
|
+
"button",
|
|
93
|
+
{
|
|
94
|
+
class: K.tab === id ? "on" : "",
|
|
95
|
+
onclick: () => {
|
|
96
|
+
K.tab = id;
|
|
97
|
+
render();
|
|
98
|
+
},
|
|
121
99
|
},
|
|
122
|
-
|
|
123
|
-
|
|
100
|
+
label,
|
|
101
|
+
),
|
|
124
102
|
),
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
),
|
|
129
|
-
listBox,
|
|
103
|
+
)
|
|
104
|
+
: null,
|
|
105
|
+
search,
|
|
130
106
|
),
|
|
107
|
+
listBox,
|
|
131
108
|
);
|
|
132
109
|
}
|
|
133
110
|
|
|
@@ -135,16 +112,10 @@ function renderList() {
|
|
|
135
112
|
if (K.tab === "machine") {
|
|
136
113
|
const list = K.data.machine.filter((m) => matches({ ...m, ownerName: "" }));
|
|
137
114
|
return [
|
|
138
|
-
h("p", { class: "skill-note" }, "这台机器上原有的技能(~/.claude/skills、~/.agents/skills
|
|
115
|
+
h("p", { class: "skill-note" }, "这台机器上原有的技能(~/.claude/skills、~/.agents/skills 等)。只有管理员的会话能用,其他人的会话里看不到、也调不了。要给别人用,请复制内容,在他的账号里新建一个。"),
|
|
139
116
|
list.length ? h("div", { class: "task-list" }, list.map(machineCard)) : h("div", { class: "skill-empty" }, "没有找到"),
|
|
140
117
|
];
|
|
141
118
|
}
|
|
142
|
-
if (K.tab === "plaza") {
|
|
143
|
-
const list = plaza().filter(matches);
|
|
144
|
-
return list.length
|
|
145
|
-
? h("div", { class: "task-list" }, list.map(plazaCard))
|
|
146
|
-
: h("div", { class: "skill-empty" }, K.query ? "没有找到" : "还没有人分享技能给你。自己建的技能可以设成「所有人」或者分享给指定的人。");
|
|
147
|
-
}
|
|
148
119
|
const list = mine().filter(matches);
|
|
149
120
|
if (list.length) return h("div", { class: "task-list" }, list.map(mineCard));
|
|
150
121
|
if (K.query) return h("div", { class: "skill-empty" }, "没有找到");
|
|
@@ -152,11 +123,7 @@ function renderList() {
|
|
|
152
123
|
"div",
|
|
153
124
|
{ class: "tasks-empty" },
|
|
154
125
|
h("div", { class: "t" }, "还没有技能"),
|
|
155
|
-
h(
|
|
156
|
-
"div",
|
|
157
|
-
{ class: "s" },
|
|
158
|
-
"点「新建」写一个,「上传」一个技能包(.zip / .skill / SKILL.md / 文件夹),去「技能广场」装别人分享的,或者在对话里让 agent「把刚才的做法存成技能」。",
|
|
159
|
-
),
|
|
126
|
+
h("div", { class: "s" }, "点「新建」写一个,「上传」一个技能包(.zip / .skill / SKILL.md / 文件夹),或者在对话里让 agent「把刚才的做法存成技能」。"),
|
|
160
127
|
);
|
|
161
128
|
}
|
|
162
129
|
|
|
@@ -203,7 +170,7 @@ function mineCard(s) {
|
|
|
203
170
|
"sep",
|
|
204
171
|
s.mine
|
|
205
172
|
? { label: "删除", danger: true, onclick: () => removeSkill(s) }
|
|
206
|
-
: { label: "卸载", danger: true, onclick: () =>
|
|
173
|
+
: { label: "卸载", danger: true, onclick: () => uninstall(s) },
|
|
207
174
|
]);
|
|
208
175
|
return h(
|
|
209
176
|
"div",
|
|
@@ -219,7 +186,7 @@ function mineCard(s) {
|
|
|
219
186
|
"div",
|
|
220
187
|
{ class: "task-meta" },
|
|
221
188
|
h("span", {}, s.mine ? "我建的" : `${s.ownerName} 建的`),
|
|
222
|
-
s.mine ? h("span", {},
|
|
189
|
+
s.mine && SHARED[s.visibility] ? h("span", {}, SHARED[s.visibility]) : null,
|
|
223
190
|
s.mine && s.installs ? h("span", {}, `${s.installs} 人安装`) : null,
|
|
224
191
|
s.source === "agent" ? h("span", {}, "agent 生成") : null,
|
|
225
192
|
varsState(s),
|
|
@@ -231,28 +198,6 @@ function mineCard(s) {
|
|
|
231
198
|
);
|
|
232
199
|
}
|
|
233
200
|
|
|
234
|
-
function plazaCard(s) {
|
|
235
|
-
const btn = s.installed
|
|
236
|
-
? h("button", { class: "btn", onclick: () => install(s, false) }, "已安装")
|
|
237
|
-
: h("button", { class: "btn primary", onclick: () => install(s, true) }, "安装");
|
|
238
|
-
const more = s.canDelete ? h("button", { class: "icon-btn", title: "更多" }, icon("more")) : null;
|
|
239
|
-
if (more) more.onclick = () => showMenu(more, [{ label: "下架(删除)", danger: true, onclick: () => removeSkill(s) }]);
|
|
240
|
-
return h(
|
|
241
|
-
"div",
|
|
242
|
-
{ class: "task-card skill-card" },
|
|
243
|
-
h("div", { class: "task-top" }, h("button", { class: "task-title skill-name", title: "查看", onclick: () => openDetail(s) }, s.name), btn, more),
|
|
244
|
-
h(
|
|
245
|
-
"div",
|
|
246
|
-
{ class: "task-meta" },
|
|
247
|
-
h("span", {}, `${s.ownerName} 建的`),
|
|
248
|
-
h("span", {}, s.visibility === "all" ? "公开" : "分享给你"),
|
|
249
|
-
h("span", {}, `更新于 ${new Date(s.updatedAt).toLocaleDateString()}`),
|
|
250
|
-
s.vars.length ? h("span", {}, `要填 ${s.vars.length} 个变量`) : null,
|
|
251
|
-
),
|
|
252
|
-
h("div", { class: "task-prompt" }, s.description),
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
201
|
function machineCard(m) {
|
|
257
202
|
return h(
|
|
258
203
|
"div",
|
|
@@ -265,12 +210,19 @@ function machineCard(m) {
|
|
|
265
210
|
|
|
266
211
|
// ---------- 操作 ----------
|
|
267
212
|
|
|
268
|
-
|
|
213
|
+
/** 卸载以前从别人那装的技能。不再有广场,卸了就装不回来了,先问一句 */
|
|
214
|
+
async function uninstall(s) {
|
|
215
|
+
const sure = await confirmDialog({
|
|
216
|
+
title: `卸载「${s.name}」?`,
|
|
217
|
+
message: `这是 ${s.ownerName} 建的技能。卸载后就装不回来了,要用的话得请他把内容发给你,自己新建一个。`,
|
|
218
|
+
confirmText: "卸载",
|
|
219
|
+
danger: true,
|
|
220
|
+
});
|
|
221
|
+
if (!sure) return;
|
|
269
222
|
try {
|
|
270
|
-
await api(`/api/skills/${s.id}/install`, { method: "POST", body: { on } });
|
|
271
|
-
toast(
|
|
272
|
-
|
|
273
|
-
if (on && s.vars.some((v) => v.required)) openVars(K.data.skills.find((x) => x.id === s.id) ?? s);
|
|
223
|
+
await api(`/api/skills/${s.id}/install`, { method: "POST", body: { on: false } });
|
|
224
|
+
toast(`已卸载 ${s.name}`);
|
|
225
|
+
void reload();
|
|
274
226
|
} catch (err) {
|
|
275
227
|
toast(err.message);
|
|
276
228
|
}
|
|
@@ -278,11 +230,9 @@ async function install(s, on) {
|
|
|
278
230
|
|
|
279
231
|
async function removeSkill(s) {
|
|
280
232
|
const sure = await confirmDialog({
|
|
281
|
-
title:
|
|
282
|
-
message: s.
|
|
283
|
-
|
|
284
|
-
: `这是 ${s.ownerName} 建的技能,下架会直接删除它,装了的人都用不了了。`,
|
|
285
|
-
confirmText: s.mine ? "删除" : "下架",
|
|
233
|
+
title: `删除技能「${s.name}」?`,
|
|
234
|
+
message: `删除后找不回来${s.installs ? `,装了它的 ${s.installs} 个人也用不了了` : ""}。`,
|
|
235
|
+
confirmText: "删除",
|
|
286
236
|
danger: true,
|
|
287
237
|
});
|
|
288
238
|
if (!sure) return;
|
|
@@ -314,7 +264,7 @@ async function openDetail(s) {
|
|
|
314
264
|
"div",
|
|
315
265
|
{ class: "task-meta" },
|
|
316
266
|
h("span", {}, d.mine ? "我建的" : `${d.ownerName} 建的`),
|
|
317
|
-
h("span", {},
|
|
267
|
+
d.mine && SHARED[d.visibility] ? h("span", {}, SHARED[d.visibility]) : null,
|
|
318
268
|
h("span", {}, `更新于 ${new Date(d.updatedAt).toLocaleString()}`),
|
|
319
269
|
),
|
|
320
270
|
h("p", { class: "skill-desc" }, d.description),
|
|
@@ -464,38 +414,6 @@ function openVars(s) {
|
|
|
464
414
|
|
|
465
415
|
// ---------- 新建 / 编辑 ----------
|
|
466
416
|
|
|
467
|
-
/** 谁能看到:仅自己 / 指定的人 / 所有人 */
|
|
468
|
-
function visibilityPicker(cur) {
|
|
469
|
-
const state = { visibility: cur?.visibility ?? "private", sharedWith: new Set(cur?.sharedWith ?? []) };
|
|
470
|
-
const users = K.data.users;
|
|
471
|
-
const select = h(
|
|
472
|
-
"select",
|
|
473
|
-
{},
|
|
474
|
-
Object.entries(VISIBILITY).map(([v, label]) =>
|
|
475
|
-
h("option", { value: v, selected: v === state.visibility, disabled: v === "users" && users.length === 0 }, v === "all" ? "所有人(出现在技能广场)" : label),
|
|
476
|
-
),
|
|
477
|
-
);
|
|
478
|
-
const people = h(
|
|
479
|
-
"div",
|
|
480
|
-
{ class: "skill-people" },
|
|
481
|
-
users.map((u) => {
|
|
482
|
-
const box = h("input", { type: "checkbox", checked: state.sharedWith.has(u.id) });
|
|
483
|
-
box.onchange = () => (box.checked ? state.sharedWith.add(u.id) : state.sharedWith.delete(u.id));
|
|
484
|
-
return h("label", { class: "skill-person" }, box, u.name);
|
|
485
|
-
}),
|
|
486
|
-
);
|
|
487
|
-
const sync = () => {
|
|
488
|
-
state.visibility = select.value;
|
|
489
|
-
people.hidden = select.value !== "users";
|
|
490
|
-
};
|
|
491
|
-
select.onchange = sync;
|
|
492
|
-
sync();
|
|
493
|
-
return {
|
|
494
|
-
el: h("div", { class: "skill-visibility" }, select, people),
|
|
495
|
-
value: () => ({ visibility: state.visibility, sharedWith: [...state.sharedWith] }),
|
|
496
|
-
};
|
|
497
|
-
}
|
|
498
|
-
|
|
499
417
|
/** 变量声明的编辑器:一行一个 */
|
|
500
418
|
function varsEditor(list) {
|
|
501
419
|
const rows = [];
|
|
@@ -580,7 +498,6 @@ async function openEditor(s) {
|
|
|
580
498
|
const body = h("textarea", { rows: 14, class: "skill-body-input", spellcheck: "false", placeholder: "写给 agent 的操作说明(Markdown)" });
|
|
581
499
|
body.value = d?.body ?? (s ? "" : BODY_TEMPLATE);
|
|
582
500
|
const vars = varsEditor(d?.vars);
|
|
583
|
-
const vis = visibilityPicker(d);
|
|
584
501
|
const save = h("button", { class: "btn primary" }, d ? "保存" : "创建");
|
|
585
502
|
const close = modal({
|
|
586
503
|
title: d ? `编辑 ${d.name}` : "新建技能",
|
|
@@ -597,21 +514,19 @@ async function openEditor(s) {
|
|
|
597
514
|
vars.el,
|
|
598
515
|
"要调的接口地址、密钥之类,每个人填自己的值。普通变量注入到他自己的会话环境里,正文里用 $变量名 引用;密钥再填上「只允许发往」的地址就受保护:agent 看不到值,正文里写成让 agent 用 skill_request 工具发请求、在地址或请求头里写 {{变量名}}。",
|
|
599
516
|
),
|
|
600
|
-
field("谁能看到", vis.el, "看得到的人可以在技能广场安装。你以后改了,装了的人也跟着用新的。"),
|
|
601
517
|
),
|
|
602
518
|
foot: h("div", { class: "modal-foot" }, save),
|
|
603
519
|
});
|
|
604
520
|
(d ? desc : name).focus();
|
|
605
521
|
save.onclick = async () => {
|
|
606
|
-
const payload = { description: desc.value, body: body.value, vars: vars.value()
|
|
522
|
+
const payload = { description: desc.value, body: body.value, vars: vars.value() };
|
|
607
523
|
save.disabled = true;
|
|
608
524
|
try {
|
|
609
525
|
if (d) await api(`/api/skills/${d.id}`, { method: "PATCH", body: payload });
|
|
610
526
|
else await api("/api/skills", { method: "POST", body: { name: name.value.trim().toLowerCase(), ...payload } });
|
|
611
527
|
close();
|
|
612
528
|
toast(d ? "已保存" : "已创建");
|
|
613
|
-
|
|
614
|
-
else void openSkills();
|
|
529
|
+
void reload();
|
|
615
530
|
} catch (err) {
|
|
616
531
|
save.disabled = false;
|
|
617
532
|
toast(err.message);
|
|
@@ -634,7 +549,7 @@ function toBase64(buf) {
|
|
|
634
549
|
|
|
635
550
|
/**
|
|
636
551
|
* 上传技能:压缩包(.zip / .skill)、单个 SKILL.md,或者整个文件夹。target 给了就是替换那个技能的内容。
|
|
637
|
-
*
|
|
552
|
+
* 传完打开编辑框让人确认说明和变量。
|
|
638
553
|
*/
|
|
639
554
|
function pickUpload(target) {
|
|
640
555
|
const choose = (folder) => {
|
|
@@ -653,7 +568,7 @@ function pickUpload(target) {
|
|
|
653
568
|
"div",
|
|
654
569
|
{ class: "modal-body" },
|
|
655
570
|
h("p", { class: "skill-note" }, "技能包里要有 SKILL.md(开头的 frontmatter 写 name 和 description),可以带脚本、模板等文件。要声明变量的话,放一个 agent-loft.json:{ \"vars\": [{ \"name\": \"MY_API_KEY\", \"secret\": true, \"required\": true }] }。"),
|
|
656
|
-
target ? h("p", { class: "skill-note" }, "替换后名字不变,包里写的 name
|
|
571
|
+
target ? h("p", { class: "skill-note" }, "替换后名字不变,包里写的 name 会被忽略。") : null,
|
|
657
572
|
h(
|
|
658
573
|
"div",
|
|
659
574
|
{ class: "skill-upload-btns" },
|
|
@@ -692,9 +607,8 @@ async function upload(files, target) {
|
|
|
692
607
|
return;
|
|
693
608
|
}
|
|
694
609
|
const s = await api("/api/skills", { method: "POST", body: { upload: payload, visibility: "private" } });
|
|
695
|
-
toast(`已导入 ${s.name}
|
|
696
|
-
|
|
697
|
-
else await openSkills();
|
|
610
|
+
toast(`已导入 ${s.name}`);
|
|
611
|
+
void reload();
|
|
698
612
|
await openEditor(s);
|
|
699
613
|
} catch (err) {
|
|
700
614
|
toast(err.message);
|
package/web/state.js
CHANGED
|
@@ -21,7 +21,7 @@ export const S = {
|
|
|
21
21
|
loadToken: 0,
|
|
22
22
|
/** 登录的用户 { id, name, role } */
|
|
23
23
|
user: null,
|
|
24
|
-
/** 主区域在显示什么:null 是对话(或新对话),"tasks"
|
|
24
|
+
/** 主区域在显示什么:null 是对话(或新对话),"tasks" 是定时任务页 */
|
|
25
25
|
view: null,
|
|
26
26
|
};
|
|
27
27
|
|
|
@@ -39,8 +39,9 @@ export const desktopQuota = typeof desktop?.onQuota === "function";
|
|
|
39
39
|
// 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 css/topbar.css 的 .in-desktop)
|
|
40
40
|
if (desktop) document.documentElement.classList.add("in-desktop");
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
* 新版桌面端顶栏的「更多」里能打开每台服务器的设置:页面「更多」里的设置就不放了。
|
|
43
|
+
* 老版本桌面端没有这个入口,照旧放,免得设置打不开。
|
|
44
44
|
*/
|
|
45
45
|
export const desktopSettings = typeof desktop?.onOpenSettings === "function";
|
|
46
|
-
|
|
46
|
+
/** 更新的桌面端:页面右上角的「更多」整个交给桌面端顶栏的「更多」(排在最上面),页面上不显示这个按钮 */
|
|
47
|
+
export const desktopMenu = typeof desktop?.setMenu === "function";
|
package/web/views/composer.js
CHANGED
|
@@ -10,6 +10,7 @@ import { cancelVoice, SpeechRec, toggleVoice } from "./voice.js";
|
|
|
10
10
|
import { closeSlashMenu, commandUsage, oneLine, slashKeydown, updateSlashMenu } from "./slash.js";
|
|
11
11
|
import { attachMention } from "./mention.js";
|
|
12
12
|
import { doAction, openModelPicker, openSessions } from "./conv-actions.js";
|
|
13
|
+
import { openSettings } from "./layout.js";
|
|
13
14
|
|
|
14
15
|
// ---------- 输入框 ----------
|
|
15
16
|
|
|
@@ -70,7 +71,7 @@ export function renderComposer() {
|
|
|
70
71
|
ui.slashMenu = h("div", { class: "slash-menu", hidden: true });
|
|
71
72
|
ui.mentionMenu = h("div", { class: "slash-menu", hidden: true });
|
|
72
73
|
ui.mention = attachMention(ui.input, ui.mentionMenu, {
|
|
73
|
-
onEmpty: () =>
|
|
74
|
+
onEmpty: () => openSettings("技能"),
|
|
74
75
|
});
|
|
75
76
|
ui.composer = h(
|
|
76
77
|
"div",
|
package/web/views/interaction.js
CHANGED
|
@@ -28,7 +28,13 @@ export function upsertInteraction(ix) {
|
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
if (ix.settled) return;
|
|
31
|
-
|
|
31
|
+
insertCard(ix);
|
|
32
|
+
notifyIfHidden(ix);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 把卡片放进对话:插在这一轮中间的话,之前的输出先封口,之后的输出接在卡片下面,顺序才对 */
|
|
36
|
+
export function insertCard(ix) {
|
|
37
|
+
if (S.items.some((i) => i.type === "ix" && i.id === ix.id)) return;
|
|
32
38
|
if (S.turnAi && !S.turnAi.done) {
|
|
33
39
|
S.turnAi.done = true;
|
|
34
40
|
S.turnAi.segment = true;
|
|
@@ -36,9 +42,11 @@ export function upsertInteraction(ix) {
|
|
|
36
42
|
S.turnAi = null;
|
|
37
43
|
}
|
|
38
44
|
addItem({ type: "ix", id: ix.id, ix });
|
|
39
|
-
notifyIfHidden(ix);
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
/** 已处理:「已回答 · 已在 Web 处理」。从会话记录里还原的不知道是谁处理的,只写结果 */
|
|
48
|
+
const settledText = (s) => [SETTLE_LABEL[s.settlement] ?? s.settlement, s.by].filter(Boolean).join(" · ");
|
|
49
|
+
|
|
42
50
|
function notifyIfHidden(ix) {
|
|
43
51
|
// 桌面端由服务端的 notice 统一弹,这里再弹就重复了
|
|
44
52
|
if (desktop || document.visibilityState === "visible" || !("Notification" in window)) return;
|
|
@@ -82,7 +90,7 @@ export function renderInteraction(item) {
|
|
|
82
90
|
h("div", { class: "ix-title" }, settled ? "" : "⚠️", spec.title),
|
|
83
91
|
h("div", { class: "ix-detail" }, renderMarkdown(spec.detail)),
|
|
84
92
|
settled
|
|
85
|
-
? h("div", { class: "ix-meta" },
|
|
93
|
+
? h("div", { class: "ix-meta" }, settledText(settled))
|
|
86
94
|
: h(
|
|
87
95
|
"div",
|
|
88
96
|
{ class: "ix-buttons" },
|
|
@@ -128,7 +136,7 @@ export function renderInteraction(item) {
|
|
|
128
136
|
);
|
|
129
137
|
}),
|
|
130
138
|
settled
|
|
131
|
-
? h("div", { class: "ix-meta" },
|
|
139
|
+
? h("div", { class: "ix-meta" }, settledText(settled))
|
|
132
140
|
: h(
|
|
133
141
|
"div",
|
|
134
142
|
{ class: "ix-buttons" },
|
package/web/views/layout.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { $app, fill, h, icon, toast } from "../lib/dom.js";
|
|
2
2
|
import { api } from "../lib/api.js";
|
|
3
|
-
import { desktop, desktopQuota, desktopSettings, S, ui } from "../state.js";
|
|
3
|
+
import { desktop, desktopMenu, desktopQuota, desktopSettings, S, ui } from "../state.js";
|
|
4
4
|
import { clearAppBadge, WS } from "../lib/ws.js";
|
|
5
5
|
import { showAuth } from "./auth.js";
|
|
6
6
|
import {
|
|
7
|
+
applyBootstrap,
|
|
8
|
+
bootstrapQuery,
|
|
7
9
|
closeSide,
|
|
8
10
|
marquee,
|
|
9
11
|
moveProjectDir,
|
|
@@ -18,10 +20,11 @@ import {
|
|
|
18
20
|
stopMarquee,
|
|
19
21
|
} from "./sidebar.js";
|
|
20
22
|
import { applyDesktopQuota, loadQuota, renderQuota } from "./quota.js";
|
|
21
|
-
import { openBotConversations, openConvContextMenu, usableAgents } from "./topbar.js";
|
|
23
|
+
import { openBotConversations, openConvContextMenu, runDesktopMenu, usableAgents } from "./topbar.js";
|
|
22
24
|
import { showMenu } from "../lib/overlay.js";
|
|
23
25
|
import { bindScrollWatch, newDraft, openConversation, scrollToBottom } from "./messages.js";
|
|
24
26
|
import { renderComposer } from "./composer.js";
|
|
27
|
+
import { createNav } from "./msg-nav.js";
|
|
25
28
|
import { renameConv } from "./conv-actions.js";
|
|
26
29
|
import { releaseForeignPushSub } from "../lib/push.js";
|
|
27
30
|
|
|
@@ -32,13 +35,14 @@ import { releaseForeignPushSub } from "../lib/push.js";
|
|
|
32
35
|
* 加载期间弹窗还没出来,连点 / 桌面端重复触发都会再开一层,所以加载中的再调用直接忽略。
|
|
33
36
|
*/
|
|
34
37
|
let settingsLoading = false;
|
|
35
|
-
|
|
38
|
+
/** tab:直接打开哪一页,如「技能」 */
|
|
39
|
+
export function openSettings(tab) {
|
|
36
40
|
if (settingsLoading) return;
|
|
37
41
|
settingsLoading = true;
|
|
38
42
|
import("../settings/index.js").then(
|
|
39
43
|
(m) => {
|
|
40
44
|
settingsLoading = false;
|
|
41
|
-
return m.openSettings();
|
|
45
|
+
return m.openSettings(tab);
|
|
42
46
|
},
|
|
43
47
|
(err) => {
|
|
44
48
|
settingsLoading = false;
|
|
@@ -47,14 +51,6 @@ function openSettings() {
|
|
|
47
51
|
);
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
/** 技能页:第一次打开时才加载 */
|
|
51
|
-
export function openSkillsView() {
|
|
52
|
-
import("./skills.js").then(
|
|
53
|
-
(m) => m.openSkills(),
|
|
54
|
-
(err) => toast(`技能页加载失败,刷新页面再试:${err.message}`),
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
54
|
/** 定时任务页:第一次打开时才加载 */
|
|
59
55
|
export function openTasksView() {
|
|
60
56
|
import("./tasks.js").then(
|
|
@@ -69,14 +65,14 @@ export async function boot() {
|
|
|
69
65
|
WS.stopped = false;
|
|
70
66
|
WS.epoch = null;
|
|
71
67
|
WS.hellos = 0;
|
|
72
|
-
const data = await api(
|
|
68
|
+
const data = await api(`/api/bootstrap${bootstrapQuery()}`);
|
|
73
69
|
S.user = data.user ?? me.user ?? null;
|
|
74
70
|
S.agents = data.agents;
|
|
75
71
|
S.bots = data.bots;
|
|
76
72
|
S.workspaceRoot = data.workspaceRoot;
|
|
77
73
|
S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
|
|
78
74
|
S.quotaEnabled = data.quotaEnabled !== false;
|
|
79
|
-
|
|
75
|
+
applyBootstrap(data);
|
|
80
76
|
S.pending = new Map(data.pending.map((i) => [i.id, i]));
|
|
81
77
|
const enabled = usableAgents().map((a) => a.id);
|
|
82
78
|
if (!S.draftAgent || !enabled.includes(S.draftAgent)) S.draftAgent = enabled[0] ?? null;
|
|
@@ -89,14 +85,18 @@ export async function boot() {
|
|
|
89
85
|
// 地址里带了会话就打开它;否则打开「最近」里最近活跃的那个对话,一个都没有才进新对话页
|
|
90
86
|
const latest = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt)[0];
|
|
91
87
|
if (fromHash === "tasks") openTasksView();
|
|
92
|
-
else if (fromHash === "skills") openSkillsView();
|
|
93
88
|
else if (fromHash && S.convs.has(fromHash)) await openConversation(fromHash);
|
|
94
89
|
else if (latest) await openConversation(latest.key);
|
|
95
90
|
else newDraft();
|
|
91
|
+
// 以前的技能页地址:技能现在在设置里
|
|
92
|
+
if (fromHash === "skills") openSettings("技能");
|
|
96
93
|
// 点了桌面端的系统通知 / 菜单栏里的待处理项:跳到对应会话
|
|
97
94
|
desktop?.onOpen((key) => {
|
|
98
|
-
|
|
95
|
+
// 不要求在 S.convs 里:侧栏分页,那条可能还没拉到;openConversation 会去服务端拿
|
|
96
|
+
if (key && key !== S.current) void openConversation(key);
|
|
99
97
|
});
|
|
98
|
+
// 页面的「更多」菜单在桌面端顶栏的「更多」里:点了哪项
|
|
99
|
+
if (desktopMenu) desktop.onMenu(runDesktopMenu);
|
|
100
100
|
// 桌面端「更多」里点了这台服务器的设置;已经开着就不再叠一层
|
|
101
101
|
if (desktopSettings) {
|
|
102
102
|
desktop.onOpenSettings(() => {
|
|
@@ -111,7 +111,7 @@ export async function boot() {
|
|
|
111
111
|
navigator.serviceWorker.addEventListener("message", (e) => {
|
|
112
112
|
if (e.data?.type !== "open") return;
|
|
113
113
|
const key = decodeURIComponent(new URL(e.data.url, location.origin).hash.slice(1));
|
|
114
|
-
if (key &&
|
|
114
|
+
if (key && key !== S.current) void openConversation(key);
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
117
|
}
|
|
@@ -179,19 +179,16 @@ function renderLayout() {
|
|
|
179
179
|
),
|
|
180
180
|
// 「最近」上面的入口,像 ChatGPT 的 Tasks。当前在任务页时高亮(renderSidebar 里切换)
|
|
181
181
|
(ui.tasksNav = h("button", { class: "btn new-chat side-nav", onclick: () => openTasksView() }, icon("clock"), "定时任务")),
|
|
182
|
-
(ui.skillsNav = h("button", { class: "btn new-chat side-nav", onclick: () => openSkillsView() }, icon("spark"), "技能")),
|
|
183
182
|
ui.convList,
|
|
184
|
-
|
|
183
|
+
// 底部只有额度:没有额度可显示(关了查询、还没查到)时整块藏起来,连分隔线一起
|
|
184
|
+
(ui.sideBottom = h(
|
|
185
185
|
"div",
|
|
186
|
-
{ class: "side-bottom" },
|
|
186
|
+
{ class: "side-bottom", hidden: true },
|
|
187
187
|
(ui.quota = h("div", { class: "quota", onpointerenter: () => renderQuota() })),
|
|
188
|
-
|
|
189
|
-
),
|
|
188
|
+
)),
|
|
190
189
|
h("div", { class: "side-resizer", title: "拖动调整宽度,双击恢复默认", onpointerdown: startSideResize, ondblclick: () => setSideWidth(null) }),
|
|
191
190
|
);
|
|
192
191
|
ui.topbar = h("div", { class: "topbar" });
|
|
193
|
-
// 聊天 / 工作切换在顶栏右上角;先建好,renderTopbar 每次把它放回去
|
|
194
|
-
ui.modeSlot = h("span", { class: "mode-slot" });
|
|
195
192
|
ui.msgInner = h("div", { class: "messages-inner" });
|
|
196
193
|
ui.spacer = h("div", { class: "messages-spacer", "aria-hidden": "true" });
|
|
197
194
|
ui.messages = h("div", { class: "messages", tabindex: "-1" }, ui.msgInner, ui.spacer);
|
|
@@ -199,7 +196,7 @@ function renderLayout() {
|
|
|
199
196
|
ui.follow = true;
|
|
200
197
|
bindScrollWatch();
|
|
201
198
|
ui.composerWrap = h("div", { class: "composer-wrap" });
|
|
202
|
-
ui.main = h("main", { class: "main" }, ui.topbar, ui.messages, ui.composerWrap);
|
|
199
|
+
ui.main = h("main", { class: "main" }, ui.topbar, ui.messages, createNav(), ui.composerWrap);
|
|
203
200
|
ui.layout = h("div", { class: "layout" }, ui.sidebar, h("div", { class: "scrim", onclick: closeSide }), ui.main);
|
|
204
201
|
fill($app, ui.layout, h("div", { class: "edge-strip", "aria-hidden": "true" }));
|
|
205
202
|
renderComposer();
|
package/web/views/mention.js
CHANGED
|
@@ -40,7 +40,7 @@ function match(list, q) {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* 给一个输入框挂上 @ 补全。menu 是放菜单的元素(调用方决定放哪、怎么定位,样式用 .slash-menu)。
|
|
43
|
-
* onEmpty
|
|
43
|
+
* onEmpty:一个技能都没有时,菜单里给个入口(去设置里的技能页)。返回 { update, keydown, close }:
|
|
44
44
|
* 输入框的 input / keydown / blur 里分别调用。
|
|
45
45
|
*/
|
|
46
46
|
export function attachMention(input, menu, { onEmpty, onPicked } = {}) {
|
|
@@ -146,7 +146,7 @@ export function attachMention(input, menu, { onEmpty, onPicked } = {}) {
|
|
|
146
146
|
},
|
|
147
147
|
},
|
|
148
148
|
h("div", { class: "slash-head" }, h("span", { class: "slash-name" }, "还没有技能")),
|
|
149
|
-
h("div", { class: "slash-desc" }, "
|
|
149
|
+
h("div", { class: "slash-desc" }, "去「设置 → 技能」新建、上传"),
|
|
150
150
|
),
|
|
151
151
|
);
|
|
152
152
|
return;
|