feat(agent): add support for selecting Codex model and reasoning effort in the canvas agent, retaining choices for subsequent tasks

This commit is contained in:
HouYunFei
2026-07-31 11:03:19 +08:00
parent 3c6af91e8d
commit 9330bdb318
9 changed files with 164 additions and 12 deletions
@@ -1,10 +1,10 @@
import { useRef, type ReactNode } from "react";
import { Button, Dropdown, Tooltip } from "antd";
import { ArrowUp, Check, ChevronUp, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Square, X } from "lucide-react";
import { ArrowUp, Check, ChevronUp, Hand, ImagePlus, LoaderCircle, RefreshCw, ShieldAlert, ShieldCheck, ShieldOff, Sparkles, 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 { AgentModel, AgentPermissionMode, AgentReasoningEffort } from "@/stores/use-agent-store";
import type { AgentChatAttachment } from "./agent-chat-message";
export function AgentChatComposer({
@@ -23,6 +23,11 @@ export function AgentChatComposer({
onConfirmToolsChange,
permissionMode,
onPermissionModeChange,
models,
model,
reasoningEffort,
onModelChange,
onReasoningEffortChange,
left,
}: {
prompt: string;
@@ -40,6 +45,11 @@ export function AgentChatComposer({
onConfirmToolsChange?: (confirmTools: boolean) => void;
permissionMode?: AgentPermissionMode;
onPermissionModeChange?: (permissionMode: AgentPermissionMode) => void;
models?: AgentModel[];
model?: string;
reasoningEffort?: AgentReasoningEffort | "";
onModelChange?: (model: string) => void;
onReasoningEffortChange?: (effort: AgentReasoningEffort) => void;
left?: ReactNode;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -95,6 +105,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}
{models?.length && model && reasoningEffort && onModelChange && onReasoningEffortChange ? <AgentModelMenu models={models} model={model} reasoningEffort={reasoningEffort} theme={theme} onModelChange={onModelChange} onReasoningEffortChange={onReasoningEffortChange} /> : null}
{left}
</div>
<div className="flex shrink-0 items-center gap-1.5">
@@ -110,6 +121,62 @@ export function AgentChatComposer({
);
}
function AgentModelMenu({ models, model, reasoningEffort, theme, onModelChange, onReasoningEffortChange }: { models: AgentModel[]; model: string; reasoningEffort: AgentReasoningEffort; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onModelChange: (model: string) => void; onReasoningEffortChange: (effort: AgentReasoningEffort) => void }) {
const current = models.find((item) => item.model === model) || models[0];
return (
<Dropdown
trigger={["click"]}
placement="topLeft"
menu={{
items: [
{
key: "model",
label: "模型",
children: models.map((item) => ({
key: item.model,
label: <PickerOption title={item.displayName || item.model} selected={item.model === model} />,
onClick: () => onModelChange(item.model),
})),
},
{
key: "effort",
label: "推理强度",
children: current.supportedReasoningEfforts.map((item) => ({
key: item.reasoningEffort,
label: <PickerOption title={effortLabels[item.reasoningEffort]} selected={item.reasoningEffort === reasoningEffort} />,
onClick: () => onReasoningEffortChange(item.reasoningEffort),
})),
},
],
}}
>
<button type="button" className="flex h-9 min-w-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: theme.node.text }} aria-label="选择 Codex 模型和推理强度">
<Sparkles className="size-3.5 shrink-0" />
<span className="max-w-28 truncate">{current.displayName || current.model}</span>
<span className="shrink-0 opacity-55">{effortLabels[reasoningEffort]}</span>
<ChevronUp className="size-3 shrink-0 opacity-50" />
</button>
</Dropdown>
);
}
const effortLabels: Record<AgentReasoningEffort, string> = {
minimal: "最低",
low: "轻度",
medium: "中",
high: "高",
xhigh: "极高",
};
function PickerOption({ title, selected }: { title: string; selected: boolean }) {
return (
<div className="flex min-w-48 items-center gap-3 py-0.5">
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
{selected ? <Check className="size-4 shrink-0" /> : null}
</div>
);
}
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 (
+38 -2
View File
@@ -13,7 +13,7 @@ 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 AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentThreadSummary } from "@/stores/use-agent-store";
import { useAgentStore, type AgentCanvasContext, type AgentChatItem, type AgentModel, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentReasoningEffort, type AgentThreadSummary } from "@/stores/use-agent-store";
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isSiteTool, runSiteTool } from "@/lib/agent/agent-site-tools";
import { activateAgentClient, discoverAgentConfig, fetchAgentJson, postCodexApproval, postState, postToolResult } from "./agent-api";
@@ -65,6 +65,7 @@ const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
type AgentModelsResponse = { ok?: boolean; data?: AgentModel[] };
type AgentCodexState = { busy?: boolean; threadId?: string; turnId?: string };
type AgentHelloEvent = { ok?: boolean; clientId?: string; codex?: AgentCodexState };
type AgentWorkspaceEvent = { activeThreadId?: string; threadId?: string; emptyThread?: boolean; draftThread?: boolean };
@@ -79,7 +80,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, permissionMode, activity, connectError, pendingTool, pendingApprovals } = useAgentStore(
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, tokenUsage, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, permissionMode, models, model, reasoningEffort, activity, connectError, pendingTool, pendingApprovals } = useAgentStore(
useShallow((state) => ({
width: state.width,
url: state.url,
@@ -99,6 +100,9 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
activeTab: state.activeTab,
confirmTools: state.confirmTools,
permissionMode: state.permissionMode,
models: state.models,
model: state.model,
reasoningEffort: state.reasoningEffort,
activity: state.activity,
connectError: state.connectError,
pendingTool: state.pendingTool,
@@ -278,6 +282,21 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
if (connected) void loadThreads();
}, [connected, loadThreads]);
useEffect(() => {
if (!connected) return;
void fetchAgentJson<AgentModelsResponse>(endpoint, token, "/agent/codex/models").then(({ data = [] }) => {
if (!data.length) return;
const savedModel = useAgentStore.getState().model;
const current = data.find((item) => item.model === savedModel) || data.find((item) => item.isDefault) || data[0];
const savedEffort = useAgentStore.getState().reasoningEffort;
const efforts = current.supportedReasoningEfforts.map((item) => item.reasoningEffort);
const nextEffort = efforts.includes(savedEffort as AgentReasoningEffort) ? savedEffort as AgentReasoningEffort : current.defaultReasoningEffort || efforts[0];
localStorage.setItem("canvas-agent-model", current.model);
localStorage.setItem("canvas-agent-reasoning-effort", nextEffort);
setAgentState({ models: data, model: current.model, reasoningEffort: nextEffort });
}).catch((error) => addEventLog("读取模型列表失败", error));
}, [connected, endpoint, setAgentState, token]);
useEffect(() => {
if (!connected) return;
const activate = () => void activateAgentClient(endpoint, token, clientIdRef.current);
@@ -325,6 +344,8 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
clientId: clientIdRef.current,
threadId,
permissionMode,
model,
effort: reasoningEffort,
attachments: files.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, dataUrl })),
}),
});
@@ -904,6 +925,21 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
onConfirmToolsChange={(confirmTools) => setAgentState({ confirmTools })}
permissionMode={permissionMode}
onPermissionModeChange={changePermissionMode}
models={models}
model={model}
reasoningEffort={reasoningEffort}
onModelChange={(model) => {
const selected = models.find((item) => item.model === model);
if (!selected) return;
const effort = selected.defaultReasoningEffort || selected.supportedReasoningEfforts[0]?.reasoningEffort;
localStorage.setItem("canvas-agent-model", model);
if (effort) localStorage.setItem("canvas-agent-reasoning-effort", effort);
setAgentState({ model, ...(effort ? { reasoningEffort: effort } : {}) });
}}
onReasoningEffortChange={(reasoningEffort) => {
localStorage.setItem("canvas-agent-reasoning-effort", reasoningEffort);
setAgentState({ reasoningEffort });
}}
left={
attachments.length ? (
<span className="text-[11px]" style={{ color: theme.node.muted }}>
+15
View File
@@ -8,6 +8,15 @@ export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; t
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 AgentReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
export type AgentModel = {
id: string;
model: string;
displayName: string;
defaultReasoningEffort: AgentReasoningEffort;
supportedReasoningEfforts: Array<{ reasoningEffort: AgentReasoningEffort; description?: string }>;
isDefault?: boolean;
};
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 };
@@ -43,6 +52,9 @@ type AgentStore = {
activeTab: AgentPanelTab;
confirmTools: boolean;
permissionMode: AgentPermissionMode;
models: AgentModel[];
model: string;
reasoningEffort: AgentReasoningEffort | "";
activity: string;
connectError: string;
pendingTool: AgentPendingToolCall | null;
@@ -86,6 +98,9 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
activeTab: "setup",
confirmTools: false,
permissionMode: typeof window === "undefined" ? "request" : (localStorage.getItem("canvas-agent-permission-mode") as AgentPermissionMode) || "request",
models: [],
model: typeof window === "undefined" ? "" : localStorage.getItem("canvas-agent-model") || "",
reasoningEffort: typeof window === "undefined" ? "" : (localStorage.getItem("canvas-agent-reasoning-effort") as AgentReasoningEffort) || "",
activity: "就绪",
connectError: "",
pendingTool: null,