feat(agent): implement permission mode handling for Codex threads and approvals

This commit is contained in:
HouYunFei
2026-07-29 16:44:51 +08:00
parent bd8cf318f7
commit b926feeb14
14 changed files with 372 additions and 46 deletions
+4 -1
View File
@@ -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 }> };
+9 -4
View File
@@ -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">
+51 -8
View File
@@ -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 }}>
+6
View File
@@ -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: () => {
+122
View File
@@ -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;
}
}