mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
fix(agent): synchronize conversation preparation state
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
+ [调整] Agent 面板连接状态、内容标签和新对话统一排版,窄面板仅隐藏文字并保留图标与数量。
|
||||
+ [优化] Agent 输入区控件随面板宽度在图标与图标加文字之间自适应。
|
||||
+ [修复] Agent Skill 草稿期间保持 MCP 活动画布隔离,支持停止临时生成任务,并禁止并发修改 Skill。
|
||||
+ [修复] Agent 新建或恢复对话时统一同步版本化会话与 MCP 初始化状态,避免多页面竞态误触 `409`、提前发送或任务后残留初始化卡片。
|
||||
|
||||
## v0.13.0 - 2026-08-03
|
||||
|
||||
|
||||
@@ -150,6 +150,41 @@ test("skills/changed 作为站点级事件单独广播", () => {
|
||||
assert.deepEqual(events, [{ type: "skills_changed", payload: {} }]);
|
||||
});
|
||||
|
||||
test("MCP 启动状态区分空对话预热与正常 turn 运行", async () => {
|
||||
const writes: Array<Record<string, unknown>> = [];
|
||||
const events: Array<{ type: string; payload: unknown }> = [];
|
||||
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
|
||||
const testClient = client as unknown as TestClient;
|
||||
|
||||
const starting = client.startThread("D:\\site", "request", true);
|
||||
const request = writes.find((item) => item.method === "thread/start");
|
||||
assert.ok(request);
|
||||
testClient.handleNotification("mcpServer/startupStatus/updated", { threadId: "thread-1", name: "notion", status: "starting" });
|
||||
testClient.handle({ id: request.id, result: { thread: { id: "thread-1" } } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const statusRequest = writes.find((item) => item.method === "mcpServerStatus/list");
|
||||
assert.ok(statusRequest);
|
||||
testClient.handleNotification("mcpServer/startupStatus/updated", { threadId: "thread-1", name: "notion", status: "failed" });
|
||||
testClient.handle({ id: statusRequest.id, result: { data: [{ name: "notion", authStatus: "notLoggedIn", tools: [], resources: [], resourceTemplates: [] }], nextCursor: null } });
|
||||
await starting;
|
||||
|
||||
const running = client.startTurn("thread-1", "test", [], "request");
|
||||
const turnRequest = writes.find((item) => item.method === "turn/start");
|
||||
assert.ok(turnRequest);
|
||||
testClient.handleNotification("mcpServer/startupStatus/updated", { threadId: "thread-1", name: "notion", status: "ready" });
|
||||
testClient.handle({ id: turnRequest.id, result: { turn: { id: "turn-1" } } });
|
||||
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
|
||||
await running;
|
||||
|
||||
assert.deepEqual(events.filter((event) => event.type === "agent_bootstrap"), [
|
||||
{ type: "agent_bootstrap", payload: { type: "mcp.startup", phase: "preheat", threadId: "thread-1", name: "notion", status: "starting", error: undefined, failureReason: undefined } },
|
||||
{ type: "agent_bootstrap", payload: { type: "mcp.startup", phase: "preheat", threadId: "thread-1", name: "notion", status: "failed", error: undefined, failureReason: undefined } },
|
||||
{ type: "agent_bootstrap", payload: { type: "mcp.complete", phase: "preheat", threadId: "thread-1", services: [{ name: "notion", authStatus: "notLoggedIn" }] } },
|
||||
{ type: "agent_bootstrap", payload: { type: "mcp.startup", phase: "runtime", threadId: "thread-1", name: "notion", status: "ready", error: undefined, failureReason: undefined } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("静默 Skill 草稿 turn 只返回结构化结果,不广播也不写历史", async () => {
|
||||
const writes: Array<Record<string, unknown>> = [];
|
||||
const events: Array<{ type: string; payload: unknown }> = [];
|
||||
|
||||
@@ -57,6 +57,8 @@ export class CodexAppClient {
|
||||
private structuredOutputByTurn = new Map<string, string>();
|
||||
private pendingSilentThreadStarts = new Set<symbol>();
|
||||
private pendingThreadStartedNotifications: JsonRecord[] = [];
|
||||
private pendingPreheatThreadStarts = 0;
|
||||
private preheatingThreadIds = new Set<string>();
|
||||
private failing = false;
|
||||
private failureMessage = "";
|
||||
|
||||
@@ -99,10 +101,22 @@ export class CodexAppClient {
|
||||
}
|
||||
|
||||
/** 创建新的 Codex 线程。 */
|
||||
async startThread(cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||
const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}), threadSource: "user" });
|
||||
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
|
||||
return thread;
|
||||
async startThread(cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) {
|
||||
if (preheat) this.pendingPreheatThreadStarts += 1;
|
||||
let threadId = "";
|
||||
try {
|
||||
const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}), threadSource: "user" });
|
||||
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
|
||||
threadId = thread.id;
|
||||
if (preheat) {
|
||||
this.preheatingThreadIds.add(threadId);
|
||||
await this.completeMcpPreheat(threadId);
|
||||
}
|
||||
return thread;
|
||||
} finally {
|
||||
if (preheat) this.pendingPreheatThreadStarts -= 1;
|
||||
if (threadId) this.preheatingThreadIds.delete(threadId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建不会持久化或向网页广播的草稿线程。 */
|
||||
@@ -116,10 +130,24 @@ export class CodexAppClient {
|
||||
}
|
||||
|
||||
/** 恢复已有 Codex 线程。 */
|
||||
async resumeThread(threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||
const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}) });
|
||||
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
|
||||
return thread;
|
||||
async resumeThread(threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) {
|
||||
if (preheat) this.pendingPreheatThreadStarts += 1;
|
||||
try {
|
||||
if (preheat) this.preheatingThreadIds.add(threadId);
|
||||
const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}) });
|
||||
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
|
||||
if (preheat) await this.completeMcpPreheat(thread.id);
|
||||
return thread;
|
||||
} finally {
|
||||
if (preheat) this.pendingPreheatThreadStarts -= 1;
|
||||
if (preheat) this.preheatingThreadIds.delete(threadId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 以 app-server 的权威 MCP 清单响应作为预热完成边界。 */
|
||||
private async completeMcpPreheat(threadId: string) {
|
||||
const result = await this.request("mcpServerStatus/list", { threadId, limit: 100, detail: "toolsAndAuthOnly" });
|
||||
this.emit("agent_bootstrap", { type: "mcp.complete", phase: "preheat", threadId, services: result.data.map(({ name, authStatus }) => ({ name, authStatus })) });
|
||||
}
|
||||
|
||||
/** 查询 Codex 线程列表。 */
|
||||
@@ -187,6 +215,7 @@ export class CodexAppClient {
|
||||
|
||||
/** 启动一个 Codex turn 并等待完成通知。 */
|
||||
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, model?: string, effort?: CodexReasoningEffort, onTurn?: (turnId: string) => void, skill?: CodexSkillSelector, messageText?: string, outputSchema?: JsonRecord) {
|
||||
this.preheatingThreadIds.delete(threadId);
|
||||
this.currentThreadId = threadId;
|
||||
this.currentTurnId = "";
|
||||
this.lastUsage = null;
|
||||
@@ -347,9 +376,11 @@ export class CodexAppClient {
|
||||
}
|
||||
if (method === "mcpServer/startupStatus/updated") {
|
||||
const value = params as unknown as CodexNotificationParams<"mcpServer/startupStatus/updated">;
|
||||
const threadId = value.threadId || this.currentThreadId;
|
||||
this.emit("agent_bootstrap", {
|
||||
type: "mcp.startup",
|
||||
threadId: value.threadId || this.currentThreadId,
|
||||
phase: this.pendingPreheatThreadStarts > 0 || this.preheatingThreadIds.has(threadId) ? "preheat" : "runtime",
|
||||
threadId,
|
||||
name: value.name,
|
||||
status: value.status,
|
||||
error: value.error,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type CodexItem = JsonRecord & { id: string; type: string; text?: string }
|
||||
export type CodexPlanStep = { step: string; status: "pending" | "inProgress" | "completed" };
|
||||
export type CodexPlanUpdate = { threadId: string; turnId: string; explanation?: string | null; plan: CodexPlanStep[]; turnStatus?: string };
|
||||
export type CodexMcpStartupStatus = { threadId: string | null; name: string; status: "starting" | "ready" | "failed" | "cancelled"; error: string | null; failureReason: "reauthenticationRequired" | null };
|
||||
export type CodexMcpServerStatus = { name: string; authStatus: "unsupported" | "notLoggedIn" | "bearerToken" | "oAuth" } & JsonRecord;
|
||||
export type CodexReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
|
||||
export type CodexModel = JsonRecord & {
|
||||
id: string;
|
||||
@@ -103,6 +104,10 @@ type CodexRequestSpec = {
|
||||
params: { limit: number; includeHidden: boolean };
|
||||
result: { data: CodexModel[]; nextCursor: string | null };
|
||||
};
|
||||
"mcpServerStatus/list": {
|
||||
params: { cursor?: string | null; limit?: number | null; detail?: "full" | "toolsAndAuthOnly" | null; threadId?: string | null };
|
||||
result: { data: CodexMcpServerStatus[]; nextCursor: string | null };
|
||||
};
|
||||
"skills/list": {
|
||||
params: { cwds: string[]; forceReload?: boolean };
|
||||
result: { data: CodexSkillsListEntry[] };
|
||||
|
||||
@@ -84,17 +84,17 @@ export async function resolveCodexApproval(requestId: string, decision: string)
|
||||
}
|
||||
|
||||
/** 创建新的 Codex 线程并记录当前线程 ID。 */
|
||||
export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||
export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) {
|
||||
const app = await getCodexApp(emit);
|
||||
const thread = await app.startThread(cwd, permissionMode);
|
||||
const thread = await app.startThread(cwd, permissionMode, preheat);
|
||||
loadedThreadId = String(field(thread, "id") || "");
|
||||
return thread;
|
||||
}
|
||||
|
||||
/** 恢复指定 Codex 线程并返回聊天历史。 */
|
||||
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", preheat = false) {
|
||||
const app = await getCodexApp(emit);
|
||||
const thread = await resumeLoadedThread(app, threadId, cwd, permissionMode, true);
|
||||
const thread = await resumeLoadedThread(app, threadId, cwd, permissionMode, true, preheat);
|
||||
const history = await loadCodexHistory(emit, threadId, cwd);
|
||||
const supplementalItems = await codexEventHistory.readThread(threadId);
|
||||
return { thread, messages: threadMessages(history.thread, app.planUpdates(threadId), supplementalItems), settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
|
||||
@@ -433,8 +433,8 @@ async function loadCodexHistory(emit: AgentEmit, threadId: string, cwd?: string)
|
||||
}
|
||||
|
||||
/** 恢复线程并统一校验工作空间与进程内活动线程。 */
|
||||
async function resumeLoadedThread(app: CodexAppClient, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", updateLoaded = true) {
|
||||
const thread = await app.resumeThread(threadId, cwd, permissionMode);
|
||||
async function resumeLoadedThread(app: CodexAppClient, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request", updateLoaded = true, preheat = false) {
|
||||
const thread = await app.resumeThread(threadId, cwd, permissionMode, preheat);
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
if (updateLoaded) loadedThreadId = String(field(thread, "id") || threadId);
|
||||
return thread;
|
||||
|
||||
@@ -214,7 +214,7 @@ test("shared thread events are broadcast with the active thread id", (t) => {
|
||||
});
|
||||
|
||||
test("new clients receive the current Codex state and later updates", (t) => {
|
||||
const session = new CanvasSession();
|
||||
const session = new CanvasSession("thread-2");
|
||||
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||
session.trackCodexEvent("codex_approval", { requestId: "approval-1", threadId: "thread-2" });
|
||||
const client = connect(session, "first", "thread-2");
|
||||
@@ -223,6 +223,7 @@ test("new clients receive the current Codex state and later updates", (t) => {
|
||||
const hello = client.event("hello");
|
||||
assert.equal(field(hello, "protocolVersion"), 5);
|
||||
assert.deepEqual(field(hello, "workspace"), { activeThreadId: "thread-2" });
|
||||
assert.deepEqual(field(hello, "conversation"), { revision: 1, conversationId: "thread-2", threadId: "thread-2", status: "ready", mcpStatuses: {} });
|
||||
assert.deepEqual(field(hello, "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||
assert.deepEqual(field(hello, "pendingApprovals"), [{ requestId: "approval-1", threadId: "thread-2" }]);
|
||||
|
||||
@@ -236,6 +237,50 @@ test("new clients receive the current Codex state and later updates", (t) => {
|
||||
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
|
||||
});
|
||||
|
||||
test("对话 revision 单调递增且 MCP 全部进入终态前保持 preparing", () => {
|
||||
const session = new CanvasSession();
|
||||
const revisions = [session.conversationStateSnapshot.revision];
|
||||
|
||||
revisions.push(session.beginConversation({ sourceClientId: "first" }).revision);
|
||||
revisions.push(session.updateConversationMcp("late-service", "starting").revision);
|
||||
revisions.push(session.completeConversationMcpInventory([{ name: "infinite-canvas", authStatus: "unsupported" }]).revision);
|
||||
const pending = session.completeConversationPreparation("thread-1");
|
||||
revisions.push(pending.revision);
|
||||
assert.equal(pending.status, "preparing");
|
||||
|
||||
const ready = session.updateConversationMcp("late-service", "ready");
|
||||
revisions.push(ready.revision);
|
||||
assert.equal(ready.status, "ready");
|
||||
assert.equal(ready.threadId, "thread-1");
|
||||
revisions.slice(1).forEach((revision, index) => assert.ok(revision > revisions[index]));
|
||||
});
|
||||
|
||||
test("可选 MCP 失败进入 warning,画布 MCP 失败进入 failed", () => {
|
||||
const optionalFailure = new CanvasSession();
|
||||
optionalFailure.beginConversation();
|
||||
optionalFailure.completeConversationMcpInventory([
|
||||
{ name: "infinite-canvas", authStatus: "unsupported" },
|
||||
{ name: "notion", authStatus: "notLoggedIn" },
|
||||
]);
|
||||
const warning = optionalFailure.completeConversationPreparation("thread-1");
|
||||
assert.equal(warning.status, "warning");
|
||||
assert.equal(warning.mcpStatuses.notion.status, "failed");
|
||||
|
||||
const requiredFailure = new CanvasSession();
|
||||
requiredFailure.beginConversation();
|
||||
requiredFailure.completeConversationMcpInventory([{ name: "infinite-canvas", authStatus: "notLoggedIn" }]);
|
||||
const failed = requiredFailure.completeConversationPreparation("thread-2");
|
||||
assert.equal(failed.status, "failed");
|
||||
assert.match(failed.error || "", /Infinite Canvas MCP/);
|
||||
|
||||
const requiredMissing = new CanvasSession();
|
||||
requiredMissing.beginConversation();
|
||||
requiredMissing.completeConversationMcpInventory([{ name: "notion", authStatus: "unsupported" }]);
|
||||
const missing = requiredMissing.completeConversationPreparation("thread-3");
|
||||
assert.equal(missing.status, "failed");
|
||||
assert.match(missing.error || "", /Infinite Canvas MCP/);
|
||||
});
|
||||
|
||||
test("Codex 写操作在多窗口之间互斥且不能与运行 turn 并发", () => {
|
||||
const session = new CanvasSession();
|
||||
assert.equal(session.beginCodexMutation(), true);
|
||||
|
||||
@@ -12,6 +12,17 @@ type PendingRequest = { clientId: string; resolve: (value: unknown) => void; rej
|
||||
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
|
||||
type ReplayEvent = { type: string; payload: Record<string, unknown> };
|
||||
export type CodexState = { busy: boolean; threadId: string; turnId: string };
|
||||
export type McpStartupState = "starting" | "ready" | "failed" | "cancelled";
|
||||
export type ConversationState = {
|
||||
revision: number;
|
||||
conversationId: string;
|
||||
threadId: string;
|
||||
status: "idle" | "preparing" | "ready" | "warning" | "running" | "failed";
|
||||
mcpStatuses: Record<string, { status: McpStartupState; error?: string | null; failureReason?: string | null }>;
|
||||
sourceClientId?: string;
|
||||
error?: string;
|
||||
};
|
||||
type McpInventoryItem = { name: string; authStatus?: string };
|
||||
export const AGENT_PROTOCOL_VERSION = 5;
|
||||
|
||||
const SITE_TOOLS = new Set<ToolName>([
|
||||
@@ -42,6 +53,19 @@ export class CanvasSession {
|
||||
private boundClientId = "";
|
||||
private focusSequence = 0;
|
||||
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
|
||||
private conversationState: ConversationState;
|
||||
private conversationInventoryComplete = false;
|
||||
private preparedConversationThreadId = "";
|
||||
|
||||
constructor(activeThreadId = "") {
|
||||
this.conversationState = {
|
||||
revision: 1,
|
||||
conversationId: activeThreadId || crypto.randomUUID(),
|
||||
threadId: activeThreadId,
|
||||
status: activeThreadId ? "ready" : "idle",
|
||||
mcpStatuses: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取当前目标网页的画布状态。 */
|
||||
private get canvasState() {
|
||||
@@ -55,7 +79,7 @@ export class CanvasSession {
|
||||
|
||||
/** 返回 Canvas Agent 当前连接状态。 */
|
||||
health() {
|
||||
return { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
|
||||
return { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy, conversation: this.conversationStateSnapshot };
|
||||
}
|
||||
|
||||
/** 返回 Codex 是否正在执行任务。 */
|
||||
@@ -72,6 +96,98 @@ export class CanvasSession {
|
||||
return { ...this.codexState };
|
||||
}
|
||||
|
||||
/** 返回站点级对话的权威快照。 */
|
||||
get conversationStateSnapshot(): ConversationState {
|
||||
return { ...this.conversationState, mcpStatuses: { ...this.conversationState.mcpStatuses } };
|
||||
}
|
||||
|
||||
/** 原子开始一次新建或恢复对话流程。 */
|
||||
beginConversation(options: { threadId?: string; conversationId?: string; sourceClientId?: string } = {}) {
|
||||
this.conversationInventoryComplete = false;
|
||||
this.preparedConversationThreadId = "";
|
||||
return this.updateConversation({
|
||||
conversationId: options.conversationId || options.threadId || crypto.randomUUID(),
|
||||
threadId: options.threadId || "",
|
||||
status: "preparing",
|
||||
mcpStatuses: {},
|
||||
sourceClientId: options.sourceClientId,
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** 记录预热阶段单个 MCP 服务的启动状态。 */
|
||||
updateConversationMcp(name: string, status: McpStartupState, error?: string | null, failureReason?: string | null) {
|
||||
if (!name || this.conversationState.status !== "preparing") return this.conversationStateSnapshot;
|
||||
const snapshot = this.updateConversation({
|
||||
mcpStatuses: { ...this.conversationState.mcpStatuses, [name]: { status, error, failureReason } },
|
||||
});
|
||||
return this.conversationInventoryComplete && this.preparedConversationThreadId
|
||||
? this.completeConversationPreparation(this.preparedConversationThreadId)
|
||||
: snapshot;
|
||||
}
|
||||
|
||||
/** 用 app-server 的完整 MCP 清单补齐未发送逐项通知的服务。 */
|
||||
completeConversationMcpInventory(services: McpInventoryItem[]) {
|
||||
if (this.conversationState.status !== "preparing") return this.conversationStateSnapshot;
|
||||
const mcpStatuses = { ...this.conversationState.mcpStatuses };
|
||||
services.filter((item) => item.name).forEach((item) => {
|
||||
const current = mcpStatuses[item.name];
|
||||
if (current?.status === "failed" || current?.status === "cancelled") return;
|
||||
mcpStatuses[item.name] = item.authStatus === "notLoggedIn"
|
||||
? { status: "failed", error: "MCP 服务未登录", failureReason: "reauthenticationRequired" }
|
||||
: { status: "ready" };
|
||||
});
|
||||
this.conversationInventoryComplete = true;
|
||||
return this.updateConversation({ mcpStatuses });
|
||||
}
|
||||
|
||||
/** MCP 清单读取结束后提交线程和最终可发送状态。 */
|
||||
completeConversationPreparation(threadId: string) {
|
||||
this.preparedConversationThreadId = threadId;
|
||||
const statuses = this.conversationState.mcpStatuses;
|
||||
const hasPending = !this.conversationInventoryComplete || Object.values(statuses).some((item) => item.status === "starting");
|
||||
const requiredFailure = statuses["infinite-canvas"]?.status !== "ready";
|
||||
const hasFailure = Object.values(statuses).some((item) => item.status === "failed" || item.status === "cancelled");
|
||||
const requiredFailureDetail = statuses["infinite-canvas"]?.error;
|
||||
return this.updateConversation({
|
||||
threadId,
|
||||
status: hasPending ? "preparing" : requiredFailure ? "failed" : hasFailure ? "warning" : "ready",
|
||||
error: requiredFailure ? `Infinite Canvas MCP 初始化失败${requiredFailureDetail ? `:${requiredFailureDetail}` : ""}` : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** 将创建线程或读取 MCP 清单的失败保存为不可发送状态。 */
|
||||
failConversationPreparation(error: string) {
|
||||
this.conversationInventoryComplete = false;
|
||||
this.preparedConversationThreadId = "";
|
||||
return this.updateConversation({ status: "failed", error });
|
||||
}
|
||||
|
||||
/** 切换到无需重新预热的既有状态,例如删除当前对话后的空状态。 */
|
||||
activateConversation(threadId: string, sourceClientId?: string) {
|
||||
this.conversationInventoryComplete = false;
|
||||
this.preparedConversationThreadId = "";
|
||||
return this.updateConversation({
|
||||
conversationId: threadId || crypto.randomUUID(),
|
||||
threadId,
|
||||
status: threadId ? "ready" : "idle",
|
||||
mcpStatuses: {},
|
||||
sourceClientId,
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
markConversationRunning(threadId: string) {
|
||||
if (!threadId || threadId !== this.conversationState.threadId || !["ready", "warning"].includes(this.conversationState.status)) return this.conversationStateSnapshot;
|
||||
return this.updateConversation({ status: "running" });
|
||||
}
|
||||
|
||||
finishConversationRun(threadId: string) {
|
||||
if (!threadId || threadId !== this.conversationState.threadId) return this.conversationStateSnapshot;
|
||||
const hasFailure = Object.values(this.conversationState.mcpStatuses).some((item) => item.status === "failed" || item.status === "cancelled");
|
||||
return this.updateConversation({ status: hasFailure ? "warning" : "ready" });
|
||||
}
|
||||
|
||||
/** 判断网页客户端是否仍连接到当前 Agent。 */
|
||||
hasClient(clientId: string) {
|
||||
return this.clients.has(clientId);
|
||||
@@ -163,7 +279,7 @@ export class CanvasSession {
|
||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||
}
|
||||
}
|
||||
sendEvent(res, "hello", { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, clientId, workspace: { activeThreadId }, codex: this.codexState, pendingApprovals: this.codexPendingApprovals });
|
||||
sendEvent(res, "hello", { ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, clientId, workspace: { activeThreadId }, conversation: this.conversationStateSnapshot, codex: this.codexState, pendingApprovals: this.codexPendingApprovals });
|
||||
if (!statusOnly && activeThreadId && this.codexState.threadId === activeThreadId) this.codexReplayEvents.forEach((event) => sendEvent(res, event.type, event.payload));
|
||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||
res.on("close", () => {
|
||||
@@ -182,6 +298,13 @@ export class CanvasSession {
|
||||
});
|
||||
}
|
||||
|
||||
private updateConversation(patch: Partial<Omit<ConversationState, "revision">>) {
|
||||
this.conversationState = { ...this.conversationState, ...patch, revision: this.conversationState.revision + 1 };
|
||||
const snapshot = this.conversationStateSnapshot;
|
||||
this.emitAll("conversation_changed", snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** 保存指定网页上报的最新画布快照。 */
|
||||
updateState(body: unknown, clientId?: string) {
|
||||
const targetClientId = clientId || this.activeClientId;
|
||||
|
||||
+112
-45
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
|
||||
import { runClaudeTurn } from "../agent/claude.js";
|
||||
import { archiveCodexThread, CodexSkillLookupError, configureCodexSkill, generateCodexSkillDraft, interruptCodexTurn, listCodexModels, listCodexSkills, listCodexThreads, readCodexThread, resolveCodexApproval, resolveCodexSkill, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
|
||||
import { archiveCodexThread, CodexSkillLookupError, configureCodexSkill, generateCodexSkillDraft, interruptCodexTurn, isRecoverableThreadError, listCodexModels, listCodexSkills, listCodexThreads, readCodexThread, resolveCodexApproval, resolveCodexSkill, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
|
||||
import type { CodexReasoningEffort, CodexSkillSelector } from "../agent/codex-protocol.js";
|
||||
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
|
||||
import { AGENT_PROTOCOL_VERSION, CanvasSession } from "../canvas/session.js";
|
||||
@@ -20,8 +20,9 @@ export function startHttpServer() {
|
||||
config.url = `http://127.0.0.1:${port}`;
|
||||
saveConfig(config);
|
||||
|
||||
const session = new CanvasSession();
|
||||
const skillStore = new SkillStore(ensureSiteWorkspace(config).workspacePath);
|
||||
const initialWorkspace = ensureSiteWorkspace(config);
|
||||
const session = new CanvasSession(initialWorkspace.activeThreadId || "");
|
||||
const skillStore = new SkillStore(initialWorkspace.workspacePath);
|
||||
/** 将 Agent 事件广播到所属线程或全部网页。 */
|
||||
const emit = (type: string, payload: unknown) => {
|
||||
const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||
@@ -29,6 +30,10 @@ export function startHttpServer() {
|
||||
session.emitAll(type, value);
|
||||
return;
|
||||
}
|
||||
if (type === "agent_bootstrap" && value.phase === "preheat") {
|
||||
if (value.type === "mcp.startup") session.updateConversationMcp(String(value.name || ""), startupStatus(value.status), String(value.error || "") || null, String(value.failureReason || "") || null);
|
||||
if (value.type === "mcp.complete") session.completeConversationMcpInventory(mcpInventory(value.services));
|
||||
}
|
||||
const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" };
|
||||
const threadId = String(value.threadId || value.thread_id || scope.threadId || ensureSiteWorkspace(config).activeThreadId || "");
|
||||
const turnId = String(value.turnId || value.turn_id || scope.turnId || "");
|
||||
@@ -43,10 +48,11 @@ export function startHttpServer() {
|
||||
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
|
||||
};
|
||||
/** 保存并广播当前站点工作空间的活跃线程。 */
|
||||
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
|
||||
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}, preserveConversation = false) => {
|
||||
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
|
||||
if (!preserveConversation) session.activateConversation(activeThreadId, String(payload.sourceClientId || "") || undefined);
|
||||
if (!session.codexBusy && session.codexThreadId !== activeThreadId) session.setCodexState({ threadId: activeThreadId, turnId: "" });
|
||||
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
|
||||
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId, conversation: session.conversationStateSnapshot });
|
||||
return workspace;
|
||||
};
|
||||
let draftThreadStart: ReturnType<typeof startCodexThread> | null = null;
|
||||
@@ -54,19 +60,45 @@ export function startHttpServer() {
|
||||
const prepareDraftThread = (clientId: string, permission: AgentPermissionMode) => {
|
||||
if (draftThreadStart) return draftThreadStart;
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
emit("agent_bootstrap", { type: "codex.preparing", sourceClientId: clientId });
|
||||
const start = startCodexThread(emit, workspace.workspacePath, permission);
|
||||
draftThreadStart = start;
|
||||
void start.then((thread) => {
|
||||
if (draftThreadStart !== start) return;
|
||||
draftThreadStart = null;
|
||||
const threadId = String((thread as Record<string, unknown>).id || "");
|
||||
if (threadId && !ensureSiteWorkspace(config).activeThreadId) setActiveThread(threadId, { emptyThread: true, draftThread: true, sourceClientId: clientId });
|
||||
}).catch((error) => {
|
||||
if (draftThreadStart === start) draftThreadStart = null;
|
||||
emit("agent_bootstrap", { type: "codex.prepare_failed", sourceClientId: clientId, error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
return start;
|
||||
let prepared!: ReturnType<typeof startCodexThread>;
|
||||
prepared = (async () => {
|
||||
emit("agent_bootstrap", { type: "codex.preparing", sourceClientId: clientId });
|
||||
try {
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath, permission, true);
|
||||
if (draftThreadStart !== prepared) return thread;
|
||||
const threadId = String((thread as Record<string, unknown>).id || "");
|
||||
if (threadId && !ensureSiteWorkspace(config).activeThreadId) {
|
||||
session.completeConversationPreparation(threadId);
|
||||
setActiveThread(threadId, { emptyThread: true, draftThread: true, sourceClientId: clientId }, true);
|
||||
}
|
||||
return thread;
|
||||
} catch (error) {
|
||||
if (draftThreadStart === prepared) {
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
session.failConversationPreparation(text);
|
||||
emit("agent_bootstrap", { type: "codex.prepare_failed", sourceClientId: clientId, error: text });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (draftThreadStart === prepared) draftThreadStart = null;
|
||||
}
|
||||
})();
|
||||
draftThreadStart = prepared;
|
||||
return prepared;
|
||||
};
|
||||
/** 恢复已有线程并等待完整 MCP 清单,供启动恢复和手动切换共用。 */
|
||||
const prepareExistingThread = async (threadId: string, clientId = "", permission: AgentPermissionMode = "request") => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
session.beginConversation({ conversationId: threadId, threadId, sourceClientId: clientId || undefined });
|
||||
emit("agent_bootstrap", { type: "codex.preparing", threadId, sourceClientId: clientId || undefined });
|
||||
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permission, true);
|
||||
session.completeConversationPreparation(threadId);
|
||||
return result;
|
||||
};
|
||||
const failPreparedConversation = (error: unknown, threadId: string, clientId = "") => {
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
session.failConversationPreparation(text);
|
||||
emit("agent_bootstrap", { type: "codex.prepare_failed", threadId, sourceClientId: clientId || undefined, error: text });
|
||||
};
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
@@ -133,7 +165,7 @@ export function startHttpServer() {
|
||||
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
|
||||
app.get("/agent/codex/workspace", (_req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
res.json({ ok: true, workspace });
|
||||
res.json({ ok: true, workspace, conversation: session.conversationStateSnapshot });
|
||||
});
|
||||
app.get("/agent/codex/models", route(async (_req, res) => res.json({ ok: true, ...(await listCodexModels(emit)) })));
|
||||
app.get("/agent/codex/skills", route(async (req, res) => {
|
||||
@@ -204,25 +236,26 @@ export function startHttpServer() {
|
||||
app.get("/agent/codex/threads", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
|
||||
res.json({ ok: true, workspace, ...result });
|
||||
res.json({ ok: true, workspace, conversation: session.conversationStateSnapshot, ...result });
|
||||
}));
|
||||
app.post("/agent/codex/threads/new", codexMutation(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
||||
const activeThreadId = String((thread as Record<string, unknown>).id || "");
|
||||
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true, sourceClientId: String(req.body?.clientId || "") });
|
||||
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
|
||||
}));
|
||||
app.post("/agent/codex/threads/reset", codexMutation((req, res) => {
|
||||
const clientId = String(req.body?.clientId || "");
|
||||
const workspace = setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: clientId });
|
||||
void prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
|
||||
res.json({ ok: true, workspace });
|
||||
session.beginConversation({ sourceClientId: clientId });
|
||||
setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: clientId }, true);
|
||||
const thread = await prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
|
||||
res.json({ ok: true, workspace: ensureSiteWorkspace(config), conversation: session.conversationStateSnapshot, thread: summarizeCodexThread(thread), messages: [] });
|
||||
}));
|
||||
app.post("/agent/codex/threads/reset", codexMutation(async (req, res) => {
|
||||
const clientId = String(req.body?.clientId || "");
|
||||
session.beginConversation({ sourceClientId: clientId });
|
||||
setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: clientId }, true);
|
||||
await prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
|
||||
res.json({ ok: true, workspace: ensureSiteWorkspace(config), conversation: session.conversationStateSnapshot });
|
||||
}));
|
||||
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
||||
res.json({ ok: true, workspace, conversation: session.conversationStateSnapshot, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
||||
}));
|
||||
app.post("/agent/codex/history/ack", (req, res) => {
|
||||
const threadId = String(req.body?.threadId || "");
|
||||
@@ -231,18 +264,23 @@ export function startHttpServer() {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post("/agent/codex/threads/:threadId/resume", codexMutation(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
||||
const nextWorkspace = setActiveThread(threadId, { sourceClientId: String(req.body?.clientId || "") });
|
||||
res.json({ ok: true, workspace: nextWorkspace, ...result });
|
||||
const clientId = String(req.body?.clientId || "");
|
||||
try {
|
||||
const result = await prepareExistingThread(threadId, clientId, permissionMode(req.body?.permissionMode));
|
||||
const nextWorkspace = setActiveThread(threadId, { sourceClientId: clientId }, true);
|
||||
res.json({ ok: true, workspace: nextWorkspace, conversation: session.conversationStateSnapshot, ...result });
|
||||
} catch (error) {
|
||||
failPreparedConversation(error, threadId, clientId);
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
app.post("/agent/codex/threads/:threadId/delete", codexMutation(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
await archiveCodexThread(emit, threadId, workspace.workspacePath);
|
||||
setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "", { sourceClientId: String(req.body?.clientId || "") });
|
||||
res.json({ ok: true });
|
||||
const nextWorkspace = setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "", { sourceClientId: String(req.body?.clientId || "") });
|
||||
res.json({ ok: true, workspace: nextWorkspace, conversation: session.conversationStateSnapshot });
|
||||
}));
|
||||
app.post("/agent/codex/turn", codexMutation(async (req, res) => {
|
||||
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
|
||||
@@ -253,7 +291,15 @@ export function startHttpServer() {
|
||||
if (!clientId || !session.hasClient(clientId)) return res.status(409).json({ ok: false, error: "发起任务的网页已断开,请重新连接后再试" });
|
||||
const requestedThreadId = String(req.body?.threadId || "");
|
||||
const activeThreadId = workspace.activeThreadId || "";
|
||||
if (requestedThreadId !== activeThreadId) return res.status(409).json({ ok: false, error: "当前会话已在其他页面切换,请同步后重试" });
|
||||
const conversation = session.conversationStateSnapshot;
|
||||
const requestedConversationId = String(req.body?.conversationId || "");
|
||||
const expectedRevision = Number(req.body?.expectedRevision || 0);
|
||||
if (requestedThreadId !== activeThreadId || conversation.threadId !== activeThreadId || (requestedConversationId && requestedConversationId !== conversation.conversationId) || (expectedRevision && expectedRevision !== conversation.revision)) {
|
||||
return res.status(409).json({ ok: false, code: "CONVERSATION_STALE", error: "当前会话已切换,已同步最新状态,请确认后重试", state: conversation });
|
||||
}
|
||||
if (!activeThreadId || !["ready", "warning"].includes(conversation.status)) {
|
||||
return res.status(409).json({ ok: false, code: "CONVERSATION_NOT_READY", error: "Codex 对话仍在初始化,请等待 MCP 加载完成", state: conversation });
|
||||
}
|
||||
const model = String(req.body?.model || "") || undefined;
|
||||
const effort = reasoningEffort(req.body?.effort);
|
||||
const skill = req.body?.skill === undefined ? undefined : await resolveCodexSkill(emit, workspace.workspacePath, skillSelector(req.body.skill), true);
|
||||
@@ -262,15 +308,10 @@ export function startHttpServer() {
|
||||
let threadId = activeThreadId;
|
||||
logger.info("Codex turn accepted", { threadId: req.body?.threadId, model: model || "default", reasoningEffort: effort || "default", promptLength: prompt.length, attachmentCount: attachments.length });
|
||||
session.bindClient(clientId);
|
||||
session.markConversationRunning(threadId);
|
||||
session.setCodexState({ busy: true, threadId, turnId: "" });
|
||||
try {
|
||||
let turnId = "";
|
||||
if (!threadId) {
|
||||
const thread = await prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
|
||||
threadId = String((thread as Record<string, unknown>).id || "");
|
||||
setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
|
||||
}
|
||||
session.setCodexState({ busy: true, threadId, turnId: "" });
|
||||
const attachmentRefs = session.setTurnAttachments(clientId, attachments);
|
||||
session.emitThread("chat_message", threadId, {
|
||||
sourceClientId: clientId,
|
||||
@@ -307,6 +348,7 @@ export function startHttpServer() {
|
||||
threadId = actualThreadId;
|
||||
setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
|
||||
}
|
||||
session.markConversationRunning(threadId);
|
||||
session.setCodexState({ busy: true, threadId, turnId: "" });
|
||||
if (threadChanged) {
|
||||
session.emitThread("chat_message", threadId, {
|
||||
@@ -333,12 +375,14 @@ export function startHttpServer() {
|
||||
session.clearTurnAttachments(clientId);
|
||||
if (clientId) session.releaseClient(clientId);
|
||||
session.setCodexState({ busy: false, threadId, turnId });
|
||||
session.finishConversationRun(threadId);
|
||||
},
|
||||
});
|
||||
res.json({ ok: true, threadId });
|
||||
} catch (error) {
|
||||
session.releaseClient(clientId);
|
||||
session.setCodexState({ busy: false, threadId, turnId: "" });
|
||||
session.finishConversationRun(threadId);
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
@@ -346,7 +390,7 @@ export function startHttpServer() {
|
||||
/** 将 Codex 写操作串行化,避免多窗口在异步请求期间交叉修改会话。 */
|
||||
function codexMutation(handler: (req: Request, res: Response) => unknown | Promise<unknown>) {
|
||||
return route(async (req, res) => {
|
||||
if (!session.beginCodexMutation()) return res.status(409).json({ ok: false, error: "Codex 正在运行或正在切换会话,请稍后重试" });
|
||||
if (!session.beginCodexMutation()) return res.status(409).json({ ok: false, code: "CONVERSATION_BUSY", error: "Codex 正在运行或正在切换会话,请稍后重试", state: session.conversationStateSnapshot });
|
||||
try {
|
||||
return await handler(req, res);
|
||||
} finally {
|
||||
@@ -385,6 +429,15 @@ export function startHttpServer() {
|
||||
console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
|
||||
if (logger.enabled) console.log(`Debug log: ${logger.filePath}`);
|
||||
logger.info("Canvas Agent started", { url: config.url, workspace: ensureSiteWorkspace(config).workspacePath, debugLog: logger.filePath });
|
||||
const activeThreadId = initialWorkspace.activeThreadId || "";
|
||||
if (activeThreadId && session.beginCodexMutation()) {
|
||||
void prepareExistingThread(activeThreadId).catch(async (error) => {
|
||||
if (!isRecoverableThreadError(error)) return failPreparedConversation(error, activeThreadId);
|
||||
session.beginConversation();
|
||||
setActiveThread("", { emptyThread: true, draftThread: true }, true);
|
||||
await prepareDraftThread("", "request");
|
||||
}).finally(() => session.endCodexMutation()).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -406,6 +459,20 @@ function reasoningEffort(value: unknown): CodexReasoningEffort | undefined {
|
||||
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max" || value === "ultra" ? value : undefined;
|
||||
}
|
||||
|
||||
function startupStatus(value: unknown): "starting" | "ready" | "failed" | "cancelled" {
|
||||
return value === "starting" || value === "ready" || value === "failed" ? value : "cancelled";
|
||||
}
|
||||
|
||||
function mcpInventory(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((item) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
||||
const server = item as Record<string, unknown>;
|
||||
const name = String(server.name || "");
|
||||
return name ? [{ name, authStatus: String(server.authStatus || "") || undefined }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取浏览器提交的 Skill 选择器;真实路径随后必须通过原生列表校验。 */
|
||||
function skillSelector(value: unknown): CodexSkillSelector {
|
||||
const selector = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
|
||||
@@ -6,9 +6,9 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
# 待测试
|
||||
|
||||
- 画布节点缩放稳定性:选中任意节点并反复拖动四角缩放时,节点工具条应在缩放期间隐藏、松开后恢复,页面不应再出现 `Maximum update depth exceeded`。
|
||||
- Agent MCP 初始化状态:首次进入空白对话或点击「新对话」后,无需先发送消息,对话区应立即显示 Codex 会话准备,并同时列出 `codex_apps`、`infinite-canvas`、`node_repl` 等全部 MCP 服务各自的启动、就绪或失败状态;初始化期间可输入并保留草稿,但发送按钮、上传和回车提交应禁用,全部完成后显示服务数量、恢复发送并复用同一个预热线程;右侧日志应记录同样的真实状态,`starting` 与 `ready` 归入信息且就绪显示绿色成功图标,只有 `failed` 归入错误、`cancelled` 归入警告,模型开始处理后不应残留初始化文案。
|
||||
- Agent MCP 初始化状态:首次进入空白对话、点击「新对话」或恢复历史对话后,无需先发送消息,对话区应立即显示 Codex 会话准备,并列出 `codex_apps`、`infinite-canvas`、`node_repl` 等完整 MCP 服务状态;全部服务进入终态前可输入并保留草稿,但发送按钮、上传和回车提交应禁用;可选 MCP 失败时应提示警告但允许发送,`infinite-canvas` 缺失或失败时应阻止发送并显示明确原因;首轮及后续任务运行期间再次收到的 MCP 状态只进入诊断日志,模型开始处理及回复完成后对话区不应残留初始化卡片。
|
||||
- Agent 模型设置:连接 Canvas Agent 后,输入框左下方应显示当前 Codex 模型与推理强度;模型列表应来自当前账号实际可用模型且不显示内部审查模型或重复项,切换模型后强度选项随模型能力更新且无空白选项,刷新页面后保留选择,发送任务时实际使用所选模型和强度;本地控制台与右侧「日志」应记录本轮使用的模型和推理强度。
|
||||
- Agent 新对话响应:一轮对话完成后点击「新对话」,聊天内容应立即清空并进入空白对话,不出现等待或新建按钮卡顿;后台应立即创建线程并预热 MCP,多个标签页应同步进入空白对话,点击后立即发送也不得误发到上一条会话或重复创建线程。
|
||||
- Agent 新对话响应:一轮对话完成后点击「新对话」,聊天内容应在服务端确认切换后清空并进入空白对话;后台应串行创建线程并预热 MCP,多个标签页应按同一会话版本同步,旧 HTTP 响应不得覆盖较新的 SSE 状态,初始化完成前不能发送到上一条会话或重复创建线程;重启 Agent 后应重新恢复并预热已有活动线程,无法恢复的失效空线程应自动替换为可用的新线程。
|
||||
- Agent 读取画布卡片:读取当前画布完成后,卡片应按非零类型显示文本、图片、配置、视频、音频、分组、其他节点及连线数量,例如「3 个文本、5 张图片、2 个配置、4 条连线」;空画布应显示「当前画布为空」,执行失败时仍应显示错误信息,刷新恢复历史后统计保持一致。
|
||||
- Agent 首次发送响应:在空白新对话中输入内容并按回车后,输入框应立即清空、用户消息应立即出现在对话中,再显示「正在思考」;线程创建或发送失败时,原输入和附件应恢复;任务运行期间输入的新草稿不应在请求成功后被清空。
|
||||
- Agent 动态工具信息:执行内置生图、查看图片、命令、文件修改或其他动态工具时,卡片标题应显示具体工具名称;执行失败时正文应显示真实错误原因,刷新并恢复历史对话后仍应保持一致,不再统一显示「工具操作已完成」。
|
||||
|
||||
@@ -39,7 +39,8 @@ export function AgentChatTimeline({
|
||||
const followMessagesRef = useRef(true);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const streaming = messages.some((message) => message.streamId);
|
||||
const working = bootstrapStatus || workingActivity(messages.at(-1));
|
||||
const showBootstrap = Boolean(bootstrapStatus && !messages.some((message) => message.role === "user" || message.role === "assistant"));
|
||||
const working = showBootstrap ? bootstrapStatus! : workingActivity(messages.at(-1));
|
||||
const updateScrollState = useCallback(() => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
@@ -89,7 +90,7 @@ export function AgentChatTimeline({
|
||||
/>
|
||||
) : null}
|
||||
{pendingApprovals.map((approval) => <AgentApprovalCard key={approval.requestId} approval={approval} theme={theme} onDecision={(decision) => onApprovalDecision(approval, decision)} />)}
|
||||
{(sending || waiting || bootstrapStatus) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} detail={"detail" in working && typeof working.detail === "string" ? working.detail : undefined} status={bootstrapStatus?.status} mcpStatuses={Object.entries(mcpStartupStatuses).map(([name, item]) => ({ name, ...item }))} activityKey={working.key} theme={theme} /> : null}
|
||||
{(sending || waiting || showBootstrap) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} detail={"detail" in working && typeof working.detail === "string" ? working.detail : undefined} status={showBootstrap ? bootstrapStatus?.status : undefined} mcpStatuses={showBootstrap ? Object.entries(mcpStartupStatuses).map(([name, item]) => ({ name, ...item })) : []} activityKey={working.key} theme={theme} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
|
||||
@@ -14,10 +14,10 @@ import { bindPendingAgentUserMessage, deleteAgentThreadMessages, deletePendingAg
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useAgentSkillStore } from "@/stores/use-agent-skill-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAgentStore, type AgentCanvasContext, type AgentChatItem, type AgentModel, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentReasoningEffort, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
import { useAgentStore, type AgentBootstrapStatus, type AgentCanvasContext, type AgentChatItem, type AgentConversationState, type AgentModel, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentReasoningEffort, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
import { type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { isSiteTool, runSiteTool } from "@/lib/agent/agent-site-tools";
|
||||
import { acknowledgeCodexHistory, activateAgentClient, discoverAgentConfig, fetchAgentJson, interruptCodexTurn, postCodexApproval, postState, postToolResult } from "@/services/api/canvas-agent";
|
||||
import { acknowledgeCodexHistory, activateAgentClient, AgentApiError, discoverAgentConfig, fetchAgentJson, interruptCodexTurn, postCodexApproval, postState, postToolResult } from "@/services/api/canvas-agent";
|
||||
import { AgentChatTimeline, AgentTaskProgress, AgentUsageBar } from "./agent-chat";
|
||||
import { AgentChatComposer } from "./agent-chat-composer";
|
||||
import { AgentConnectView } from "./agent-connect-view";
|
||||
@@ -72,22 +72,51 @@ const AGENT_REASONING_EFFORTS = new Set<AgentReasoningEffort>(["minimal", "low",
|
||||
const AGENT_REASONING_LABELS: Record<AgentReasoningEffort, string> = { minimal: "最低", low: "轻度", medium: "中", high: "高", xhigh: "极高", max: "最高", ultra: "Ultra" };
|
||||
|
||||
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
|
||||
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
|
||||
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[]; settledTurnIds?: string[]; historyReady?: boolean };
|
||||
type AgentWorkspaceResponse = { ok?: boolean; workspace?: AgentWorkspace };
|
||||
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; conversation?: AgentConversationState; data?: AgentThreadSummary[] };
|
||||
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; conversation?: AgentConversationState; thread?: AgentThreadSummary; messages?: AgentChatItem[]; settledTurnIds?: string[]; historyReady?: boolean };
|
||||
type AgentWorkspaceResponse = { ok?: boolean; workspace?: AgentWorkspace; conversation?: AgentConversationState };
|
||||
type AgentTurnResponse = { ok?: boolean; threadId?: string };
|
||||
type AgentModelsResponse = { ok?: boolean; data?: AgentModel[] };
|
||||
type AgentCodexState = { busy?: boolean; threadId?: string; turnId?: string };
|
||||
type AgentHelloEvent = { ok?: boolean; protocolVersion?: number; clientId?: string; workspace?: { activeThreadId?: string }; codex?: AgentCodexState; pendingApprovals?: AgentPendingApproval[] };
|
||||
type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; sourceClientId?: string; emptyThread?: boolean; draftThread?: boolean };
|
||||
type AgentHelloEvent = { ok?: boolean; protocolVersion?: number; clientId?: string; workspace?: { activeThreadId?: string }; conversation?: AgentConversationState; codex?: AgentCodexState; pendingApprovals?: AgentPendingApproval[] };
|
||||
type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; sourceClientId?: string; emptyThread?: boolean; draftThread?: boolean; conversation?: AgentConversationState };
|
||||
type AgentChatEvent = { threadId?: string; turnId?: string; sourceClientId?: string; replayed?: boolean; message?: AgentChatItem };
|
||||
type AgentBootstrapEvent = { type?: "codex.preparing" | "codex.prepare_failed" | "mcp.startup"; threadId?: string; name?: string; status?: "starting" | "ready" | "failed" | "cancelled"; error?: string | null; failureReason?: string | null };
|
||||
type AgentBootstrapEvent = { type?: "codex.preparing" | "codex.prepare_failed" | "mcp.startup" | "mcp.complete"; phase?: "preheat" | "runtime"; threadId?: string; name?: string; status?: "starting" | "ready" | "failed" | "cancelled"; error?: string | null; failureReason?: string | null };
|
||||
type AgentClientGlobal = typeof globalThis & { __infiniteCanvasAgentClientIdPromise?: Promise<string> };
|
||||
|
||||
function authoritativeHistoryTurnKeys(threadId: string, settledTurnIds: string[]) {
|
||||
return new Set(settledTurnIds.map((turnId) => `${threadId}\0${turnId}`));
|
||||
}
|
||||
|
||||
function agentErrorState(error: unknown) {
|
||||
return error instanceof AgentApiError ? (error.response as { state?: AgentConversationState }).state : undefined;
|
||||
}
|
||||
|
||||
function conversationBootstrapView(conversation: AgentConversationState) {
|
||||
const mcpStartupStatuses: Record<string, AgentBootstrapStatus> = Object.fromEntries(Object.entries(conversation.mcpStatuses).map(([name, item]) => {
|
||||
const view: AgentBootstrapStatus = item.status === "starting"
|
||||
? { key: `mcp:${name}:starting`, text: `正在启动 MCP:${name}`, detail: "正在建立工具连接并读取可用工具列表", status: "running" }
|
||||
: item.status === "ready"
|
||||
? { key: `mcp:${name}:ready`, text: `MCP 已就绪:${name}`, detail: "工具列表加载完成,可以开始对话", status: "ready" }
|
||||
: { key: `mcp:${name}:${item.status}`, text: item.status === "failed" ? `MCP 启动失败:${name}` : `MCP 启动已取消:${name}`, detail: item.error || "工具服务未能完成初始化", status: "error" };
|
||||
return [name, view];
|
||||
}));
|
||||
const services = Object.values(mcpStartupStatuses);
|
||||
const pending = services.filter((item) => item.status === "running").length;
|
||||
const bootstrapStatus: AgentBootstrapStatus | null = conversation.status === "idle" || conversation.status === "preparing"
|
||||
? services.length
|
||||
? { key: "mcp:starting", text: "正在启动 MCP 服务", detail: pending ? `还有 ${pending} 个工具服务正在初始化` : "正在确认工具服务状态", status: "running" }
|
||||
: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }
|
||||
: conversation.status === "warning"
|
||||
? { key: "mcp:warning", text: "部分 MCP 服务初始化失败", detail: "其余工具已就绪,可以开始对话", status: "error" }
|
||||
: conversation.status === "failed"
|
||||
? { key: "codex:prepare_failed", text: "Codex 对话初始化失败", detail: conversation.error || "无法创建 Codex 会话", status: "error" }
|
||||
: conversation.status === "ready"
|
||||
? { key: "mcp:ready", text: `${services.length} 个 MCP 服务已完成初始化`, detail: "工具列表加载完成,可以开始对话", status: "ready" }
|
||||
: null;
|
||||
return { bootstrapStatus, mcpStartupStatuses };
|
||||
}
|
||||
|
||||
export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { message, modal } = App.useApp();
|
||||
@@ -97,7 +126,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
// 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入,
|
||||
// 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅,
|
||||
// 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。
|
||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, permissionMode, models, model, reasoningEffort, activity, connectError, pendingTool, pendingApprovals } = useAgentStore(
|
||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, permissionMode, models, model, reasoningEffort, activity, conversation, connectError, pendingTool, pendingApprovals } = useAgentStore(
|
||||
useShallow((state) => ({
|
||||
width: state.width,
|
||||
url: state.url,
|
||||
@@ -121,13 +150,15 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
model: state.model,
|
||||
reasoningEffort: state.reasoningEffort,
|
||||
activity: state.activity,
|
||||
conversation: state.conversation,
|
||||
connectError: state.connectError,
|
||||
pendingTool: state.pendingTool,
|
||||
pendingApprovals: state.pendingApprovals,
|
||||
})),
|
||||
);
|
||||
const setAgentState = useAgentStore((state) => state.setAgentState);
|
||||
const agentInitializing = useAgentStore((state) => state.bootstrapStatus?.status === "running");
|
||||
const conversationReady = conversation.status === "ready" || conversation.status === "warning";
|
||||
const conversationBusy = conversation.status === "preparing" || conversation.status === "running";
|
||||
const closePanel = useAgentStore((state) => state.closePanel);
|
||||
const pushMessage = useAgentStore((state) => state.addMessage);
|
||||
const pushEventLog = useAgentStore((state) => state.addEventLog);
|
||||
@@ -238,6 +269,21 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
});
|
||||
return loadThreadsSequenceRef.current;
|
||||
}, [setAgentState]);
|
||||
const applyConversationState = useCallback((next: AgentConversationState, force = false) => {
|
||||
const current = useAgentStore.getState();
|
||||
if (!next?.revision || !force && next.revision <= current.conversation.revision) return false;
|
||||
const conversationChanged = next.conversationId !== current.conversation.conversationId;
|
||||
if (conversationChanged || next.threadId !== current.activeThreadId) {
|
||||
applyWorkspaceChange({
|
||||
activeThreadId: next.threadId,
|
||||
emptyThread: conversationChanged || !current.activeThreadId,
|
||||
draftThread: next.status === "preparing",
|
||||
sourceClientId: next.sourceClientId,
|
||||
});
|
||||
}
|
||||
setAgentState({ conversation: next, ...conversationBootstrapView(next) });
|
||||
return true;
|
||||
}, [applyWorkspaceChange, setAgentState]);
|
||||
const loadThreads = useCallback(async (skipHistory = false, expectedTurnId = "") => {
|
||||
if (!connectedRef.current && !useAgentStore.getState().connected) return;
|
||||
let sequence = ++loadThreadsSequenceRef.current;
|
||||
@@ -245,9 +291,13 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
try {
|
||||
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads`);
|
||||
if (sequence !== loadThreadsSequenceRef.current) return;
|
||||
if (data.conversation) {
|
||||
applyConversationState(data.conversation);
|
||||
sequence = loadThreadsSequenceRef.current;
|
||||
}
|
||||
const current = useAgentStore.getState();
|
||||
const currentThreadId = data.workspace?.activeThreadId ?? current.activeThreadId;
|
||||
if (currentThreadId !== current.activeThreadId) sequence = applyWorkspaceChange({ activeThreadId: currentThreadId });
|
||||
const currentThreadId = current.activeThreadId || data.workspace?.activeThreadId || "";
|
||||
if (!data.conversation && currentThreadId !== current.activeThreadId) sequence = applyWorkspaceChange({ activeThreadId: currentThreadId });
|
||||
if (sequence !== loadThreadsSequenceRef.current || useAgentStore.getState().activeThreadId !== currentThreadId) return;
|
||||
setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "" });
|
||||
if (currentThreadId && !skipHistory) {
|
||||
@@ -261,7 +311,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
} finally {
|
||||
if (sequence === loadThreadsSequenceRef.current && !threadOperationRef.current) setAgentState({ loadingThreads: false });
|
||||
}
|
||||
}, [applyWorkspaceChange, endpoint, loadThreadSnapshot, setAgentState, token]);
|
||||
}, [applyConversationState, applyWorkspaceChange, endpoint, loadThreadSnapshot, setAgentState, token]);
|
||||
// canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -318,8 +368,9 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
}
|
||||
const codex = hello?.codex;
|
||||
const busy = Boolean(codex?.busy);
|
||||
const nextThreadId = hello?.workspace?.activeThreadId ?? useAgentStore.getState().activeThreadId;
|
||||
applyWorkspaceChange({ activeThreadId: nextThreadId });
|
||||
const nextThreadId = hello?.conversation?.threadId ?? hello?.workspace?.activeThreadId ?? useAgentStore.getState().activeThreadId;
|
||||
if (hello?.conversation) applyConversationState(hello.conversation, true);
|
||||
else applyWorkspaceChange({ activeThreadId: nextThreadId });
|
||||
const current = useAgentStore.getState();
|
||||
const nextTurnId = codex?.threadId === nextThreadId ? codex.turnId ?? "" : "";
|
||||
if (nextTurnId) liveTurnKeysRef.current.add(`${nextThreadId}\0${nextTurnId}`);
|
||||
@@ -345,12 +396,14 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
if (!headless) message.success("本地 Agent 已连接");
|
||||
void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null);
|
||||
if (document.visibilityState === "visible" && document.hasFocus()) void activateAgentClient(endpoint, token, clientId);
|
||||
if (!busy && !nextThreadId) {
|
||||
setAgentState({ bootstrapStatus: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }, mcpStartupStatuses: {} });
|
||||
void fetchAgentJson(endpoint, token, "/agent/codex/threads/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId, permissionMode }) }).catch((error) => {
|
||||
setAgentState({ bootstrapStatus: { key: "codex:prepare_failed", text: "Codex 对话初始化失败", detail: error instanceof Error ? error.message : "无法创建 Codex 会话", status: "error" } });
|
||||
addEventLog("Codex 对话初始化失败", error);
|
||||
});
|
||||
if (!busy && !nextThreadId && (!hello?.conversation || hello.conversation.status === "idle")) {
|
||||
void fetchAgentJson<AgentWorkspaceResponse>(endpoint, token, "/agent/codex/threads/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId, permissionMode }) })
|
||||
.then((result) => result.conversation && applyConversationState(result.conversation))
|
||||
.catch((error) => {
|
||||
const state = agentErrorState(error);
|
||||
if (state) applyConversationState(state);
|
||||
addEventLog("Codex 对话初始化失败", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
source.addEventListener("codex_state", (event) => {
|
||||
@@ -411,15 +464,17 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const data = parseEventData<AgentBootstrapEvent>(event);
|
||||
if (!data?.type) return;
|
||||
if (data.type === "codex.preparing") {
|
||||
setAgentState({ bootstrapStatus: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }, mcpStartupStatuses: {} });
|
||||
addEventLog("正在初始化 Codex 对话", "正在创建会话并启动画布工具服务", data);
|
||||
return;
|
||||
}
|
||||
if (data.type === "codex.prepare_failed") {
|
||||
setAgentState({ bootstrapStatus: { key: "codex:prepare_failed", text: "Codex 对话初始化失败", detail: data.error || "无法创建 Codex 会话", status: "error" } });
|
||||
addEventLog("Codex 对话初始化失败", data.error, data);
|
||||
return;
|
||||
}
|
||||
if (data.type === "mcp.complete") {
|
||||
addEventLog("MCP 状态确认完成", "已读取 Codex 返回的完整工具服务清单", data);
|
||||
return;
|
||||
}
|
||||
if (!data.name || !data.status) return;
|
||||
const label = data.name;
|
||||
const status = data.status === "starting"
|
||||
@@ -429,20 +484,12 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
: data.status === "failed"
|
||||
? { text: `MCP 启动失败:${label}`, detail: data.error || "工具服务未能完成初始化", status: "error" as const }
|
||||
: { text: `MCP 启动已取消:${label}`, detail: "工具服务初始化已取消", status: "error" as const };
|
||||
const mcpStartupStatuses = { ...useAgentStore.getState().mcpStartupStatuses, [label]: { key: `mcp:${label}:${data.status}`, ...status } };
|
||||
const services = Object.values(mcpStartupStatuses);
|
||||
const failed = services.some((item) => item.status === "error");
|
||||
const ready = services.length > 0 && services.every((item) => item.status === "ready");
|
||||
setAgentState({
|
||||
mcpStartupStatuses,
|
||||
bootstrapStatus: failed
|
||||
? { key: "mcp:failed", text: "部分 MCP 服务初始化失败", detail: "可以查看下方服务状态和诊断日志", status: "error" }
|
||||
: ready
|
||||
? { key: "mcp:ready", text: `${services.length} 个 MCP 服务已就绪`, detail: "工具列表加载完成,可以开始对话", status: "ready" }
|
||||
: { key: "mcp:starting", text: "正在启动 MCP 服务", detail: `正在初始化 ${services.length} 个工具服务`, status: "running" },
|
||||
});
|
||||
addEventLog(status.text, status.detail, data);
|
||||
});
|
||||
source.addEventListener("conversation_changed", (event) => {
|
||||
const data = parseEventData<AgentConversationState>(event);
|
||||
if (data) enqueueEvent(() => { applyConversationState(data); });
|
||||
});
|
||||
source.addEventListener("workspace_changed", (event) => {
|
||||
const data = parseEventData<AgentWorkspaceEvent>(event);
|
||||
if (!data) return;
|
||||
@@ -455,7 +502,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
if (keepPendingMessage && nextThreadId) {
|
||||
await moveAgentUserMessage(pendingThreadId, nextThreadId, pendingMessage!.clientMessageId || pendingMessage!.itemId || pendingMessage!.id).catch(() => undefined);
|
||||
}
|
||||
applyWorkspaceChange(data);
|
||||
if (data.conversation) applyConversationState(data.conversation);
|
||||
else applyWorkspaceChange(data);
|
||||
if (!data.draftThread) void loadThreads(Boolean(data.emptyThread));
|
||||
});
|
||||
});
|
||||
@@ -545,7 +593,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
loadThreadsSequenceRef.current += 1;
|
||||
useAgentSkillStore.getState().reset();
|
||||
};
|
||||
}, [applyWorkspaceChange, clientReady, enabled, endpoint, loadSkills, loadThreads, message, setAgentState, token]);
|
||||
}, [applyConversationState, applyWorkspaceChange, clientReady, enabled, endpoint, loadSkills, loadThreads, message, setAgentState, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) void loadThreads();
|
||||
@@ -600,7 +648,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
const selectedSkillRevision = skillState.selectionRevision;
|
||||
const requestPrompt = promptWithAttachments(text, files);
|
||||
const currentState = useAgentStore.getState();
|
||||
if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads) return;
|
||||
if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads || !["ready", "warning"].includes(currentState.conversation.status)) return;
|
||||
if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
|
||||
addMessage({ role: "error", title: "图片过大", text: "图片附件超过 30MB,请删减后再发送。" });
|
||||
return;
|
||||
@@ -627,6 +675,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
messageId,
|
||||
clientId: clientIdRef.current,
|
||||
threadId,
|
||||
conversationId: currentBeforeSend.conversation.conversationId,
|
||||
expectedRevision: currentBeforeSend.conversation.revision,
|
||||
permissionMode,
|
||||
model,
|
||||
effort: reasoningEffort,
|
||||
@@ -649,7 +699,10 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
} catch (error) {
|
||||
if (files.length) await deletePendingAgentUserMessage(messageId).catch(() => undefined);
|
||||
const text = error instanceof Error ? error.message : "发送失败";
|
||||
const busy = text.includes("Codex 正在运行");
|
||||
const response = error instanceof AgentApiError ? error.response as { code?: string; state?: AgentConversationState } : undefined;
|
||||
if (response?.state) applyConversationState(response.state);
|
||||
const stale = response?.code === "CONVERSATION_STALE";
|
||||
const busy = response?.code === "CONVERSATION_BUSY" || text.includes("Codex 正在运行");
|
||||
const state = useAgentStore.getState();
|
||||
const removeFailedPending = (messages: AgentChatItem[]) => messages.filter((item) => item.clientMessageId !== messageId || Boolean(item.turnId));
|
||||
threadMessagesRef.current.forEach((messages, cachedThreadId) => {
|
||||
@@ -657,16 +710,17 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
if (next.length !== messages.length) threadMessagesRef.current.set(cachedThreadId, next);
|
||||
});
|
||||
const ownsCurrentThread = state.activeThreadId === (threadId || requestThreadId);
|
||||
const restoreDraft = state.prompt || state.attachments.length ? {} : { prompt, attachments: files };
|
||||
if (ownsCurrentThread) {
|
||||
setAgentState({
|
||||
activity: busy ? "Codex 正在运行" : "发送失败",
|
||||
activity: stale ? "会话已同步" : busy ? "Codex 正在运行" : "发送失败",
|
||||
sending: false,
|
||||
messages: removeFailedPending(state.messages),
|
||||
...(state.prompt || state.attachments.length ? {} : { prompt, attachments: files }),
|
||||
...restoreDraft,
|
||||
});
|
||||
addMessage({ threadId: state.activeThreadId, turnId: "", role: "error", title: busy ? "任务仍在运行" : "发送失败", text });
|
||||
addMessage({ threadId: state.activeThreadId, turnId: "", role: "error", title: stale ? "会话已同步" : busy ? "任务仍在运行" : "发送失败", text });
|
||||
} else {
|
||||
setAgentState({ sending: false, messages: removeFailedPending(state.messages) });
|
||||
setAgentState({ sending: false, messages: removeFailedPending(state.messages), ...restoreDraft });
|
||||
}
|
||||
addEventLog("发送失败", error);
|
||||
}
|
||||
@@ -905,6 +959,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
sending: false,
|
||||
pendingTool: null,
|
||||
pendingApprovals: [],
|
||||
conversation: { revision: 0, conversationId: "", threadId: "", status: "idle", mcpStatuses: {} },
|
||||
bootstrapStatus: null,
|
||||
mcpStartupStatuses: {},
|
||||
...patch,
|
||||
@@ -928,19 +983,18 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
|
||||
const startNewThread = async () => {
|
||||
const current = useAgentStore.getState();
|
||||
if (!current.connected || current.sending || current.waiting || current.loadingThreads) return;
|
||||
if (!current.connected || current.sending || current.waiting || current.loadingThreads || ["preparing", "running"].includes(current.conversation.status)) return;
|
||||
const operation = beginThreadOperation();
|
||||
applyWorkspaceChange({ activeThreadId: "", emptyThread: true, draftThread: true, sourceClientId: clientIdRef.current });
|
||||
clearSkillSelection();
|
||||
setAgentState({ activeTab: "chat", activity: "正在新建对话", bootstrapStatus: { key: "codex:preparing", text: "正在初始化 Codex 对话", detail: "正在创建会话并启动画布工具服务", status: "running" }, mcpStartupStatuses: {} });
|
||||
setAgentState({ activeTab: "chat", activity: "正在新建对话" });
|
||||
try {
|
||||
const result = await fetchAgentJson<AgentWorkspaceResponse>(endpoint, token, "/agent/codex/threads/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId: clientIdRef.current, permissionMode }) });
|
||||
if (threadOperationRef.current !== operation) return;
|
||||
const latest = useAgentStore.getState();
|
||||
if (latest.activeThreadId || latest.messages.length) applyWorkspaceChange({ activeThreadId: result.workspace?.activeThreadId || "", emptyThread: true, draftThread: true, sourceClientId: clientIdRef.current });
|
||||
if (result.conversation) applyConversationState(result.conversation);
|
||||
setAgentState({ activeTab: "chat", activity: "新对话" });
|
||||
} catch (error) {
|
||||
setAgentState({ bootstrapStatus: { key: "codex:prepare_failed", text: "Codex 对话初始化失败", detail: error instanceof Error ? error.message : "无法创建 Codex 会话", status: "error" } });
|
||||
const state = agentErrorState(error);
|
||||
if (state) applyConversationState(state);
|
||||
addEventLog("新建对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "新建对话失败");
|
||||
await loadThreads();
|
||||
@@ -951,13 +1005,16 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
|
||||
const resumeThread = async (threadId: string) => {
|
||||
const current = useAgentStore.getState();
|
||||
if (!current.connected || !threadId || current.sending || current.waiting || current.loadingThreads) return;
|
||||
if (!current.connected || !threadId || current.sending || current.waiting || current.loadingThreads || ["preparing", "running"].includes(current.conversation.status)) return;
|
||||
const operation = beginThreadOperation();
|
||||
try {
|
||||
await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode, clientId: clientIdRef.current }) });
|
||||
const result = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode, clientId: clientIdRef.current }) });
|
||||
if (result.conversation) applyConversationState(result.conversation);
|
||||
await loadThreads();
|
||||
if (useAgentStore.getState().activeThreadId === threadId) setAgentState({ activeTab: "chat", activity: "已恢复会话" });
|
||||
} catch (error) {
|
||||
const state = agentErrorState(error);
|
||||
if (state) applyConversationState(state);
|
||||
addEventLog("恢复对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "恢复对话失败");
|
||||
await loadThreads();
|
||||
@@ -1257,7 +1314,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
right={
|
||||
<>
|
||||
<Tooltip title="新对话" placement="bottom">
|
||||
<Button size="small" type="text" className="!h-8 !w-8 !min-w-8 !px-0 @min-[560px]:!w-auto @min-[560px]:!min-w-0 @min-[560px]:!px-[7px]" aria-label="新对话" disabled={!connected || loadingThreads || sending || waiting} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
|
||||
<Button size="small" type="text" className="!h-8 !w-8 !min-w-8 !px-0 @min-[560px]:!w-auto @min-[560px]:!min-w-0 @min-[560px]:!px-[7px]" aria-label="新对话" disabled={!connected || loadingThreads || sending || waiting || conversationBusy} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
|
||||
<span className="hidden @min-[560px]:inline">新对话</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -1290,7 +1347,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
activeThreadId={activeThreadId}
|
||||
workspacePath={workspacePath}
|
||||
loading={loadingThreads}
|
||||
busy={sending || waiting}
|
||||
busy={sending || waiting || conversationBusy}
|
||||
connected={connected}
|
||||
onRefresh={() => void loadThreads()}
|
||||
onNewThread={() => void startNewThread()}
|
||||
@@ -1314,9 +1371,13 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
<AgentChatComposer
|
||||
prompt={prompt}
|
||||
attachments={attachments.map(agentAttachmentToChatAttachment)}
|
||||
disabled={!connected || agentInitializing}
|
||||
disabled={!connected || !conversationReady || loadingThreads}
|
||||
sending={sending || waiting}
|
||||
placeholder={agentInitializing ? "MCP 初始化中,完成后即可发送" : "询问 Codex,或让它操作网站/画布"}
|
||||
placeholder={conversation.status === "idle" || conversation.status === "preparing"
|
||||
? "MCP 初始化中,完成后即可发送"
|
||||
: conversation.status === "failed"
|
||||
? "Codex 对话初始化失败,请新建或恢复对话"
|
||||
: "询问 Codex,或让它操作网站/画布"}
|
||||
theme={theme}
|
||||
onPromptChange={(prompt) => setAgentState({ prompt })}
|
||||
onSubmit={sendPrompt}
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { AgentReasoningEffort } from "@/stores/use-agent-store";
|
||||
|
||||
type AgentConfigResponse = { ok?: boolean; protocolVersion?: number; url?: string; token?: string; hasToken?: boolean };
|
||||
|
||||
export class AgentApiError<T = unknown> extends Error {
|
||||
constructor(readonly status: number, readonly response: T & { code?: string; error?: string; msg?: string }) {
|
||||
super(response.error || response.msg || "本地 Agent 请求失败");
|
||||
this.name = "AgentApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentSkillScope = "user" | "repo" | "system" | "admin";
|
||||
export type AgentSkillInterface = { displayName?: string | null; shortDescription?: string | null; defaultPrompt?: string | null };
|
||||
export type AgentSkillSummary = {
|
||||
@@ -103,7 +110,7 @@ export async function fetchAgentJson<T>(endpoint: string, token: string, path: s
|
||||
const url = `${endpoint}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
|
||||
const res = await fetch(url, init);
|
||||
const data = (await res.json().catch(() => ({}))) as T & { error?: string; msg?: string };
|
||||
if (!res.ok) throw new Error(data.error || data.msg || "本地 Agent 请求失败");
|
||||
if (!res.ok) throw new AgentApiError(res.status, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops
|
||||
export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number };
|
||||
export type AgentTokenUsage = { input: number; cached: number; output: number };
|
||||
export type AgentBootstrapStatus = { key: string; text: string; detail: string; status: "running" | "ready" | "error" };
|
||||
export type AgentConversationState = {
|
||||
revision: number;
|
||||
conversationId: string;
|
||||
threadId: string;
|
||||
status: "idle" | "preparing" | "ready" | "warning" | "running" | "failed";
|
||||
mcpStatuses: Record<string, { status: "starting" | "ready" | "failed" | "cancelled"; error?: string | null; failureReason?: string | null }>;
|
||||
sourceClientId?: string;
|
||||
error?: string;
|
||||
};
|
||||
export type AgentPanelTab = "chat" | "setup" | "history" | "skills" | "log";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 6000;
|
||||
@@ -59,6 +68,7 @@ type AgentStore = {
|
||||
model: string;
|
||||
reasoningEffort: AgentReasoningEffort | "";
|
||||
activity: string;
|
||||
conversation: AgentConversationState;
|
||||
bootstrapStatus: AgentBootstrapStatus | null;
|
||||
mcpStartupStatuses: Record<string, AgentBootstrapStatus>;
|
||||
connectError: string;
|
||||
@@ -108,6 +118,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
model: typeof window === "undefined" ? "" : localStorage.getItem("canvas-agent-model") || "",
|
||||
reasoningEffort: typeof window === "undefined" ? "" : (localStorage.getItem("canvas-agent-reasoning-effort") as AgentReasoningEffort) || "",
|
||||
activity: "就绪",
|
||||
conversation: { revision: 0, conversationId: "", threadId: "", status: "idle", mcpStatuses: {} },
|
||||
bootstrapStatus: null,
|
||||
mcpStartupStatuses: {},
|
||||
connectError: "",
|
||||
@@ -145,7 +156,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
agentSource = null;
|
||||
if (connectTimer) clearTimeout(connectTimer);
|
||||
connectTimer = null;
|
||||
set({ enabled: false, connected: false, silentConnect: false, activity: "离线", bootstrapStatus: null, mcpStartupStatuses: {}, ...patch });
|
||||
set({ enabled: false, connected: false, silentConnect: false, activity: "离线", conversation: { revision: 0, conversationId: "", threadId: "", status: "idle", mcpStatuses: {} }, bootstrapStatus: null, mcpStartupStatuses: {}, ...patch });
|
||||
},
|
||||
addMessage: (item) => set((state) => ({ messages: [...state.messages, item] })),
|
||||
addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })),
|
||||
|
||||
Reference in New Issue
Block a user