mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-31 05:31:13 +08:00
feat(agent): implement permission mode handling for Codex threads and approvals
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
+ [优化] Agent Markdown 代码块与外链确认弹窗改为紧凑中文样式,改善右侧面板阅读体验。
|
||||
+ [优化] Agent 思考摘要支持 Markdown 渲染,改善强调、列表和代码内容的阅读效果。
|
||||
+ [修复] Agent 对话启用 Codex 可读思考摘要,并修复一轮结束或刷新恢复后思考摘要、命令和工具过程记录缺失的问题。
|
||||
+ [优化] Agent 思考摘要和命令执行改为无边框紧凑折叠行,并保留 Codex 实时返回的具体思考摘要。
|
||||
|
||||
@@ -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<string, Error | null>();
|
||||
private pendingDeltas = new Map<string, PendingDelta>();
|
||||
private plansByTurn = new Map<string, CodexPlanUpdate>();
|
||||
private approvalRequests = new Map<string, { id: number; method: string; params: JsonRecord }>();
|
||||
|
||||
/** 保存 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 extends CodexRequestMethod>(method: Method, params: CodexRequestParams<Method>) {
|
||||
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 输入。 */
|
||||
|
||||
@@ -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<string, never>;
|
||||
};
|
||||
"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": {
|
||||
|
||||
@@ -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<unknown> = 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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, unknown>).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<string, unknown>).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);
|
||||
|
||||
@@ -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 原文。
|
||||
|
||||
@@ -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<T>(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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
@@ -89,6 +94,7 @@ export function AgentChatComposer({
|
||||
</>
|
||||
) : null}
|
||||
{onConfirmToolsChange ? <ToolConfirmationMenu confirmTools={Boolean(confirmTools)} theme={theme} onChange={onConfirmToolsChange} /> : null}
|
||||
{permissionMode && onPermissionModeChange ? <PermissionModeMenu permissionMode={permissionMode} theme={theme} onChange={onPermissionModeChange} /> : null}
|
||||
{left}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
@@ -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 (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
placement="topLeft"
|
||||
menu={{
|
||||
items: permissionOptions.map((item) => ({
|
||||
key: item.key,
|
||||
label: <ConfirmationOption icon={item.icon} title={item.title} description={item.description} selected={permissionMode === item.key} />,
|
||||
onClick: () => onChange(item.key),
|
||||
})),
|
||||
}}
|
||||
>
|
||||
<button type="button" className="flex h-9 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: permissionMode === "full" ? "#ea580c" : theme.node.text }} aria-label="选择 Codex 权限模式">
|
||||
{current.icon}
|
||||
<span>{current.shortTitle}</span>
|
||||
<ChevronUp className="size-3 opacity-50" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
const permissionOptions: Array<{ key: AgentPermissionMode; title: string; shortTitle: string; description: string; icon: ReactNode }> = [
|
||||
{ key: "request", title: "请求批准", shortTitle: "请求批准", description: "编辑工作区外文件或联网时始终询问", icon: <ShieldAlert className="size-3.5" /> },
|
||||
{ key: "automatic", title: "自动审查", shortTitle: "自动审查", description: "由 Codex 审查风险操作,必要时再询问", icon: <ShieldCheck className="size-3.5" /> },
|
||||
{ key: "full", title: "完全访问权限", shortTitle: "完全访问", description: "不受限制地访问网络和本机文件", icon: <ShieldOff className="size-3.5" /> },
|
||||
];
|
||||
|
||||
function ToolConfirmationMenu({ confirmTools, theme, onChange }: { confirmTools: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (confirmTools: boolean) => void }) {
|
||||
return (
|
||||
<Dropdown
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Button, Image } from "antd";
|
||||
import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, FilePenLine, FileText, ListChecks, LoaderCircle, Search, TerminalSquare, Wrench, XCircle } from "lucide-react";
|
||||
import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, FilePenLine, FileText, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { AgentPendingApproval } from "@/stores/use-agent-store";
|
||||
|
||||
const streamdownProps = {
|
||||
className: "agent-streamdown",
|
||||
controls: { code: { copy: true, download: false }, table: { copy: true, download: false, fullscreen: false } },
|
||||
lineNumbers: false,
|
||||
translations: {
|
||||
close: "关闭",
|
||||
copied: "已复制",
|
||||
copyCode: "复制代码",
|
||||
copyLink: "复制链接",
|
||||
externalLinkWarning: "即将打开以下外部链接,请确认链接可信。",
|
||||
openExternalLink: "打开外部链接?",
|
||||
openLink: "继续打开",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type AgentChatAttachment = { id: string; name: string; url: string };
|
||||
export type AgentChatMessageItem = {
|
||||
@@ -45,7 +61,7 @@ export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: {
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap break-words">{item.text}</div>
|
||||
) : (
|
||||
<Streamdown animated isAnimating={!!item.streamId}>{item.text}</Streamdown>
|
||||
<Streamdown {...streamdownProps} animated isAnimating={!!item.streamId}>{item.text}</Streamdown>
|
||||
)}
|
||||
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} alignRight={isUser} /> : null}
|
||||
{item.meta ? <div className={`mt-1 text-[11px] tabular-nums opacity-55 ${isUser ? "text-right" : ""}`}>{item.meta}</div> : 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 (
|
||||
<div className="min-w-0 rounded-xl border px-3 py-3" style={{ borderColor: "rgba(234,88,12,.32)", background: "rgba(234,88,12,.035)", color: theme.node.text }}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-orange-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
{approval.reason ? <div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{approval.reason}</div> : null}
|
||||
{target ? <div className="mt-1.5 break-all rounded-lg px-2.5 py-2 font-mono text-[11px] leading-4" style={{ background: theme.toolbar.panel, color: theme.node.text }}>{target}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap justify-end gap-1.5 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<Button danger type="text" className="!h-8" onClick={() => onDecision("decline")}>拒绝</Button>
|
||||
<Button type="text" className="!h-8" onClick={() => onDecision("accept")}>允许一次</Button>
|
||||
<Button type="text" className="!h-8" style={{ color: "#ea580c" }} onClick={() => onDecision("acceptForSession")}>本会话允许</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <AgentPlanCard title={title} plan={plan} theme={theme} />;
|
||||
@@ -122,7 +162,7 @@ function AgentReasoningSummary({ text, detail, theme }: { text: string; detail?:
|
||||
</div>
|
||||
</summary>
|
||||
<div className="break-words pb-1 pl-6 pr-2 text-xs leading-5 [&_code]:rounded [&_code]:px-1 [&_p]:my-1 [&_pre]:my-2" style={{ color: theme.node.muted }}>
|
||||
<Streamdown animated isAnimating={running}>{text}</Streamdown>
|
||||
<Streamdown {...streamdownProps} animated isAnimating={running}>{text}</Streamdown>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
@@ -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 }> };
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={listRef} className="thin-scrollbar h-full select-text space-y-4 overflow-y-auto px-4 pb-12 pt-4" onScroll={updateScrollState}>
|
||||
@@ -65,7 +69,8 @@ export function AgentChatTimeline({
|
||||
onApprove={onApproveTool}
|
||||
/>
|
||||
) : null}
|
||||
{(sending || waiting) && !streaming && !pendingTool ? <AgentWorkingMessage text={working.text} activityKey={working.key} theme={theme} /> : null}
|
||||
{pendingApprovals.map((approval) => <AgentApprovalCard key={approval.requestId} approval={approval} theme={theme} onDecision={(decision) => onApprovalDecision(approval, decision)} />)}
|
||||
{(sending || waiting) && !streaming && !pendingTool && !pendingApprovals.length ? <AgentWorkingMessage text={working.text} activityKey={working.key} theme={theme} /> : null}
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<Tooltip title="滚动到底部" placement="left">
|
||||
|
||||
@@ -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<AgentPendingToolCall>(event);
|
||||
if (data) void handleToolCall(endpoint, token, data);
|
||||
});
|
||||
source.addEventListener("codex_approval", (event) => {
|
||||
const data = parseEventData<AgentPendingApproval>(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<AgentEventPayload>(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<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
|
||||
const created = await fetchAgentJson<AgentThreadResponse>(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<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
|
||||
const data = await fetchAgentJson<AgentThreadResponse>(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<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }),
|
||||
fetchAgentJson<AgentThreadResponse>(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?
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AgentChatTimeline theme={theme} pendingTool={pendingTool} sending={sending} waiting={waiting} onRejectTool={rejectPendingTool} onApproveTool={approvePendingTool} />
|
||||
<AgentChatTimeline theme={theme} pendingTool={pendingTool} pendingApprovals={pendingApprovals} sending={sending} waiting={waiting} onRejectTool={rejectPendingTool} onApproveTool={approvePendingTool} onApprovalDecision={decideApproval} />
|
||||
<AgentTaskProgress theme={theme} busy={sending || waiting} />
|
||||
{tokenUsage ? <AgentUsageBar usage={tokenUsage} theme={theme} /> : null}
|
||||
<AgentChatComposer
|
||||
@@ -816,6 +857,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
onRemoveAttachment={removeAttachment}
|
||||
confirmTools={confirmTools}
|
||||
onConfirmToolsChange={(confirmTools) => setAgentState({ confirmTools })}
|
||||
permissionMode={permissionMode}
|
||||
onPermissionModeChange={changePermissionMode}
|
||||
left={
|
||||
attachments.length ? (
|
||||
<span className="text-[11px]" style={{ color: theme.node.muted }}>
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
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<Omit<AgentStore, "setAgentState" | "connectAgent" | "disconnectAgent" | "addMessage" | "addEventLog" | "clearEventLogs" | "openPanel" | "closePanel" | "togglePanel" | "setCanvasContext">>) => void;
|
||||
openPanel: () => void;
|
||||
closePanel: () => void;
|
||||
@@ -81,9 +85,11 @@ export const useAgentStore = create<AgentStore>((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: () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user