mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-01 14:11:41 +08:00
feat(agent): add local file reveal functionality in system file manager
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
## Unreleased
|
||||
|
||||
+ [新增] 画布 Agent 支持请求批准、自动审查和完全访问三档 Codex 权限,并可在对话中审批文件、命令与网络访问。
|
||||
+ [修复] Agent 回复中的本地绝对文件路径改为在系统文件管理器中定位,不再被浏览器错误拼接为 localhost 地址。
|
||||
+ [优化] Agent Markdown 代码块与外链确认弹窗改为紧凑中文样式,改善右侧面板阅读体验。
|
||||
+ [优化] Agent 思考摘要支持 Markdown 渲染,改善强调、列表和代码内容的阅读效果。
|
||||
+ [修复] Agent 对话启用 Codex 可读思考摘要,并修复一轮结束或刷新恢复后思考摘要、命令和工具过程记录缺失的问题。
|
||||
|
||||
@@ -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<void>((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);
|
||||
|
||||
@@ -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 原文。
|
||||
|
||||
@@ -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<T>(endpoint: string, token: string, path: string, init?: RequestInit) {
|
||||
const url = `${endpoint}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
|
||||
const res = await fetch(url, init);
|
||||
|
||||
@@ -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) => <AgentLinkModal {...props} /> },
|
||||
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 (
|
||||
<Modal open={isOpen} onCancel={onClose} footer={null} centered width={420} title={localPath ? "打开本地文件?" : "打开外部链接?"}>
|
||||
<div className="text-sm text-black/55 dark:text-white/55">
|
||||
{localPath ? "将在本机文件管理器中定位该路径,不会通过浏览器打开。" : "即将打开以下外部链接,请确认链接可信。"}
|
||||
</div>
|
||||
<div className="mt-4 max-h-32 overflow-auto break-all rounded-lg bg-black/[.035] px-3 py-2.5 font-mono text-xs leading-5 dark:bg-white/[.06]">{localPath || url}</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button type="text" icon={<Copy className="size-4" />} onClick={() => copyText(localPath || url, localPath ? "路径已复制" : "链接已复制")}>
|
||||
{localPath ? "复制路径" : "复制链接"}
|
||||
</Button>
|
||||
<Button type="text" loading={opening} icon={localPath ? <FolderOpen className="size-4" /> : <ExternalLink className="size-4" />} onClick={open}>
|
||||
{localPath ? "在文件管理器中显示" : "继续打开"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user