agent-loft 0.2.31 → 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 +14 -5
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +90 -8
- package/src/agents/claude/session.ts +81 -12
- package/src/agents/codex/session.ts +70 -13
- package/src/channels/feishu/bot.ts +2 -2
- package/src/channels/web/api.ts +72 -2
- package/src/channels/web/conv-pages.ts +68 -0
- package/src/channels/web/http.ts +2 -2
- package/src/channels/web/server.ts +20 -3
- package/src/channels/web/skills-api.ts +225 -0
- package/src/core/chat-mode.ts +3 -3
- package/src/core/command-catalog.ts +8 -1
- package/src/core/commands.ts +3 -0
- package/src/core/hub.ts +168 -18
- package/src/core/personal.ts +3 -1
- package/src/core/skill-runtime.ts +359 -0
- package/src/core/skills-bridge.ts +288 -0
- package/src/core/skills.ts +676 -0
- package/src/core/tasks-bridge.ts +5 -1
- package/src/core/tasks-mcp.ts +11 -0
- package/src/core/types.ts +44 -3
- package/src/core/usage.ts +70 -0
- package/src/core/zip.ts +98 -0
- package/src/main.ts +4 -0
- package/src/mcp/tasks-server.ts +125 -4
- 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 +385 -0
- package/web/css/topbar.css +131 -0
- package/web/index.html +1 -0
- package/web/lib/dom.js +3 -1
- package/web/lib/markdown.js +84 -14
- package/web/lib/ws.js +49 -5
- package/web/settings/index.js +7 -3
- package/web/settings/skills.js +617 -0
- package/web/state.js +4 -3
- package/web/views/composer.js +16 -3
- package/web/views/interaction.js +12 -4
- package/web/views/layout.js +22 -15
- package/web/views/mention.js +193 -0
- 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 -16
- package/web/views/tasks.js +11 -2
- package/web/views/topbar.js +168 -40
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;
|
|
@@ -61,14 +65,14 @@ export async function boot() {
|
|
|
61
65
|
WS.stopped = false;
|
|
62
66
|
WS.epoch = null;
|
|
63
67
|
WS.hellos = 0;
|
|
64
|
-
const data = await api(
|
|
68
|
+
const data = await api(`/api/bootstrap${bootstrapQuery()}`);
|
|
65
69
|
S.user = data.user ?? me.user ?? null;
|
|
66
70
|
S.agents = data.agents;
|
|
67
71
|
S.bots = data.bots;
|
|
68
72
|
S.workspaceRoot = data.workspaceRoot;
|
|
69
73
|
S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
|
|
70
74
|
S.quotaEnabled = data.quotaEnabled !== false;
|
|
71
|
-
|
|
75
|
+
applyBootstrap(data);
|
|
72
76
|
S.pending = new Map(data.pending.map((i) => [i.id, i]));
|
|
73
77
|
const enabled = usableAgents().map((a) => a.id);
|
|
74
78
|
if (!S.draftAgent || !enabled.includes(S.draftAgent)) S.draftAgent = enabled[0] ?? null;
|
|
@@ -84,10 +88,15 @@ export async function boot() {
|
|
|
84
88
|
else if (fromHash && S.convs.has(fromHash)) await openConversation(fromHash);
|
|
85
89
|
else if (latest) await openConversation(latest.key);
|
|
86
90
|
else newDraft();
|
|
91
|
+
// 以前的技能页地址:技能现在在设置里
|
|
92
|
+
if (fromHash === "skills") openSettings("技能");
|
|
87
93
|
// 点了桌面端的系统通知 / 菜单栏里的待处理项:跳到对应会话
|
|
88
94
|
desktop?.onOpen((key) => {
|
|
89
|
-
|
|
95
|
+
// 不要求在 S.convs 里:侧栏分页,那条可能还没拉到;openConversation 会去服务端拿
|
|
96
|
+
if (key && key !== S.current) void openConversation(key);
|
|
90
97
|
});
|
|
98
|
+
// 页面的「更多」菜单在桌面端顶栏的「更多」里:点了哪项
|
|
99
|
+
if (desktopMenu) desktop.onMenu(runDesktopMenu);
|
|
91
100
|
// 桌面端「更多」里点了这台服务器的设置;已经开着就不再叠一层
|
|
92
101
|
if (desktopSettings) {
|
|
93
102
|
desktop.onOpenSettings(() => {
|
|
@@ -102,7 +111,7 @@ export async function boot() {
|
|
|
102
111
|
navigator.serviceWorker.addEventListener("message", (e) => {
|
|
103
112
|
if (e.data?.type !== "open") return;
|
|
104
113
|
const key = decodeURIComponent(new URL(e.data.url, location.origin).hash.slice(1));
|
|
105
|
-
if (key &&
|
|
114
|
+
if (key && key !== S.current) void openConversation(key);
|
|
106
115
|
});
|
|
107
116
|
}
|
|
108
117
|
}
|
|
@@ -171,17 +180,15 @@ function renderLayout() {
|
|
|
171
180
|
// 「最近」上面的入口,像 ChatGPT 的 Tasks。当前在任务页时高亮(renderSidebar 里切换)
|
|
172
181
|
(ui.tasksNav = h("button", { class: "btn new-chat side-nav", onclick: () => openTasksView() }, icon("clock"), "定时任务")),
|
|
173
182
|
ui.convList,
|
|
174
|
-
|
|
183
|
+
// 底部只有额度:没有额度可显示(关了查询、还没查到)时整块藏起来,连分隔线一起
|
|
184
|
+
(ui.sideBottom = h(
|
|
175
185
|
"div",
|
|
176
|
-
{ class: "side-bottom" },
|
|
186
|
+
{ class: "side-bottom", hidden: true },
|
|
177
187
|
(ui.quota = h("div", { class: "quota", onpointerenter: () => renderQuota() })),
|
|
178
|
-
|
|
179
|
-
),
|
|
188
|
+
)),
|
|
180
189
|
h("div", { class: "side-resizer", title: "拖动调整宽度,双击恢复默认", onpointerdown: startSideResize, ondblclick: () => setSideWidth(null) }),
|
|
181
190
|
);
|
|
182
191
|
ui.topbar = h("div", { class: "topbar" });
|
|
183
|
-
// 聊天 / 工作切换在顶栏右上角;先建好,renderTopbar 每次把它放回去
|
|
184
|
-
ui.modeSlot = h("span", { class: "mode-slot" });
|
|
185
192
|
ui.msgInner = h("div", { class: "messages-inner" });
|
|
186
193
|
ui.spacer = h("div", { class: "messages-spacer", "aria-hidden": "true" });
|
|
187
194
|
ui.messages = h("div", { class: "messages", tabindex: "-1" }, ui.msgInner, ui.spacer);
|
|
@@ -189,7 +196,7 @@ function renderLayout() {
|
|
|
189
196
|
ui.follow = true;
|
|
190
197
|
bindScrollWatch();
|
|
191
198
|
ui.composerWrap = h("div", { class: "composer-wrap" });
|
|
192
|
-
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);
|
|
193
200
|
ui.layout = h("div", { class: "layout" }, ui.sidebar, h("div", { class: "scrim", onclick: closeSide }), ui.main);
|
|
194
201
|
fill($app, ui.layout, h("div", { class: "edge-strip", "aria-hidden": "true" }));
|
|
195
202
|
renderComposer();
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { fill, h } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
|
|
4
|
+
// ---------- @ 技能补全 ----------
|
|
5
|
+
//
|
|
6
|
+
// 光标前面是「@xxx」(@ 在开头或者前面是空白、标点)时弹出能用的技能,选中后补成「@name 」。
|
|
7
|
+
// 输入框和定时任务的「要做的事」共用:发出去之后由服务端认消息里的 @name(skill-runtime.ts),
|
|
8
|
+
// 前端只负责补全,不用另外传选了哪些。
|
|
9
|
+
// 列表缓存半分钟;技能有变化时服务端推 { t: "skills" },缓存作废。
|
|
10
|
+
|
|
11
|
+
let cache = null;
|
|
12
|
+
document.addEventListener("aim:skills", () => (cache = null));
|
|
13
|
+
|
|
14
|
+
async function loadSkills() {
|
|
15
|
+
if (cache && Date.now() - cache.at < 30_000) return cache.list;
|
|
16
|
+
const data = await api("/api/skills/mention");
|
|
17
|
+
cache = { at: Date.now(), list: data.skills };
|
|
18
|
+
return data.skills;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 光标前的 @ 片段:返回 { start, query },不在 @ 里就是 null */
|
|
22
|
+
function mentionAt(input) {
|
|
23
|
+
const pos = input.selectionStart ?? input.value.length;
|
|
24
|
+
if (pos !== (input.selectionEnd ?? pos)) return null;
|
|
25
|
+
const m = /(^|[\s((,。,.!!??::;;"'“”‘’、])@([a-z0-9_-]*)$/i.exec(input.value.slice(0, pos));
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
return { start: pos - m[2].length - 1, query: m[2].toLowerCase() };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function match(list, q) {
|
|
31
|
+
if (!q) return list;
|
|
32
|
+
const tiers = [[], [], []];
|
|
33
|
+
for (const s of list) {
|
|
34
|
+
if (s.name.startsWith(q)) tiers[0].push(s);
|
|
35
|
+
else if (s.name.includes(q)) tiers[1].push(s);
|
|
36
|
+
else if (s.description.toLowerCase().includes(q)) tiers[2].push(s);
|
|
37
|
+
}
|
|
38
|
+
return tiers.flat();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 给一个输入框挂上 @ 补全。menu 是放菜单的元素(调用方决定放哪、怎么定位,样式用 .slash-menu)。
|
|
43
|
+
* onEmpty:一个技能都没有时,菜单里给个入口(去设置里的技能页)。返回 { update, keydown, close }:
|
|
44
|
+
* 输入框的 input / keydown / blur 里分别调用。
|
|
45
|
+
*/
|
|
46
|
+
export function attachMention(input, menu, { onEmpty, onPicked } = {}) {
|
|
47
|
+
const st = { open: false, items: [], index: 0, token: 0, at: null };
|
|
48
|
+
|
|
49
|
+
const close = () => {
|
|
50
|
+
st.open = false;
|
|
51
|
+
st.items = [];
|
|
52
|
+
st.index = 0;
|
|
53
|
+
st.token++;
|
|
54
|
+
menu.hidden = true;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const highlight = () => {
|
|
58
|
+
menu.querySelectorAll(".slash-item").forEach((el, i) => el.classList.toggle("on", i === st.index));
|
|
59
|
+
menu.querySelector(".slash-item.on")?.scrollIntoView({ block: "nearest" });
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const pick = (s) => {
|
|
63
|
+
const at = mentionAt(input);
|
|
64
|
+
if (!at) return close();
|
|
65
|
+
const before = input.value.slice(0, at.start);
|
|
66
|
+
const after = input.value.slice(input.selectionStart);
|
|
67
|
+
const insert = `@${s.name}${after.startsWith(" ") ? "" : " "}`;
|
|
68
|
+
input.value = `${before}${insert}${after}`;
|
|
69
|
+
const caret = before.length + insert.length;
|
|
70
|
+
input.setSelectionRange(caret, caret);
|
|
71
|
+
close();
|
|
72
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
73
|
+
input.focus();
|
|
74
|
+
onPicked?.(s);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const render = (items, note) => {
|
|
78
|
+
st.open = true;
|
|
79
|
+
menu.hidden = false;
|
|
80
|
+
if (!items) {
|
|
81
|
+
fill(menu, h("div", { class: "slash-empty" }, note ?? h("span", { class: "spinner" })));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
fill(
|
|
85
|
+
menu,
|
|
86
|
+
items.map((s, i) =>
|
|
87
|
+
h(
|
|
88
|
+
"button",
|
|
89
|
+
{
|
|
90
|
+
class: `slash-item${i === st.index ? " on" : ""}`,
|
|
91
|
+
type: "button",
|
|
92
|
+
onmousedown: (e) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
pick(s);
|
|
95
|
+
},
|
|
96
|
+
onmousemove: () => {
|
|
97
|
+
if (st.index !== i) {
|
|
98
|
+
st.index = i;
|
|
99
|
+
highlight();
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
h(
|
|
104
|
+
"div",
|
|
105
|
+
{ class: "slash-head" },
|
|
106
|
+
h("span", { class: "slash-name" }, `@${s.name}`),
|
|
107
|
+
s.kind === "machine" ? h("span", { class: "badge" }, "本机") : null,
|
|
108
|
+
),
|
|
109
|
+
h("div", { class: "slash-desc" }, s.description || "(无说明)"),
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
highlight();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const update = async () => {
|
|
117
|
+
const at = mentionAt(input);
|
|
118
|
+
if (!at) return close();
|
|
119
|
+
const token = ++st.token;
|
|
120
|
+
if (!st.open) render(null);
|
|
121
|
+
let list;
|
|
122
|
+
try {
|
|
123
|
+
list = await loadSkills();
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (token === st.token) render(null, `读取技能失败:${err.message}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const now = mentionAt(input);
|
|
129
|
+
if (token !== st.token || !now) return;
|
|
130
|
+
if (list.length === 0) {
|
|
131
|
+
if (now.query || !onEmpty) return close();
|
|
132
|
+
st.items = [];
|
|
133
|
+
st.open = true;
|
|
134
|
+
menu.hidden = false;
|
|
135
|
+
fill(
|
|
136
|
+
menu,
|
|
137
|
+
h(
|
|
138
|
+
"button",
|
|
139
|
+
{
|
|
140
|
+
class: "slash-item on",
|
|
141
|
+
type: "button",
|
|
142
|
+
onmousedown: (e) => {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
close();
|
|
145
|
+
onEmpty();
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
h("div", { class: "slash-head" }, h("span", { class: "slash-name" }, "还没有技能")),
|
|
149
|
+
h("div", { class: "slash-desc" }, "去「设置 → 技能」新建、上传"),
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const items = match(list, now.query);
|
|
155
|
+
if (items.length === 0) return close();
|
|
156
|
+
st.items = items;
|
|
157
|
+
st.index = Math.min(st.index, items.length - 1);
|
|
158
|
+
render(items);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** 菜单开着时接管方向键 / Tab / 回车 / Esc;返回 true 表示已处理 */
|
|
162
|
+
const keydown = (e) => {
|
|
163
|
+
if (!st.open) return false;
|
|
164
|
+
if (e.isComposing) return false;
|
|
165
|
+
if (e.key === "Escape") {
|
|
166
|
+
close();
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (st.items.length === 0) return false;
|
|
170
|
+
switch (e.key) {
|
|
171
|
+
case "ArrowDown":
|
|
172
|
+
st.index = (st.index + 1) % st.items.length;
|
|
173
|
+
highlight();
|
|
174
|
+
return true;
|
|
175
|
+
case "ArrowUp":
|
|
176
|
+
st.index = (st.index - 1 + st.items.length) % st.items.length;
|
|
177
|
+
highlight();
|
|
178
|
+
return true;
|
|
179
|
+
case "Tab":
|
|
180
|
+
pick(st.items[st.index]);
|
|
181
|
+
return true;
|
|
182
|
+
case "Enter":
|
|
183
|
+
if (e.shiftKey) return false;
|
|
184
|
+
pick(st.items[st.index]);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// 光标移动(点击、方向键离开 @ 片段)也要跟着开关
|
|
191
|
+
input.addEventListener("click", () => void update());
|
|
192
|
+
return { update, keydown, close, get open() { return st.open; } };
|
|
193
|
+
}
|