forked from EduCraft/curriculum-project-hub
feat(hub): 文件收发 + 卡片回调 + 更好的流式
client.ts 重写: - downloadMessageFile: 下载飞书消息里的文件/图片到本地 - sendFile: 上传文件到飞书 + 发送文件消息(msg_type=file) - CardActionEvent 类型 + startFeishuListenerWithClient 加 onCardAction 回调参数,注册 card.action.trigger 事件(消除了 'no card.action.trigger handle' 警告) - 去掉未使用的 card builder 函数 trigger.ts: - 处理 file/image 消息:下载到 workspace/.cph/inbox/,prompt 里告诉 agent 文件路径 - 流式 throttle 从 800ms 降到 400ms,初始占位从 '…' 改为空(更干净) - run 完成后扫描 workspace/build/ 下 5 分钟内的新文件,自动发送给 老师(cph build 产出的 PDF 自动回传) tsc rc=0。
This commit is contained in:
+52
-17
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
||||
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, sendFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
||||
import type { ModelRegistry } from "../agent/models.js";
|
||||
import { runAgent, type ProjectContext } from "../agent/runner.js";
|
||||
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||
@@ -58,8 +58,8 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
});
|
||||
}
|
||||
|
||||
// Only react to text messages in group chats.
|
||||
if (msg.message_type !== "text") return;
|
||||
// Only react to text, file, or image messages in group chats.
|
||||
if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image") return;
|
||||
const chatId = msg.chat_id;
|
||||
if (chatId === "") return;
|
||||
|
||||
@@ -86,13 +86,34 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = extractPrompt(msg);
|
||||
if (prompt === null) return;
|
||||
|
||||
let cleanPrompt: string | null = null;
|
||||
let downloadedFiles: string[] = [];
|
||||
if (msg.message_type === "text") {
|
||||
cleanPrompt = extractPrompt(msg);
|
||||
} else if (msg.message_type === "file" || msg.message_type === "image") {
|
||||
// Download the file/image to the workspace, build a prompt around it.
|
||||
const project = await deps.prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { workspaceDir: true },
|
||||
});
|
||||
if (project === null) return;
|
||||
try {
|
||||
const content = JSON.parse(msg.content) as { file_key?: string; image_key?: string };
|
||||
const key = content.file_key ?? content.image_key ?? "";
|
||||
if (key !== "") {
|
||||
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`;
|
||||
const savePath = `${project.workspaceDir}/.cph/inbox/${fileName}`;
|
||||
await downloadMessageFile(rt, msg.message_id, key, savePath);
|
||||
downloadedFiles.push(savePath);
|
||||
cleanPrompt = `用户发来一个文件: ${savePath}`;
|
||||
}
|
||||
} catch { return; }
|
||||
}
|
||||
if (cleanPrompt === null) return;
|
||||
// Slash commands: session management, not agent runs. These bypass the
|
||||
// lock — they don't create a run, so they can't conflict with one.
|
||||
if (prompt.startsWith("/")) {
|
||||
const cmd = prompt.split(/\s+/)[0];
|
||||
if (cleanPrompt.startsWith("/")) {
|
||||
const cmd = cleanPrompt.split(/\s+/)[0];
|
||||
switch (cmd) {
|
||||
case "/new": {
|
||||
await deps.prisma.agentSession.updateMany({
|
||||
@@ -151,7 +172,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
|
||||
// slashes are left as literal text (extractRole returns null). Falls back
|
||||
// to "draft" when no role is named — the registry degrades gracefully.
|
||||
const { roleId: parsedRole, prompt: cleanPrompt } = extractRole(prompt, deps.models);
|
||||
const { roleId: parsedRole } = extractRole(cleanPrompt, deps.models);
|
||||
const roleId = parsedRole ?? "draft";
|
||||
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
||||
// canTriggerAgent above: that checked "can trigger an agent at all";
|
||||
@@ -236,13 +257,12 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
// Emoji reaction on the user's message (acknowledge receipt).
|
||||
await reactToMessage(rt, msg.message_id, "THUMBSUP");
|
||||
|
||||
// Stream agent response as text messages — no cards, no truncation.
|
||||
// Send an initial placeholder, patch it as text arrives. If text exceeds
|
||||
// 4000 chars, start a new message and continue there.
|
||||
let currentMsgId = await sendTextMessage(rt, chatId, "…");
|
||||
// Stream agent response as text-as-card messages — patchable, markdown,
|
||||
// no truncation. Lower throttle (400ms) for smoother streaming.
|
||||
let currentMsgId = await sendTextMessage(rt, chatId, "");
|
||||
let accText = "";
|
||||
let lastPatch = 0;
|
||||
const PATCH_INTERVAL = 800;
|
||||
const PATCH_INTERVAL = 400;
|
||||
const MAX_MSG_LEN = 4000;
|
||||
|
||||
const flushText = async () => {
|
||||
@@ -250,12 +270,11 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
if (now - lastPatch < PATCH_INTERVAL || currentMsgId === null) return;
|
||||
lastPatch = now;
|
||||
if (accText.length > MAX_MSG_LEN) {
|
||||
// Split: finalize current message, start a new one.
|
||||
await patchTextMessage(rt, currentMsgId, accText.slice(0, MAX_MSG_LEN));
|
||||
accText = accText.slice(MAX_MSG_LEN);
|
||||
currentMsgId = await sendTextMessage(rt, chatId, accText || "…");
|
||||
currentMsgId = await sendTextMessage(rt, chatId, accText);
|
||||
} else {
|
||||
await patchTextMessage(rt, currentMsgId, accText || "…");
|
||||
await patchTextMessage(rt, currentMsgId, accText);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -290,6 +309,22 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
},
|
||||
});
|
||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.finished", metadata: { status: result.status } });
|
||||
|
||||
// Send any new files in build/ as file messages.
|
||||
const { readdir, stat } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const buildDir = join(project.workspaceDir, "build");
|
||||
try {
|
||||
const entries = await readdir(buildDir);
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(buildDir, entry);
|
||||
const s = await stat(fullPath);
|
||||
if (s.isFile() && s.mtimeMs > Date.now() - 5 * 60 * 1000) {
|
||||
// File created in the last 5 minutes — likely from this run.
|
||||
await sendFile(rt, chatId, fullPath, entry);
|
||||
}
|
||||
}
|
||||
} catch { /* no build/ dir — fine */ }
|
||||
})
|
||||
.catch(async (e) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user