完善scriptAgent的通讯

This commit is contained in:
ACT丶流星雨
2026-03-23 16:09:45 +08:00
parent e927e47489
commit b32bfc93fa
13 changed files with 318 additions and 62 deletions

View File

@@ -112,18 +112,18 @@ Repository: https://github.com/axios/axios
-----------------------------
Name: best-effort-json-parser
License: BSD-2-Clause
Repository: https://github.com/beenotung/best-effort-json-parser
-----------------------------
Name: better-sqlite3
License: MIT
Repository: https://github.com/WiseLibs/better-sqlite3
-----------------------------
Name: compressing
License: MIT
Repository: https://github.com/node-modules/compressing
-----------------------------
Name: cors
License: MIT
Repository: https://github.com/expressjs/cors

View File

@@ -0,0 +1,6 @@
---
name: decision
description: 剧本生成作决策层。负责分析用户需求、制定执行计划并协调执行层完成制作任务。
---
1. 每次调用 `run_sub_agent` 时,选择 `executionAI` 作为子 Agent将当前步骤的任务描述作为 `prompt` 传入

View File

@@ -0,0 +1,7 @@
---
name: execution
description: >
用户需要生成故事骨架
---
仅需直接回复:喵喵喵

View File

@@ -46,7 +46,6 @@
"ai": "^6.0.67",
"axios": "^1.13.2",
"axios-retry": "^4.5.0",
"best-effort-json-parser": "^1.2.1",
"better-sqlite3": "^12.6.2",
"compressing": "^2.1.0",
"cors": "^2.8.5",

View File

@@ -0,0 +1,143 @@
import { Socket } from "socket.io";
import { tool } from "ai";
import { z } from "zod";
import u from "@/utils";
import Memory from "@/utils/agent/memory";
import { useSkill } from "@/utils/agent/skillsTools";
import useTools from "@/agents/scriptAgent/tools";
import ResTool from "@/socket/resTool";
export interface AgentContext {
socket: Socket;
isolationKey: string;
text: string;
abortSignal?: AbortSignal;
resTool: ResTool;
}
function buildSystemPrompt(skillPrompt: string, mem: Awaited<ReturnType<Memory["get"]>>): string {
let memoryContext = "";
if (mem.rag.length) {
memoryContext += `[相关记忆]\n${mem.rag.map((r) => r.content).join("\n")}`;
}
if (mem.summaries.length) {
if (memoryContext) memoryContext += "\n\n";
memoryContext += `[历史摘要]\n${mem.summaries.map((s, i) => `${i + 1}. ${s.content}`).join("\n")}`;
}
if (mem.shortTerm.length) {
if (memoryContext) memoryContext += "\n\n";
memoryContext += `[近期对话]\n${mem.shortTerm.map((m) => `${m.role}: ${m.content}`).join("\n")}`;
}
if (!memoryContext) return skillPrompt;
return `${skillPrompt}\n\n## Memory\n以下是你对用户的记忆可作为参考但不要主动提及\n${memoryContext}`;
}
const subAgentList = ["executionAI", "supervisionAI"] as const;
export async function decisionAI(ctx: AgentContext) {
const { isolationKey, text, abortSignal } = ctx;
const memory = new Memory("scriptAgent", isolationKey);
await memory.add("user", text);
const [skill, mem] = await Promise.all([useSkill("script-agent", "decision"), memory.get(text)]);
const systemPrompt = buildSystemPrompt(skill.prompt, mem);
const prefixSystem = `请调用run_sub_agent完成任务`;
const { textStream } = await u.Ai.Text("scriptAgent").stream({
system: prefixSystem + systemPrompt,
messages: [{ role: "user", content: text }],
abortSignal,
tools: {
...skill.tools,
...memory.getTools(),
run_sub_agent: runSubAgent(ctx),
...useTools(ctx.resTool),
},
onFinish: async (completion) => {
await memory.add("assistant:decision", completion.text);
},
});
return textStream;
}
//====================== 执行层 ======================
export async function executionAI(ctx: AgentContext) {
const { isolationKey, text, abortSignal, resTool } = ctx;
resTool.systemMessage("执行层AI 接管聊天");
const memory = new Memory("scriptAgent", isolationKey);
const [skill, mem] = await Promise.all([useSkill("script-agent", "execution"), memory.get(text)]);
const systemPrompt = buildSystemPrompt(skill.prompt, mem);
const { textStream } = await u.Ai.Text("scriptAgent").stream({
system: systemPrompt,
messages: [{ role: "user", content: text }],
abortSignal,
tools: {
...skill.tools,
...memory.getTools(),
...useTools(ctx.resTool),
},
onFinish: async (completion) => {
await memory.add("assistant:execution", completion.text);
},
});
return textStream;
}
export async function supervisionAI(ctx: AgentContext) {
const { isolationKey, text, abortSignal } = ctx;
const memory = new Memory("scriptAgent", isolationKey);
const [skill, mem] = await Promise.all([useSkill("script-agent", "supervision"), memory.get(text)]);
const systemPrompt = buildSystemPrompt(skill.prompt, mem);
const { textStream } = await u.Ai.Text("scriptAgent").stream({
system: systemPrompt,
messages: [{ role: "user", content: text }],
abortSignal,
tools: {
...skill.tools,
...memory.getTools(),
},
onFinish: async (completion) => {
await memory.add("assistant:supervision", completion.text);
},
});
return textStream;
}
//工具函数
function runSubAgent(parentCtx: AgentContext) {
return tool({
description: "启动子Agent执行独立任务。可用子Agent:executionAI, decisionAI, supervisionAI",
inputSchema: z.object({
agent: z.enum(["executionAI", "supervisionAI"]).describe("子Agent名称"),
prompt: z.string().describe("交给子Agent的任务描述"),
}),
execute: async ({ agent, prompt }) => {
const fn = [executionAI, supervisionAI][subAgentList.indexOf(agent)];
//运行子Agent
const subTextStream = await fn({ ...parentCtx, text: prompt });
let msg: ReturnType<typeof parentCtx.resTool.textMessage>;
let fullResponse = "";
for await (const chunk of subTextStream) {
if (!msg!) msg = parentCtx.resTool.textMessage();
msg.send(chunk);
fullResponse += chunk;
}
msg!.end();
return fullResponse;
},
});
}

View File

@@ -0,0 +1,78 @@
import { tool, Tool } from "ai";
import { z } from "zod";
import _ from "lodash";
import ResTool from "@/socket/resTool";
export const planData = z.object({
event: z.string().describe("章节事件"),
storySkeleton: z.string().describe("故事骨架"),
adaptationStrategy: z.string().describe("改编策略"),
script: z.string().describe("剧本内容"),
});
export type planData = z.infer<typeof planData>;
const keySchema = z.enum(Object.keys(planData.shape) as [keyof planData, ...Array<keyof planData>]);
const planDataKeyLabels = Object.fromEntries(
Object.entries(planData.shape).map(([key, schema]) => [key, (schema as z.ZodTypeAny).description ?? key]),
) as Record<keyof planData, string>;
export default (resTool: ResTool, toolsNames?: string[]) => {
const { socket } = resTool;
const tools: Record<string, Tool> = {
get_planData: tool({
description: "获取工作区数据",
inputSchema: z.object({
key: keySchema.describe("数据key"),
}),
execute: async ({ key }) => {
resTool.systemMessage(`正在阅读 ${planDataKeyLabels[key]} 数据...`);
console.log("[tools] get_planData", key);
const planData: planData = await new Promise((resolve) => socket.emit("getPlanData", { key }, (res: any) => resolve(res)));
return planData[key];
},
}),
set_planData_event: tool({
description: "保存章节事件到工作区",
inputSchema: z.object({ value: planData.shape.event }),
execute: async ({ value }) => {
console.log("[tools] set_planData event", value);
resTool.systemMessage("正在保存 章节事件 数据");
socket.emit("setPlanData", { key: "event", value });
return true;
},
}),
set_planData_storySkeleton: tool({
description: "保存故事骨架到工作区",
inputSchema: z.object({ value: planData.shape.storySkeleton }),
execute: async ({ value }) => {
console.log("[tools] set_planData storySkeleton", value);
resTool.systemMessage("正在保存 故事骨架 数据");
socket.emit("setPlanData", { key: "storySkeleton", value });
return true;
},
}),
set_planData_adaptationStrategy: tool({
description: "保存改编策略到工作区",
inputSchema: z.object({ value: planData.shape.adaptationStrategy }),
execute: async ({ value }) => {
console.log("[tools] set_planData adaptationStrategy", value);
resTool.systemMessage("正在保存 改编策略 数据");
socket.emit("setPlanData", { key: "adaptationStrategy", value });
return true;
},
}),
set_planData_script: tool({
description: "保存剧本内容到工作区",
inputSchema: z.object({ value: planData.shape.script }),
execute: async ({ value }) => {
console.log("[tools] set_planData script", value);
resTool.systemMessage("正在保存 剧本 数据");
socket.emit("setPlanData", { key: "script", value });
return true;
},
}),
};
return toolsNames ? Object.fromEntries(Object.entries(tools).filter(([n]) => toolsNames.includes(n))) : tools;
};

View File

@@ -10,11 +10,12 @@ export default router.post(
validateFields({
projectId: z.number(),
episodesId: z.number().optional(),
agentType: z.enum(["scriptAgent", "productionAgent"]),
type: z.enum(["message", "summary", "all"]).optional(),
}),
async (req, res) => {
const { projectId, episodesId, type = "all" } = req.body;
const isolationKey = `${projectId}:${episodesId ?? ""}`;
const { projectId, episodesId,agentType, type = "all" } = req.body;
const isolationKey = `${projectId}:${agentType}${episodesId ? `:${episodesId}` : ""}`;
if (type === "all") {
await u.db("memories").where({ isolationKey }).del();

View File

@@ -18,11 +18,12 @@ export default router.post(
"/",
validateFields({
projectId: z.number(),
agentType: z.enum(["scriptAgent", "productionAgent"]),
episodesId: z.number().optional(),
}),
async (req, res) => {
const { projectId, episodesId } = req.body;
const isolationKey = `${projectId}:${episodesId ?? ""}`;
const { projectId, agentType, episodesId } = req.body;
const isolationKey = `${projectId}:${agentType}${episodesId ? `:${episodesId}` : ""}`;
const rows = await u
.db("memories")

View File

@@ -2,7 +2,7 @@ import express from "express";
import u from "@/utils";
import { z } from "zod";
import { success } from "@/lib/responseFormat";
import compressing from 'compressing';
import compressing from "compressing";
import { validateFields } from "@/middleware/middleware";
const router = express.Router();

View File

@@ -1,11 +1,11 @@
import { Server } from "socket.io";
import productionAgent from "./routes/productionAgent";
import chat from "./routes/chat";
import scriptAgent from "./routes/scriptAgent";
export default (io: Server) => {
const routes: Record<string, (nsp: ReturnType<Server["of"]>) => void> = {
productionAgent,
chat,
scriptAgent,
};
for (const [name, handler] of Object.entries(routes)) {

View File

@@ -1,43 +0,0 @@
import { Namespace, Socket } from "socket.io";
const users = new Map<string, string>(); // socketId -> username
export default (nsp: Namespace) => {
nsp.on("connection", (socket: Socket) => {
console.log("[chat] 用户已连接:", socket.id);
socket.on("userLogin", (username: string) => {
users.set(socket.id, username);
socket.broadcast.emit("notification", `${username} 加入了聊天室`);
});
socket.on("sendMessage", (data: { message: string }) => {
const username = users.get(socket.id) || "匿名";
const msg = {
type: "user" as const,
username,
message: data.message,
time: new Date().toLocaleTimeString(),
};
nsp.emit("newMessage", msg);
});
socket.on("typing", () => {
const username = users.get(socket.id);
if (username) socket.broadcast.emit("userTyping", username);
});
socket.on("stopTyping", () => {
socket.broadcast.emit("userStopTyping");
});
socket.on("disconnect", () => {
const username = users.get(socket.id);
if (username) {
users.delete(socket.id);
socket.broadcast.emit("notification", `${username} 离开了聊天室`);
}
console.log("[chat] 用户已断开:", socket.id);
});
});
};

View File

@@ -0,0 +1,69 @@
import jwt from "jsonwebtoken";
import u from "@/utils";
import { Namespace, Socket } from "socket.io";
import * as agent from "@/agents/scriptAgent/index";
import ResTool from "@/socket/resTool";
async function verifyToken(rawToken: string): Promise<Boolean> {
const setting = await u.db("o_setting").where("key", "tokenKey").select("value").first();
if (!setting) return false;
const { value: tokenKey } = setting;
if (!rawToken) return false;
const token = rawToken.replace("Bearer ", "");
try {
jwt.verify(token, tokenKey as string);
return true;
} catch (err) {
return false;
}
}
export default (nsp: Namespace) => {
nsp.on("connection", async (socket: Socket) => {
const token = socket.handshake.auth.token;
if (!token || !(await verifyToken(token))) {
console.log("[scriptAgent] 连接失败token无效");
socket.disconnect();
return;
}
const isolationKey = socket.handshake.auth.isolationKey;
if (!isolationKey) {
console.log("[scriptAgent] 连接失败,缺少 isolationKey");
socket.disconnect();
return;
}
console.log("[scriptAgent] 已连接:", socket.id);
const resTool = new ResTool(socket);
let abortController: AbortController | null = null;
socket.on("message", async (text: string) => {
abortController?.abort();
abortController = new AbortController();
const currentController = abortController;
const textStream = await agent.decisionAI({ socket, isolationKey, text, abortSignal: currentController.signal, resTool });
let msg = resTool.textMessage();
try {
for await (const chunk of textStream) {
msg.send(chunk);
}
} catch (err: any) {
if (err.name !== "AbortError") throw err;
} finally {
msg.end();
if (abortController === currentController) {
abortController = null;
}
}
});
socket.on("stop", () => {
abortController?.abort();
abortController = null;
});
});
};

View File

@@ -1267,11 +1267,6 @@ basic-auth@~2.0.1:
dependencies:
safe-buffer "5.1.2"
best-effort-json-parser@^1.2.1:
version "1.2.1"
resolved "https://registry.npmmirror.com/best-effort-json-parser/-/best-effort-json-parser-1.2.1.tgz#e8d0b8355a0c268d918681faa0e3cf6aa192ea00"
integrity sha512-UICSLibQdzS1f+PBsi3u2YE3SsdXcWicHUg3IMvfuaePS2AYnZJdJeKhGv5OM8/mqJwPt79aDrEJ1oa84tELvw==
better-sqlite3@^12.6.2:
version "12.6.2"
resolved "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.6.2.tgz#770649f28a62e543a360f3dfa1afe4cc944b1937"