diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f00813..94e2dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased + [新增] 画布 Agent 支持请求批准、自动审查和完全访问三档 Codex 权限,并可在对话中审批文件、命令与网络访问。 ++ [修复] Agent 回复中的本地绝对文件路径改为在系统文件管理器中定位,不再被浏览器错误拼接为 localhost 地址。 + [优化] Agent Markdown 代码块与外链确认弹窗改为紧凑中文样式,改善右侧面板阅读体验。 + [优化] Agent 思考摘要支持 Markdown 渲染,改善强调、列表和代码内容的阅读效果。 + [修复] Agent 对话启用 Codex 可读思考摘要,并修复一轮结束或刷新恢复后思考摘要、命令和工具过程记录缺失的问题。 diff --git a/canvas-agent/src/server/http.ts b/canvas-agent/src/server/http.ts index 7c87ebe..0b9cdbe 100644 --- a/canvas-agent/src/server/http.ts +++ b/canvas-agent/src/server/http.ts @@ -1,3 +1,6 @@ +import { spawn } from "node:child_process"; +import { stat } from "node:fs/promises"; +import path from "node:path"; import express, { type NextFunction, type Request, type Response } from "express"; import { runClaudeTurn } from "../agent/claude.js"; @@ -72,6 +75,13 @@ export function startHttpServer() { res.setHeader("Cache-Control", "no-store"); res.type(attachment.type).send(Buffer.from(data, "base64")); })); + app.post("/agent/local-file/reveal", route(async (req, res) => { + const filePath = String(req.body?.path || ""); + if (!path.isAbsolute(filePath)) return res.status(400).json({ ok: false, error: "文件路径必须是绝对路径" }); + const file = await stat(filePath); + await revealLocalFile(filePath, file.isDirectory()); + res.json({ ok: true }); + })); app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) }))); app.get("/agent/codex/workspace", (_req, res) => { const workspace = ensureSiteWorkspace(config); @@ -225,6 +235,24 @@ function permissionMode(value: unknown): AgentPermissionMode { return value === "automatic" || value === "full" ? value : "request"; } +/** 使用当前操作系统的文件管理器定位本地文件。 */ +function revealLocalFile(filePath: string, isDirectory: boolean) { + const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open"; + const args = process.platform === "darwin" + ? ["-R", filePath] + : process.platform === "win32" + ? [isDirectory ? filePath : `/select,${filePath}`] + : [isDirectory ? filePath : path.dirname(filePath)]; + return new Promise((resolve, reject) => { + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + child.once("error", reject); + }); +} + /** 结合服务配置解析当前请求 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 12a6fc4..280499e 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,7 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 -- Agent Markdown 样式:右侧 Agent 回复中的代码块应为横向占满消息区域、无语言标题和无双层边框的紧凑代码条,单行内容不应保留纵向大面积空白,复制操作仅在悬停时弱化显示;行内代码、链接在浅色和深色主题下应清晰;点击外部链接后应显示中文紧凑确认弹窗,长路径能够正常换行且复制、继续打开和关闭操作可用。 +- Agent Markdown 样式:右侧 Agent 回复中的代码块应为横向占满消息区域、无语言标题和无双层边框的紧凑代码条,单行内容不应保留纵向大面积空白,复制操作仅在悬停时弱化显示;行内代码、链接在浅色和深色主题下应清晰;点击外部链接后应显示中文紧凑确认弹窗,长路径能够正常换行且复制、继续打开和关闭操作可用;点击 `/Users/`、`/home/` 等本地绝对文件路径时应改为提示在系统文件管理器中定位,支持复制路径,且浏览器地址不应跳转为 localhost 文件路径。 - 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 34289da..a0503f6 100644 --- a/web/src/components/agent/agent-api.ts +++ b/web/src/components/agent/agent-api.ts @@ -26,6 +26,10 @@ export async function postCodexApproval(endpoint: string, token: string, request await fetchAgentJson(endpoint, token, "/agent/codex/approval", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ requestId, decision }) }); } +export async function revealAgentLocalFile(endpoint: string, token: string, path: string) { + await fetchAgentJson(endpoint, token, "/agent/local-file/reveal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) }); +} + 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); diff --git a/web/src/components/agent/agent-chat-message.tsx b/web/src/components/agent/agent-chat-message.tsx index 9ba09ef..df6e20f 100644 --- a/web/src/components/agent/agent-chat-message.tsx +++ b/web/src/components/agent/agent-chat-message.tsx @@ -1,14 +1,17 @@ import { useEffect, useState, type ReactNode } from "react"; -import { Button, Image } from "antd"; -import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, FilePenLine, FileText, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react"; -import { Streamdown } from "streamdown"; +import { App, Button, Image, Modal } from "antd"; +import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, Copy, ExternalLink, FilePenLine, FileText, FolderOpen, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react"; +import { Streamdown, type LinkSafetyModalProps } from "streamdown"; +import { useCopyText } from "@/hooks/use-copy-text"; import { canvasThemes } from "@/lib/canvas-theme"; -import type { AgentPendingApproval } from "@/stores/use-agent-store"; +import { useAgentStore, type AgentPendingApproval } from "@/stores/use-agent-store"; +import { revealAgentLocalFile } from "./agent-api"; const streamdownProps = { className: "agent-streamdown", controls: { code: { copy: true, download: false }, table: { copy: true, download: false, fullscreen: false } }, + linkSafety: { enabled: true, renderModal: (props: LinkSafetyModalProps) => }, lineNumbers: false, translations: { close: "关闭", @@ -21,6 +24,69 @@ const streamdownProps = { }, } as const; +function AgentLinkModal({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) { + const { message } = App.useApp(); + const copyText = useCopyText(); + const localPath = localFilePath(url); + const [opening, setOpening] = useState(false); + const open = async () => { + if (!localPath) return onConfirm(); + const { url: endpoint, token } = useAgentStore.getState(); + setOpening(true); + try { + await revealAgentLocalFile(endpoint, token, localPath); + message.success("已在文件管理器中定位"); + onClose(); + } catch (error) { + message.error(error instanceof Error ? error.message : "无法打开本地文件"); + } finally { + setOpening(false); + } + }; + return ( + +
+ {localPath ? "将在本机文件管理器中定位该路径,不会通过浏览器打开。" : "即将打开以下外部链接,请确认链接可信。"} +
+
{localPath || url}
+
+ + +
+
+ ); +} + +function localFilePath(value: string) { + let decoded = value; + try { + decoded = decodeURI(value); + } catch {} + if (decoded.startsWith("file://")) { + try { + return decodeURIComponent(new URL(decoded).pathname); + } catch { + return ""; + } + } + if (/^[A-Za-z]:[\\/]/.test(decoded)) return decoded; + let pathname = decoded; + if (decoded.startsWith("http://") || decoded.startsWith("https://")) { + try { + const parsed = new URL(decoded); + if (!["localhost", "127.0.0.1"].includes(parsed.hostname)) return ""; + pathname = parsed.pathname; + } catch { + return ""; + } + } + return /^\/(?:Users|home|private|tmp|Volumes|var\/folders)\//.test(pathname) ? decodeURIComponent(pathname) : ""; +} + export type AgentChatAttachment = { id: string; name: string; url: string }; export type AgentChatMessageItem = { id: string;