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.
Files changed (48) hide show
  1. package/README.md +14 -5
  2. package/package.json +1 -1
  3. package/src/agents/claude/driver.ts +90 -8
  4. package/src/agents/claude/session.ts +81 -12
  5. package/src/agents/codex/session.ts +70 -13
  6. package/src/channels/feishu/bot.ts +2 -2
  7. package/src/channels/web/api.ts +72 -2
  8. package/src/channels/web/conv-pages.ts +68 -0
  9. package/src/channels/web/http.ts +2 -2
  10. package/src/channels/web/server.ts +20 -3
  11. package/src/channels/web/skills-api.ts +225 -0
  12. package/src/core/chat-mode.ts +3 -3
  13. package/src/core/command-catalog.ts +8 -1
  14. package/src/core/commands.ts +3 -0
  15. package/src/core/hub.ts +168 -18
  16. package/src/core/personal.ts +3 -1
  17. package/src/core/skill-runtime.ts +359 -0
  18. package/src/core/skills-bridge.ts +288 -0
  19. package/src/core/skills.ts +676 -0
  20. package/src/core/tasks-bridge.ts +5 -1
  21. package/src/core/tasks-mcp.ts +11 -0
  22. package/src/core/types.ts +44 -3
  23. package/src/core/usage.ts +70 -0
  24. package/src/core/zip.ts +98 -0
  25. package/src/main.ts +4 -0
  26. package/src/mcp/tasks-server.ts +125 -4
  27. package/web/css/composer.css +0 -25
  28. package/web/css/messages.css +169 -0
  29. package/web/css/sidebar.css +5 -5
  30. package/web/css/skills.css +385 -0
  31. package/web/css/topbar.css +131 -0
  32. package/web/index.html +1 -0
  33. package/web/lib/dom.js +3 -1
  34. package/web/lib/markdown.js +84 -14
  35. package/web/lib/ws.js +49 -5
  36. package/web/settings/index.js +7 -3
  37. package/web/settings/skills.js +617 -0
  38. package/web/state.js +4 -3
  39. package/web/views/composer.js +16 -3
  40. package/web/views/interaction.js +12 -4
  41. package/web/views/layout.js +22 -15
  42. package/web/views/mention.js +193 -0
  43. package/web/views/messages.js +239 -81
  44. package/web/views/msg-nav.js +120 -0
  45. package/web/views/quota.js +1 -0
  46. package/web/views/sidebar.js +114 -16
  47. package/web/views/tasks.js +11 -2
  48. package/web/views/topbar.js +168 -40
@@ -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" }, `${SETTLE_LABEL[settled.settlement] ?? settled.settlement} · ${settled.by}`)
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" }, `${SETTLE_LABEL[settled.settlement] ?? settled.settlement} · ${settled.by}`)
139
+ ? h("div", { class: "ix-meta" }, settledText(settled))
132
140
  : h(
133
141
  "div",
134
142
  { class: "ix-buttons" },
@@ -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
- function openSettings() {
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("/api/bootstrap");
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
- S.convs = new Map(data.conversations.map((c) => [c.key, c]));
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
- if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
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 && S.convs.has(key) && key !== S.current) void openConversation(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
- h(
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
- h("button", { class: "icon-btn side-gear", title: "设置", onclick: () => (openSettings(), closeSide()) }, icon("gear")),
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
+ }