feat(agent): 画布 Agent - 重构工作流上下文与技能交互

This commit is contained in:
ddCat committed 2026-08-24 12:09:15 +08:00
1 parent 919037e5d9
commit 326572ea03
33 files changed
+2230 -248

No files matched your search

+61 -6
View File
@@ -13,7 +13,9 @@ import type { AgentAttachment, AgentEmit } from "./types.js";
type Json = Record<string, unknown>;
type AgentEvent = Json & { type: string; usage?: unknown };
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
type CodexRunOptions = { threadId?: string; cwd?: string; onThreadId?: (threadId: string) => void };
export type AgentSkillReference = { skillId?: string; name: string; description?: string; instruction?: string };
export type CodexSkillInput = { type: "skill"; name: string; path: string };
type CodexRunOptions = { threadId?: string; cwd?: string; skills?: AgentSkillReference[]; onThreadId?: (threadId: string) => void };
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
let codexQueue: Promise<unknown> = Promise.resolve();
@@ -35,16 +37,20 @@ export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments:
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
let files: string[] = [];
let skillDirectories: string[] = [];
try {
files = await writeAttachmentFiles(attachments);
const preparedSkills = await writeSkillFiles(options.skills || []);
skillDirectories = preparedSkills.directories;
codexApp ||= await CodexAppClient.start(emit);
const threadId = await ensureCodexThread(codexApp, options);
if (threadId !== options.threadId) options.onThreadId?.(threadId);
await codexApp.startTurn(threadId, prompt, files);
await codexApp.startTurn(threadId, prompt, files, preparedSkills.inputs);
} catch (error) {
emit("agent_error", { message: errorMessage(error) });
} finally {
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
await Promise.all(skillDirectories.map((directory) => fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)));
}
}
@@ -162,8 +168,8 @@ class CodexAppClient {
return this.request("thread/archive", { threadId });
}
async startTurn(threadId: string, prompt: string, images: string[]) {
const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" });
async startTurn(threadId: string, prompt: string, images: string[], skills: CodexSkillInput[] = []) {
const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images, skills), approvalPolicy: "never" });
const turnId = String(field(field(result, "turn"), "id") || "");
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
const completed = this.completedTurns.get(turnId);
@@ -289,8 +295,16 @@ export function codexConfig(configDir = CONFIG_DIR) {
};
}
function codexInput(prompt: string, images: string[]) {
return [{ type: "text", text: prompt, text_elements: [] }, ...images.map((file) => ({ type: "localImage", path: file }))];
export function codexInput(prompt: string, images: string[], skills: CodexSkillInput[]) {
// Skill inputs are first-class app-server UserInput items. Do not add a
// `$skill` marker or copy SKILL.md into the text item: doing either turns
// native skill selection back into prompt expansion and makes the skill
// visible as ordinary user text in history.
return [
{ type: "text", text: prompt, text_elements: [] },
...images.map((file) => ({ type: "localImage", path: file })),
...skills,
];
}
function normalizeCodexNotification(method: string, params: Json): AgentEvent | null {
@@ -421,6 +435,9 @@ function toolName(name: string) {
if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_context") return "读取画布上下文";
if (name === "canvas_find_nodes") return "检索画布节点";
if (name === "canvas_get_node") return "读取画布节点";
if (name === "canvas_get_connection") return "读取画布连线";
if (name === "canvas_get_generation_tasks") return "读取生成任务";
if (name === "canvas_get_resources") return "读取画布资源";
if (name === "canvas_validate_ops") return "校验画布操作";
if (name === "canvas_get_selection") return "读取选区";
@@ -448,6 +465,44 @@ async function writeAttachmentFile(item: AgentAttachment) {
return file;
}
export async function writeSkillFiles(skills: AgentSkillReference[]) {
const directories: string[] = [];
const inputs: CodexSkillInput[] = [];
const usedNames = new Set<string>();
try {
for (const skill of skills.slice(0, 8)) {
const instruction = String(skill.instruction || "").trim().slice(0, 24_000);
if (!instruction) continue;
const baseName = `canvas-${safeSkillSegment(skill.skillId || skill.name)}`;
const name = uniqueSkillName(baseName, usedNames);
usedNames.add(name);
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "infinite-canvas-skill-"));
directories.push(directory);
const file = path.join(directory, "SKILL.md");
const description = String(skill.description || skill.name).trim().slice(0, 500).replace(/[\r\n]+/g, " ");
const body = [`---`, `name: ${name}`, `description: ${JSON.stringify(description)}`, `---`, ``, `# ${skill.name}`, ``, instruction, ``].join("\n");
await fs.writeFile(file, body, "utf8");
inputs.push({ type: "skill", name, path: file });
}
return { directories, inputs };
} catch (error) {
await Promise.all(directories.map((directory) => fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)));
throw error;
}
}
function uniqueSkillName(baseName: string, usedNames: Set<string>) {
if (!usedNames.has(baseName)) return baseName;
let suffix = 2;
while (usedNames.has(`${baseName}-${suffix}`)) suffix += 1;
return `${baseName}-${suffix}`;
}
function safeSkillSegment(value: string) {
const normalized = value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
return normalized || "skill";
}
function imageExt(type = "") {
if (type.includes("png")) return "png";
if (type.includes("webp")) return "webp";