feat(agent): Agent 工作台 - 计划插话记忆、统一模型选择器并修复摘要与视频入库

将计划清单、运行中插话和个人记忆接入云端 Agent;全站模型选择器统一为左右二级菜单。画布摘要按预算截断不再整轮拒绝,首页视频未知宽高可入库。README 补充贡献者 morrow。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-17 20:21:25 +08:00
parent b6bae4f957
commit 6892732b06
80 changed files with 7364 additions and 182 deletions

View File

@@ -4,7 +4,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, Bot, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react";
import { saveAs } from "file-saver";
import { buildAgentDebugExport } from "@/lib/canvas/agent-debug-export";
import { markdownPlainText } from "@/lib/markdown-plain-text";
import { agentPlanVisible, latestAgentPlanItems, pendingAgentQuestion } from "@/lib/canvas/cloud-agent-plan";
import { nanoid } from "nanoid";
import { ModelPicker } from "@/components/model-picker";
@@ -14,7 +14,7 @@ import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { agentErrorPresentation, agentSubmissionErrorTitle } from "@/lib/canvas/agent-error-presentation";
import { cancelAgentRun, getAgentCapabilities, getAgentProfile, getAgentRun, createAgentRun, decideAgentApproval, sendAgentMessage, subscribeAgentEvents, updateAgentProfile, type AgentEvent, type AgentPermissionMode, type AgentProfileScope, type AgentProfileView, type AgentReasoningMode, type AgentRun } from "@/services/api/agent";
import { cancelAgentRun, getAgentCapabilities, getAgentProfile, getAgentRun, createAgentRun, decideAgentApproval, sendAgentInterjection, sendAgentMessage, subscribeAgentEvents, updateAgentProfile, type AgentEvent, type AgentPermissionMode, type AgentProfileScope, type AgentProfileView, type AgentReasoningMode, type AgentRun } from "@/services/api/agent";
import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation";
import { agentApprovalMatchesSettings, agentImageApproval } from "@/lib/canvas/agent-media-approval";
import type { AgentMediaSettings } from "@/services/api/agent";
@@ -26,7 +26,7 @@ import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
import { AgentChatComposer, AgentChatMessage, AgentWorkingMessage, type CloudAgentChatMessage } from "./canvas-cloud-agent-chat-ui";
import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, AgentWorkingMessage, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui";
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings";
import { useAgentPanelLayout } from "./use-agent-panel-layout";
@@ -79,6 +79,10 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
const [activeConversationId, setActiveConversationId] = useState(() => nanoid());
const [historyHydrated, setHistoryHydrated] = useState(false);
const [pendingHydrated, setPendingHydrated] = useState(false);
const [planMinimized, setPlanMinimized] = useState(false);
const planItems = useMemo(() => latestAgentPlanItems(messages), [messages]);
const planVisible = agentPlanVisible(planItems);
const pendingQuestion = useMemo(() => pendingAgentQuestion(messages), [messages]);
const panelLayout = useAgentPanelLayout();
const lastSeqRef = useRef(0);
const canvasSyncRef = useRef<ReturnType<typeof createAgentCanvasSync> | null>(null);
@@ -321,7 +325,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
lastSeqRef.current = event.seq;
}
setMessages((current) => current.filter((item) => item.id !== `stream-error-${run.id}`));
applyAgentEvent(event, setMessages, setRun, setApproval);
applyAgentEvent(event, setMessages, setRun, setApproval, setPrompt);
canvasSyncRef.current?.receive(event);
},
{
@@ -345,8 +349,33 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
return () => window.removeEventListener("online", reconnect);
}, [run?.id, connectionStatus]);
const submit = async () => {
const value = prompt.trim();
const interject = async (value: string) => {
const activeRun = run;
if (!value || !activeRun?.id || busy || connectionStatus !== "connected" || currentScope.current !== conversationScope || !historyHydrated) return;
const scope = conversationScope;
const messageId = `user-${crypto.randomUUID()}`;
setBusy(true);
try {
await sendAgentInterjection(activeRun.id, { text: value, messageId });
if (currentScope.current !== scope) return;
setPrompt("");
setMessages((current) => appendUniqueMessage(current, { id: messageId, role: "user", text: value, interjection: "sent" }));
} catch (cause) {
if (currentScope.current !== scope) return;
const status = (cause as { status?: number }).status;
setMessages((current) => appendAgentError(current, `interject-error-${activeConversationId}-${messageId}`, cause, "插话没有送达"));
if (status === 409) setPrompt(value);
} finally {
if (currentScope.current === scope) setBusy(false);
}
};
const submit = async (override?: string) => {
const value = (override ?? prompt).trim();
if (running) {
await interject(value);
return;
}
if (!value || busy || running || (run && connectionStatus !== "connected") || submissionRequestRef.current || !historyHydrated || !pendingHydrated || currentScope.current !== conversationScope) return;
const scope = conversationScope;
submissionRequestRef.current = true;
@@ -689,11 +718,21 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
onApprove={(settings) => void submitApproval("approve", settings)}
onReject={() => void submitApproval("reject")}
/>
{planVisible ? <AgentPlanBar items={planItems} theme={theme} minimized={planMinimized} onToggle={() => setPlanMinimized((value) => !value)} /> : null}
{pendingQuestion ? (
<AgentQuestionBar
question={pendingQuestion}
theme={theme}
disabled={approvalSubmitting || connectionStatus !== "connected"}
onAnswer={(label) => void submit(label)}
/>
) : null}
<AgentChatComposer
prompt={prompt}
disabled={busy || running || Boolean(run && connectionStatus !== "connected") || !historyHydrated || !pendingHydrated}
sending={busy || running}
placeholder="输入操作指导;用 @ 引用画布节点,用 / 引用 Skills"
disabled={Boolean(run && connectionStatus !== "connected") || !historyHydrated || !pendingHydrated}
sending={busy}
running={running}
placeholder={running ? "运行中可直接插话,会在它下一步生效" : "输入操作指导;用 @ 引用画布节点,用 / 引用 Skills"}
theme={theme}
onPromptChange={setPrompt}
onSubmit={() => void submit()}
@@ -1047,7 +1086,7 @@ function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange
}
function ApprovalPreviewItemView({ item, theme, onFocusNode }: { item: ReturnType<typeof agentApprovalPresentation>["items"][number]; theme: CanvasTheme; onFocusNode?: (nodeId: string) => void }) {
const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : item.operation === "create_storyboard" ? "创建分镜" : item.operation === "edit_storyboard" ? "修改分镜" : "生成";
const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : item.operation === "create_storyboard" ? "创建分镜" : item.operation === "edit_storyboard" ? "修改分镜" : item.operation === "plan_step" ? "计划" : "生成";
const renderNode = (title: string | undefined, id: string | undefined, typeLabel: string | undefined, role: "source" | "target" | "node") => {
if (!title) return null;
const content = <><span className="canvas-agent-approval-node-title">{title}</span>{typeLabel ? <span className="canvas-agent-approval-node-type">{typeLabel}</span> : null}</>;
@@ -1093,7 +1132,7 @@ function formatConversationTime(value: string) {
if (date.toDateString() === today.toDateString()) return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
return date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
}
function applyAgentEvent(event: AgentEvent, setMessages: Dispatch<SetStateAction<CloudAgentChatMessage[]>>, setRun: Dispatch<SetStateAction<AgentRun | null>>, setApproval: Dispatch<SetStateAction<ApprovalState | null>>) {
function applyAgentEvent(event: AgentEvent, setMessages: Dispatch<SetStateAction<CloudAgentChatMessage[]>>, setRun: Dispatch<SetStateAction<AgentRun | null>>, setApproval: Dispatch<SetStateAction<ApprovalState | null>>, setPrompt?: Dispatch<SetStateAction<string>>) {
const payload = event.payload || {};
const text = String(payload.text || payload.summary || payload.message || "");
if (event.type === "run_status") {
@@ -1132,6 +1171,51 @@ function applyAgentEvent(event: AgentEvent, setMessages: Dispatch<SetStateAction
.map((item) => item.id === id ? { ...item, reasoning: true } : item));
return;
}
if (event.type === "plan_updated" && Array.isArray(payload.items)) {
const id = `plan-${event.runId}`;
const planItems = payload.items as CloudAgentPlanItem[];
setMessages((current) => {
const index = current.findIndex((entry) => entry.id === id);
const message: CloudAgentChatMessage = { id, role: "tool", text: "", planItems };
if (index < 0) return [...current, message];
const next = [...current];
next[index] = message;
return next;
});
return;
}
if (event.type === "user_interjection") {
if (!text) return;
setMessages((current) => appendUniqueMessage(current, { id: String(payload.messageId || event.eventId), role: "user", text, interjection: "sent" }));
return;
}
if (event.type === "user_interjection_dropped") {
const messageId = String(payload.messageId || event.eventId);
const reason = String(payload.reason || "本轮已结束");
setMessages((current) => {
const marked = current.map((item) => item.id === messageId ? { ...item, interjection: "undelivered" as const } : item);
return appendUniqueMessage(marked, { id: `interjection-dropped-${messageId}`, role: "system", text: `${reason},这条插话没有送到模型。需要的话重新发一次,它会作为新一轮。` });
});
setPrompt?.((current) => (current.trim() ? current : text));
return;
}
if (event.type === "user_question") {
const options = Array.isArray(payload.options)
? (payload.options as Array<{ label?: unknown; detail?: unknown }>)
.map((option) => ({ label: String(option?.label || "").trim(), detail: option?.detail === undefined ? undefined : String(option.detail) }))
.filter((option) => option.label)
: [];
const question = String(payload.question || "").trim();
if (!question || options.length < 2) return;
const id = `question-${event.runId}:${event.seq ?? event.eventId}`;
setMessages((current) => appendUniqueMessage(current, {
id,
role: "assistant",
text: "",
question: { question, options, allowFreeform: payload.allowFreeform !== false },
}));
return;
}
if (event.type === "assistant_message") {
setMessages((current) => upsertTextMessage(current, String(payload.messageId || event.eventId), text, false));
return;