agent-loft 0.2.30 → 0.2.32
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 +11 -1
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +13 -8
- package/src/agents/claude/session.ts +38 -6
- package/src/agents/codex/session.ts +32 -5
- package/src/channels/feishu/bot.ts +8 -4
- package/src/channels/feishu/turn-stream.ts +3 -2
- package/src/channels/web/api.ts +8 -0
- package/src/channels/web/http.ts +2 -2
- package/src/channels/web/server.ts +21 -1
- package/src/channels/web/skills-api.ts +225 -0
- package/src/channels/web/tasks-api.ts +94 -0
- package/src/core/chat-mode.ts +16 -16
- package/src/core/command-catalog.ts +8 -1
- package/src/core/commands.ts +3 -0
- package/src/core/hub.ts +147 -19
- package/src/core/memory-mcp.ts +1 -1
- package/src/core/personal.ts +5 -1
- package/src/core/skill-runtime.ts +359 -0
- package/src/core/skills-bridge.ts +288 -0
- package/src/core/skills.ts +675 -0
- 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 +256 -0
- package/src/core/tasks-mcp.ts +66 -0
- package/src/core/tasks.ts +281 -0
- package/src/core/types.ts +21 -0
- package/src/core/zip.ts +98 -0
- package/src/main.ts +12 -0
- package/src/mcp/tasks-server.ts +327 -0
- package/web/css/skills.css +368 -0
- package/web/css/tasks.css +455 -0
- package/web/index.html +2 -0
- package/web/lib/dom.js +1 -0
- package/web/lib/ws.js +8 -0
- package/web/state.js +2 -0
- package/web/views/composer.js +15 -3
- package/web/views/conv-actions.js +15 -7
- package/web/views/layout.js +22 -1
- package/web/views/mention.js +193 -0
- package/web/views/messages.js +23 -2
- package/web/views/sidebar.js +4 -0
- package/web/views/skills.js +703 -0
- package/web/views/tasks.js +469 -0
- package/web/views/topbar.js +9 -3
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { fill, h, icon, starsLoader, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { renderMarkdown } from "../lib/markdown.js";
|
|
4
|
+
import { confirmDialog, modal, showMenu } from "../lib/overlay.js";
|
|
5
|
+
import { S, ui } from "../state.js";
|
|
6
|
+
import { WS } from "../lib/ws.js";
|
|
7
|
+
import { closeSide, renderSidebar } from "./sidebar.js";
|
|
8
|
+
import { renderTopbar } from "./topbar.js";
|
|
9
|
+
|
|
10
|
+
// ---------- 技能页 ----------
|
|
11
|
+
// 三块:我的技能(自己建的 + 装了的,可以停用、填变量)、技能广场(别人分享给我、公开的,点安装)、
|
|
12
|
+
// 本机技能(机器上原有的,只有管理员看得到、用得到)。
|
|
13
|
+
// 装的是作者那一份本身:作者改了,装了的人跟着用新的。技能有变化时服务端推 { t: "skills" },这里重新拉。
|
|
14
|
+
|
|
15
|
+
const K = { data: null, tab: "mine", query: "", token: 0, reloadTimer: 0 };
|
|
16
|
+
|
|
17
|
+
const VISIBILITY = { private: "仅自己", users: "指定的人", all: "所有人" };
|
|
18
|
+
|
|
19
|
+
export async function openSkills() {
|
|
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")));
|
|
35
|
+
await reload();
|
|
36
|
+
ui.messages.scrollTop = 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function reload() {
|
|
40
|
+
const my = ++K.token;
|
|
41
|
+
try {
|
|
42
|
+
const data = await api("/api/skills");
|
|
43
|
+
if (my !== K.token || S.view !== "skills") return;
|
|
44
|
+
K.data = data;
|
|
45
|
+
render();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (my === K.token && S.view === "skills") fill(ui.msgInner, h("div", { class: "empty" }, err.message));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
document.addEventListener("aim:skills", () => {
|
|
52
|
+
if (S.view !== "skills") return;
|
|
53
|
+
clearTimeout(K.reloadTimer);
|
|
54
|
+
K.reloadTimer = setTimeout(() => void reload(), 300);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function mine() {
|
|
58
|
+
return K.data.skills.filter((s) => s.installed).sort((a, b) => a.name.localeCompare(b.name));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function plaza() {
|
|
62
|
+
return K.data.skills.filter((s) => !s.mine).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function matches(s) {
|
|
66
|
+
const q = K.query.trim().toLowerCase();
|
|
67
|
+
return !q || s.name.includes(q) || s.description.toLowerCase().includes(q) || (s.ownerName ?? "").toLowerCase().includes(q);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function render() {
|
|
71
|
+
const tabs = [
|
|
72
|
+
["mine", `我的技能 ${mine().length}`],
|
|
73
|
+
["plaza", `技能广场 ${plaza().length}`],
|
|
74
|
+
...(K.data.machine ? [["machine", `本机技能 ${K.data.machine.length}`]] : []),
|
|
75
|
+
];
|
|
76
|
+
const search = h("input", { class: "skill-search", type: "search", placeholder: "搜索名字、说明、作者", value: K.query });
|
|
77
|
+
search.oninput = () => {
|
|
78
|
+
K.query = search.value;
|
|
79
|
+
fill(listBox, renderList());
|
|
80
|
+
};
|
|
81
|
+
const listBox = h("div", { class: "skill-list-box" }, renderList());
|
|
82
|
+
fill(
|
|
83
|
+
ui.msgInner,
|
|
84
|
+
h(
|
|
85
|
+
"div",
|
|
86
|
+
{ class: "tasks-page skills-page" },
|
|
87
|
+
h(
|
|
88
|
+
"div",
|
|
89
|
+
{ class: "tasks-head" },
|
|
90
|
+
h(
|
|
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
|
+
),
|
|
106
|
+
),
|
|
107
|
+
h(
|
|
108
|
+
"div",
|
|
109
|
+
{ class: "skill-tabs" },
|
|
110
|
+
h(
|
|
111
|
+
"div",
|
|
112
|
+
{ class: "seg" },
|
|
113
|
+
tabs.map(([id, label]) =>
|
|
114
|
+
h(
|
|
115
|
+
"button",
|
|
116
|
+
{
|
|
117
|
+
class: K.tab === id ? "on" : "",
|
|
118
|
+
onclick: () => {
|
|
119
|
+
K.tab = id;
|
|
120
|
+
render();
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
label,
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
search,
|
|
128
|
+
),
|
|
129
|
+
listBox,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderList() {
|
|
135
|
+
if (K.tab === "machine") {
|
|
136
|
+
const list = K.data.machine.filter((m) => matches({ ...m, ownerName: "" }));
|
|
137
|
+
return [
|
|
138
|
+
h("p", { class: "skill-note" }, "这台机器上原有的技能(~/.claude/skills、~/.agents/skills 等)。只有管理员的会话能用,其他人的会话里看不到、也调不了。要分给别人用,请复制内容新建一个 agent-loft 技能再分享。"),
|
|
139
|
+
list.length ? h("div", { class: "task-list" }, list.map(machineCard)) : h("div", { class: "skill-empty" }, "没有找到"),
|
|
140
|
+
];
|
|
141
|
+
}
|
|
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
|
+
const list = mine().filter(matches);
|
|
149
|
+
if (list.length) return h("div", { class: "task-list" }, list.map(mineCard));
|
|
150
|
+
if (K.query) return h("div", { class: "skill-empty" }, "没有找到");
|
|
151
|
+
return h(
|
|
152
|
+
"div",
|
|
153
|
+
{ class: "tasks-empty" },
|
|
154
|
+
h("div", { class: "t" }, "还没有技能"),
|
|
155
|
+
h(
|
|
156
|
+
"div",
|
|
157
|
+
{ class: "s" },
|
|
158
|
+
"点「新建」写一个,「上传」一个技能包(.zip / .skill / SKILL.md / 文件夹),去「技能广场」装别人分享的,或者在对话里让 agent「把刚才的做法存成技能」。",
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------- 卡片 ----------
|
|
164
|
+
|
|
165
|
+
/** 变量:几个、缺几个必填的 */
|
|
166
|
+
function varsState(s) {
|
|
167
|
+
if (!s.vars.length) return null;
|
|
168
|
+
const missing = s.vars.filter((v) => v.required && !K.data.vars[v.name]?.set);
|
|
169
|
+
const pending = s.vars.filter((v) => K.data.vars[v.name]?.pending);
|
|
170
|
+
if (missing.length) return h("span", { class: "skill-warn" }, `${missing.length} 个变量没填`);
|
|
171
|
+
if (pending.length) return h("span", { class: "skill-warn" }, "密钥的发送地址待确认");
|
|
172
|
+
return h("span", {}, `${s.vars.length} 个变量`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 要去「填写变量」处理的:必填的没填,或者受保护密钥的地址待确认 */
|
|
176
|
+
function varsNeedAttention(s) {
|
|
177
|
+
return s.vars.some((v) => (v.required && !K.data.vars[v.name]?.set) || K.data.vars[v.name]?.pending);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function mineCard(s) {
|
|
181
|
+
const toggle = h("input", { type: "checkbox", checked: s.enabled });
|
|
182
|
+
toggle.onchange = async () => {
|
|
183
|
+
try {
|
|
184
|
+
await api(`/api/skills/${s.id}/enable`, { method: "POST", body: { on: toggle.checked } });
|
|
185
|
+
toast(toggle.checked ? "已启用" : "已停用");
|
|
186
|
+
void reload();
|
|
187
|
+
} catch (err) {
|
|
188
|
+
toggle.checked = !toggle.checked;
|
|
189
|
+
toast(err.message);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const more = h("button", { class: "icon-btn", title: "更多" }, icon("more"));
|
|
193
|
+
more.onclick = () =>
|
|
194
|
+
showMenu(more, [
|
|
195
|
+
{ label: "查看", onclick: () => openDetail(s) },
|
|
196
|
+
...(s.vars.length ? [{ label: "填写变量…", onclick: () => openVars(s) }] : []),
|
|
197
|
+
...(s.mine
|
|
198
|
+
? [
|
|
199
|
+
{ label: "编辑…", onclick: () => openEditor(s) },
|
|
200
|
+
{ label: "用文件替换…", onclick: () => pickUpload(s) },
|
|
201
|
+
]
|
|
202
|
+
: []),
|
|
203
|
+
"sep",
|
|
204
|
+
s.mine
|
|
205
|
+
? { label: "删除", danger: true, onclick: () => removeSkill(s) }
|
|
206
|
+
: { label: "卸载", danger: true, onclick: () => install(s, false) },
|
|
207
|
+
]);
|
|
208
|
+
return h(
|
|
209
|
+
"div",
|
|
210
|
+
{ class: `task-card skill-card${s.enabled ? "" : " off"}` },
|
|
211
|
+
h(
|
|
212
|
+
"div",
|
|
213
|
+
{ class: "task-top" },
|
|
214
|
+
h("button", { class: "task-title skill-name", title: "查看", onclick: () => openDetail(s) }, s.name),
|
|
215
|
+
h("label", { class: "switch", title: s.enabled ? "已启用,点击停用" : "已停用,点击启用" }, toggle, h("span", { class: "slider" })),
|
|
216
|
+
more,
|
|
217
|
+
),
|
|
218
|
+
h(
|
|
219
|
+
"div",
|
|
220
|
+
{ class: "task-meta" },
|
|
221
|
+
h("span", {}, s.mine ? "我建的" : `${s.ownerName} 建的`),
|
|
222
|
+
s.mine ? h("span", {}, `${VISIBILITY[s.visibility]}可见${s.visibility === "users" ? `(${s.sharedWith.length} 人)` : ""}`) : null,
|
|
223
|
+
s.mine && s.installs ? h("span", {}, `${s.installs} 人安装`) : null,
|
|
224
|
+
s.source === "agent" ? h("span", {}, "agent 生成") : null,
|
|
225
|
+
varsState(s),
|
|
226
|
+
),
|
|
227
|
+
h("div", { class: "task-prompt" }, s.description),
|
|
228
|
+
varsNeedAttention(s)
|
|
229
|
+
? h("button", { class: "task-run", onclick: () => openVars(s) }, s.vars.some((v) => v.required && !K.data.vars[v.name]?.set) ? "填写变量后才能正常使用 →" : "确认密钥的发送地址 →")
|
|
230
|
+
: null,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
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
|
+
function machineCard(m) {
|
|
257
|
+
return h(
|
|
258
|
+
"div",
|
|
259
|
+
{ class: "task-card skill-card" },
|
|
260
|
+
h("div", { class: "task-top" }, h("button", { class: "task-title skill-name", onclick: () => openMachine(m) }, m.name), h("span", { class: "badge" }, m.agents.map((a) => (a === "claude" ? "Claude" : "Codex")).join(" · "))),
|
|
261
|
+
h("div", { class: "task-meta" }, h("span", { title: m.dir }, m.dir)),
|
|
262
|
+
h("div", { class: "task-prompt" }, m.description || "(没有说明)"),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------- 操作 ----------
|
|
267
|
+
|
|
268
|
+
async function install(s, on) {
|
|
269
|
+
try {
|
|
270
|
+
await api(`/api/skills/${s.id}/install`, { method: "POST", body: { on } });
|
|
271
|
+
toast(on ? `已安装 ${s.name},新消息起可用` : `已卸载 ${s.name}`);
|
|
272
|
+
await reload();
|
|
273
|
+
if (on && s.vars.some((v) => v.required)) openVars(K.data.skills.find((x) => x.id === s.id) ?? s);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
toast(err.message);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function removeSkill(s) {
|
|
280
|
+
const sure = await confirmDialog({
|
|
281
|
+
title: s.mine ? `删除技能「${s.name}」?` : `下架「${s.name}」?`,
|
|
282
|
+
message: s.mine
|
|
283
|
+
? `删除后找不回来${s.installs ? `,装了它的 ${s.installs} 个人也用不了了` : ""}。`
|
|
284
|
+
: `这是 ${s.ownerName} 建的技能,下架会直接删除它,装了的人都用不了了。`,
|
|
285
|
+
confirmText: s.mine ? "删除" : "下架",
|
|
286
|
+
danger: true,
|
|
287
|
+
});
|
|
288
|
+
if (!sure) return;
|
|
289
|
+
try {
|
|
290
|
+
await api(`/api/skills/${s.id}`, { method: "DELETE" });
|
|
291
|
+
toast("已删除");
|
|
292
|
+
void reload();
|
|
293
|
+
} catch (err) {
|
|
294
|
+
toast(err.message);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------- 查看 ----------
|
|
299
|
+
|
|
300
|
+
async function openDetail(s) {
|
|
301
|
+
const body = h("div", { class: "modal-body skill-detail" }, starsLoader());
|
|
302
|
+
modal({ title: s.name, body, wide: true });
|
|
303
|
+
let d;
|
|
304
|
+
try {
|
|
305
|
+
d = await api(`/api/skills/${s.id}`);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
fill(body, h("div", { class: "error-text" }, err.message));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const filePreview = h("div", { class: "skill-file-preview" });
|
|
311
|
+
fill(
|
|
312
|
+
body,
|
|
313
|
+
h(
|
|
314
|
+
"div",
|
|
315
|
+
{ class: "task-meta" },
|
|
316
|
+
h("span", {}, d.mine ? "我建的" : `${d.ownerName} 建的`),
|
|
317
|
+
h("span", {}, `${VISIBILITY[d.visibility]}可见`),
|
|
318
|
+
h("span", {}, `更新于 ${new Date(d.updatedAt).toLocaleString()}`),
|
|
319
|
+
),
|
|
320
|
+
h("p", { class: "skill-desc" }, d.description),
|
|
321
|
+
d.vars.length
|
|
322
|
+
? h(
|
|
323
|
+
"div",
|
|
324
|
+
{ class: "skill-section" },
|
|
325
|
+
h("h4", {}, "变量"),
|
|
326
|
+
h(
|
|
327
|
+
"ul",
|
|
328
|
+
{ class: "skill-vars" },
|
|
329
|
+
d.vars.map((v) =>
|
|
330
|
+
h(
|
|
331
|
+
"li",
|
|
332
|
+
{},
|
|
333
|
+
h("code", {}, v.name),
|
|
334
|
+
v.required ? h("span", { class: "badge" }, "必填") : null,
|
|
335
|
+
v.secret ? h("span", { class: "badge" }, v.hosts?.length ? `受保护:只发往 ${v.hosts.join("、")}` : "密钥") : null,
|
|
336
|
+
K.data.vars[v.name]?.set ? h("span", { class: "badge ok" }, "已填") : null,
|
|
337
|
+
v.description ? h("span", { class: "skill-var-desc" }, v.description) : null,
|
|
338
|
+
),
|
|
339
|
+
),
|
|
340
|
+
),
|
|
341
|
+
d.installed ? h("button", { class: "btn", onclick: () => openVars(d) }, "填写变量…") : null,
|
|
342
|
+
)
|
|
343
|
+
: null,
|
|
344
|
+
h("div", { class: "skill-section" }, h("h4", {}, "SKILL.md"), h("div", { class: "skill-md" }, renderMarkdown(d.body))),
|
|
345
|
+
d.files.length
|
|
346
|
+
? h(
|
|
347
|
+
"div",
|
|
348
|
+
{ class: "skill-section" },
|
|
349
|
+
h("h4", {}, `附带文件(${d.files.length})`),
|
|
350
|
+
h(
|
|
351
|
+
"div",
|
|
352
|
+
{ class: "skill-files" },
|
|
353
|
+
d.files.map((f) =>
|
|
354
|
+
h(
|
|
355
|
+
"button",
|
|
356
|
+
{
|
|
357
|
+
class: "skill-file",
|
|
358
|
+
onclick: async () => {
|
|
359
|
+
try {
|
|
360
|
+
const r = await api(`/api/skills/${s.id}/file?path=${encodeURIComponent(f.path)}`);
|
|
361
|
+
fill(filePreview, h("div", { class: "skill-file-name" }, f.path), r.text === null ? h("div", { class: "skill-note" }, "二进制文件,不预览") : h("pre", {}, r.text));
|
|
362
|
+
} catch (err) {
|
|
363
|
+
toast(err.message);
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
h("code", {}, f.path),
|
|
368
|
+
h("span", {}, sizeText(f.size)),
|
|
369
|
+
),
|
|
370
|
+
),
|
|
371
|
+
),
|
|
372
|
+
filePreview,
|
|
373
|
+
)
|
|
374
|
+
: null,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function openMachine(m) {
|
|
379
|
+
const body = h("div", { class: "modal-body skill-detail" }, starsLoader());
|
|
380
|
+
modal({ title: m.name, body, wide: true });
|
|
381
|
+
try {
|
|
382
|
+
const d = await api(`/api/skills/machine?name=${encodeURIComponent(m.name)}`);
|
|
383
|
+
fill(body, h("div", { class: "task-meta" }, h("span", {}, d.dir)), h("pre", { class: "skill-raw" }, d.skillMd));
|
|
384
|
+
} catch (err) {
|
|
385
|
+
fill(body, h("div", { class: "error-text" }, err.message));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function sizeText(n) {
|
|
390
|
+
return n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------- 变量 ----------
|
|
394
|
+
|
|
395
|
+
/** 填这个技能要用的变量。值按名字存,几个技能用同一个名字就共用一个值 */
|
|
396
|
+
function openVars(s) {
|
|
397
|
+
const rows = s.vars.map((v) => {
|
|
398
|
+
const cur = K.data.vars[v.name];
|
|
399
|
+
const input = h("input", {
|
|
400
|
+
type: v.secret ? "password" : "text",
|
|
401
|
+
autocomplete: "off",
|
|
402
|
+
spellcheck: "false",
|
|
403
|
+
placeholder: cur?.set ? (v.secret ? "已填写(不显示),留空不改" : "") : v.required ? "必填" : "可不填",
|
|
404
|
+
value: cur?.value ?? "",
|
|
405
|
+
});
|
|
406
|
+
const clear = cur?.set ? h("button", { type: "button", class: "btn ghost", onclick: () => ((input.value = ""), (input.dataset.clear = "1"), (clear.hidden = true)) }, "清除") : null;
|
|
407
|
+
// 受保护的密钥:agent 看不到值,只能经 agent-loft 发往这些地址;作者改了地址要这里再确认
|
|
408
|
+
const guard = v.secret && v.hosts?.length
|
|
409
|
+
? h("small", { class: "skill-guard" }, `受保护:agent 看不到这个值,只能通过 agent-loft 代发请求,发往 ${(cur?.hosts?.length ? cur.hosts : v.hosts).join("、")}。填写即表示同意发往这些地址。`)
|
|
410
|
+
: null;
|
|
411
|
+
const r = { v, input, approve: false };
|
|
412
|
+
const pending = cur?.pending?.length
|
|
413
|
+
? h(
|
|
414
|
+
"div",
|
|
415
|
+
{ class: "skill-pending" },
|
|
416
|
+
`作者新加了发送地址:${cur.pending.join("、")}。确认之前,这个密钥不会发往新地址。`,
|
|
417
|
+
h("button", { type: "button", class: "btn small", onclick: (e) => ((r.approve = true), (e.currentTarget.textContent = "保存后生效"), (e.currentTarget.disabled = true)) }, "确认"),
|
|
418
|
+
)
|
|
419
|
+
: null;
|
|
420
|
+
r.el = h("div", { class: "field" }, h("span", {}, h("code", {}, v.name), v.required ? " *" : ""), h("div", { class: "skill-var-row" }, input, clear), v.description ? h("small", {}, v.description) : null, guard, pending);
|
|
421
|
+
return r;
|
|
422
|
+
});
|
|
423
|
+
const save = h("button", { class: "btn primary" }, "保存");
|
|
424
|
+
const close = modal({
|
|
425
|
+
title: `${s.name} 的变量`,
|
|
426
|
+
body: h(
|
|
427
|
+
"div",
|
|
428
|
+
{ class: "modal-body task-form" },
|
|
429
|
+
h(
|
|
430
|
+
"p",
|
|
431
|
+
{ class: "skill-note" },
|
|
432
|
+
"只给你自己的会话用。普通变量在工作模式下作为环境变量注入,agent 能读到值;标了「受保护」的密钥 agent 看不到,只能让 agent-loft 代发到指定地址。几个技能用同一个变量名时共用一个值。",
|
|
433
|
+
),
|
|
434
|
+
rows.map((r) => r.el),
|
|
435
|
+
),
|
|
436
|
+
foot: h("div", { class: "modal-foot" }, save),
|
|
437
|
+
});
|
|
438
|
+
rows[0]?.input.focus();
|
|
439
|
+
save.onclick = async () => {
|
|
440
|
+
const values = {};
|
|
441
|
+
for (const r of rows) {
|
|
442
|
+
const val = r.input.value;
|
|
443
|
+
if (r.input.dataset.clear === "1" && !val) values[r.v.name] = null;
|
|
444
|
+
else if (val || !r.v.secret) {
|
|
445
|
+
// 密钥留空 = 不改;普通变量留空 = 删掉
|
|
446
|
+
if (val !== (K.data.vars[r.v.name]?.value ?? "")) values[r.v.name] = val || null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const approve = rows.filter((r) => r.approve).map((r) => r.v.name);
|
|
450
|
+
// 什么都没改:别去存(存一次会让会话重新加载)
|
|
451
|
+
if (!Object.keys(values).length && !approve.length) return close();
|
|
452
|
+
save.disabled = true;
|
|
453
|
+
try {
|
|
454
|
+
await api("/api/skills/vars", { method: "PUT", body: { values, approve } });
|
|
455
|
+
close();
|
|
456
|
+
toast("已保存,新消息起生效");
|
|
457
|
+
void reload();
|
|
458
|
+
} catch (err) {
|
|
459
|
+
save.disabled = false;
|
|
460
|
+
toast(err.message);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------- 新建 / 编辑 ----------
|
|
466
|
+
|
|
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
|
+
/** 变量声明的编辑器:一行一个 */
|
|
500
|
+
function varsEditor(list) {
|
|
501
|
+
const rows = [];
|
|
502
|
+
const box = h("div", { class: "skill-var-edit" });
|
|
503
|
+
const add = (v = {}) => {
|
|
504
|
+
const name = h("input", { value: v.name ?? "", placeholder: "变量名,如 WEATHER_API_KEY", spellcheck: "false" });
|
|
505
|
+
name.oninput = () => (name.value = name.value.toUpperCase().replace(/[^A-Z0-9_]/g, "_"));
|
|
506
|
+
const desc = h("input", { value: v.description ?? "", placeholder: "说明:是什么、去哪拿" });
|
|
507
|
+
const secret = h("input", { type: "checkbox", checked: !!v.secret });
|
|
508
|
+
const required = h("input", { type: "checkbox", checked: !!v.required });
|
|
509
|
+
// 密钥才有:填了允许的地址就是受保护的密钥
|
|
510
|
+
const hosts = h("input", {
|
|
511
|
+
value: (v.hosts ?? []).join(", "),
|
|
512
|
+
placeholder: "可不填。填了就受保护,比如 api.example.com, 192.168.8.2:8080",
|
|
513
|
+
spellcheck: "false",
|
|
514
|
+
title: "填了就受保护:不进环境变量,agent 看不到值,只能让 agent-loft 代发到这些地址",
|
|
515
|
+
});
|
|
516
|
+
const hostsLine = h("label", { class: "skill-var-hosts" }, h("span", {}, "只允许发往"), hosts);
|
|
517
|
+
const syncHosts = () => (hostsLine.hidden = !secret.checked);
|
|
518
|
+
secret.onchange = syncHosts;
|
|
519
|
+
syncHosts();
|
|
520
|
+
const row = h(
|
|
521
|
+
"div",
|
|
522
|
+
{ class: "skill-var-item" },
|
|
523
|
+
h(
|
|
524
|
+
"div",
|
|
525
|
+
{ class: "skill-var-line" },
|
|
526
|
+
name,
|
|
527
|
+
desc,
|
|
528
|
+
h("label", {}, secret, "密钥"),
|
|
529
|
+
h("label", {}, required, "必填"),
|
|
530
|
+
h("button", { type: "button", class: "icon-btn", title: "删掉", onclick: () => (row.remove(), rows.splice(rows.indexOf(r), 1)) }, icon("x")),
|
|
531
|
+
),
|
|
532
|
+
hostsLine,
|
|
533
|
+
);
|
|
534
|
+
const r = { name, desc, secret, required, hosts };
|
|
535
|
+
rows.push(r);
|
|
536
|
+
box.append(row);
|
|
537
|
+
};
|
|
538
|
+
for (const v of list ?? []) add(v);
|
|
539
|
+
return {
|
|
540
|
+
el: h("div", {}, box, h("button", { type: "button", class: "btn ghost", onclick: () => add() }, icon("plus"), "加一个变量")),
|
|
541
|
+
value: () =>
|
|
542
|
+
rows
|
|
543
|
+
.filter((r) => r.name.value.trim())
|
|
544
|
+
.map((r) => ({
|
|
545
|
+
name: r.name.value.trim(),
|
|
546
|
+
description: r.desc.value.trim(),
|
|
547
|
+
secret: r.secret.checked,
|
|
548
|
+
required: r.required.checked,
|
|
549
|
+
...(r.secret.checked && r.hosts.value.trim() ? { hosts: r.hosts.value.split(/[\s,,、]+/).filter(Boolean) } : {}),
|
|
550
|
+
})),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const BODY_TEMPLATE = `## 什么时候用
|
|
555
|
+
|
|
556
|
+
(用户要做什么的时候用这个技能)
|
|
557
|
+
|
|
558
|
+
## 怎么做
|
|
559
|
+
|
|
560
|
+
1. 第一步……
|
|
561
|
+
2. 调接口的例子:\`curl -H "Authorization: Bearer $MY_API_KEY" https://example.com/api\`
|
|
562
|
+
|
|
563
|
+
## 注意
|
|
564
|
+
|
|
565
|
+
- 不要把密钥打印出来`;
|
|
566
|
+
|
|
567
|
+
/** s:编辑这个(自己的);不给就是新建。preset:上传后预填 */
|
|
568
|
+
async function openEditor(s) {
|
|
569
|
+
let d = null;
|
|
570
|
+
if (s) {
|
|
571
|
+
try {
|
|
572
|
+
d = await api(`/api/skills/${s.id}`);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
return toast(err.message);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const name = h("input", { value: d?.name ?? "", placeholder: "小写字母、数字和 -,比如 weather-report", spellcheck: "false", disabled: !!d });
|
|
578
|
+
const desc = h("textarea", { rows: 2, maxlength: "1024", placeholder: "一两句话说清什么时候用它。agent 靠这句话判断要不要用这个技能。" });
|
|
579
|
+
desc.value = d?.description ?? "";
|
|
580
|
+
const body = h("textarea", { rows: 14, class: "skill-body-input", spellcheck: "false", placeholder: "写给 agent 的操作说明(Markdown)" });
|
|
581
|
+
body.value = d?.body ?? (s ? "" : BODY_TEMPLATE);
|
|
582
|
+
const vars = varsEditor(d?.vars);
|
|
583
|
+
const vis = visibilityPicker(d);
|
|
584
|
+
const save = h("button", { class: "btn primary" }, d ? "保存" : "创建");
|
|
585
|
+
const close = modal({
|
|
586
|
+
title: d ? `编辑 ${d.name}` : "新建技能",
|
|
587
|
+
wide: true,
|
|
588
|
+
tall: true,
|
|
589
|
+
body: h(
|
|
590
|
+
"div",
|
|
591
|
+
{ class: "modal-body task-form" },
|
|
592
|
+
field("名字", name, d ? "建好后不能改。输入框里用 @名字 点名使用。" : "全站唯一,建好后不能改。输入框里用 @名字 点名使用。"),
|
|
593
|
+
field("说明", desc),
|
|
594
|
+
field("正文(SKILL.md)", body, d?.files?.length ? `另有 ${d.files.length} 个附带文件,要改的话用「用文件替换」上传整个包。` : "可以引用同目录下的脚本、资料(上传技能包时一起带上)。"),
|
|
595
|
+
field(
|
|
596
|
+
"变量",
|
|
597
|
+
vars.el,
|
|
598
|
+
"要调的接口地址、密钥之类,每个人填自己的值。普通变量注入到他自己的会话环境里,正文里用 $变量名 引用;密钥再填上「只允许发往」的地址就受保护:agent 看不到值,正文里写成让 agent 用 skill_request 工具发请求、在地址或请求头里写 {{变量名}}。",
|
|
599
|
+
),
|
|
600
|
+
field("谁能看到", vis.el, "看得到的人可以在技能广场安装。你以后改了,装了的人也跟着用新的。"),
|
|
601
|
+
),
|
|
602
|
+
foot: h("div", { class: "modal-foot" }, save),
|
|
603
|
+
});
|
|
604
|
+
(d ? desc : name).focus();
|
|
605
|
+
save.onclick = async () => {
|
|
606
|
+
const payload = { description: desc.value, body: body.value, vars: vars.value(), ...vis.value() };
|
|
607
|
+
save.disabled = true;
|
|
608
|
+
try {
|
|
609
|
+
if (d) await api(`/api/skills/${d.id}`, { method: "PATCH", body: payload });
|
|
610
|
+
else await api("/api/skills", { method: "POST", body: { name: name.value.trim().toLowerCase(), ...payload } });
|
|
611
|
+
close();
|
|
612
|
+
toast(d ? "已保存" : "已创建");
|
|
613
|
+
if (S.view === "skills") void reload();
|
|
614
|
+
else void openSkills();
|
|
615
|
+
} catch (err) {
|
|
616
|
+
save.disabled = false;
|
|
617
|
+
toast(err.message);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function field(label, input, hint) {
|
|
623
|
+
return h("div", { class: "field" }, h("span", {}, label), input, hint ? h("small", {}, hint) : null);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---------- 上传 ----------
|
|
627
|
+
|
|
628
|
+
function toBase64(buf) {
|
|
629
|
+
const bytes = new Uint8Array(buf);
|
|
630
|
+
let bin = "";
|
|
631
|
+
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
632
|
+
return btoa(bin);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* 上传技能:压缩包(.zip / .skill)、单个 SKILL.md,或者整个文件夹。target 给了就是替换那个技能的内容。
|
|
637
|
+
* 新建的先设成「仅自己」,传完打开编辑框让人确认说明、变量和分享范围。
|
|
638
|
+
*/
|
|
639
|
+
function pickUpload(target) {
|
|
640
|
+
const choose = (folder) => {
|
|
641
|
+
const input = h("input", { type: "file", style: { display: "none" }, ...(folder ? { webkitdirectory: true, multiple: true } : { accept: ".zip,.skill,.md" }) });
|
|
642
|
+
input.onchange = () => {
|
|
643
|
+
const files = [...input.files];
|
|
644
|
+
input.remove();
|
|
645
|
+
if (files.length) void upload(files, target);
|
|
646
|
+
};
|
|
647
|
+
document.body.append(input);
|
|
648
|
+
input.click();
|
|
649
|
+
};
|
|
650
|
+
const close = modal({
|
|
651
|
+
title: target ? `用文件替换 ${target.name}` : "上传技能",
|
|
652
|
+
body: h(
|
|
653
|
+
"div",
|
|
654
|
+
{ class: "modal-body" },
|
|
655
|
+
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 会被忽略;装了它的人会跟着用新的。") : null,
|
|
657
|
+
h(
|
|
658
|
+
"div",
|
|
659
|
+
{ class: "skill-upload-btns" },
|
|
660
|
+
h("button", { class: "btn primary", onclick: () => (close(), choose(false)) }, "选压缩包或 SKILL.md"),
|
|
661
|
+
h("button", { class: "btn", onclick: () => (close(), choose(true)) }, "选文件夹"),
|
|
662
|
+
),
|
|
663
|
+
),
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function upload(files, target) {
|
|
668
|
+
let payload;
|
|
669
|
+
try {
|
|
670
|
+
const total = files.reduce((n, f) => n + f.size, 0);
|
|
671
|
+
if (total > 10 * 1024 * 1024) throw new Error("太大了(最多 10MB)");
|
|
672
|
+
const one = files.length === 1 ? files[0] : null;
|
|
673
|
+
if (one && /\.(zip|skill)$/i.test(one.name)) payload = { zip: toBase64(await one.arrayBuffer()) };
|
|
674
|
+
else {
|
|
675
|
+
const list = [];
|
|
676
|
+
for (const f of files) {
|
|
677
|
+
// 选文件夹时带相对路径;单个 SKILL.md 就叫 SKILL.md
|
|
678
|
+
const rel = f.webkitRelativePath || (one ? "SKILL.md" : f.name);
|
|
679
|
+
list.push({ path: rel, data: toBase64(await f.arrayBuffer()) });
|
|
680
|
+
}
|
|
681
|
+
payload = { files: list };
|
|
682
|
+
}
|
|
683
|
+
} catch (err) {
|
|
684
|
+
return toast(err.message);
|
|
685
|
+
}
|
|
686
|
+
toast("上传中…", 1500);
|
|
687
|
+
try {
|
|
688
|
+
if (target) {
|
|
689
|
+
await api(`/api/skills/${target.id}`, { method: "PATCH", body: { upload: payload } });
|
|
690
|
+
toast(`已替换 ${target.name}`);
|
|
691
|
+
void reload();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const s = await api("/api/skills", { method: "POST", body: { upload: payload, visibility: "private" } });
|
|
695
|
+
toast(`已导入 ${s.name},现在只有你自己看得到`);
|
|
696
|
+
if (S.view === "skills") await reload();
|
|
697
|
+
else await openSkills();
|
|
698
|
+
await openEditor(s);
|
|
699
|
+
} catch (err) {
|
|
700
|
+
toast(err.message);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|