mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-03 07:21:13 +08:00
feat(agent): implement global Agent panel with site navigation and interrupt functionality
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Bot, PanelRightClose } from "lucide-react";
|
||||
import { Button, Switch, Tooltip } from "antd";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { CANVAS_AGENT_PANEL_MOTION_MS, useAgentStore } from "@/stores/use-agent-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000;
|
||||
|
||||
export function AgentPanel() {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const width = useAgentStore((state) => state.width);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const panelMounted = useAgentStore((state) => state.panelMounted);
|
||||
const panelOpen = useAgentStore((state) => state.panelOpen);
|
||||
const panelClosing = useAgentStore((state) => state.panelClosing);
|
||||
const confirmTools = useAgentStore((state) => state.confirmTools);
|
||||
const setAgentState = useAgentStore((state) => state.setAgentState);
|
||||
const closePanel = useAgentStore((state) => state.closePanel);
|
||||
|
||||
|
||||
const startResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = width;
|
||||
let nextWidth = startWidth;
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
nextWidth = Math.min(760, Math.max(360, startWidth + startX - moveEvent.clientX));
|
||||
setAgentState({ width: nextWidth });
|
||||
};
|
||||
const onUp = () => {
|
||||
localStorage.setItem("canvas-agent-panel-width", String(nextWidth));
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
setResizing(false);
|
||||
};
|
||||
setResizing(true);
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
if (!panelMounted) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative z-[70] flex h-full shrink-0"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }}
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ overflow: "clip", pointerEvents: panelClosing ? "none" : undefined }}
|
||||
>
|
||||
<motion.aside
|
||||
className="relative flex h-full shrink-0 flex-col border-l"
|
||||
initial={{ x: 48 }}
|
||||
animate={{ x: panelClosing ? 28 : 0 }}
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
>
|
||||
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整右侧面板宽度" />
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b px-4" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid size-8 place-items-center rounded-lg">
|
||||
<Bot className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold leading-5">Agent</div>
|
||||
<div className="truncate text-xs" style={{ color: theme.node.muted }}>全站助手</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs" style={{ color: theme.node.muted }}>
|
||||
<Switch size="small" checked={confirmTools} onChange={(confirmTools) => setAgentState({ confirmTools })} />
|
||||
工具确认
|
||||
</label>
|
||||
<Tooltip title="收起对话">
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={{ color: theme.node.muted }} icon={<PanelRightClose className="size-4" />} onClick={closePanel} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
<CanvasLocalAgentPanel embedded />
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Square, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
|
||||
import { isPlainEnterKey } from "@/lib/keyboard-event";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -152,6 +152,7 @@ export function AgentChatComposer({
|
||||
theme,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onAddFiles,
|
||||
onRemoveAttachment,
|
||||
left,
|
||||
@@ -164,6 +165,7 @@ export function AgentChatComposer({
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onPromptChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStop?: () => void;
|
||||
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
|
||||
onRemoveAttachment?: (id: string) => void;
|
||||
left?: ReactNode;
|
||||
@@ -221,7 +223,13 @@ export function AgentChatComposer({
|
||||
) : null}
|
||||
{left}
|
||||
</div>
|
||||
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{sending && onStop ? (
|
||||
<Button danger shape="circle" className="!h-10 !w-10 !min-w-10" icon={<Square className="size-4" />} onClick={() => void onStop()} aria-label="停止" />
|
||||
) : (
|
||||
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { App, Button, Input, Segmented, Tooltip } from "antd";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, RotateCcw, Square, Terminal, Trash2 } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/canvas/use-canvas-agent-store";
|
||||
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
|
||||
|
||||
const PANEL_MOTION_SECONDS = 0.5;
|
||||
const MAX_ATTACHMENTS = 6;
|
||||
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
|
||||
const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
|
||||
@@ -34,23 +32,22 @@ type AgentEventPayload = {
|
||||
type AgentEventItem = { id?: string; type?: string; text?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } };
|
||||
|
||||
type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
|
||||
type AgentWorkspace = { canvasId: string; workspacePath: string; activeThreadId?: string };
|
||||
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 AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean };
|
||||
|
||||
export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedded, headless, autoConnect, onApplyOps, onUndoOps }: { snapshot: CanvasAgentSnapshot; canUndoOps: boolean; collapsed?: boolean; embedded?: boolean; headless?: boolean; autoConnect?: boolean; onApplyOps: (ops: CanvasAgentOp[]) => unknown; onUndoOps: () => CanvasAgentSnapshot | null }) {
|
||||
export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const user = useUserStore((state) => state.user);
|
||||
const { message, modal } = App.useApp();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useCanvasAgentStore();
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, canvasContext, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useAgentStore();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const snapshotRef = useRef(snapshot);
|
||||
const canvasContextRef = useRef(canvasContext);
|
||||
const confirmToolsRef = useRef(confirmTools);
|
||||
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
|
||||
const onApplyOpsRef = useRef(onApplyOps);
|
||||
const autoConnectRef = useRef(false);
|
||||
const connectedRef = useRef(false);
|
||||
const errorLoggedRef = useRef(false);
|
||||
@@ -59,11 +56,10 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
|
||||
const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken");
|
||||
const loadThreads = useCallback(async () => {
|
||||
const projectId = snapshotRef.current.projectId;
|
||||
if ((!connectedRef.current && !useCanvasAgentStore.getState().connected) || !projectId) return;
|
||||
if (!connectedRef.current && !useAgentStore.getState().connected) return;
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads?canvasId=${encodeURIComponent(projectId)}`);
|
||||
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads`);
|
||||
const nextThreadId = data.workspace?.activeThreadId || "";
|
||||
setAgentState({
|
||||
threads: data.data || [],
|
||||
@@ -72,7 +68,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
messages: [],
|
||||
});
|
||||
if (nextThreadId) {
|
||||
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}?canvasId=${encodeURIComponent(projectId)}`);
|
||||
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`);
|
||||
setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) });
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -83,17 +79,14 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
}, [endpoint, setAgentState, token]);
|
||||
|
||||
useEffect(() => {
|
||||
snapshotRef.current = snapshot;
|
||||
}, [snapshot]);
|
||||
canvasContextRef.current = canvasContext;
|
||||
}, [canvasContext]);
|
||||
useEffect(() => {
|
||||
confirmToolsRef.current = confirmTools;
|
||||
}, [confirmTools]);
|
||||
useEffect(() => {
|
||||
pendingToolRef.current = pendingTool;
|
||||
}, [pendingTool]);
|
||||
useEffect(() => {
|
||||
onApplyOpsRef.current = onApplyOps;
|
||||
}, [onApplyOps]);
|
||||
useEffect(() => {
|
||||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
||||
}, [messages, pendingTool, waiting]);
|
||||
@@ -108,9 +101,9 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
source.addEventListener("hello", () => {
|
||||
errorLoggedRef.current = false;
|
||||
connectedRef.current = true;
|
||||
setAgentState({ connected: true, activity: "已连接", connectError: "", messages: useCanvasAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
|
||||
setAgentState({ connected: true, activity: "已连接", connectError: "", messages: useAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
|
||||
if (!headless) message.success("本地 Agent 已连接");
|
||||
void postState(endpoint, token, clientId, snapshotRef.current);
|
||||
void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null);
|
||||
});
|
||||
source.addEventListener("tool_call", (event) => {
|
||||
const data = parseEventData<AgentPendingToolCall>(event);
|
||||
@@ -159,16 +152,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
if (connected) void loadThreads();
|
||||
}, [connected, loadThreads]);
|
||||
|
||||
useEffect(() => {
|
||||
clearAgentSession({ activity: connected ? "切换画布" : activity });
|
||||
if (connected) void loadThreads();
|
||||
}, [snapshot.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, snapshot), 300);
|
||||
const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContext?.snapshot || null), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [connected, endpoint, snapshot, token]);
|
||||
}, [canvasContext?.snapshot, connected, endpoint, token]);
|
||||
|
||||
const sendPrompt = async () => {
|
||||
const text = prompt.trim();
|
||||
@@ -183,7 +171,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
addMessage({ role: "user", text: text || "发送了图片", attachments: files });
|
||||
addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
|
||||
try {
|
||||
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, canvasId: snapshotRef.current.projectId, threadId: useCanvasAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) });
|
||||
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, threadId: useAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) });
|
||||
if (!res.ok) throw new Error("本地 Agent 拒绝了请求");
|
||||
const data = (await res.json()) as { threadId?: string };
|
||||
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
|
||||
@@ -202,10 +190,22 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
}
|
||||
};
|
||||
|
||||
const stopTurn = async () => {
|
||||
if (!connected || (!sending && !waiting)) return;
|
||||
setAgentState({ activity: "停止中" });
|
||||
try {
|
||||
await fetch(`${endpoint}/agent/codex/interrupt?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" } });
|
||||
setAgentState({ activity: "已停止", sending: false, waiting: false });
|
||||
addEventLog("用户停止", {});
|
||||
} catch {
|
||||
setAgentState({ activity: "就绪", sending: false, waiting: false });
|
||||
}
|
||||
};
|
||||
|
||||
const addAttachments = async (files: FileList | File[] | null) => {
|
||||
if (!files) return;
|
||||
const images = Array.from(files).filter((file) => file.type.startsWith("image/"));
|
||||
const prev = useCanvasAgentStore.getState().attachments;
|
||||
const prev = useAgentStore.getState().attachments;
|
||||
try {
|
||||
const next = await Promise.all(images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
|
||||
const dataUrl = await readDataUrl(file);
|
||||
@@ -253,15 +253,28 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
|
||||
const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
|
||||
try {
|
||||
const input: { ops?: CanvasAgentOp[] } = payload.input || {};
|
||||
setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : "读取画布", waiting: true });
|
||||
const input: { ops?: CanvasAgentOp[]; path?: string } = payload.input || {};
|
||||
setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : payload.name === "site_navigate" ? "跳转页面" : "读取画布", waiting: true });
|
||||
addEventLog(toolName(payload.name), payload, payload);
|
||||
const result = payload.name === "canvas_apply_ops" ? onApplyOpsRef.current(input.ops || []) : snapshotRef.current;
|
||||
let result: unknown;
|
||||
if (payload.name === "site_navigate") {
|
||||
const path = input.path || "/";
|
||||
navigate(path);
|
||||
result = { ok: true, path };
|
||||
} else if (payload.name === "canvas_apply_ops") {
|
||||
const context = canvasContextRef.current;
|
||||
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
|
||||
result = context.applyOps(input.ops || []);
|
||||
void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
|
||||
} else {
|
||||
const snapshot = canvasContextRef.current?.snapshot;
|
||||
if (!snapshot) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
|
||||
result = snapshot;
|
||||
}
|
||||
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
|
||||
if (payload.name === "canvas_apply_ops") void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
|
||||
setAgentState({ activity: "工具完成", waiting: true });
|
||||
addEventLog(`${toolName(payload.name)}完成`, result, result);
|
||||
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } });
|
||||
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "画布操作失败";
|
||||
setAgentState({ activity: "工具失败", waiting: false });
|
||||
@@ -288,7 +301,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
};
|
||||
|
||||
const undoLastTool = () => {
|
||||
const restored = onUndoOps();
|
||||
const restored = canvasContextRef.current?.undoOps() || null;
|
||||
if (!restored) return;
|
||||
setAgentState({ activity: "已撤销" });
|
||||
addMessage({ role: "tool", title: "已撤销", text: "上一次工具操作", detail: restored });
|
||||
@@ -356,11 +369,10 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
}
|
||||
|
||||
const startNewThread = async () => {
|
||||
const projectId = snapshotRef.current.projectId;
|
||||
if (!connected || !projectId) return;
|
||||
if (!connected) 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({ canvasId: projectId }) });
|
||||
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
|
||||
setAgentState({ activeThreadId: data.thread?.id || data.workspace?.activeThreadId || "", messages: [], activeTab: "chat", activity: "新对话" });
|
||||
await loadThreads();
|
||||
} catch (error) {
|
||||
@@ -372,11 +384,10 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
};
|
||||
|
||||
const resumeThread = async (threadId: string) => {
|
||||
const projectId = snapshotRef.current.projectId;
|
||||
if (!connected || !projectId || !threadId) return;
|
||||
if (!connected || !threadId) return;
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
|
||||
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
|
||||
setAgentState({ activeThreadId: data.thread?.id || threadId, messages: normalizeHistoryMessages(data.messages || []), activeTab: "chat", activity: "已恢复会话" });
|
||||
await loadThreads();
|
||||
} catch (error) {
|
||||
@@ -388,12 +399,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
};
|
||||
|
||||
const deleteThread = async (threadId: string) => {
|
||||
const projectId = snapshotRef.current.projectId;
|
||||
if (!connected || !projectId || !threadId) return;
|
||||
if (!connected || !threadId) return;
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
|
||||
const current = useCanvasAgentStore.getState();
|
||||
await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
|
||||
const current = useAgentStore.getState();
|
||||
setAgentState({
|
||||
threads: current.threads.filter((thread) => thread.id !== threadId),
|
||||
activeThreadId: current.activeThreadId === threadId ? "" : current.activeThreadId,
|
||||
@@ -420,31 +430,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
});
|
||||
};
|
||||
|
||||
const startResize = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = width;
|
||||
let nextWidth = startWidth;
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
nextWidth = clamp(startWidth + startX - moveEvent.clientX, 360, 760);
|
||||
setAgentState({ width: nextWidth });
|
||||
};
|
||||
const onUp = () => {
|
||||
localStorage.setItem("canvas-agent-panel-width", String(nextWidth));
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
setResizing(false);
|
||||
};
|
||||
setResizing(true);
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
const addMessage = (item: Omit<AgentChatItem, "id">) => {
|
||||
const text = normalizeText(item.text);
|
||||
if (!text && !item.attachments?.length) return;
|
||||
const next = { ...item, id: `${Date.now()}-${Math.random()}`, text };
|
||||
const currentMessages = useCanvasAgentStore.getState().messages;
|
||||
const currentMessages = useAgentStore.getState().messages;
|
||||
if (next.streamId) {
|
||||
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
|
||||
if (index >= 0) {
|
||||
@@ -456,7 +446,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
if (last?.role === "assistant" && next.role === "assistant" && last.title === next.title) {
|
||||
const merged = mergeAgentText(last.text, next.text);
|
||||
if (merged === last.text) return;
|
||||
setAgentState({ messages: [...useCanvasAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] });
|
||||
setAgentState({ messages: [...useAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] });
|
||||
return;
|
||||
}
|
||||
pushMessage(next);
|
||||
@@ -497,7 +487,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
}}
|
||||
right={
|
||||
<>
|
||||
<Button size="small" type="text" disabled={!canUndoOps} icon={<RotateCcw className="size-3.5" />} onClick={undoLastTool}>
|
||||
<Button size="small" type="text" disabled={!canvasContext?.canUndo} icon={<RotateCcw className="size-3.5" />} onClick={undoLastTool}>
|
||||
撤销
|
||||
</Button>
|
||||
</>
|
||||
@@ -553,10 +543,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
attachments={attachments.map(agentAttachmentToChatAttachment)}
|
||||
disabled={!connected}
|
||||
sending={sending || waiting}
|
||||
placeholder="询问 Codex,或让它操作画布"
|
||||
placeholder="询问 Codex,或让它操作网站/画布"
|
||||
theme={theme}
|
||||
onPromptChange={(prompt) => setAgentState({ prompt })}
|
||||
onSubmit={sendPrompt}
|
||||
onStop={stopTurn}
|
||||
onAddFiles={addAttachments}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
left={attachments.length ? <span className="text-[11px]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
|
||||
@@ -567,28 +558,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
|
||||
);
|
||||
|
||||
if (headless) return null;
|
||||
if (embedded) return content;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative z-[70] flex h-full shrink-0"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: collapsed ? 0 : width + 1, opacity: collapsed ? 0 : 1 }}
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ overflow: "clip", pointerEvents: collapsed ? "none" : undefined }}
|
||||
>
|
||||
<motion.aside
|
||||
className="relative flex h-full shrink-0 flex-col border-l"
|
||||
initial={{ x: 48 }}
|
||||
animate={{ x: collapsed ? 28 : 0 }}
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
>
|
||||
<div className="absolute left-0 top-0 h-full w-1 cursor-col-resize transition hover:bg-current/20" onPointerDown={startResize} />
|
||||
{content}
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
);
|
||||
return embedded ? content : null;
|
||||
}
|
||||
|
||||
function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }: { logs: AgentEventLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: AgentLogContext; onClear: () => void; onCopied: (text: string) => void; onCopyBlocked: (text: string) => void }) {
|
||||
@@ -775,9 +745,9 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
|
||||
);
|
||||
}
|
||||
|
||||
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot) {
|
||||
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
|
||||
try {
|
||||
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot) });
|
||||
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot ? { ...snapshot, hasCanvas: true } : { hasCanvas: false }) });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -906,6 +876,7 @@ function toolName(name: string) {
|
||||
if (name === "canvas_select_nodes") return "选择节点";
|
||||
if (name === "canvas_set_viewport") return "调整视口";
|
||||
if (name === "canvas_run_generation") return "触发生成";
|
||||
if (name === "site_navigate") return "网站跳转";
|
||||
return name;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fetchChannelModels } from "@/services/api/image";
|
||||
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
|
||||
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
|
||||
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
|
||||
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
|
||||
import { useAgentStore } from "@/stores/use-agent-store";
|
||||
import { createModelChannel, defaultBaseUrlForApiFormat, filterModelsByCapability, modelOptionLabel, modelOptionsFromChannels, normalizeModelOptionValue, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
type ModelGroup = {
|
||||
@@ -76,16 +76,16 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
|
||||
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
|
||||
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
|
||||
const agentUrl = useCanvasAgentStore((state) => state.url);
|
||||
const agentToken = useCanvasAgentStore((state) => state.token);
|
||||
const agentConnected = useCanvasAgentStore((state) => state.connected);
|
||||
const agentEnabled = useCanvasAgentStore((state) => state.enabled);
|
||||
const agentActivity = useCanvasAgentStore((state) => state.activity);
|
||||
const agentConnectError = useCanvasAgentStore((state) => state.connectError);
|
||||
const agentConfirmTools = useCanvasAgentStore((state) => state.confirmTools);
|
||||
const setAgentState = useCanvasAgentStore((state) => state.setAgentState);
|
||||
const connectAgent = useCanvasAgentStore((state) => state.connectAgent);
|
||||
const disconnectAgent = useCanvasAgentStore((state) => state.disconnectAgent);
|
||||
const agentUrl = useAgentStore((state) => state.url);
|
||||
const agentToken = useAgentStore((state) => state.token);
|
||||
const agentConnected = useAgentStore((state) => state.connected);
|
||||
const agentEnabled = useAgentStore((state) => state.enabled);
|
||||
const agentActivity = useAgentStore((state) => state.activity);
|
||||
const agentConnectError = useAgentStore((state) => state.connectError);
|
||||
const agentConfirmTools = useAgentStore((state) => state.confirmTools);
|
||||
const setAgentState = useAgentStore((state) => state.setAgentState);
|
||||
const connectAgent = useAgentStore((state) => state.connectAgent);
|
||||
const disconnectAgent = useAgentStore((state) => state.disconnectAgent);
|
||||
const modelOptions = config.models.map((model) => ({ label: modelOptionLabel(config, model), value: model }));
|
||||
const webdavReady = Boolean(webdav.url.trim());
|
||||
useEffect(() => setActiveTab(initialTab), [initialTab]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Menu } from "lucide-react";
|
||||
import { Bot, Menu } from "lucide-react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
@@ -8,17 +8,19 @@ import { MobileNavDrawer } from "@/components/layout/mobile-nav-drawer";
|
||||
import { UserStatusActions } from "@/components/layout/user-status-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
|
||||
import { useAgentStore } from "@/stores/use-agent-store";
|
||||
import { useConfigStore } from "@/stores/use-config-store";
|
||||
|
||||
export function AppTopNav() {
|
||||
const { pathname } = useLocation();
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const autoConnectRef = useRef(false);
|
||||
const agentToken = useCanvasAgentStore((state) => state.token);
|
||||
const agentEnabled = useCanvasAgentStore((state) => state.enabled);
|
||||
const agentConnected = useCanvasAgentStore((state) => state.connected);
|
||||
const connectAgent = useCanvasAgentStore((state) => state.connectAgent);
|
||||
const agentToken = useAgentStore((state) => state.token);
|
||||
const agentEnabled = useAgentStore((state) => state.enabled);
|
||||
const agentConnected = useAgentStore((state) => state.connected);
|
||||
const connectAgent = useAgentStore((state) => state.connectAgent);
|
||||
const togglePanel = useAgentStore((state) => state.togglePanel);
|
||||
const panelOpen = useAgentStore((state) => state.panelOpen);
|
||||
const hideHeader = /^\/canvas\/[^/]+/.test(pathname);
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
|
||||
@@ -81,6 +83,9 @@ export function AppTopNav() {
|
||||
|
||||
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
|
||||
<CodexStatusButton />
|
||||
<Tooltip title={panelOpen ? "收起 Agent" : "打开 Agent"}>
|
||||
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" icon={<Bot className="size-4" />} onClick={togglePanel} aria-label="打开 Agent" />
|
||||
</Tooltip>
|
||||
<UserStatusActions />
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,10 +99,10 @@ export function AppTopNav() {
|
||||
}
|
||||
|
||||
function CodexStatusButton() {
|
||||
const connected = useCanvasAgentStore((state) => state.connected);
|
||||
const enabled = useCanvasAgentStore((state) => state.enabled);
|
||||
const activity = useCanvasAgentStore((state) => state.activity);
|
||||
const connectError = useCanvasAgentStore((state) => state.connectError);
|
||||
const connected = useAgentStore((state) => state.connected);
|
||||
const enabled = useAgentStore((state) => state.enabled);
|
||||
const activity = useAgentStore((state) => state.activity);
|
||||
const connectError = useAgentStore((state) => state.connectError);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const color = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : "currentColor";
|
||||
const title = connectError || (connected ? activity || "Codex 已连接" : enabled ? "Codex 连接中" : "Codex 未连接");
|
||||
|
||||
Reference in New Issue
Block a user