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.
Files changed (47) hide show
  1. package/README.md +11 -1
  2. package/package.json +1 -1
  3. package/src/agents/claude/driver.ts +13 -8
  4. package/src/agents/claude/session.ts +38 -6
  5. package/src/agents/codex/session.ts +32 -5
  6. package/src/channels/feishu/bot.ts +8 -4
  7. package/src/channels/feishu/turn-stream.ts +3 -2
  8. package/src/channels/web/api.ts +8 -0
  9. package/src/channels/web/http.ts +2 -2
  10. package/src/channels/web/server.ts +21 -1
  11. package/src/channels/web/skills-api.ts +225 -0
  12. package/src/channels/web/tasks-api.ts +94 -0
  13. package/src/core/chat-mode.ts +16 -16
  14. package/src/core/command-catalog.ts +8 -1
  15. package/src/core/commands.ts +3 -0
  16. package/src/core/hub.ts +147 -19
  17. package/src/core/memory-mcp.ts +1 -1
  18. package/src/core/personal.ts +5 -1
  19. package/src/core/skill-runtime.ts +359 -0
  20. package/src/core/skills-bridge.ts +288 -0
  21. package/src/core/skills.ts +675 -0
  22. package/src/core/store.ts +2 -0
  23. package/src/core/task-prompt.ts +38 -0
  24. package/src/core/task-runner.ts +232 -0
  25. package/src/core/task-schedule.ts +250 -0
  26. package/src/core/tasks-bridge.ts +256 -0
  27. package/src/core/tasks-mcp.ts +66 -0
  28. package/src/core/tasks.ts +281 -0
  29. package/src/core/types.ts +21 -0
  30. package/src/core/zip.ts +98 -0
  31. package/src/main.ts +12 -0
  32. package/src/mcp/tasks-server.ts +327 -0
  33. package/web/css/skills.css +368 -0
  34. package/web/css/tasks.css +455 -0
  35. package/web/index.html +2 -0
  36. package/web/lib/dom.js +1 -0
  37. package/web/lib/ws.js +8 -0
  38. package/web/state.js +2 -0
  39. package/web/views/composer.js +15 -3
  40. package/web/views/conv-actions.js +15 -7
  41. package/web/views/layout.js +22 -1
  42. package/web/views/mention.js +193 -0
  43. package/web/views/messages.js +23 -2
  44. package/web/views/sidebar.js +4 -0
  45. package/web/views/skills.js +703 -0
  46. package/web/views/tasks.js +469 -0
  47. package/web/views/topbar.js +9 -3
@@ -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
+ }
@@ -13,7 +13,15 @@ import { changeCwd, forkConversation } from "./conv-actions.js";
13
13
 
14
14
  // ---------- 打开会话 ----------
15
15
 
16
+ /** 从定时任务页、技能页回到对话:把输入框放回来 */
17
+ function leaveTasksView() {
18
+ if (!S.view) return;
19
+ S.view = null;
20
+ ui.main.classList.remove("tasks-view");
21
+ }
22
+
16
23
  export function newDraft() {
24
+ leaveTasksView();
17
25
  if (S.current) WS.raw({ t: "unsub", key: S.current });
18
26
  S.current = null;
19
27
  S.items = [];
@@ -57,6 +65,7 @@ export function renderEmpty() {
57
65
  }
58
66
 
59
67
  export async function openConversation(key) {
68
+ leaveTasksView();
60
69
  const token = ++S.loadToken;
61
70
  if (S.current && S.current !== key) WS.raw({ t: "unsub", key: S.current });
62
71
  S.current = key;
@@ -349,8 +358,13 @@ function renderItem(item) {
349
358
  return h("div");
350
359
  }
351
360
 
361
+ /** 定时任务的 agent 判断这次不用提醒时,回复第一行写的暗号(见 core/task-prompt.ts) */
362
+ const SILENT_MARK = "[[静默]]";
363
+
352
364
  function renderAi(item) {
353
- const body = item.committed + (item.partial ? (item.committed ? "\n\n" : "") + item.partial : "");
365
+ const raw = item.committed + (item.partial ? (item.committed ? "\n\n" : "") + item.partial : "");
366
+ const silent = raw.trimStart().startsWith(SILENT_MARK);
367
+ const body = silent ? raw.trimStart().slice(SILENT_MARK.length).trim() : raw;
354
368
  const working = !item.done;
355
369
  const steps =
356
370
  item.steps.length > 0
@@ -380,7 +394,14 @@ function renderAi(item) {
380
394
  "div",
381
395
  { class: "msg-ai" },
382
396
  steps,
383
- body ? renderMarkdown(body) : null,
397
+ // 定时任务这次没什么要说的:折叠成一行,展开能看到它顺手记的检查结果
398
+ silent && body
399
+ ? h("details", { class: "silent-run" }, h("summary", {}, "🔕 定时任务:这次无需提醒"), renderMarkdown(body))
400
+ : silent
401
+ ? h("div", { class: "silent-run" }, "🔕 定时任务:这次无需提醒")
402
+ : body
403
+ ? renderMarkdown(body)
404
+ : null,
384
405
  // 还在回复:末尾挂上星星动画和实时用时;什么都还没出来时旁边带一句「思考中…」
385
406
  working
386
407
  ? h(
@@ -267,6 +267,8 @@ export function renderSidebar() {
267
267
  return;
268
268
  }
269
269
  sidebarStale = false;
270
+ ui.tasksNav?.classList.toggle("active", S.view === "tasks");
271
+ ui.skillsNav?.classList.toggle("active", S.view === "skills");
270
272
  const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
271
273
  const visible = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt);
272
274
  const cur = S.current ? S.convs.get(S.current) : null;
@@ -294,6 +296,8 @@ export function renderSidebar() {
294
296
  onclick: () => open(c.key),
295
297
  },
296
298
  orphan ? h("span", { class: "brand-icon lead fork-mark", html: ICON.fork }) : null,
299
+ // 定时任务的专用对话
300
+ c.taskId ? h("span", { class: "brand-icon lead task-mark", title: "定时任务的对话", html: ICON.clock }) : null,
297
301
  h("span", { class: "title" }, convTitle(c)),
298
302
  status([c.key]),
299
303
  );