agent-loft 0.2.30 → 0.2.31
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 +5 -0
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +3 -2
- package/src/agents/claude/session.ts +11 -4
- package/src/agents/codex/session.ts +9 -3
- package/src/channels/feishu/bot.ts +8 -4
- package/src/channels/feishu/turn-stream.ts +3 -2
- package/src/channels/web/api.ts +4 -0
- package/src/channels/web/server.ts +13 -1
- package/src/channels/web/tasks-api.ts +94 -0
- package/src/core/chat-mode.ts +14 -14
- package/src/core/hub.ts +95 -16
- package/src/core/memory-mcp.ts +1 -1
- package/src/core/personal.ts +3 -1
- 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 +252 -0
- package/src/core/tasks-mcp.ts +55 -0
- package/src/core/tasks.ts +281 -0
- package/src/core/types.ts +2 -0
- package/src/main.ts +8 -0
- package/src/mcp/tasks-server.ts +206 -0
- package/web/css/tasks.css +455 -0
- package/web/index.html +1 -0
- package/web/lib/ws.js +4 -0
- package/web/state.js +2 -0
- package/web/views/conv-actions.js +15 -7
- package/web/views/layout.js +12 -1
- package/web/views/messages.js +23 -2
- package/web/views/sidebar.js +3 -0
- package/web/views/tasks.js +460 -0
- package/web/views/topbar.js +9 -3
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
|
|
|
58
58
|
- **多用户**(信任型):管理员在 设置 → 用户 里给别人建账号。每个人只看得到自己的对话、授权和通知,设置只有管理员能改,见下面「安全」。
|
|
59
59
|
- **聊天模式 / 工作模式**:右上角的「💬 聊天 / 🛠 工作」,或者发 `/chat`、`/work`,同一条对话随时切换、上下文接着。聊天模式换成精简的系统提示,只留联网搜索,不加载技能、MCP、插件,不执行命令、不读写本机文件(文本附件的内容直接放进消息),每轮的固定开销小得多:实测一句话 Claude 约 1.6 万 → 2 千 token,Codex 约 2 万 → 1.2 万(Codex 的基础指令改不了)。适合日常聊天、查资料;要动手干活时切回工作模式。切换后第一轮要重建提示缓存,会贵一些;在工作模式里读过的大段内容还留在历史里。
|
|
60
60
|
- **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;整理哪些对话也能选:全部/只整理聊天模式的/只整理工作模式的;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
|
|
61
|
+
- **定时任务**:像 ChatGPT 的 Tasks,侧栏「最近」上面的「定时任务」。到点把一段提示词交给 agent,结果发回对话:一次 / 每天 / 工作日 / 每周 / 每月 / 每隔几小时 / 自定义 cron,按服务端时区,两次至少隔 15 分钟。**有需要才提醒**(默认):agent 自己判断这次有没有要告诉你的事(比如「有新版本才告诉我」),没有就在回复第一行写 `[[静默]]`,推送、飞书都不发,对话里折叠成一行;没写暗号一律照常提醒。结果发到**专用对话**(任务自己的一条对话,第一次运行时新建、之后续用,可选聊天 / 工作模式和目录),或者**原来的对话**。也能直接在对话里说「每天早上 9 点帮我……」:agent 用 `create_task` / `update_task` / `delete_task` / `list_tasks` 工具管理,新建、修改、删除都会先弹授权卡让你确认(只有暂停不用;没有「总是允许」),任务自己运行的那一轮里不能再建、改、删任务。按人隔离:每人只看得到、改得了自己的任务(最多 30 个),删用户时一起删;到点再核对一次对话还是不是他的,对话被删或换了主人就暂停任务。到点对话在忙(正在回复或挂着授权)就等它,最多 30 分钟;服务停着时错过 2 小时以内的启动后补跑一次。工具和记忆一样只给有主人的对话,飞书群聊里没有。
|
|
61
62
|
- **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
|
|
62
63
|
|
|
63
64
|
## 安装
|
|
@@ -160,9 +161,13 @@ src/
|
|
|
160
161
|
memory-curator.ts 录入聊天、回填已有对话、聊完后自动整理记忆
|
|
161
162
|
feishu-links.ts 飞书账号 ↔ 用户的绑定和绑定码
|
|
162
163
|
chat-mode.ts 聊天模式告诉 agent 的话(Claude 换系统提示,Codex 附在消息后面)
|
|
164
|
+
tasks.ts 定时任务的存储(按人隔离);task-schedule.ts 频率和 cron;task-prompt.ts 每次运行附给 agent 的说明和静默暗号
|
|
165
|
+
task-runner.ts 定时任务的调度:到点核对主人和对话、送进对话、记下结果
|
|
166
|
+
tasks-bridge.ts 定时任务工具在主进程这头:核对签名、弹授权卡、增删改(tasks-mcp.ts 定怎么拉起)
|
|
163
167
|
pairing.ts uploads.ts commands.ts …
|
|
164
168
|
mcp/
|
|
165
169
|
memory-server.ts 记忆工具(remember / forget / search_past_chats)的 stdio MCP 服务,两个 agent 共用
|
|
170
|
+
tasks-server.ts 定时任务工具的 stdio MCP 服务:只传话,经 unix socket 转给主进程
|
|
166
171
|
agents/
|
|
167
172
|
claude/ Claude Agent SDK 驱动
|
|
168
173
|
codex/ codex app-server 驱动
|
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@ import { agentEnvironment } from "../../core/agent-env.js";
|
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import type { AgentCommand } from "../../core/command-catalog.js";
|
|
15
15
|
import { splitAttachmentNote } from "../../core/uploads.js";
|
|
16
|
+
import { stripContext } from "../../core/chat-mode.js";
|
|
16
17
|
import { config } from "../../core/config.js";
|
|
17
18
|
import type {
|
|
18
19
|
AgentDriver,
|
|
@@ -69,7 +70,7 @@ function readCommands(cwd: string): Promise<AgentCommand[]> {
|
|
|
69
70
|
const SESSION_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
70
71
|
|
|
71
72
|
function titleOf(info: { customTitle?: string; summary?: string; firstPrompt?: string }): string {
|
|
72
|
-
const raw = (info.customTitle || info.summary || info.firstPrompt || "").replace(/\s+/g, " ").trim();
|
|
73
|
+
const raw = stripContext(info.customTitle || info.summary || info.firstPrompt || "").replace(/\s+/g, " ").trim();
|
|
73
74
|
if (!raw) return "(无标题)";
|
|
74
75
|
return raw.length > 60 ? `${raw.slice(0, 60)}…` : raw;
|
|
75
76
|
}
|
|
@@ -157,7 +158,7 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
157
158
|
const texts = blocks.filter((b) => b.type === "text" && b.text).map((b) => b.text!);
|
|
158
159
|
if (texts.length === 0) continue; // 纯 tool_result
|
|
159
160
|
// 我们自己拼进去的附件清单:从正文里拆出来,单独作为附件给前端展示
|
|
160
|
-
const { text, attachments } = splitAttachmentNote(texts.join("\n"));
|
|
161
|
+
const { text, attachments } = splitAttachmentNote(stripContext(texts.join("\n")));
|
|
161
162
|
if (!text && attachments.length === 0) continue;
|
|
162
163
|
if (text.startsWith("<command-") || text.startsWith("<local-command")) continue;
|
|
163
164
|
if (text.startsWith("<task-notification>")) {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { agentEnvironment } from "../../core/agent-env.js";
|
|
17
17
|
import { chatSystemPrompt } from "../../core/chat-mode.js";
|
|
18
18
|
import { MEMORY_MCP_NAME, memoryMcpSpawn } from "../../core/memory-mcp.js";
|
|
19
|
+
import { TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
|
|
19
20
|
import { describeAttachments, inlineTextBlocks, readTextAttachments } from "../../core/uploads.js";
|
|
20
21
|
import type {
|
|
21
22
|
AgentEvents,
|
|
@@ -127,6 +128,8 @@ function buildContent(input: UserInput, chat: boolean): string | ContentBlock[]
|
|
|
127
128
|
|
|
128
129
|
/** 记忆工具在 Claude 里的全名 */
|
|
129
130
|
const MEMORY_TOOLS = new Set(["remember", "forget", "search_past_chats"].map((t) => `mcp__${MEMORY_MCP_NAME}__${t}`));
|
|
131
|
+
/** 定时任务工具:要用户确认的那几个由 agent-loft 自己弹卡(tasks-bridge.ts),这里不再问一遍 */
|
|
132
|
+
const TASK_TOOLS = new Set(["list_tasks", "create_task", "update_task", "delete_task"].map((t) => `mcp__${TASKS_MCP_NAME}__${t}`));
|
|
130
133
|
/** 聊天模式的全部工具:只能联网,碰不到本机 */
|
|
131
134
|
const CHAT_TOOLS = ["WebSearch", "WebFetch"];
|
|
132
135
|
|
|
@@ -145,9 +148,12 @@ const CHAT_TOOLS = ["WebSearch", "WebFetch"];
|
|
|
145
148
|
*/
|
|
146
149
|
export function modeOptions(opts: SessionOptions): Partial<Options> {
|
|
147
150
|
const memory = opts.memory;
|
|
148
|
-
const mcpServers: Record<string, McpServerConfig> =
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
const mcpServers: Record<string, McpServerConfig> = {
|
|
152
|
+
...(memory
|
|
153
|
+
? { [MEMORY_MCP_NAME]: { type: "stdio", ...memoryMcpSpawn(memory.userId, "claude", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } }
|
|
154
|
+
: {}),
|
|
155
|
+
...(opts.tasks ? { [TASKS_MCP_NAME]: { type: "stdio", ...tasksMcpSpawn(opts.tasks.userId, opts.tasks.convKey, "claude") } } : {}),
|
|
156
|
+
};
|
|
151
157
|
if (opts.mode === "chat") {
|
|
152
158
|
return {
|
|
153
159
|
systemPrompt: { type: "custom", prompt: chatSystemPrompt("WebSearch(搜索)和 WebFetch(读网页)", memory?.prompt), snapshot: false },
|
|
@@ -165,7 +171,7 @@ export function modeOptions(opts: SessionOptions): Partial<Options> {
|
|
|
165
171
|
const append = [memory?.prompt, switched].filter(Boolean).join("\n\n");
|
|
166
172
|
return {
|
|
167
173
|
systemPrompt: { type: "preset", preset: "claude_code", ...(append ? { append } : {}), snapshot: false },
|
|
168
|
-
...(
|
|
174
|
+
...(Object.keys(mcpServers).length ? { mcpServers } : {}),
|
|
169
175
|
};
|
|
170
176
|
}
|
|
171
177
|
|
|
@@ -351,6 +357,7 @@ export class ClaudeSession implements AgentSession {
|
|
|
351
357
|
// 自家的记忆工具,以及聊天模式下的联网工具:不问。
|
|
352
358
|
// 不用 allowedTools 放行 —— SDK 会对每个会话打一条「canUseTool 被绕过」的警告
|
|
353
359
|
if (MEMORY_TOOLS.has(toolName) && this.opts.memory) return { behavior: "allow", updatedInput: input };
|
|
360
|
+
if (TASK_TOOLS.has(toolName) && this.opts.tasks) return { behavior: "allow", updatedInput: input };
|
|
354
361
|
if (this.opts.mode === "chat" && CHAT_TOOLS.includes(toolName)) return { behavior: "allow", updatedInput: input };
|
|
355
362
|
|
|
356
363
|
// 提问不是授权:它问的是「你选哪个」
|
|
@@ -7,6 +7,7 @@ import { refusalFor, UnsupportedRequest } from "./refusals.js";
|
|
|
7
7
|
import { config } from "../../core/config.js";
|
|
8
8
|
import { contextBlock } from "../../core/chat-mode.js";
|
|
9
9
|
import { MEMORY_MCP_NAME, memoryMcpSpawn } from "../../core/memory-mcp.js";
|
|
10
|
+
import { TASK_TOOL_TIMEOUT_SEC, TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
|
|
10
11
|
import { describeAttachments, inlineTextBlocks, readTextAttachments } from "../../core/uploads.js";
|
|
11
12
|
import type {
|
|
12
13
|
AgentEvents,
|
|
@@ -130,7 +131,7 @@ export async function userMcpServers(): Promise<string[]> {
|
|
|
130
131
|
if (userMcpCache && Date.now() - userMcpCache.at < 60_000) return userMcpCache.names;
|
|
131
132
|
try {
|
|
132
133
|
const res = await appServer.call<{ config?: { mcp_servers?: Record<string, unknown> | null } }>("config/read", {});
|
|
133
|
-
const names = Object.keys(res.config?.mcp_servers ?? {}).filter((n) => n !== MEMORY_MCP_NAME);
|
|
134
|
+
const names = Object.keys(res.config?.mcp_servers ?? {}).filter((n) => n !== MEMORY_MCP_NAME && n !== TASKS_MCP_NAME);
|
|
134
135
|
userMcpCache = { names, at: Date.now() };
|
|
135
136
|
return names;
|
|
136
137
|
} catch (err) {
|
|
@@ -363,12 +364,17 @@ export class CodexSession implements AgentSession {
|
|
|
363
364
|
*/
|
|
364
365
|
private async modeParams(minimal = false): Promise<Record<string, unknown>> {
|
|
365
366
|
const memory = this.opts.memory;
|
|
366
|
-
const
|
|
367
|
+
const tasks = this.opts.tasks;
|
|
368
|
+
const mcp = {
|
|
369
|
+
...(memory ? { [`mcp_servers.${MEMORY_MCP_NAME}`]: memoryMcpSpawn(memory.userId, "codex", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } : {}),
|
|
370
|
+
// 新建 / 修改任务要等用户点授权卡,Codex 默认 60 秒就算工具超时
|
|
371
|
+
...(tasks ? { [`mcp_servers.${TASKS_MCP_NAME}`]: { ...tasksMcpSpawn(tasks.userId, tasks.convKey, "codex"), tool_timeout_sec: TASK_TOOL_TIMEOUT_SEC } } : {}),
|
|
372
|
+
};
|
|
367
373
|
if (this.chat && !minimal) {
|
|
368
374
|
const off = Object.fromEntries((await userMcpServers()).map((n) => [`mcp_servers.${n}.enabled`, false]));
|
|
369
375
|
return { config: { ...CHAT_CONFIG, ...off, ...mcp } };
|
|
370
376
|
}
|
|
371
|
-
return
|
|
377
|
+
return Object.keys(mcp).length ? { config: mcp } : {};
|
|
372
378
|
}
|
|
373
379
|
|
|
374
380
|
/**
|
|
@@ -492,22 +492,26 @@ export class FeishuBot {
|
|
|
492
492
|
// 别处(Web、别的飞书聊天)说的话,同步一句过来,不然这边看着没头没尾
|
|
493
493
|
const o = ev.origin;
|
|
494
494
|
const here = o.channel === "feishu" && o.channelId === this.cfg.id && (!o.chatId || o.chatId === chatId);
|
|
495
|
-
|
|
495
|
+
// 定时任务的提示词不同步过来:每次运行都发一条,飞书里就全是它了。结果出来时再带上任务名
|
|
496
|
+
if (!here && o.channel !== "task") {
|
|
496
497
|
const files = ev.attachments.length ? `\n\n📎 ${ev.attachments.join("、")}` : "";
|
|
497
498
|
const icon = o.channel === "web" ? "💻" : "💬";
|
|
498
499
|
void this.client.sendCard(chatId, replyCard(`${icon} **${o.name ?? "Web"}**:${ev.text}${files}`));
|
|
499
500
|
}
|
|
500
501
|
return;
|
|
501
502
|
}
|
|
503
|
+
// 定时任务这一轮不画过程卡:跑完之前不知道要不要提醒,卡片一发出去飞书就响了
|
|
502
504
|
case "step":
|
|
503
|
-
this.stream(key, chatId).addStep(ev.text);
|
|
505
|
+
if (!hub.currentTaskTurn(key)) this.stream(key, chatId).addStep(ev.text);
|
|
504
506
|
return;
|
|
505
507
|
case "text":
|
|
506
|
-
this.stream(key, chatId).addText(ev.text);
|
|
508
|
+
if (!hub.currentTaskTurn(key)) this.stream(key, chatId).addText(ev.text);
|
|
507
509
|
return;
|
|
508
510
|
case "result": {
|
|
509
511
|
const stream = this.stream(key, chatId);
|
|
510
|
-
stream.finish(
|
|
512
|
+
if (ev.silent) stream.finish("", ev.footer, true);
|
|
513
|
+
else if (ev.task) stream.finish(`⏰ **${ev.task.title}**\n\n${ev.text}`, ev.footer);
|
|
514
|
+
else stream.finish(ev.text, ev.footer);
|
|
511
515
|
void stream.drain().then(() => this.clearTyping(key));
|
|
512
516
|
return;
|
|
513
517
|
}
|
|
@@ -69,8 +69,9 @@ export class TurnStream {
|
|
|
69
69
|
/**
|
|
70
70
|
* 收尾:过程卡定格为完成态,然后把最终回复作为普通消息发出去。
|
|
71
71
|
* 整轮没有过程时不会留下任何卡片 —— 简单问答就该只有一条回复。
|
|
72
|
+
* quiet:不发回复(定时任务判断这次不用提醒),只把过程卡定格。
|
|
72
73
|
*/
|
|
73
|
-
finish(finalText: string, footer?: string): void {
|
|
74
|
+
finish(finalText: string, footer?: string, quiet = false): void {
|
|
74
75
|
this.enqueue(async () => {
|
|
75
76
|
if (this.messageId) {
|
|
76
77
|
await this.deps.updateCard(
|
|
@@ -79,7 +80,7 @@ export class TurnStream {
|
|
|
79
80
|
);
|
|
80
81
|
}
|
|
81
82
|
const reply = finalText.trim() || this.text.trim();
|
|
82
|
-
if (reply) await this.deps.sendReply(reply);
|
|
83
|
+
if (reply && !quiet) await this.deps.sendReply(reply);
|
|
83
84
|
this.reset();
|
|
84
85
|
});
|
|
85
86
|
}
|
package/src/channels/web/api.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
} from "./auth.js";
|
|
63
63
|
import { canSeeConv, pushOwnedBy, sessionVisibleTo } from "./access.js";
|
|
64
64
|
import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, SECURITY_HEADERS, sendJson } from "./http.js";
|
|
65
|
+
import { handleTasksApi } from "./tasks-api.js";
|
|
65
66
|
import { addSubscription, listSubscriptions, pushToAll, removeSubscription, removeSubscriptionsOf, vapidPublicKey } from "./push.js";
|
|
66
67
|
|
|
67
68
|
export const COOKIE = "aim_session";
|
|
@@ -524,6 +525,9 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
524
525
|
return;
|
|
525
526
|
}
|
|
526
527
|
|
|
528
|
+
// ----- 定时任务 -----
|
|
529
|
+
if (p.startsWith("/api/tasks") && (await handleTasksApi(req, res, p, user))) return;
|
|
530
|
+
|
|
527
531
|
// ----- 目录选择器:列工作区内的子目录 -----
|
|
528
532
|
if (p === "/api/dirs" && method === "GET") {
|
|
529
533
|
const listing = listWorkspaceDirs(url.searchParams.get("path") ?? "");
|
|
@@ -13,6 +13,7 @@ import { parseCommand } from "../../core/commands.js";
|
|
|
13
13
|
import { GLOBAL_ALWAYS_DENIED, hub, type EventEnvelope } from "../../core/hub.js";
|
|
14
14
|
import { interactions, type Interaction } from "../../core/interactions.js";
|
|
15
15
|
import { getConversation } from "../../core/store.js";
|
|
16
|
+
import { taskEvents } from "../../core/tasks.js";
|
|
16
17
|
import { RUNNING_VERSION } from "../../core/upgrade.js";
|
|
17
18
|
import type { Settlement } from "../../core/types.js";
|
|
18
19
|
import { getUser, isAdmin, legacyOwnerId, type User } from "../../core/users.js";
|
|
@@ -180,6 +181,11 @@ function broadcastInteraction(i: Interaction): void {
|
|
|
180
181
|
for (const client of clients) if (canSeeKey(client.user, i.convKey)) send(client, { t: "ix", interaction: i });
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
/** 某人的定时任务变了(增删改、跑完一次):只告诉他自己开着的页面,页面自己去重新拉 */
|
|
185
|
+
function broadcastTasks(owner: string): void {
|
|
186
|
+
for (const client of clients) if (client.user.id === owner) send(client, { t: "tasks" });
|
|
187
|
+
}
|
|
188
|
+
|
|
183
189
|
// ---------- 系统通知(Web Push) ----------
|
|
184
190
|
|
|
185
191
|
/** 有页面正在前台看着这条会话:人就在眼前,不用推 */
|
|
@@ -235,10 +241,14 @@ function pushEvent(env: EventEnvelope): void {
|
|
|
235
241
|
const ev = env.ev;
|
|
236
242
|
if (ev.type !== "result" && ev.type !== "error") return;
|
|
237
243
|
if (!isWebConv(env.key)) return;
|
|
244
|
+
// 定时任务这次判断不用提醒
|
|
245
|
+
if (ev.type === "result" && ev.silent) return;
|
|
238
246
|
const name = convName(env.key);
|
|
239
247
|
const payload =
|
|
240
248
|
ev.type === "result"
|
|
241
|
-
?
|
|
249
|
+
? ev.task
|
|
250
|
+
? { title: `⏰ ${ev.task.title}`, body: plainPreview(ev.text) || "(没有文字回复)" }
|
|
251
|
+
: { title: `${name} · 回复完成`, body: plainPreview(ev.text) || "(没有文字回复)" }
|
|
242
252
|
: { title: `${name} · 出错了`, body: plainPreview(ev.message) };
|
|
243
253
|
notify(env.key, { ...payload, tag: `reply:${env.key}` });
|
|
244
254
|
}
|
|
@@ -474,6 +484,7 @@ export async function startWeb(): Promise<void> {
|
|
|
474
484
|
interactions.on("created", pushInteraction);
|
|
475
485
|
interactions.on("updated", broadcastInteraction);
|
|
476
486
|
interactions.on("settled", broadcastInteraction);
|
|
487
|
+
taskEvents.on("changed", broadcastTasks);
|
|
477
488
|
|
|
478
489
|
await listen(server, host, port);
|
|
479
490
|
console.log(`[web] 已监听 http://${host}:${port}`);
|
|
@@ -503,6 +514,7 @@ export async function stopWeb(): Promise<void> {
|
|
|
503
514
|
interactions.off("created", pushInteraction);
|
|
504
515
|
interactions.off("updated", broadcastInteraction);
|
|
505
516
|
interactions.off("settled", broadcastInteraction);
|
|
517
|
+
taskEvents.off("changed", broadcastTasks);
|
|
506
518
|
if (heartbeat) clearInterval(heartbeat);
|
|
507
519
|
for (const client of clients) client.ws.close(1001, "服务重启");
|
|
508
520
|
clients.clear();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// 定时任务页面的接口:/api/tasks。每个人只看得到、改得了自己的任务(别人的一律当不存在)。
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { config, resolveWorkspacePath } from "../../core/config.js";
|
|
4
|
+
import { hub } from "../../core/hub.js";
|
|
5
|
+
import { memoryOwnerOf } from "../../core/personal.js";
|
|
6
|
+
import { getConversation } from "../../core/store.js";
|
|
7
|
+
import { forgetTask, isTaskRunning, runTaskNow } from "../../core/task-runner.js";
|
|
8
|
+
import { serverTimeZone } from "../../core/task-schedule.js";
|
|
9
|
+
import { createTask, deleteTask, getTask, listTasks, updateTask, viewOf, type Task, type TaskInput } from "../../core/tasks.js";
|
|
10
|
+
import type { User } from "../../core/users.js";
|
|
11
|
+
import { HttpError, readJsonBody, sendJson } from "./http.js";
|
|
12
|
+
|
|
13
|
+
function view(task: Task) {
|
|
14
|
+
const key = task.target.key;
|
|
15
|
+
const conv = key ? getConversation(key) : undefined;
|
|
16
|
+
return {
|
|
17
|
+
...viewOf(task),
|
|
18
|
+
running: isTaskRunning(task.id),
|
|
19
|
+
// 结果在哪条对话里看(专用对话第一次运行前还没有)
|
|
20
|
+
...(conv && !conv.hidden ? { convKey: conv.key, convTitle: conv.title ?? "" } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 表单 / 请求里的字段先在这里核一遍:agent 要启用着、目录要在工作区里、对话要是自己的 */
|
|
25
|
+
function checkInput(user: User, body: TaskInput): TaskInput {
|
|
26
|
+
const out: TaskInput = { ...body };
|
|
27
|
+
if (body.agent !== undefined) {
|
|
28
|
+
const agent = String(body.agent);
|
|
29
|
+
if (!hub.enabledAgents().some((d) => d.id === agent)) throw new HttpError(400, `${hub.label(agent)} 没有启用`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof body.cwd === "string" && body.cwd.trim()) {
|
|
32
|
+
const cwd = resolveWorkspacePath(body.cwd.trim());
|
|
33
|
+
if (!cwd) throw new HttpError(400, `目录必须存在,且在 ${config().workspaceRoot} 之内`);
|
|
34
|
+
out.cwd = cwd === config().workspaceRoot ? "" : cwd;
|
|
35
|
+
}
|
|
36
|
+
const target = body.target as { kind?: string; key?: string } | undefined;
|
|
37
|
+
if (target?.kind === "conv") {
|
|
38
|
+
const conv = target.key ? getConversation(target.key) : undefined;
|
|
39
|
+
if (!conv || conv.hidden || memoryOwnerOf(conv) !== user.id) throw new HttpError(400, "回复到的那条对话不存在了,改成专用对话吧");
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const bad = (err: unknown) => (err instanceof HttpError ? err : new HttpError(400, err instanceof Error ? err.message : String(err)));
|
|
45
|
+
|
|
46
|
+
/** 处理了就返回 true */
|
|
47
|
+
export async function handleTasksApi(req: IncomingMessage, res: ServerResponse, p: string, user: User): Promise<boolean> {
|
|
48
|
+
const method = req.method ?? "GET";
|
|
49
|
+
if (p === "/api/tasks" && method === "GET") {
|
|
50
|
+
sendJson(res, 200, { tasks: listTasks(user.id).map(view), tz: serverTimeZone(), now: Date.now() });
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (p === "/api/tasks" && method === "POST") {
|
|
54
|
+
const body = await readJsonBody<TaskInput>(req);
|
|
55
|
+
// 没选 agent(只启用了一个时表单里不显示):用第一个启用着的,不能默认成一个可能没启用的
|
|
56
|
+
if (body.agent === undefined || body.agent === "") body.agent = hub.enabledAgents()[0]?.id;
|
|
57
|
+
let task: Task;
|
|
58
|
+
try {
|
|
59
|
+
task = createTask(user.id, checkInput(user, body), "user");
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw bad(err);
|
|
62
|
+
}
|
|
63
|
+
sendJson(res, 200, view(task));
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
const m = /^\/api\/tasks\/([^/]+)(?:\/(run))?$/.exec(p);
|
|
67
|
+
if (!m) return false;
|
|
68
|
+
const task = getTask(user.id, decodeURIComponent(m[1]!));
|
|
69
|
+
if (!task) throw new HttpError(404, "这个任务不存在");
|
|
70
|
+
if (m[2] === "run" && method === "POST") {
|
|
71
|
+
const error = runTaskNow(task);
|
|
72
|
+
if (error) throw new HttpError(409, error);
|
|
73
|
+
sendJson(res, 200, view(task));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (!m[2] && method === "PATCH") {
|
|
77
|
+
const body = await readJsonBody<TaskInput>(req);
|
|
78
|
+
let updated: Task;
|
|
79
|
+
try {
|
|
80
|
+
updated = updateTask(user.id, task.id, checkInput(user, body));
|
|
81
|
+
} catch (err) {
|
|
82
|
+
throw bad(err);
|
|
83
|
+
}
|
|
84
|
+
sendJson(res, 200, view(updated));
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (!m[2] && method === "DELETE") {
|
|
88
|
+
deleteTask(user.id, task.id);
|
|
89
|
+
forgetTask(task);
|
|
90
|
+
sendJson(res, 200, { ok: true });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
package/src/core/chat-mode.ts
CHANGED
|
@@ -42,21 +42,21 @@ export function contextBlock(mode: "chat" | "work", memory: string | undefined,
|
|
|
42
42
|
mode === "chat"
|
|
43
43
|
? ["当前是「聊天模式」,你作为用户的私人 AI 助手和他聊天:", ...chatRules(search)]
|
|
44
44
|
: ["当前是「工作模式」:可以执行命令、读写文件,按平常的方式干活。之前如果说过「聊天模式下不能做」,那个限制已经解除。"];
|
|
45
|
-
return [
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
CLOSE,
|
|
52
|
-
].join("\n");
|
|
45
|
+
return wrapContext([...head, ...(memory ? ["", memory] : [])]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 包成 agent-loft 附上的背景:用户看不到(读历史、实时显示时都剥掉),agent 照着做 */
|
|
49
|
+
export function wrapContext(lines: string[]): string {
|
|
50
|
+
return [OPEN, "(这段是 agent-loft 自动附上的背景,不是用户写的。照着做,但不要在回复里提到它。)", "", ...lines, CLOSE].join("\n");
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
/** 剥掉 contextBlock
|
|
53
|
+
/** 剥掉 contextBlock(读历史、列会话标题时用)。一条消息里可能有好几段(定时任务的说明 + Codex 的模式说明) */
|
|
56
54
|
export function stripContext(text: string): string {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
let out = text;
|
|
56
|
+
for (let start = out.indexOf(OPEN); start >= 0; start = out.indexOf(OPEN)) {
|
|
57
|
+
const end = out.indexOf(CLOSE, start);
|
|
58
|
+
const rest = end < 0 ? "" : out.slice(end + CLOSE.length);
|
|
59
|
+
out = `${out.slice(0, start)}${rest}`;
|
|
60
|
+
}
|
|
61
|
+
return out === text ? text : out.trim();
|
|
62
62
|
}
|