feat(agent): 画布 Agent - 重构工作流上下文与技能交互
This commit is contained in:
1 parent
919037e5d9
commit
326572ea03
33 files changed
+2230
-248
No files matched your search
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user