feat: rename package to @basketikun/canvas-agent and update related configurations

This commit is contained in:
HouYunFei
2026-06-15 17:16:27 +08:00
parent e422285c59
commit b6d358d6f8
16 changed files with 1160 additions and 157 deletions
+176 -9
View File
@@ -11,6 +11,8 @@ 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 };
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
let codexQueue: Promise<unknown> = Promise.resolve();
let codexApp: CodexAppClient | null = null;
@@ -22,19 +24,19 @@ export function withAgentPrompt(prompt: string) {
return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : "";
}
export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = []) {
export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
if (!prompt.trim()) return;
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments));
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments, options));
await codexQueue;
}
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[]) {
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
let files: string[] = [];
try {
files = await writeAttachmentFiles(attachments);
codexApp ||= await CodexAppClient.start(emit);
codexThreadId ||= await codexApp.startThread();
await codexApp.startTurn(codexThreadId, prompt, files);
const threadId = await ensureCodexThread(codexApp, options);
await codexApp.startTurn(threadId, prompt, files);
} catch (error) {
emit("agent_error", { message: errorMessage(error) });
} finally {
@@ -42,6 +44,46 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
}
}
export async function startCodexThread(emit: AgentEmit, cwd?: string) {
codexApp ||= await CodexAppClient.start(emit);
const thread = await codexApp.startThread(cwd);
codexThreadId = String(field(thread, "id") || "");
return thread;
}
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
codexApp ||= await CodexAppClient.start(emit);
const thread = await codexApp.resumeThread(threadId, cwd);
codexThreadId = String(field(thread, "id") || threadId);
return { thread, messages: threadMessages(thread) };
}
export async function listCodexThreads(emit: AgentEmit, options: { cwd?: string; all?: boolean; searchTerm?: string; limit?: number }) {
codexApp ||= await CodexAppClient.start(emit);
const result = await codexApp.listThreads({
limit: options.limit || 40,
sortKey: "updated_at",
sortDirection: "desc",
sourceKinds: ["cli", "vscode", "appServer", "exec"],
...(options.all ? {} : { cwd: options.cwd }),
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
});
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread) : [];
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
}
export async function readCodexThread(emit: AgentEmit, threadId: string) {
codexApp ||= await CodexAppClient.start(emit);
const result = await codexApp.readThread(threadId);
const thread = field(result, "thread") || {};
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
}
export async function archiveCodexThread(emit: AgentEmit, threadId: string) {
codexApp ||= await CodexAppClient.start(emit);
await codexApp.archiveThread(threadId);
}
export function runClaudeTurn(prompt: string, emit: AgentEmit) {
if (!prompt.trim()) return;
const child = spawnAgent("claude", ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--allowedTools", "mcp__infinite-canvas__*", prompt], ["ignore", "pipe", "pipe"], emit);
@@ -49,6 +91,21 @@ export function runClaudeTurn(prompt: string, emit: AgentEmit) {
pipeJsonLines(child, emit, "claude");
}
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) {
if (options.threadId) {
if (codexThreadId !== options.threadId) {
const thread = await app.resumeThread(options.threadId, options.cwd);
codexThreadId = String(field(thread, "id") || options.threadId);
}
return codexThreadId;
}
if (!codexThreadId) {
const thread = await app.startThread(options.cwd);
codexThreadId = String(field(thread, "id") || "");
}
return codexThreadId;
}
class CodexAppClient {
private nextId = 1;
private buffer = "";
@@ -78,11 +135,32 @@ class CodexAppClient {
return client;
}
async startThread() {
const result = await this.request("thread/start", { approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), threadSource: "user" });
const id = String(field(field(result, "thread"), "id") || "");
async startThread(cwd?: string) {
const result = await this.request("thread/start", { approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}), threadSource: "user" });
const thread = field(result, "thread") as Json | undefined;
const id = String(field(thread, "id") || "");
if (!id) throw new Error("Codex app-server 没有返回 thread id");
return id;
return thread || {};
}
async resumeThread(threadId: string, cwd?: string) {
const result = await this.request("thread/resume", { threadId, approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}) });
const thread = field(result, "thread") as Json | undefined;
const id = String(field(thread, "id") || "");
if (!id) throw new Error("Codex app-server 没有返回 thread id");
return thread || {};
}
listThreads(params: Json) {
return this.request("thread/list", params);
}
readThread(threadId: string) {
return this.request("thread/read", { threadId, includeTurns: true });
}
archiveThread(threadId: string) {
return this.request("thread/archive", { threadId });
}
async startTurn(threadId: string, prompt: string, images: string[]) {
@@ -245,6 +323,95 @@ function field(value: unknown, key: string) {
return value && typeof value === "object" ? (value as Json)[key] : undefined;
}
export function summarizeCodexThread(thread: unknown) {
return {
id: String(field(thread, "id") || ""),
sessionId: String(field(thread, "sessionId") || ""),
preview: displayUserText(String(field(thread, "preview") || "")),
name: stringOrNull(field(thread, "name")),
cwd: String(field(thread, "cwd") || ""),
status: String(field(thread, "status") || ""),
source: field(thread, "source"),
threadSource: field(thread, "threadSource"),
createdAt: Number(field(thread, "createdAt") || 0),
updatedAt: Number(field(thread, "updatedAt") || 0),
};
}
function threadMessages(thread: unknown): AgentHistoryMessage[] {
const turns = arrayValue(field(thread, "turns"));
const messages: AgentHistoryMessage[] = [];
turns.forEach((turn, turnIndex) => {
arrayValue(field(turn, "items")).forEach((item, itemIndex) => {
const type = String(field(item, "type") || "");
const id = String(field(item, "id") || `${turnIndex}-${itemIndex}`);
if (type === "userMessage") {
const text = displayUserText(userInputText(field(item, "content")));
if (text) messages.push({ id, role: "user", text });
}
if (type === "agentMessage") {
const text = String(field(item, "text") || "").trim();
if (text) messages.push({ id, role: "assistant", title: "Codex", text, streamId: id });
}
if (type === "mcpToolCall") {
const tool = String(field(item, "tool") || "工具调用");
const error = field(field(item, "error"), "message");
messages.push({ id, role: error ? "error" : "tool", title: toolName(tool), text: error ? String(error) : `${toolName(tool)} ${String(field(item, "status") || "完成")}`, detail: item });
}
if (type === "commandExecution") {
const command = String(field(item, "command") || "").trim();
if (command) messages.push({ id, role: "tool", title: "命令", text: command, detail: { cwd: field(item, "cwd"), status: field(item, "status"), exitCode: field(item, "exitCode") } });
}
if (type === "fileChange") messages.push({ id, role: "tool", title: "文件变更", text: "Codex 修改了文件", detail: item });
});
});
return messages.filter((item) => item.text).slice(-120);
}
function userInputText(content: unknown) {
return arrayValue(content)
.map((item) => {
const type = String(field(item, "type") || "");
if (type === "text") return String(field(item, "text") || "");
if (type === "image" || type === "localImage") return "图片附件";
if (type === "mention") return `@${String(field(item, "name") || "文件")}`;
return "";
})
.filter(Boolean)
.join("\n");
}
function displayUserText(text: string) {
const value = text.trim();
const marker = "用户请求:";
const index = value.lastIndexOf(marker);
return (index >= 0 ? value.slice(index + marker.length) : value).trim();
}
function arrayValue(value: unknown) {
return Array.isArray(value) ? value : [];
}
function stringOrNull(value: unknown) {
return typeof value === "string" && value.trim() ? value : null;
}
function toolName(name: string) {
if (name === "canvas_apply_ops") return "画布操作";
if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_selection") return "读取选区";
if (name === "canvas_export_snapshot") return "导出快照";
if (name === "canvas_create_text_node") return "创建文本";
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
if (name === "canvas_create_generation_flow") return "创建生成流程";
if (name === "canvas_generate_text") return "生成文本";
if (name === "canvas_generate_image") return "生成图片";
if (name === "canvas_generate_video") return "生成视频";
if (name === "canvas_generate_audio") return "生成音频";
if (name === "canvas_run_generation") return "触发生成";
return name;
}
async function writeAttachmentFiles(attachments: AgentAttachment[]) {
return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile));
}