diff --git a/data/skills/production-agent/SKILL.md b/data/skills/production-agent/SKILL.md new file mode 100644 index 0000000..4b7f24a --- /dev/null +++ b/data/skills/production-agent/SKILL.md @@ -0,0 +1,36 @@ +--- +name: production-agent +description: 短剧漫剧制作助手。协助用户进行剧本创作、分镜设计、角色设定、场景描述、对白润色及制作流程规划。当用户提及剧本、分镜、角色、场景、对白、短剧、漫剧等关键词时使用此技能。 +--- + +# Production Agent + +短剧漫剧制作专用技能,提供剧本创作、分镜设计、角色设定等全流程指导。 + +## 何时使用 + +当用户需要以下帮助时激活此技能: + +- 剧本创作与优化 +- 分镜脚本设计 +- 角色设定与描述 +- 场景构建与描绘 +- 对白润色与调整 +- 制作流程规划 + +## 工作指引 + +1. 理解用户的创作意图,根据项目类型(短剧/漫剧)调整输出风格 +2. 遵循标准的剧本格式,包含场景描述、角色动作、对白等要素 +3. 保持角色一致性,关注剧情连贯性 +4. 输出使用中文 + +## 参考资料 + +本技能附带以下参考资料,根据任务需要使用 `read_skill_file` 工具按需加载: + +- [剧本格式规范](references/script-format.md) — 场景描述、对白、旁白、转场等标准写法 +- [角色设定模板](references/character-template.md) — 基础信息、性格特点、背景设定、角色弧光 +- [分镜设计指南](references/storyboard-guide.md) — 景别分类、常用构图、镜头运动、分镜描述模板 + +**注意**:根据用户当前任务选择性加载对应参考资料,不要一次性全部加载。 diff --git a/data/skills/production-agent/references/character-template.md b/data/skills/production-agent/references/character-template.md new file mode 100644 index 0000000..41bafc6 --- /dev/null +++ b/data/skills/production-agent/references/character-template.md @@ -0,0 +1,67 @@ +# 角色设定模板 + +## 基础信息 + +- **姓名**: +- **年龄**: +- **性别**: +- **职业**: +- **外貌特征**:身高、体型、发型、标志性特征 + +## 性格特点 + +- **核心性格**:用 3-5 个关键词概括 +- **优点**: +- **缺点**: +- **口头禅**: +- **说话风格**:正式/随意/文艺/粗犷 + +## 人物背景 + +- **家庭背景**: +- **成长经历**: +- **关键转折事件**: + +## 人物关系 + +| 角色 | 关系 | 互动方式 | +|------|------|----------| +| | | | + +## 角色弧光 + +- **起点状态**:故事开始时的状态 +- **核心冲突**:角色面临的主要矛盾 +- **转变契机**:什么事件触发角色转变 +- **终点状态**:故事结束时的状态 + +## 填写示例 + +### 基础信息 + +- **姓名**:林晓 +- **年龄**:26 岁 +- **性别**:女 +- **职业**:自由插画师 +- **外貌特征**:165cm,偏瘦,黑色短发微卷,左耳戴银色耳钉 + +### 性格特点 + +- **核心性格**:敏感、倔强、善良 +- **优点**:对艺术有极强的感知力,重情义 +- **缺点**:逃避冲突,容易钻牛角尖 +- **口头禅**:"算了吧" +- **说话风格**:随意,偶尔会冒出文艺的比喻 + +### 人物背景 + +- **家庭背景**:单亲家庭,母亲独自经营花店 +- **成长经历**:从小在花店长大,高中获美术竞赛一等奖后决定走艺术道路 +- **关键转折事件**:大学毕业作品展遭导师否定后退学 + +### 角色弧光 + +- **起点状态**:对自己的能力缺乏信心,靠接零散商稿维生 +- **核心冲突**:内心的艺术追求与现实生存压力 +- **转变契机**:一位老画家看中她的画作并邀请合作 +- **终点状态**:重新找回创作的勇气,举办个人画展 diff --git a/data/skills/production-agent/references/script-format.md b/data/skills/production-agent/references/script-format.md new file mode 100644 index 0000000..709e45b --- /dev/null +++ b/data/skills/production-agent/references/script-format.md @@ -0,0 +1,61 @@ +# 标准剧本格式规范 + +## 场景描述 + +``` +场景 1 - 内景/咖啡厅/日 +``` + +- 以"场景 + 编号"开头 +- 标注内景/外景 +- 标注地点 +- 标注时间(日/夜/黄昏/清晨) + +## 角色动作 + +用括号标注角色动作和表情: + +``` +小明(皱眉,放下咖啡杯) +``` + +## 对白格式 + +``` +小明:你确定这个计划可行? +小红:(犹豫片刻)我……我不确定。 +``` + +## 旁白/画外音 + +``` +【旁白】三年前的那个夏天,一切都还没有开始。 +``` + +## 转场标注 + +- 切至:硬切 +- 淡入/淡出:渐变过渡 +- 叠化:两个画面重叠过渡 + +## 完整示例 + +``` +场景 1 - 内景/咖啡厅/日 + +(阳光透过落地窗洒进咖啡厅,背景音乐轻柔) + +小明独自坐在靠窗的位置,低头搅动着咖啡。 + +小红推门走入,环顾四周后看到小明。 + +小红:(微笑着走过来)好久不见。 +小明:(抬头,愣了一下)你……怎么来了? +小红:(坐下,放下手提包)路过这里,想起你说过喜欢这家店。 + +【旁白】他们已经三年没有见面了。 + +—— 切至 —— + +场景 2 - 外景/街道/夜 +``` diff --git a/data/skills/production-agent/references/storyboard-guide.md b/data/skills/production-agent/references/storyboard-guide.md new file mode 100644 index 0000000..12fdc27 --- /dev/null +++ b/data/skills/production-agent/references/storyboard-guide.md @@ -0,0 +1,61 @@ +# 分镜设计指南 + +## 景别分类 + +| 景别 | 范围 | 用途 | +|------|------|------| +| 远景 | 环境全貌 | 交代环境、气氛渲染 | +| 全景 | 人物全身 | 展示人物与环境关系 | +| 中景 | 膝盖以上 | 日常对话、叙事推进 | +| 近景 | 胸部以上 | 表情细节、情绪传达 | +| 特写 | 面部/物件 | 强调情绪或关键道具 | + +## 常用构图 + +- **三分法**:主体置于三分线交叉点 +- **对称构图**:营造庄重、对峙感 +- **引导线构图**:利用线条引导视线 +- **框中框**:通过门窗等框住主体 +- **前景遮挡**:增加画面层次感 + +## 镜头运动 + +- **推**:由远及近,聚焦主体 +- **拉**:由近及远,展示全貌 +- **摇**:固定位置旋转,扫视场景 +- **移**:跟随角色移动 +- **升降**:垂直运动,营造压迫或释放感 +- **手持**:模拟真实视角,增加临场感 + +## 分镜描述模板 + +``` +镜号:001 +景别:近景 +角度:平视 +构图:三分法,人物偏左 +动作:小明缓缓抬起头 +对白:小明:"原来是你。" +音效:雨声渐弱 +时长:3s +``` + +## 完整分镜示例 + +### 场景:咖啡厅重逢 + +| 镜号 | 景别 | 构图 | 内容描述 | 对白/音效 | 时长 | +|------|------|------|----------|-----------|------| +| 001 | 远景 | 对称 | 咖啡厅外观,暖黄灯光 | 轻柔钢琴BGM | 2s | +| 002 | 中景 | 三分法 | 小明独坐窗边,搅动咖啡 | 咖啡杯碰撞声 | 3s | +| 003 | 全景 | 引导线 | 小红推门进入,逆光 | 门铃声 | 2s | +| 004 | 近景 | 居中 | 小明抬头,表情从平淡到惊讶 | 小红:"好久不见。" | 3s | +| 005 | 特写 | 居中 | 小明的眼睛,瞳孔微微放大 | BGM渐弱 | 1.5s | +| 006 | 中景 | 对称 | 两人面对面坐下 | 小明:"你怎么来了?" | 3s | + +### 情绪节奏说明 + +- 001-002:平静、孤独(慢节奏) +- 003:转折信号(节奏变化) +- 004-005:情绪高点(短促镜头) +- 006:回归对话(节奏恢复) \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 02b5f08..170b444 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,7 +9,6 @@ import logger from "morgan"; import cors from "cors"; import buildRoute from "@/core"; import fs from "fs"; -import path from "path"; import u from "@/utils"; import jwt from "jsonwebtoken"; import socketInit from "@/socket/index"; diff --git a/src/lib/initDB.ts b/src/lib/initDB.ts index 1df3ecc..821e853 100644 --- a/src/lib/initDB.ts +++ b/src/lib/initDB.ts @@ -50,18 +50,6 @@ export default async (knex: Knex, forceInit: boolean = false): Promise => }, initData: async (knex) => {}, }, - //技能表 - { - name: "o_skills", - builder: (table) => { - table.integer("id").notNullable(); - table.string("name"); - table.integer("startTime"); - table.primary(["id"]); - table.unique(["id"]); - }, - initData: async (knex) => {}, - }, //Agent配置表 { name: "o_agentDeploy", @@ -186,64 +174,9 @@ export default async (knex: Knex, forceInit: boolean = false): Promise => ]); }, }, - //模型表 - { - name: "o_model", - builder: (table) => { - table.integer("id").notNullable(); - table.text("type"); - table.text("model"); - table.text("modelType"); - table.text("apiKey"); - table.text("baseUrl"); - table.text("manufacturer"); - table.integer("createTime"); - table.integer("index"); - table.primary(["id"]); - table.unique(["id"]); - }, - initData: async (knex) => {}, - }, - //提示词表 - { - name: "o_prompts", - builder: (table) => { - table.integer("id").notNullable(); - table.text("code"); // 代号,唯一标识 - table.text("name"); // 名称/描述 - table.text("type"); // 类型:mainAgent/subAgent/system - table.text("parentCode"); // 父级代号(subAgent关联主agent) - table.text("defaultValue"); // 默认提示词 - table.text("customValue"); // 自定义修改值 - table.primary(["id"]); - table.unique(["id"]); - table.unique(["code"]); // 代号唯一 - }, - initData: async (knex) => {}, - }, - //资产表 - { - name: "o_assets", - builder: (table) => { - table.integer("id").notNullable(); - table.text("name"); - table.text("prompt"); - table.text("remark"); - table.text("type"); - table.text("describe"); - table.integer("imageId").unsigned().references("id").inTable("o_image"); - table.integer("sonId"); - table.integer("projectId"); - table.integer("startTime"); - table.text("state"); - table.primary(["id"]); - table.unique(["id"]); - }, - initData: async (knex) => {}, - }, //任务中心表 { - name: "o_myTasks", + name: "o_tasks", builder: (table) => { table.integer("id").notNullable(); table.integer("projectId"); @@ -333,24 +266,38 @@ export default async (knex: Knex, forceInit: boolean = false): Promise => table.unique(["id"]); }, }, - //剧本-大纲 + //资产表 { - name: "o_scriptOutline", + name: "o_assets", builder: (table) => { table.integer("id").notNullable(); - table.integer("scriptId").unsigned().references("id").inTable("o_script"); - table.integer("outlineId").unsigned().references("id").inTable("o_outline"); + table.text("name"); + table.text("prompt"); + table.text("remark"); + table.text("type"); + table.text("describe"); + table.integer("imageId").unsigned().references("id").inTable("o_image"); + table.integer("sonId"); + table.integer("projectId"); + table.integer("startTime"); + table.text("state"); table.primary(["id"]); table.unique(["id"]); }, + initData: async (knex) => {}, }, - //剧本-资产 + //生成图片表 { - name: "o_scriptAssets", + name: "o_image", builder: (table) => { table.integer("id").notNullable(); - table.integer("assetsId").unsigned().references("id").inTable("o_assets"); - table.integer("scriptId").unsigned().references("id").inTable("o_script"); + table.text("filePath"); + table.text("type"); + table.integer("assetsId"); + table.integer("scriptId"); + table.integer("projectId"); + table.integer("videoId"); + table.text("state"); table.primary(["id"]); table.unique(["id"]); }, @@ -366,13 +313,13 @@ export default async (knex: Knex, forceInit: boolean = false): Promise => table.unique(["id"]); }, }, - //分镜-剧本 + //flowData-剧本 { - name: "o_storyboardScript", + name: "o_flowData", builder: (table) => { table.integer("id").notNullable(); - table.integer("storyboardId").unsigned().references("id").inTable("o_storyboard"); - table.integer("scriptId").unsigned().references("id").inTable("o_script"); + table.string("name"); + table.integer("createTime"); table.primary(["id"]); table.unique(["id"]); }, @@ -397,19 +344,6 @@ export default async (knex: Knex, forceInit: boolean = false): Promise => table.unique(["id"]); }, }, - //聊天记录 - { - name: "o_chatHistory", - builder: (table) => { - table.integer("id").notNullable(); - table.text("type"); - table.text("data"); - table.text("novel"); - table.integer("projectId"); - table.primary(["id"]); - table.unique(["id"]); - }, - }, //视频配置 { name: "o_videoConfig", @@ -434,18 +368,19 @@ export default async (knex: Knex, forceInit: boolean = false): Promise => table.unique(["id"]); }, }, - //生成图片表 + //供应商配置表 { - name: "o_image", + name: "o_vendorConfig", builder: (table) => { table.integer("id").notNullable(); - table.text("filePath"); - table.text("type"); - table.integer("assetsId"); - table.integer("scriptId"); - table.integer("projectId"); - table.integer("videoId"); - table.text("state"); + table.text("name"); + table.text("version"); + table.text("icon"); + table.text("inputs"); // 输入项配置 JSON + table.text("inputValues"); // 输入项值 JSON + table.text("models"); // 模型配置 JSON + table.text("code"); // 模型配置 JSON + table.integer("createTime"); table.primary(["id"]); table.unique(["id"]); }, @@ -479,7 +414,7 @@ export default async (knex: Knex, forceInit: boolean = false): Promise => table.text("embedding"); // 向量嵌入 JSON table.text("relatedMessageIds"); // summary关联的message id列表 JSON table.integer("summarized").defaultTo(0); // message是否已被总结 0/1 - table.integer("createdAt").notNullable(); + table.integer("createTime").notNullable(); table.primary(["id"]); table.index(["isolationKey", "type"]); table.index(["isolationKey", "summarized"]); diff --git a/src/routes/agents/getMemory.ts b/src/routes/agents/getMemory.ts index 2dfeef8..5768ca8 100644 --- a/src/routes/agents/getMemory.ts +++ b/src/routes/agents/getMemory.ts @@ -18,14 +18,14 @@ export default router.post( const rows = await u .db("memories") .where({ isolationKey, type: "message" }) - .orderBy("createdAt", "asc") - .select("id", "content", "createdAt"); + .orderBy("createTime", "asc") + .select("id", "content", "createTime"); const history = rows.map((row) => ({ id: row.id, role: "user", content: [{ type: "text", status: "complete", data: row.content }], - createdAt: row.createdAt, + createTime: row.createTime, })); res.status(200).send(success({ history })); diff --git a/src/routes/agents/productionAgent.ts b/src/routes/agents/productionAgent.ts index 4bbb912..2b92169 100644 --- a/src/routes/agents/productionAgent.ts +++ b/src/routes/agents/productionAgent.ts @@ -1,9 +1,8 @@ -import { tool } from "ai"; -import { z } from "zod"; import express from "express"; import { createAGUIStream } from "@/utils/agent/aguiTools"; import u from "@/utils"; import Memory from "@/utils/agent/memory"; +import { useSkill } from "@/utils/agent/skillsTools"; const router = express.Router(); @@ -14,19 +13,20 @@ function delay(ms: number) { export default router.post("/", async (req, res) => { const { prompt: text, projectId, episodesId } = req.body; const isolationKey = `${projectId}:${episodesId}`; + + //记忆 const memory = new Memory("productionAgent", isolationKey); + //skill + const skill = await useSkill("production-agent"); const agui = createAGUIStream(res); agui.runStarted(); // 存入用户消息 - await memory.add( "user",text); + await memory.add("user", text); - // 获取记忆上下文 + // 获取记忆上下文 const mem = await memory.get(text); - - console.log("======================================================"); - // 构建记忆上下文文本(顺序:历史摘要 → 相关记忆 → 近期对话) const memoryContext = [ mem.rag.length > 0 && `[相关记忆]\n${mem.rag.map((r) => r.content).join("\n")}`, mem.summaries.length > 0 && `[历史摘要]\n${mem.summaries.map((s, i) => `${i + 1}. ${s.content}`).join("\n")}`, @@ -35,9 +35,10 @@ export default router.post("/", async (req, res) => { .filter(Boolean) .join("\n\n"); - console.log("%c Line:27 🍏 memoryContext", "background:#3f7cff", memoryContext); - const systemPrompt = `You are a helpful assistant.${memoryContext ? `\n\n以下是你对用户的记忆,可作为参考:\n${memoryContext}` : ""}`; + const systemPrompt = [skill.prompt, memoryContext && `## Memory\n以下是你对用户的记忆,可作为参考但不要主动提及:\n${memoryContext}`] + .filter(Boolean) + .join("\n\n"); const messages = [ { @@ -50,21 +51,12 @@ export default router.post("/", async (req, res) => { system: systemPrompt, messages, tools: { - deepRetrieve: tool({ - description: "深度检索记忆:当你需要回忆与某个关键词相关的详细历史信息时使用此工具", - inputSchema: z.object({ - keyword: z.string().describe("要检索的关键词"), - }), - execute: async ({ keyword }) => { - const results = await memory.deepRetrieve(keyword); - if (results.length === 0) return { found: false, message: "未找到相关记忆" }; - return { found: true, memories: results.map((r) => r.content) }; - }, - }), + ...skill.tools, + ...memory.getTools(), }, onFinish: async (completion) => { // 存入助手回复 - await memory.add( "assistant",completion.text); + await memory.add("assistant", completion.text); }, }); diff --git a/src/routes/project/delProject.ts b/src/routes/project/delProject.ts index 4acd5bf..c80c1fa 100644 --- a/src/routes/project/delProject.ts +++ b/src/routes/project/delProject.ts @@ -26,7 +26,7 @@ export default router.post( await u.db("o_project").where("id", id).delete(); await u.db("o_novel").where("projectId", id).delete(); await u.db("o_outline").where("projectId", id).delete(); - await u.db("o_myTasks").where("projectId", id).delete(); + await u.db("o_tasks").where("projectId", id).delete(); await u.db("o_script").where("projectId", id).delete(); await u.db("o_assets").where("projectId", id).delete(); @@ -45,8 +45,6 @@ export default router.post( await u.db("o_video").whereIn("scriptId", scriptIds).delete(); - await u.db("o_chatHistory").where("projectId", id).delete(); - try { await u.oss.deleteDirectory(`${id}/`); console.log(`项目 ${id} 的OSS文件夹删除成功`); diff --git a/src/routes/task/getMyTaskApi.ts b/src/routes/task/getMyTaskApi.ts index eeb68f4..ea9c94c 100644 --- a/src/routes/task/getMyTaskApi.ts +++ b/src/routes/task/getMyTaskApi.ts @@ -16,27 +16,27 @@ export default router.post( const { taskClass, state, page = 1, limit = 10 }: any = req.body; const offset = (page - 1) * limit; const data = await u - .db("o_myTasks") - .leftJoin("o_project", "o_project.id", "o_myTasks.projectId") + .db("o_tasks") + .leftJoin("o_project", "o_project.id", "o_tasks.projectId") .andWhere((qb) => { if (taskClass) { - qb.andWhere("o_myTasks.taskClass", taskClass); + qb.andWhere("o_tasks.taskClass", taskClass); } if (state) { - qb.andWhere("o_myTasks.state", state); + qb.andWhere("o_tasks.state", state); } }) - .select("o_myTasks.*", "o_project.* ") + .select("o_tasks.*", "o_project.* ") .offset(offset) .limit(limit); const totalQuery = (await u - .db("o_myTasks") + .db("o_tasks") .andWhere((qb) => { if (taskClass) { - qb.andWhere("o_myTasks.taskClass", taskClass); + qb.andWhere("o_tasks.taskClass", taskClass); } if (state) { - qb.andWhere("o_myTasks.state", state); + qb.andWhere("o_tasks.state", state); } }) .count("* as total") diff --git a/src/routes/task/getTaskCategories.ts b/src/routes/task/getTaskCategories.ts index 3e97170..cb60760 100644 --- a/src/routes/task/getTaskCategories.ts +++ b/src/routes/task/getTaskCategories.ts @@ -11,7 +11,7 @@ export default router.post( projectId: z.number(), }), async (req, res) => { - const data = await u.db("o_myTasks").where("projectId", req.body.projectId).select("taskClass").groupBy("taskClass"); + const data = await u.db("o_tasks").where("projectId", req.body.projectId).select("taskClass").groupBy("taskClass"); res.status(200).send(success(data)); }, ); diff --git a/src/routes/task/taskDetails.ts b/src/routes/task/taskDetails.ts index b84d3f1..3d9a38b 100644 --- a/src/routes/task/taskDetails.ts +++ b/src/routes/task/taskDetails.ts @@ -12,7 +12,7 @@ export default router.post( }), async (req, res) => { const { taskId } = req.body; - const data = await u.db("o_myTasks").where("id", taskId).select("*").first(); + const data = await u.db("o_tasks").where("id", taskId).select("*").first(); res.status(200).send(success(data)); } ); diff --git a/src/types/database.d.ts b/src/types/database.d.ts index ddd1ec9..ffaa589 100644 --- a/src/types/database.d.ts +++ b/src/types/database.d.ts @@ -1,4 +1,4 @@ -// @db-hash ad26ece2cf8002d48518ba2b8cd908f8 +// @db-hash 2f9e6a9e9145cead00652858cafb9159 //该文件由脚本自动生成,请勿手动修改 export interface memories { @@ -58,6 +58,11 @@ export interface o_eventChapter { 'id'?: number; 'novelId'?: number | null; } +export interface o_flowData { + 'createTime'?: number | null; + 'id'?: number; + 'name'?: string | null; +} export interface o_image { 'assetsId'?: number | null; 'filePath'?: string | null; @@ -166,6 +171,17 @@ export interface o_storyboardScript { 'scriptId'?: number | null; 'storyboardId'?: number | null; } +export interface o_tasks { + 'describe'?: string | null; + 'id'?: number; + 'model'?: string | null; + 'projectId'?: number | null; + 'reason'?: string | null; + 'relatedObjects'?: string | null; + 'startTime'?: number | null; + 'state'?: string | null; + 'taskClass'?: string | null; +} export interface o_user { 'id'?: number; 'name'?: string | null; @@ -223,6 +239,7 @@ export interface DB { "o_chatHistory": o_chatHistory; "o_event": o_event; "o_eventChapter": o_eventChapter; + "o_flowData": o_flowData; "o_image": o_image; "o_model": o_model; "o_myTasks": o_myTasks; @@ -238,6 +255,7 @@ export interface DB { "o_skills": o_skills; "o_storyboard": o_storyboard; "o_storyboardScript": o_storyboardScript; + "o_tasks": o_tasks; "o_user": o_user; "o_vendorConfig": o_vendorConfig; "o_video": o_video; diff --git a/src/utils/agent/memory.ts b/src/utils/agent/memory.ts index 307b426..e0b6458 100644 --- a/src/utils/agent/memory.ts +++ b/src/utils/agent/memory.ts @@ -2,6 +2,8 @@ import u from "@/utils"; import { v4 as uuidv4 } from "uuid"; import { getEmbedding, cosineSimilarity } from "./embedding"; import type { memories as MemoryRow } from "@/types/database"; +import { tool } from "ai"; +import { z } from "zod"; // ── 可调配置 ── const messagesPerSummary = 3; // 每累积多少条message触发一次summary生成 @@ -56,7 +58,6 @@ class Memory { async add( role: string = "user",content: string) { const id = uuidv4(); const embedding = await getEmbedding(content); - const now = Date.now(); const isolationKey = this.isolationKey; await u.db("memories").insert({ @@ -68,11 +69,11 @@ class Memory { embedding: JSON.stringify(embedding), relatedMessageIds: null, summarized: 0, - createdAt: now, + createTime: Date.now(), } as any); // 检查未总结消息数量 - const unsummarized = await u.db("memories").where({ isolationKey, type: "message", summarized: 0 }).orderBy("createdAt", "asc"); + const unsummarized = await u.db("memories").where({ isolationKey, type: "message", summarized: 0 }).orderBy("createTime", "asc"); if (unsummarized.length >= messagesPerSummary) { const batch = unsummarized.slice(0, messagesPerSummary); @@ -91,7 +92,7 @@ class Memory { embedding: JSON.stringify(summaryEmbedding), relatedMessageIds: JSON.stringify(batchIds), summarized: 0, - createdAt: Date.now(), + createTime: Date.now(), }); // 标记已总结 @@ -105,12 +106,12 @@ class Memory { const shortTerm = await u .db("memories") .where({ isolationKey, type: "message", summarized: 0 }) - .orderBy("createdAt", "desc") + .orderBy("createTime", "desc") .limit(shortTermLimit); shortTerm.reverse(); // 最旧在前 // summaries: 最近的 summary - const summaries = await u.db("memories").where({ isolationKey, type: "summary" }).orderBy("createdAt", "desc").limit(summaryLimit); + const summaries = await u.db("memories").where({ isolationKey, type: "summary" }).orderBy("createTime", "desc").limit(summaryLimit); summaries.reverse(); // rag: 向量搜索所有 messages @@ -119,12 +120,12 @@ class Memory { const ragResults = vectorSearch(allMessages, queryEmbedding, ragLimit); return { - shortTerm: shortTerm.map((m: any) => ({ id: m.id, role: m.role, content: m.content, createdAt: m.createdAt })), + shortTerm: shortTerm.map((m: any) => ({ id: m.id, role: m.role, content: m.content, createTime: m.createTime })), summaries: summaries.map((s) => ({ id: s.id, content: s.content, relatedMessageIds: JSON.parse(s.relatedMessageIds || "[]"), - createdAt: s.createdAt, + createTime: s.createTime, })), rag: ragResults.map((r) => ({ id: r.id, content: r.content, similarity: r.similarity })), }; @@ -153,9 +154,25 @@ class Memory { if (messageIds.length === 0) return []; - const messages = await u.db("memories").whereIn("id", messageIds).orderBy("createdAt", "asc"); + const messages = await u.db("memories").whereIn("id", messageIds).orderBy("createTime", "asc"); - return messages.map((m) => ({ id: m.id, content: m.content, createdAt: m.createdAt })); + return messages.map((m) => ({ id: m.id, content: m.content, createTime: m.createTime })); + } + + getTools() { + return { + deepRetrieve: tool({ + description: "深度检索记忆:当你需要回忆与某个关键词相关的详细历史信息时使用此工具", + inputSchema: z.object({ + keyword: z.string().describe("要检索的关键词"), + }), + execute: async ({ keyword }) => { + const results = await this.deepRetrieve(keyword); + if (results.length === 0) return { found: false, message: "未找到相关记忆" }; + return { found: true, memories: results.map((r) => r.content) }; + }, + }), + }; } } diff --git a/src/utils/agent/skillsTools.ts b/src/utils/agent/skillsTools.ts new file mode 100644 index 0000000..6c6f77d --- /dev/null +++ b/src/utils/agent/skillsTools.ts @@ -0,0 +1,323 @@ +import { tool } from "ai"; +import { z } from "zod"; +import path from "path"; +import fs from "fs/promises"; +import isPathInside from "is-path-inside"; +import getPath from "@/utils/getPath"; + +// ==================== 类型 ==================== + +interface SkillRecord { + name: string; + description: string; + location: string; // SKILL.md 绝对路径 + baseDir: string; // skill 目录绝对路径 +} + +// ==================== Step 2: 解析 SKILL.md ==================== + +/** + * 解析 SKILL.md frontmatter + * 支持 YAML 单行值及多行块标量(>、>-、|、|-) + */ +function parseFrontmatter(content: string): { name: string; description: string } { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match?.[1]) throw new Error("No frontmatter found"); + + const result: Record = {}; + const lines = match[1].split("\n"); + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) { i++; continue; } + + const key = line.slice(0, colonIndex).trim(); + if (!key) { i++; continue; } + + let value = line.slice(colonIndex + 1).trim(); + + // 检测 YAML 块标量指示符 (>, >-, |, |-) + if (/^[>|]-?$/.test(value)) { + const fold = value.startsWith(">"); + const parts: string[] = []; + i++; + while (i < lines.length) { + const next = lines[i]; + // 缩进行属于当前块 + if (/^\s+/.test(next)) { + parts.push(next.trim()); + i++; + } else { + break; + } + } + value = fold ? parts.join(" ") : parts.join("\n"); + } else { + i++; + } + + result[key] = value; + } + + if (!result.name) throw new Error("Frontmatter missing required field: name"); + if (!result.description) throw new Error("Frontmatter missing required field: description"); + + return { name: result.name, description: result.description }; +} + +/** + * 去除 frontmatter,返回正文(body) + */ +function stripFrontmatter(content: string): string { + const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/); + return match ? content.slice(match[0].length).trim() : content.trim(); +} + +// ==================== 资源枚举 ==================== + +/** + * 递归扫描目录,返回相对路径列表(排除 SKILL.md) + */ +async function listResources(dir: string, base: string = ""): Promise { + const files: string[] = []; + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return files; + } + for (const entry of entries) { + const rel = base ? `${base}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + files.push(...(await listResources(path.join(dir, entry.name), rel))); + } else if (entry.name !== "SKILL.md") { + files.push(rel); + } + } + return files; +} + +// ==================== Step 1: 发现 skills ==================== + +/** + * 扫描指定目录,发现所有包含 SKILL.md 的子目录 + */ +async function discoverSkills(directories: string[]): Promise { + const skills: SkillRecord[] = []; + const seenNames = new Set(); + + for (const dir of directories) { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const baseDir = path.join(dir, entry.name); + const location = path.join(baseDir, "SKILL.md"); + + let content: string; + try { + content = await fs.readFile(location, "utf-8"); + } catch { + continue; + } + + let metadata: { name: string; description: string }; + try { + metadata = parseFrontmatter(content); + } catch (e) { + console.log(`[Skill] ⚠️ 跳过 "${entry.name}":${(e as Error).message}`); + continue; + } + + // 宽松校验:name 与目录名不匹配时仅告警 + if (metadata.name !== entry.name) { + console.log(`[Skill] ⚠️ 技能名 "${metadata.name}" 与目录名 "${entry.name}" 不一致,仍加载`); + } + if (metadata.name.length > 64) { + console.log(`[Skill] ⚠️ 技能名 "${metadata.name}" 超过 64 字符,仍加载`); + } + + // 先发现的同名 skill 优先(项目级覆盖用户级) + if (seenNames.has(metadata.name)) { + console.log(`[Skill] ⚠️ 技能 "${metadata.name}" 名称冲突,已被先前发现的同名技能覆盖`); + continue; + } + seenNames.add(metadata.name); + + skills.push({ + name: metadata.name, + description: metadata.description, + location, + baseDir, + }); + + console.log(`[Skill] ✅ 发现技能:${metadata.name} — ${metadata.description}`); + } + } + + return skills; +} + +// ==================== Step 3: 构建技能目录 ==================== + +/** + * 构建 XML 格式的技能目录 + 行为指令,注入到 system prompt + */ +function buildCatalog(skills: SkillRecord[]): string { + if (skills.length === 0) return ""; + + const skillsXml = skills + .map( + (s) => + ` \n ${s.name}\n ${s.description}\n ` + ) + .join("\n"); + + return [ + "## Skills", + "以下技能提供了专业任务的专用指令。", + "当任务与某个技能的描述匹配时,调用 activate_skill 工具并传入技能名称来加载完整指令。", + "加载后遵循技能指令执行任务,需要时调用 read_skill_file 读取资源文件内容。", + "", + "", + skillsXml, + "", + ].join("\n"); +} + +// ==================== Step 4 & 5: 激活 + 执行工具 ==================== + +/** + * 创建 activate_skill 和 read_skill_file 工具 + */ +function createSkillTools(skills: SkillRecord[]) { + // 激活去重:记录当前会话已激活的 skill + const activated = new Set(); + + const validNames = skills.map((s) => s.name); + + return { + activate_skill: tool({ + description: `激活一个技能,加载其完整指令和捆绑资源列表到上下文。可用技能:${validNames.join(", ")}`, + inputSchema: z.object({ + name: z.enum(validNames as [string, ...string[]]).describe("要激活的技能名称"), + }), + execute: async ({ name }) => { + const skill = skills.find((s) => s.name === name); + if (!skill) { + console.log(`[Skill] ❌ 激活失败:未找到技能 "${name}"`); + return { error: `Skill '${name}' not found` }; + } + + // Step 5: 去重检查 + if (activated.has(name)) { + console.log(`[Skill] ℹ️ 技能 "${name}" 已在当前会话中激活,跳过重复注入`); + return { already_active: true, message: `技能 "${name}" 已激活,无需重复加载` }; + } + + let content: string; + try { + content = await fs.readFile(skill.location, "utf-8"); + } catch { + console.log(`[Skill] ❌ 激活失败:无法读取 ${skill.location}`); + return { error: `Failed to read SKILL.md for '${name}'` }; + } + + const body = stripFrontmatter(content); + const resources = await listResources(skill.baseDir); + + activated.add(name); + + const resourcesXml = + resources.length > 0 + ? `\n\n${resources.map((f) => ` ${f}`).join("\n")}\n` + : ""; + + const wrapped = [ + ``, + body, + "", + `Skill directory: ${skill.baseDir}`, + `相对路径基于此技能目录解析,使用 read_skill_file 工具读取资源文件。`, + resourcesXml, + ``, + ].join("\n"); + + console.log( + `[Skill] 📖 已激活技能:${skill.name}(${body.length} 字符,${resources.length} 个资源文件)` + ); + + return { content: wrapped }; + }, + }), + + read_skill_file: tool({ + description: "读取已激活技能目录下的资源文件。传入 activate_skill 返回的 skill_resources 中的文件路径。", + inputSchema: z.object({ + skillName: z.string().describe("技能名称"), + filePath: z.string().describe("资源文件的相对路径,来自 activate_skill 返回的 skill_resources"), + }), + execute: async ({ skillName, filePath: relPath }) => { + const skill = skills.find((s) => s.name === skillName); + if (!skill) { + console.log(`[Skill] ❌ 读取失败:未找到技能 "${skillName}"`); + return { error: `Skill '${skillName}' not found` }; + } + + const fullPath = path.resolve(path.join(skill.baseDir, relPath)); + + if (!isPathInside(fullPath, skill.baseDir)) { + console.log(`[Skill] 🚫 路径越界已拦截:"${relPath}" 超出技能目录范围`); + return { error: "Access denied: path is outside skill directory" }; + } + + try { + const fileContent = await fs.readFile(fullPath, "utf-8"); + console.log(`[Skill] 📄 已读取文件:${skillName}/${relPath}(${fileContent.length} 字符)`); + return { content: fileContent }; + } catch { + console.log(`[Skill] ❌ 读取失败:未找到文件 "${relPath}"`); + return { error: `File not found: ${relPath}` }; + } + }, + }), + }; +} + +// ==================== 对外接口 ==================== + +/** + * 使用指定 skill(渐进式披露) + * + * 遵循 agentskills.io 规范: + * Step 1 — Discovery: 扫描 data/skills/{name} 目录 + * Step 2 — Parse: 提取 frontmatter 元数据 + * Step 3 — Disclose: 构建 XML 目录注入 system prompt + * Step 4 — Activate: activate_skill 工具加载完整指令 + 结构化包装 + 资源列表 + * Step 5 — Manage: read_skill_file 读取资源 + 激活去重 + * + * @param name skill 名称,对应 data/skills/{name} 目录 + */ +export async function useSkill(name: string) { + const skills = await discoverSkills([getPath("skills")]); + + // 过滤出指定 skill + const matched = skills.filter((s) => s.name === name); + if (matched.length === 0) { + console.log(`[Skill] ⚠️ 未发现名为 "${name}" 的技能`); + return { prompt: "", tools: {} }; + } + + return { + prompt: buildCatalog(matched), + tools: createSkillTools(matched), + }; +} diff --git a/src/utils/taskRecord.ts b/src/utils/taskRecord.ts index 9aa6d10..a097ab0 100644 --- a/src/utils/taskRecord.ts +++ b/src/utils/taskRecord.ts @@ -38,7 +38,7 @@ export default async function taskRecord( } } - const [id] = await db("o_myTasks").insert({ + const [id] = await db("o_tasks").insert({ projectId, taskClass, relatedObjects: opteorContent, @@ -50,7 +50,7 @@ export default async function taskRecord( /** 任务成功时调用 done(1),失败时调用 done(-1, '原因') */ return async function done(state: 1 | -1, reason?: string) { - await db("o_myTasks") + await db("o_tasks") .where("id", id) .update({ state: taskStateMap[state],