diff --git a/CHANGELOG.md b/CHANGELOG.md index cae4403..787718f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ++ [优化] Agent Markdown 代码块与外链确认弹窗改为紧凑中文样式,改善右侧面板阅读体验。 + [优化] Agent 思考摘要支持 Markdown 渲染,改善强调、列表和代码内容的阅读效果。 + [修复] Agent 对话启用 Codex 可读思考摘要,并修复一轮结束或刷新恢复后思考摘要、命令和工具过程记录缺失的问题。 + [优化] Agent 思考摘要和命令执行改为无边框紧凑折叠行,并保留 Codex 实时返回的具体思考摘要。 diff --git a/canvas-agent/src/agent/codex-client.ts b/canvas-agent/src/agent/codex-client.ts index f625fb4..1f12356 100644 --- a/canvas-agent/src/agent/codex-client.ts +++ b/canvas-agent/src/agent/codex-client.ts @@ -7,7 +7,7 @@ import { VERSION } from "../config.js"; import { logger } from "../utils/logger.js"; import { field, type JsonRecord } from "../utils/value.js"; import type { CodexNotificationParams, CodexPlanUpdate, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js"; -import type { AgentEmit } from "./types.js"; +import type { AgentEmit, AgentPermissionMode } from "./types.js"; type AgentEvent = JsonRecord & { type: string; usage?: unknown }; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void }; @@ -31,6 +31,7 @@ export class CodexAppClient { private completedTurns = new Map(); private pendingDeltas = new Map(); private plansByTurn = new Map(); + private approvalRequests = new Map(); /** 保存 app-server 子进程和事件出口。 */ private constructor(private child: ChildProcess, private emit: AgentEmit) {} @@ -62,15 +63,15 @@ export class CodexAppClient { } /** 创建新的 Codex 线程。 */ - async startThread(cwd?: string) { - const { thread } = await this.request("thread/start", { approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}), threadSource: "user" }); + 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; } /** 恢复已有 Codex 线程。 */ - async resumeThread(threadId: string, cwd?: string) { - const { thread } = await this.request("thread/resume", { threadId, approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}) }); + 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; } @@ -103,9 +104,9 @@ export class CodexAppClient { } /** 启动一个 Codex turn 并等待完成通知。 */ - async startTurn(threadId: string, prompt: string, images: string[], onTurn?: (turnId: string) => void) { + async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void) { this.currentThreadId = threadId; - const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" }); + const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode) }); const turnId = turn.id; if (!turnId) throw new Error("Codex app-server 没有返回 turn id"); this.currentTurnId = turnId; @@ -136,6 +137,19 @@ export class CodexAppClient { } } + /** 回复网页端已经确认的 Codex 权限请求。 */ + resolveApproval(requestId: string, decision: string) { + const request = this.approvalRequests.get(requestId); + if (!request) return false; + this.approvalRequests.delete(requestId); + const permissions = field(request.params, "permissions") || field(request.params, "requestedPermissions"); + const result = request.method === "item/permissions/requestApproval" + ? { permissions: decision === "decline" ? {} : permissions || {}, scope: decision === "acceptForSession" ? "session" : "turn" } + : { decision }; + this.write({ id: request.id, result }); + return true; + } + /** 发送 JSON-RPC 请求并保存待处理 Promise。 */ private request(method: Method, params: CodexRequestParams) { const id = this.nextId++; @@ -187,6 +201,12 @@ export class CodexAppClient { /** 转换并广播 app-server 通知。 */ private handleNotification(method: string, params: JsonRecord) { + if (method === "serverRequest/resolved") { + const requestId = String(field(params, "requestId") || ""); + if (requestId) this.approvalRequests.delete(requestId); + this.emit("codex_approval_resolved", { requestId, ...params }); + return; + } if (!field(params, "threadId") && this.currentThreadId && (method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated")) params = { ...params, threadId: this.currentThreadId }; if (method === "item/agentMessage/delta") { const value = params as unknown as CodexNotificationParams<"item/agentMessage/delta">; @@ -274,9 +294,16 @@ export class CodexAppClient { /** 自动回复 app-server 发起的授权或交互请求。 */ private answerServerRequest(message: JsonRecord) { const method = String(message.method); + const params = (field(message, "params") as JsonRecord) || {}; + if (["item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval"].includes(method)) { + const requestId = String(message.id); + this.approvalRequests.set(requestId, { id: Number(message.id), method, params }); + this.emit("codex_approval", { requestId, method, ...params }); + return; + } const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" }; this.write({ id: message.id, result }); - this.emit("agent_event", { agent: "codex", type: "server.request", method, params: message.params, result }); + this.emit("agent_event", { agent: "codex", type: "server.request", method, params, result }); } /** 完成指定 JSON-RPC 请求。 */ @@ -299,6 +326,7 @@ export class CodexAppClient { this.activeTurns.clear(); this.pendingDeltas.clear(); this.textByItem.clear(); + this.approvalRequests.clear(); this.currentThreadId = ""; this.currentTurnId = ""; } @@ -313,8 +341,19 @@ function canvasAgentMcpCommand() { } /** 生成 Codex app-server 使用的 MCP 配置。 */ -function codexConfig() { - return { model_reasoning_summary: "auto", mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } }; +function codexConfig(permissionMode: AgentPermissionMode) { + return { model_reasoning_summary: "auto", ...(permissionMode === "automatic" ? { approvals_reviewer: "auto_review" } : {}), mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } }; +} + +function threadSettings(permissionMode: AgentPermissionMode) { + return { approvalPolicy: permissionMode === "full" ? "never" as const : "onRequest" as const, sandbox: permissionMode === "full" ? "dangerFullAccess" as const : "workspaceWrite" as const, config: codexConfig(permissionMode) }; +} + +function turnSettings(permissionMode: AgentPermissionMode) { + return { + approvalPolicy: permissionMode === "full" ? "never" as const : "onRequest" as const, + sandboxPolicy: permissionMode === "full" ? { type: "dangerFullAccess" as const } : { type: "workspaceWrite" as const, networkAccess: false }, + }; } /** 将文本和本地图片转换为 Codex turn 输入。 */ diff --git a/canvas-agent/src/agent/codex-protocol.ts b/canvas-agent/src/agent/codex-protocol.ts index 58cab06..1c235ae 100644 --- a/canvas-agent/src/agent/codex-protocol.ts +++ b/canvas-agent/src/agent/codex-protocol.ts @@ -12,8 +12,8 @@ export type CodexTurnInput = | { type: "localImage"; path: string }; type ThreadOptions = { - approvalPolicy: "never"; - sandbox: "workspace-write"; + approvalPolicy: "never" | "onRequest"; + sandbox: "workspaceWrite" | "dangerFullAccess"; config: JsonRecord; cwd?: string; }; @@ -54,7 +54,7 @@ type CodexRequestSpec = { result: Record; }; "turn/start": { - params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" }; + params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "onRequest"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" } }; result: { turn: CodexTurn }; }; "turn/interrupt": { diff --git a/canvas-agent/src/agent/codex.ts b/canvas-agent/src/agent/codex.ts index cb962fe..499d38d 100644 --- a/canvas-agent/src/agent/codex.ts +++ b/canvas-agent/src/agent/codex.ts @@ -6,9 +6,9 @@ import { logger } from "../utils/logger.js"; import { errorMessage, field } from "../utils/value.js"; import { CodexAppClient } from "./codex-client.js"; import { summarizeCodexThread, threadMessages } from "./codex-history.js"; -import type { AgentAttachment, AgentEmit } from "./types.js"; +import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js"; -type CodexRunOptions = { threadId?: string; cwd?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void }; +type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void }; let codexQueue: Promise = Promise.resolve(); let codexApp: CodexAppClient | null = null; @@ -31,20 +31,25 @@ export async function interruptCodexTurn(threadId?: string) { return await codexApp.interruptCurrentTurn(); } +/** 回复当前 app-server 的待处理权限请求。 */ +export async function resolveCodexApproval(requestId: string, decision: string) { + return Boolean(codexApp?.resolveApproval(requestId, decision)); +} + /** 创建新的 Codex 线程并记录当前线程 ID。 */ -export async function startCodexThread(emit: AgentEmit, cwd?: string) { +export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request") { const app = await getCodexApp(emit); - const thread = await app.startThread(cwd); + const thread = await app.startThread(cwd, permissionMode); codexThreadId = String(field(thread, "id") || ""); if (codexThreadId) unmaterializedThreadIds.add(codexThreadId); return thread; } /** 恢复指定 Codex 线程并返回聊天历史。 */ -export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { +export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") { const app = await getCodexApp(emit); await loadCodexThread(emit, threadId, cwd, false); - const thread = await app.resumeThread(threadId, cwd); + const thread = await app.resumeThread(threadId, cwd, permissionMode); assertThreadWorkspace(thread, cwd); codexThreadId = String(field(thread, "id") || threadId); const historyThread = await loadCodexThread(emit, codexThreadId, cwd, true); @@ -110,7 +115,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age options.onThread?.(threadId); unmaterializedThreadIds.delete(threadId); try { - await app.startTurn(threadId, prompt, files, options.onTurn); + await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn); } catch (error) { if (!isRecoverableThreadError(error)) throw error; emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` }); @@ -118,7 +123,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit); options.onThread?.(threadId); unmaterializedThreadIds.delete(threadId); - await app.startTurn(threadId, prompt, files, options.onTurn); + await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn); } } catch (error) { logger.error("Codex turn failed", error); @@ -136,7 +141,7 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, try { const result = await app.readThread(options.threadId, false); assertThreadWorkspace(field(result, "thread") || {}, options.cwd); - const thread = await app.resumeThread(options.threadId, options.cwd); + const thread = await app.resumeThread(options.threadId, options.cwd, options.permissionMode || "request"); assertThreadWorkspace(thread, options.cwd); codexThreadId = String(field(thread, "id") || options.threadId); return codexThreadId; @@ -146,7 +151,7 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, } } if (!codexThreadId) { - const thread = await app.startThread(options.cwd); + const thread = await app.startThread(options.cwd, options.permissionMode || "request"); codexThreadId = String(field(thread, "id") || ""); if (codexThreadId) unmaterializedThreadIds.add(codexThreadId); } diff --git a/canvas-agent/src/agent/types.ts b/canvas-agent/src/agent/types.ts index 4febde1..43cd85a 100644 --- a/canvas-agent/src/agent/types.ts +++ b/canvas-agent/src/agent/types.ts @@ -3,3 +3,6 @@ export type AgentEmit = (type: string, payload: unknown) => void; /** 用户随当前 Agent 消息上传的附件。 */ export type AgentAttachment = { id?: string; name?: string; type?: string; size?: number; width?: number; height?: number; dataUrl?: string }; + +/** Codex 文件、命令和网络权限模式。 */ +export type AgentPermissionMode = "request" | "automatic" | "full"; diff --git a/canvas-agent/src/server/http.ts b/canvas-agent/src/server/http.ts index cf6036f..7c87ebe 100644 --- a/canvas-agent/src/server/http.ts +++ b/canvas-agent/src/server/http.ts @@ -1,8 +1,8 @@ import express, { type NextFunction, type Request, type Response } from "express"; import { runClaudeTurn } from "../agent/claude.js"; -import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js"; -import type { AgentAttachment } from "../agent/types.js"; +import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js"; +import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js"; import { CanvasSession } from "../canvas/session.js"; import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js"; import { logger } from "../utils/logger.js"; @@ -82,10 +82,10 @@ export function startHttpServer() { const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") }); res.json({ ok: true, workspace, ...result }); })); - app.post("/agent/codex/threads/new", route(async (_req, res) => { + app.post("/agent/codex/threads/new", route(async (req, res) => { if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" }); const workspace = ensureSiteWorkspace(config); - const thread = await startCodexThread(emit, workspace.workspacePath); + const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode)); const activeThreadId = String((thread as Record).id || ""); const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true }); res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] }); @@ -104,7 +104,7 @@ export function startHttpServer() { if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" }); const workspace = ensureSiteWorkspace(config); const threadId = routeParam(req.params.threadId); - const result = await resumeCodexThread(emit, threadId, workspace.workspacePath); + const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permissionMode(req.body?.permissionMode)); const nextWorkspace = setActiveThread(threadId); res.json({ ok: true, workspace: nextWorkspace, ...result }); })); @@ -129,7 +129,7 @@ export function startHttpServer() { let threadId = String(req.body?.threadId || workspace.activeThreadId || ""); let turnId = ""; if (!threadId) { - const thread = await startCodexThread(emit, workspace.workspacePath); + const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode)); threadId = String((thread as Record).id || ""); setActiveThread(threadId, { emptyThread: true }); } else if (threadId !== workspace.activeThreadId) { @@ -150,6 +150,7 @@ export function startHttpServer() { void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), turnEmit, attachments, { threadId, cwd: workspace.workspacePath, + permissionMode: permissionMode(req.body?.permissionMode), appEmit: emit, onStart: clientId ? () => session.bindClient(clientId) : undefined, onThread: (actualThreadId) => { @@ -181,6 +182,12 @@ export function startHttpServer() { throw error; } })); + app.post("/agent/codex/approval", route(async (req, res) => { + const decision = String(req.body?.decision || ""); + if (!["accept", "acceptForSession", "decline", "cancel"].includes(decision)) return res.status(400).json({ ok: false, error: "无效的审批决定" }); + const ok = await resolveCodexApproval(String(req.body?.requestId || ""), decision); + res.status(ok ? 200 : 409).json({ ok, ...(ok ? {} : { error: "审批请求已失效" }) }); + })); app.post("/agent/codex/interrupt", route(async (req, res) => res.json({ ok: await interruptCodexTurn(String(req.body?.threadId || "")) }))); app.post("/agent/claude/turn", (req, res) => { runClaudeTurn(String(req.body?.prompt || ""), emit); @@ -214,6 +221,10 @@ function routeParam(value: string | string[]) { return Array.isArray(value) ? value[0] || "" : value; } +function permissionMode(value: unknown): AgentPermissionMode { + return value === "automatic" || value === "full" ? value : "request"; +} + /** 结合服务配置解析当前请求 URL。 */ function requestUrl(req: Request, config: CanvasAgentConfig) { return new URL(req.originalUrl || req.url || "/", config.url); diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 85f4961..123b9e8 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- Agent Markdown 样式:右侧 Agent 回复中的代码块应使用紧凑细边框样式,不再出现大面积空白,只保留复制操作;行内代码、链接在浅色和深色主题下应清晰;点击外部链接后应显示中文紧凑确认弹窗,长路径能够正常换行且复制、继续打开和关闭操作可用。 - Agent 工具确认模式:右侧面板标题栏不应再显示「工具确认」开关;对话输入框左下方应显示确认模式选择,默认选中「自动确认」,画布写入工具应直接执行;切换为「手动确认」后,画布写入工具应在对话中展示等待确认卡片,并支持批准或拒绝。 - Canvas Agent Codex 升级:启动 Canvas Agent 后应能正常连接 Codex、创建或恢复会话、发送消息并调用画布工具,实际运行的 Codex CLI 版本应为 0.145.0;运行中停止任务应只中断当前 turn,随后无需重启 Agent 即可继续发送新任务。 - Canvas Agent Debug:使用 `npx -y @basketikun/canvas-agent --debug` 启动后,终端应按“时间 级别 消息 详情”的纯文本单行格式输出日志并显示日志文件路径,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;普通启动保持原有简洁输出,日志中不应出现连接 token 或图片 Data URL 原文。 diff --git a/web/src/components/agent/agent-api.ts b/web/src/components/agent/agent-api.ts index aa2f5fa..34289da 100644 --- a/web/src/components/agent/agent-api.ts +++ b/web/src/components/agent/agent-api.ts @@ -22,6 +22,10 @@ export async function postToolResult(endpoint: string, token: string, clientId: await fetch(`${endpoint}/canvas/result?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); } +export async function postCodexApproval(endpoint: string, token: string, requestId: string, decision: "accept" | "acceptForSession" | "decline") { + await fetchAgentJson(endpoint, token, "/agent/codex/approval", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId, decision }) }); +} + export async function fetchAgentJson(endpoint: string, token: string, path: string, init?: RequestInit) { const url = `${endpoint}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`; const res = await fetch(url, init); @@ -40,4 +44,3 @@ export async function discoverAgentConfig(endpoint: string) { return null; } } - diff --git a/web/src/components/agent/agent-chat-composer.tsx b/web/src/components/agent/agent-chat-composer.tsx index d9a01c8..cb3e8bf 100644 --- a/web/src/components/agent/agent-chat-composer.tsx +++ b/web/src/components/agent/agent-chat-composer.tsx @@ -1,9 +1,10 @@ import { useRef, type ReactNode } from "react"; import { Button, Dropdown, Tooltip } from "antd"; -import { ArrowUp, Check, ChevronUp, Hand, ImagePlus, LoaderCircle, RefreshCw, Square, X } from "lucide-react"; +import { ArrowUp, Check, ChevronUp, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { isPlainEnterKey } from "@/lib/keyboard-event"; +import type { AgentPermissionMode } from "@/stores/use-agent-store"; import type { AgentChatAttachment } from "./agent-chat-message"; export function AgentChatComposer({ @@ -20,6 +21,8 @@ export function AgentChatComposer({ onRemoveAttachment, confirmTools, onConfirmToolsChange, + permissionMode, + onPermissionModeChange, left, }: { prompt: string; @@ -35,6 +38,8 @@ export function AgentChatComposer({ onRemoveAttachment?: (id: string) => void; confirmTools?: boolean; onConfirmToolsChange?: (confirmTools: boolean) => void; + permissionMode?: AgentPermissionMode; + onPermissionModeChange?: (permissionMode: AgentPermissionMode) => void; left?: ReactNode; }) { const fileInputRef = useRef(null); @@ -89,6 +94,7 @@ export function AgentChatComposer({ ) : null} {onConfirmToolsChange ? : null} + {permissionMode && onPermissionModeChange ? : null} {left}
@@ -104,6 +110,35 @@ export function AgentChatComposer({ ); } +function PermissionModeMenu({ permissionMode, theme, onChange }: { permissionMode: AgentPermissionMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (permissionMode: AgentPermissionMode) => void }) { + const current = permissionOptions.find((item) => item.key === permissionMode) || permissionOptions[0]; + return ( + ({ + key: item.key, + label: , + onClick: () => onChange(item.key), + })), + }} + > + + + ); +} + +const permissionOptions: Array<{ key: AgentPermissionMode; title: string; shortTitle: string; description: string; icon: ReactNode }> = [ + { key: "request", title: "请求批准", shortTitle: "请求批准", description: "编辑工作区外文件或联网时始终询问", icon: }, + { key: "automatic", title: "自动审查", shortTitle: "自动审查", description: "由 Codex 审查风险操作,必要时再询问", icon: }, + { key: "full", title: "完全访问权限", shortTitle: "完全访问", description: "不受限制地访问网络和本机文件", icon: }, +]; + function ToolConfirmationMenu({ confirmTools, theme, onChange }: { confirmTools: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (confirmTools: boolean) => void }) { return ( {item.text}
) : ( - {item.text} + {item.text} )} {item.attachments?.length ? : null} {item.meta ?
{item.meta}
: null} @@ -83,6 +99,30 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro ); } +export function AgentApprovalCard({ approval, theme, onDecision }: { approval: AgentPendingApproval; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onDecision: (decision: "accept" | "acceptForSession" | "decline") => void }) { + const isFile = approval.method === "item/fileChange/requestApproval"; + const isNetwork = Boolean(approval.networkApprovalContext); + const title = isNetwork ? "请求网络访问" : isFile ? "请求编辑文件" : approval.method === "item/permissions/requestApproval" ? "请求扩展权限" : "请求执行命令"; + const target = isNetwork ? approvalTarget(approval.networkApprovalContext) : isFile ? approval.grantRoot || approval.cwd : commandText(approval.command) || approval.cwd; + return ( +
+
+ +
+
{title}
+ {approval.reason ?
{approval.reason}
: null} + {target ?
{target}
: null} +
+
+
+ + + +
+
+ ); +} + export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const plan = planDetail(detail); if (plan) return ; @@ -122,7 +162,7 @@ function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?:
- {text} + {text}
); @@ -205,6 +245,18 @@ function waitingTime(seconds: number) { return `已等待 ${minutes} 分 ${seconds % 60} 秒`; } +function commandText(value: unknown) { + if (Array.isArray(value)) return value.map(String).join(" "); + return typeof value === "string" ? value : ""; +} + +function approvalTarget(value: unknown) { + const host = String(objectField(value, "host") || ""); + const protocol = String(objectField(value, "protocol") || ""); + const port = String(objectField(value, "port") || ""); + return host ? `${protocol ? `${protocol}://` : ""}${host}${port ? `:${port}` : ""}` : ""; +} + type PlanTask = { step: string; status: string }; type PlanDetail = { status: string; tasks: PlanTask[]; explanation?: string }; type UserDetail = { kind?: string; status?: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> }; diff --git a/web/src/components/agent/agent-chat.tsx b/web/src/components/agent/agent-chat.tsx index 55cd31e..12bcc27 100644 --- a/web/src/components/agent/agent-chat.tsx +++ b/web/src/components/agent/agent-chat.tsx @@ -5,8 +5,8 @@ import { motion, useSpring, useTransform } from "motion/react"; import { canvasThemes } from "@/lib/canvas-theme"; import { summarizeCanvasAgentOps } from "@/lib/canvas/canvas-agent-ops"; -import { useAgentStore, type AgentChatItem, type AgentPendingToolCall, type AgentTokenUsage } from "@/stores/use-agent-store"; -import { AgentChatMessage, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message"; +import { useAgentStore, type AgentChatItem, type AgentPendingApproval, type AgentPendingToolCall, type AgentTokenUsage } from "@/stores/use-agent-store"; +import { AgentApprovalCard, AgentChatMessage, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage } from "./agent-chat-message"; import { agentMessageToChatMessage, currentPlanMessage, isPlanMessage, latestPlanMessage, toolCallDetail, toolName, workingActivity } from "./agent-event-formatters"; const SCROLL_BOTTOM_THRESHOLD = 48; @@ -14,17 +14,21 @@ const SCROLL_BOTTOM_THRESHOLD = 48; export function AgentChatTimeline({ theme, pendingTool, + pendingApprovals, sending, waiting, onRejectTool, onApproveTool, + onApprovalDecision, }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; pendingTool: AgentPendingToolCall | null; + pendingApprovals: AgentPendingApproval[]; sending: boolean; waiting: boolean; onRejectTool: () => void; onApproveTool: () => void; + onApprovalDecision: (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => void; }) { const messages = useAgentStore((state) => state.messages); const listRef = useRef(null); @@ -49,7 +53,7 @@ export function AgentChatTimeline({ useEffect(() => { const frame = requestAnimationFrame(() => (followMessagesRef.current ? scrollToBottom("auto") : updateScrollState())); return () => cancelAnimationFrame(frame); - }, [messages, pendingTool, scrollToBottom, updateScrollState, waiting]); + }, [messages, pendingApprovals, pendingTool, scrollToBottom, updateScrollState, waiting]); return (
@@ -65,7 +69,8 @@ export function AgentChatTimeline({ onApprove={onApproveTool} /> ) : null} - {(sending || waiting) && !streaming && !pendingTool ? : null} + {pendingApprovals.map((approval) => onApprovalDecision(approval, decision)} />)} + {(sending || waiting) && !streaming && !pendingTool && !pendingApprovals.length ? : null}
{showScrollToBottom ? ( diff --git a/web/src/components/agent/local-agent-panel.tsx b/web/src/components/agent/local-agent-panel.tsx index 65aaf80..7b37040 100644 --- a/web/src/components/agent/local-agent-panel.tsx +++ b/web/src/components/agent/local-agent-panel.tsx @@ -12,10 +12,10 @@ import { uploadImage } from "@/services/image-storage"; import { deleteAgentThreadMessages, readAgentUserMessages, saveAgentUserMessage } from "@/services/agent-chat-storage"; import { useThemeStore } from "@/stores/use-theme-store"; import { useShallow } from "zustand/react/shallow"; -import { useAgentStore, type AgentCanvasContext, type AgentChatItem, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store"; +import { useAgentStore, type AgentCanvasContext, type AgentChatItem, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentThreadSummary } from "@/stores/use-agent-store"; import {summarizeCanvasAgentOps, type CanvasAgentOp, CanvasAgentSnapshot} from "@/lib/canvas/canvas-agent-ops"; import { isSiteTool, runSiteTool } from "@/lib/agent/agent-site-tools"; -import { activateAgentClient, discoverAgentConfig, fetchAgentJson, postState, postToolResult } from "./agent-api"; +import { activateAgentClient, discoverAgentConfig, fetchAgentJson, postCodexApproval, postState, postToolResult } from "./agent-api"; import { AgentChatTimeline, AgentTaskProgress, AgentUsageBar } from "./agent-chat"; import { AgentChatComposer } from "./agent-chat-composer"; import { AgentConnectView } from "./agent-connect-view"; @@ -78,7 +78,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, activity, connectError, pendingTool } = useAgentStore( + const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, permissionMode, activity, connectError, pendingTool, pendingApprovals } = useAgentStore( useShallow((state) => ({ width: state.width, url: state.url, @@ -97,9 +97,11 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? loadingThreads: state.loadingThreads, activeTab: state.activeTab, confirmTools: state.confirmTools, + permissionMode: state.permissionMode, activity: state.activity, connectError: state.connectError, pendingTool: state.pendingTool, + pendingApprovals: state.pendingApprovals, })), ); const setAgentState = useAgentStore((state) => state.setAgentState); @@ -198,6 +200,16 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? const data = parseEventData(event); if (data) void handleToolCall(endpoint, token, data); }); + source.addEventListener("codex_approval", (event) => { + const data = parseEventData(event); + if (!data || !isCurrentThreadEvent(data)) return; + setAgentState({ pendingApprovals: [...useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== data.requestId), data], activity: "等待权限确认" }); + addEventLog("等待权限确认", data.reason || data.method, data); + }); + source.addEventListener("codex_approval_resolved", (event) => { + const data = parseEventData<{ requestId?: string }>(event); + if (data?.requestId) setAgentState({ pendingApprovals: useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== data.requestId) }); + }); source.addEventListener("agent_event", (event) => { const data = parseEventData(event); if (data) enqueueEvent(() => { @@ -212,7 +224,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? const current = useAgentStore.getState(); const keepPendingMessage = Boolean(data.emptyThread && current.sending && current.activeThreadId === nextThreadId); pendingToolRef.current = null; - setAgentState({ activeThreadId: nextThreadId, ...(keepPendingMessage ? {} : { messages: [] }), tokenUsage: null, pendingTool: null }); + setAgentState({ activeThreadId: nextThreadId, ...(keepPendingMessage ? {} : { messages: [] }), tokenUsage: null, pendingTool: null, pendingApprovals: [] }); await loadThreads(Boolean(data.emptyThread)); }); }); @@ -291,7 +303,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? let threadId = useAgentStore.getState().activeThreadId; try { if (!threadId) { - const created = await fetchAgentJson(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }); + const created = await fetchAgentJson(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode }) }); threadId = created.thread?.id || created.workspace?.activeThreadId || ""; if (!threadId) throw new Error("新建对话失败"); setAgentState({ activeThreadId: threadId, messages: [], tokenUsage: null }); @@ -308,6 +320,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? messageId, clientId: clientIdRef.current, threadId, + permissionMode, attachments: files.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, dataUrl })), }), }); @@ -462,6 +475,33 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? await runToolCall(endpoint, token, tool); }; + const decideApproval = async (approval: AgentPendingApproval, decision: "accept" | "acceptForSession" | "decline") => { + setAgentState({ pendingApprovals: useAgentStore.getState().pendingApprovals.filter((item) => item.requestId !== approval.requestId), activity: decision === "decline" ? "已拒绝权限请求" : "Codex 正在运行" }); + try { + await postCodexApproval(endpoint, token, approval.requestId, decision); + addEventLog(decision === "decline" ? "已拒绝权限" : "已批准权限", approval.reason || approval.method, approval); + } catch (error) { + addEventLog("权限审批失败", error); + message.error(error instanceof Error ? error.message : "权限审批失败"); + } + }; + + const changePermissionMode = (nextMode: AgentPermissionMode) => { + const apply = () => { + localStorage.setItem("canvas-agent-permission-mode", nextMode); + setAgentState({ permissionMode: nextMode }); + }; + if (nextMode !== "full") return apply(); + modal.confirm({ + title: "启用完全访问权限", + content: "Codex 将不受沙箱限制,可访问互联网及本机任意文件。请仅在信任当前任务时使用。", + okText: "启用完全访问", + okType: "danger", + cancelText: "取消", + onOk: apply, + }); + }; + const toggleAgentConnection = async ({ silent = false }: { silent?: boolean } = {}) => { if (enabled) { clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" }); @@ -525,6 +565,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? waiting: false, sending: false, pendingTool: null, + pendingApprovals: [], ...patch, }); pendingToolRef.current = null; @@ -534,7 +575,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? if (!connected || sending || waiting) return; setAgentState({ loadingThreads: true }); try { - const data = await fetchAgentJson(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }); + const data = await fetchAgentJson(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode }) }); setAgentState({ activeThreadId: data.thread?.id || data.workspace?.activeThreadId || "", messages: [], tokenUsage: null, activeTab: "chat", activity: "新对话" }); } catch (error) { addEventLog("新建对话失败", error); @@ -550,7 +591,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? try { const current = useAgentStore.getState(); const [data, storedMessages] = await Promise.all([ - fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }), + fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ permissionMode }) }), readAgentUserMessages(threadId), ]); const localMessages = current.activeThreadId === threadId ? current.messages : []; @@ -799,7 +840,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded? /> ) : ( <> - + {tokenUsage ? : null} setAgentState({ confirmTools })} + permissionMode={permissionMode} + onPermissionModeChange={changePermissionMode} left={ attachments.length ? ( diff --git a/web/src/stores/use-agent-store.ts b/web/src/stores/use-agent-store.ts index 2f256f5..446ce86 100644 --- a/web/src/stores/use-agent-store.ts +++ b/web/src/stores/use-agent-store.ts @@ -7,6 +7,8 @@ export type AgentAttachment = { id: string; name: string; type: string; size: nu export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; historyText?: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string }; export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown }; export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record }; +export type AgentPermissionMode = "request" | "automatic" | "full"; +export type AgentPendingApproval = { requestId: string; method: string; threadId?: string; turnId?: string; itemId?: string; reason?: string; command?: unknown; cwd?: string; grantRoot?: string; networkApprovalContext?: unknown; permissions?: unknown }; export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot; undoOps: () => CanvasAgentSnapshot | null; canUndo: boolean }; 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 }; @@ -40,9 +42,11 @@ type AgentStore = { loadingThreads: boolean; activeTab: AgentPanelTab; confirmTools: boolean; + permissionMode: AgentPermissionMode; activity: string; connectError: string; pendingTool: AgentPendingToolCall | null; + pendingApprovals: AgentPendingApproval[]; setAgentState: (patch: Partial>) => void; openPanel: () => void; closePanel: () => void; @@ -81,9 +85,11 @@ export const useAgentStore = create((set, get) => ({ loadingThreads: false, activeTab: "setup", confirmTools: false, + permissionMode: typeof window === "undefined" ? "request" : (localStorage.getItem("canvas-agent-permission-mode") as AgentPermissionMode) || "request", activity: "就绪", connectError: "", pendingTool: null, + pendingApprovals: [], setAgentState: (patch) => set(patch), openPanel: () => set({ panelOpen: true, panelMounted: true, panelClosing: false }), closePanel: () => { diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index dc5d5dc..18c0b3b 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -471,3 +471,125 @@ animation: none; mix-blend-mode: normal; } + +@layer components { + .agent-streamdown { + overflow-wrap: anywhere; + } + + .agent-streamdown > :first-child { + margin-top: 0; + } + + .agent-streamdown > :last-child { + margin-bottom: 0; + } + + .agent-streamdown [data-streamdown="code-block"] { + margin-block: 0.75rem; + overflow: hidden; + border-color: color-mix(in srgb, currentColor 13%, transparent); + border-radius: 0.75rem; + background: color-mix(in srgb, currentColor 3%, transparent); + } + + .agent-streamdown [data-streamdown="code-block-header"] { + min-height: 0; + padding: 0.45rem 0.75rem; + border-color: color-mix(in srgb, currentColor 9%, transparent); + background: transparent; + font-size: 0.625rem; + line-height: 1rem; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.52; + } + + .agent-streamdown [data-streamdown="code-block-body"] { + padding: 0.8rem 0.9rem; + background: transparent !important; + font-size: 0.75rem; + line-height: 1.25rem; + } + + .agent-streamdown [data-streamdown="code-block-actions"] { + gap: 0; + margin: 0.35rem; + padding: 0; + border: 0; + border-radius: 0.375rem; + background: color-mix(in srgb, var(--background) 88%, transparent); + box-shadow: none; + } + + .agent-streamdown [data-streamdown="code-block-actions"] button { + padding: 0.35rem; + border-radius: 0.375rem; + } + + .agent-streamdown [data-streamdown="inline-code"] { + padding: 0.12rem 0.35rem; + border: 0; + border-radius: 0.3rem; + background: color-mix(in srgb, currentColor 7%, transparent); + font-size: 0.88em; + } + + .agent-streamdown [data-streamdown="link"] { + color: inherit; + text-decoration-color: color-mix(in srgb, currentColor 35%, transparent); + text-underline-offset: 0.2em; + } + + [data-streamdown="link-safety-modal"] { + padding: 1rem; + background: rgb(28 25 23 / 24%); + backdrop-filter: blur(8px); + } + + [data-streamdown="link-safety-modal"] > div { + max-width: 24rem; + gap: 1rem; + padding: 1.25rem; + border-color: color-mix(in srgb, currentColor 12%, transparent); + border-radius: 1rem; + box-shadow: 0 20px 60px rgb(28 25 23 / 18%); + } + + [data-streamdown="link-safety-modal"] > div > button { + top: 0.9rem; + right: 0.9rem; + } + + [data-streamdown="link-safety-modal"] > div > div:first-of-type { + gap: 0.25rem; + padding-right: 2rem; + } + + [data-streamdown="link-safety-modal"] > div > div:first-of-type > div { + gap: 0.5rem; + font-size: 1rem; + } + + [data-streamdown="link-safety-modal"] > div > div:nth-of-type(2) { + max-height: 7rem; + padding: 0.75rem; + border: 1px solid color-mix(in srgb, currentColor 9%, transparent); + background: color-mix(in srgb, currentColor 4%, transparent); + font-size: 0.7rem; + line-height: 1.15rem; + overflow-wrap: anywhere; + } + + [data-streamdown="link-safety-modal"] > div > div:last-child { + justify-content: flex-end; + } + + [data-streamdown="link-safety-modal"] > div > div:last-child button { + flex: none; + min-width: 7rem; + padding: 0.5rem 0.8rem; + border-radius: 0.6rem; + font-size: 0.75rem; + } +}