Merge remote-tracking branch 'upstream/main' into main (保留常驻通知与顺延迁移版本)

This commit is contained in:
✨await
2026-09-17 23:25:50 +08:00
120 changed files with 9314 additions and 1625 deletions

View File

@@ -42,7 +42,7 @@ export function CanvasAgentImageApprovalSettings({ initial, value, onChange, the
return <fieldset disabled={disabled} className="min-w-0 space-y-3 border-0 p-0" data-canvas-no-zoom data-canvas-wheel-scroll aria-label="图片生成设置">
<div className="space-y-1.5">
<div className="text-xs" style={{ color: theme.node.muted }}>生成模型</div>
<ModelPicker config={config} capability="image" value={model} onChange={changeModel} requirements={requirements} fullWidth showOptionPrices placeholder="选择生成模型" />
<ModelPicker config={config} capability="image" value={model} onChange={changeModel} requirements={requirements} variant="creation" fullWidth showOptionPrices placeholder="选择生成模型" popoverClassName="agent-model-picker-popover" />
</div>
{model ? <ImageSettingsPanel config={{ ...config, model, imageModel: model, size: settings.size, quality: settings.quality, count: "1" }} onConfigChange={changeOption} theme={theme} showTitle={false} showCount={false} showTransparent={false} className="min-w-0 space-y-3" /> : <p className="text-xs" style={{ color: theme.node.muted }}>当前模型不在可选目录中,可重新选择;提交时将重新校验模型与规格。</p>}
<p className="text-xs" style={{ color: theme.node.text }}>本次规格:{settings.size}{settings.quality ? ` · ${settings.quality}` : ""}</p>

View File

@@ -4,7 +4,7 @@ import { Tooltip } from "@/components/ui/base/tooltip";
import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowUp, AtSign, CheckCircle2, ChevronDown, CircleAlert, CircleDot, Eye, ImagePlus, LoaderCircle, Pencil, Plus, RotateCcw, Sparkles, Square, X, XCircle } from "lucide-react";
import { ArrowUp, AtSign, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Eye, HelpCircle, ImagePlus, ListChecks, LoaderCircle, Pencil, Plus, RotateCcw, Sparkles, Square, X, XCircle } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
@@ -24,6 +24,12 @@ type CloudAgentOperationImpact = {
items: string[];
warning?: string;
};
export type CloudAgentPlanItem = { id: string; title: string; status: "pending" | "doing" | "done" };
export type CloudAgentUserQuestion = {
question: string;
options: Array<{ label: string; detail?: string }>;
allowFreeform?: boolean;
};
export type CloudAgentChatMessage = {
id: string;
role: "user" | "assistant" | "system" | "tool" | "error";
@@ -31,9 +37,12 @@ export type CloudAgentChatMessage = {
text: string;
streaming?: boolean;
reasoning?: boolean;
planItems?: CloudAgentPlanItem[];
question?: CloudAgentUserQuestion;
meta?: string;
detail?: unknown;
attachments?: CloudAgentChatAttachment[];
interjection?: "sent" | "undelivered";
};
export type CloudAgentQuickAction = { label: string; prompt: string };
@@ -156,6 +165,16 @@ export function AgentChatMessage({
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentTimelineMarker theme={theme} tone="agent" /> : null}
<div className={`min-w-0 text-sm leading-6 ${isUser ? "max-w-[82%] rounded-2xl rounded-br-md px-3.5 py-2.5 text-right" : "max-w-[calc(100%-36px)] flex-1 text-left"}`} style={{ color, ...(isUser ? { background: theme.node.agentUserMessage } : {}) }}>
{item.interjection ? (
<span
className="mb-1 inline-flex items-center rounded-full px-1.5 py-[1px] text-[var(--fs-label)] leading-4"
style={item.interjection === "undelivered"
? { background: `${theme.accent.danger}22`, color: theme.accent.danger }
: { background: "rgba(255,255,255,0.16)", opacity: 0.72 }}
>
{item.interjection === "undelivered" ? "插话未送达" : "插话"}
</span>
) : null}
{item.role === "assistant" ? (
<AIMessageMarkdown className="text-left" isStreaming={isStreaming}>
{item.text}
@@ -343,11 +362,89 @@ export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (t
);
}
export function AgentPlanBar({ items, theme, minimized, onToggle }: {
items: CloudAgentPlanItem[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
minimized: boolean;
onToggle: () => void;
}) {
const doneCount = items.filter((entry) => entry.status === "done").length;
const allDone = doneCount === items.length;
return (
<div className="agent-plan-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ background: theme.node.fill, border: `1px solid ${theme.node.stroke}`, color: theme.node.text }}>
<button type="button" className="flex w-full items-center gap-2 px-3 py-2 text-left focus-visible:outline focus-visible:outline-2" aria-expanded={!minimized} onClick={onToggle}>
<ListChecks className="size-3.5 shrink-0" style={{ color: allDone ? "#429477" : theme.node.muted }} />
<span className="text-xs font-semibold">本轮待办</span>
<span className="text-[11px] tabular-nums opacity-50">{doneCount}/{items.length}</span>
<span className="min-w-0 flex-1" />
<span className="shrink-0 text-[11px] opacity-50">{minimized ? "展开" : "收起"}</span>
{minimized ? <ChevronDown className="size-3.5 shrink-0 opacity-50" /> : <ChevronUp className="size-3.5 shrink-0 opacity-50" />}
</button>
{minimized ? null : (
<ul className="thin-scrollbar max-h-40 space-y-1 overflow-y-auto px-3 pb-2">
{items.map((entry) => {
const done = entry.status === "done";
const doing = entry.status === "doing";
const Icon = done ? CheckCircle2 : doing ? LoaderCircle : CircleDot;
return (
<li key={entry.id} className="flex min-w-0 items-start gap-1.5 text-xs">
<Icon className={doing ? "mt-[3px] size-3 shrink-0 animate-spin" : "mt-[3px] size-3 shrink-0"} style={{ color: done ? "#429477" : doing ? theme.accent.primary : theme.node.muted }} />
<span className={done ? "min-w-0 break-words line-through opacity-50" : "min-w-0 break-words"}>{entry.title}</span>
</li>
);
})}
</ul>
)}
</div>
);
}
export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }: {
question: CloudAgentUserQuestion;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onAnswer: (label: string) => void;
disabled?: boolean;
}) {
return (
<div className="agent-question-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ background: theme.node.fill, border: `1px solid ${theme.accent.primary}`, color: theme.node.text }}>
<div className="flex items-start gap-2 px-3 pt-2.5">
<HelpCircle className="mt-[1px] size-3.5 shrink-0" style={{ color: theme.accent.primary }} />
<span className="min-w-0 flex-1 text-xs font-semibold leading-5">{question.question}</span>
</div>
<div className="flex flex-wrap gap-2 px-3 pb-2 pt-2">
{question.options.map((option) => (
<button
key={option.label}
type="button"
disabled={disabled}
title={option.detail || option.label}
className="max-w-full rounded-md border px-3 py-1.5 text-left text-xs transition focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{ borderColor: theme.node.stroke, background: theme.toolbar.itemHover }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onAnswer(option.label);
}}
>
<span className="block break-words font-medium">{option.label}</span>
{option.detail ? <span className="mt-0.5 block break-words text-[10px] leading-4 opacity-60">{option.detail}</span> : null}
</button>
))}
</div>
<div className="px-3 pb-2 text-[10px] opacity-50">
{question.allowFreeform === false ? "请从上面选一项。" : "点一项即可,也可以在下方输入框里自己说明。"}
</div>
</div>
);
}
export function AgentChatComposer({
prompt,
attachments = [],
disabled,
sending,
running,
placeholder,
theme,
onPromptChange,
@@ -365,6 +462,7 @@ export function AgentChatComposer({
attachments?: CloudAgentChatAttachment[];
disabled?: boolean;
sending?: boolean;
running?: boolean;
placeholder: string;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onPromptChange: (value: string) => void;
@@ -397,7 +495,7 @@ export function AgentChatComposer({
const attachmentReferences = useMemo(() => agentAttachmentReferences(attachments), [attachments]);
const skillReferences = useMemo(() => buildSkillMentionReferences(availableSlashSkills), [availableSlashSkills]);
const composerReferences = useMemo(() => [...references, ...skillReferences, ...attachmentReferences].filter((reference, index, all) => all.findIndex((item) => item.id === reference.id) === index), [attachmentReferences, references, skillReferences]);
const canStop = Boolean(sending && onStop);
const canStop = Boolean(running && onStop);
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
const reducedMotion = useReducedMotion();
const activeSlashIndex = Math.min(Math.max(slashIndex, 0), Math.max(slashCandidates.length - 1, 0));
@@ -643,32 +741,47 @@ export function AgentChatComposer({
{left}
</div>
<div className="agent-composer-submit flex items-center gap-2">
<span className="agent-composer-send-hint">Enter 换行 · ⌘/Ctrl+Enter 发送</span>
<span className="agent-composer-send-hint">{canStop ? "运行中:发送即插话,下一步生效" : "Enter 换行 · ⌘/Ctrl+Enter 发送"}</span>
{canStop ? <motion.button
type="button"
disabled={stopping}
aria-label="停止本轮"
title="停止当前 Agent 运行"
onClick={() => void onStop?.()}
whileHover={!reducedMotion && !stopping ? { scale: 1.06, y: -1 } : undefined}
whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined}
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }}
transition={{ type: "spring", stiffness: 420, damping: 24 }}
className="grid size-9 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{ background: theme.accent.danger, color: theme.accent.onPrimary, boxShadow: `0 8px 20px ${theme.accent.danger}45` }}
>
{stopping ? <LoaderCircle className="size-4 animate-spin" /> : <Square className="size-3.5" fill="currentColor" />}
</motion.button> : null}
<motion.button
type="button"
disabled={!canSubmit && !canStop}
aria-label={canStop ? "停止本轮" : sending ? "发送中" : "发送"}
title={canStop ? "停止当前 Agent 运行" : "点击发送;⌘/Ctrl+Enter 发送"}
onClick={() => void (canStop ? onStop?.() : onSubmit())}
whileHover={(canSubmit || canStop) && !reducedMotion ? { scale: 1.06, y: -1 } : undefined}
whileTap={(canSubmit || canStop) && !reducedMotion ? { scale: 0.9, y: 1 } : undefined}
disabled={!canSubmit}
aria-label={sending ? "发送中" : canStop ? "插话" : "发送"}
title={canStop ? "插话:Agent 下一次开口时看到它" : "点击发送;⌘/Ctrl+Enter 发送"}
onClick={() => onSubmit()}
whileHover={canSubmit && !reducedMotion ? { scale: 1.06, y: -1 } : undefined}
whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined}
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1, rotate: 0 }}
transition={sending && !reducedMotion ? { duration: 0.42, ease: "easeOut" } : { type: "spring", stiffness: 420, damping: 24 }}
className="grid size-9 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{
background: canStop ? theme.accent.danger : canSubmit || sending ? theme.accent.primary : theme.spatial.surface,
color: canStop || canSubmit || sending ? theme.accent.onPrimary : theme.node.muted,
boxShadow: canStop || canSubmit || sending ? `0 8px 20px ${canStop ? theme.accent.danger : theme.accent.primary}45` : "none",
background: canSubmit || sending ? theme.accent.primary : theme.spatial.surface,
color: canSubmit || sending ? theme.accent.onPrimary : theme.node.muted,
boxShadow: canSubmit || sending ? `0 8px 20px ${theme.accent.primary}45` : "none",
}}
>
<motion.span
key={stopping ? "stopping" : canStop ? "stop" : sending ? "sending" : "ready"}
key={stopping ? "stopping" : sending ? "sending" : "ready"}
initial={reducedMotion ? false : { opacity: 0, scale: 0.65, rotate: sending ? -25 : 25 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.18, ease: "easeOut" }}
className="grid place-items-center"
>
{stopping || (sending && !canStop) ? <LoaderCircle className="size-4 animate-spin" /> : canStop ? <Square className="size-3.5" fill="currentColor" /> : <ArrowUp className="size-4" />}
{sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
</motion.span>
</motion.button>
</div>

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;

View File

@@ -1,15 +1,16 @@
import { useEffect, useState, type ReactNode } from "react";
import { Button, Checkbox, Input } from "antd";
import { ArrowLeft, Check, ChevronRight, Cpu, Gauge, LockKeyhole, PlugZap, Search, ShieldCheck, Sparkles, Wrench } from "lucide-react";
import { ArrowLeft, BookMarked, Check, ChevronRight, Cpu, Gauge, LockKeyhole, PlugZap, Search, ShieldCheck, Sparkles, Wrench } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import type { CanvasTheme } from "@/lib/canvas-theme";
import AgentMemoryPane from "@/pages/settings/agent-memory-pane";
import type { AgentPermissionMode, AgentProfileLayer, AgentProfileScope, AgentProfileView, AgentReasoningMode } from "@/services/api/agent";
import type { Skill } from "@/services/api/skills";
import type { AiConfig } from "@/stores/use-config-store";
export type AgentContextKey = "canvas" | "resources" | "generation_history" | "skills" | "project";
type SettingsSection = "home" | "profile" | "skills" | "mcp" | "context" | "budget";
type SettingsSection = "home" | "profile" | "memories" | "skills" | "mcp" | "context" | "budget";
type AgentSettingsProps = {
theme: CanvasTheme;
@@ -83,6 +84,7 @@ export function CanvasCloudAgentSettings(props: AgentSettingsProps) {
{section === "home" ? <SettingsHome props={props} theme={theme} onOpen={setSection} /> : null}
{section === "profile" ? <ProfileWorkspace props={props} theme={theme} /> : null}
{section === "memories" ? <MemoriesWorkspace /> : null}
{section === "skills" ? <SkillsWorkspace props={props} theme={theme} tab={skillTab} onTabChange={setSkillTab} /> : null}
{section === "mcp" ? <McpWorkspace theme={theme} /> : null}
{section === "context" ? <ContextWorkspace props={props} theme={theme} /> : null}
@@ -126,6 +128,7 @@ function SettingsHome({ props, theme, onOpen }: { props: AgentSettingsProps; the
<div className="space-y-1">
<SettingRow theme={theme} icon={<Cpu className="size-4" />} title="上下文" summary={`${props.nodeCount} 个节点 · ${props.contextScope.length} 个范围`} onClick={() => onOpen("context")} />
<SettingRow theme={theme} icon={<Sparkles className="size-4" />} title="长期偏好" summary={profileSummary(props.profileView)} onClick={() => onOpen("profile")} />
<SettingRow theme={theme} icon={<BookMarked className="size-4" />} title="个人记忆" summary="批准、添加、导出导入;只影响你的会话" onClick={() => onOpen("memories")} />
<SettingRow theme={theme} icon={<Sparkles className="size-4" />} title="Skills · 用户技能库" summary={`${props.installedSkills.length} 个已安装 · 本轮启用 ${props.selectedSkillIds.length} 个`} onClick={() => onOpen("skills")} />
<SettingRow theme={theme} icon={<Wrench className="size-4" />} title="工具与连接" summary="画布、技能参考文件、生成任务" onClick={() => onOpen("mcp")} />
<SettingRow theme={theme} icon={<Gauge className="size-4" />} title="预算" summary={`每轮最多 ${props.maxCredits || "未设置"} 积分 · 固定计价模型`} onClick={() => onOpen("budget")} />
@@ -135,6 +138,14 @@ function SettingsHome({ props, theme, onOpen }: { props: AgentSettingsProps; the
);
}
function MemoriesWorkspace() {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-4">
<AgentMemoryPane compact />
</div>
);
}
function SkillsWorkspace({ props, theme, tab, onTabChange }: { props: AgentSettingsProps; theme: CanvasTheme; tab: "installed" | "market"; onTabChange: (tab: "installed" | "market") => void }) {
const query = props.skillSearch.trim().toLocaleLowerCase();
const source = tab === "installed" ? props.installedSkills.filter((skill) => `${skill.skillName} ${skill.description || ""}`.toLocaleLowerCase().includes(query)) : props.marketSkills;
@@ -302,8 +313,8 @@ function SettingRow({ theme, icon, title, summary, onClick }: { theme: CanvasThe
function TabButton({ active, label, onClick, theme }: { active: boolean; label: string; onClick: () => void; theme: CanvasTheme }) { return <button type="button" aria-pressed={active} className="min-w-0 flex-1 rounded-lg px-2 py-2 text-xs font-medium transition-colors focus-visible:outline focus-visible:outline-2" style={{ background: active ? theme.toolbar.itemHover : "transparent", color: active ? theme.node.text : theme.node.muted }} onClick={onClick}>{label}</button>; }
function SettingLabel({ label, hint }: { label: string; hint?: string }) { return <div className="mb-2 flex items-center justify-between text-xs font-semibold"><span>{label}</span>{hint ? <span className="text-[10px] font-normal opacity-40">{hint}</span> : null}</div>; }
function BudgetInput({ label, hint, value, onChange, theme }: { label: string; hint: string; value: string; onChange: (value: string) => void; theme: CanvasTheme }) { return <label className="block"><span className="flex items-center justify-between text-xs font-medium"><span>{label}</span><span className="text-[10px] opacity-40">{hint}</span></span><Input size="large" value={value} onChange={(event) => onChange(event.target.value.replace(/[^0-9]/g, ""))} inputMode="numeric" className="mt-2 !rounded-lg" style={{ background: theme.node.fill }} /></label>; }
function sectionTitle(section: SettingsSection) { return section === "profile" ? "长期偏好" : section === "skills" ? "Skills" : section === "mcp" ? "MCP 与工具" : section === "context" ? "上下文" : "预算"; }
function sectionSubtitle(section: SettingsSection) { return section === "profile" ? "用户、项目和画布的长期行为偏好" : section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; }
function sectionTitle(section: SettingsSection) { return section === "profile" ? "长期偏好" : section === "memories" ? "个人记忆" : section === "skills" ? "Skills" : section === "mcp" ? "MCP 与工具" : section === "context" ? "上下文" : "预算"; }
function sectionSubtitle(section: SettingsSection) { return section === "profile" ? "用户、项目和画布的长期行为偏好" : section === "memories" ? "只属于你,批准后才会注入会话" : section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; }
export function agentPermissionLabel(mode: AgentPermissionMode) { return permissionOptions.find((option) => option.value === mode)?.label || "请求审批"; }
export function agentPermissionVisual(mode: AgentPermissionMode) { const option = permissionOptions.find((item) => item.value === mode) || permissionOptions[0]; return { color: option.color, soft: `${option.color}1f` }; }

View File

@@ -3,28 +3,11 @@
overscroll-behavior: contain;
}
/* Agent 浮窗高于普通画布层,模型菜单必须叠在对话窗之上。 */
/* Agent 浮窗高于普通画布层,模型菜单必须叠在对话窗之上;菜单本身与全站二级选择器同一套。 */
.agent-model-picker-popover {
z-index: calc(var(--z-modal-overlay) + 1000) !important;
}
.agent-model-picker-popover .canvas-model-picker-menu {
width: min(340px, calc(100vw - 24px)) !important;
max-width: min(340px, calc(100vw - 24px)) !important;
}
.agent-model-picker-popover .canvas-model-picker-two-pane {
grid-template-columns: minmax(0, 1fr) !important;
}
.agent-model-picker-popover .canvas-model-picker-brand-rail {
display: none !important;
}
.agent-model-picker-popover .canvas-model-picker-model-pane {
min-width: 0 !important;
}
/* Flex 子页必须允许收缩,长模型名和 Skill 文案不能决定窗口宽度。 */
.canvas-agent-panel > div,
.canvas-agent-settings,

View File

@@ -42,7 +42,7 @@ export function ModelPicker({
onMissingConfig,
showSelectedPrice = true,
showOptionPrices = showSelectedPrice,
variant = "default",
variant = "creation",
requirements,
showConfiguredModelName = false,
}: ModelPickerProps) {
@@ -173,10 +173,8 @@ export function ModelPicker({
ref={menuRef}
data-canvas-no-zoom
className={cn(
"canvas-model-picker-menu max-w-[calc(100vw-24px)]",
creationVariant
? cn("creation-model-picker-menu", activeGroupKey === null ? "is-brand-list" : "is-model-list")
: "w-[var(--panel-width-compact)]",
"canvas-model-picker-menu creation-model-picker-menu max-w-[calc(100vw-24px)]",
activeGroupKey === null ? "is-brand-list" : "is-model-list",
)}
style={
{
@@ -252,7 +250,7 @@ export function ModelPicker({
model={displayModel}
capability={capability}
theme={theme}
creationVariant={creationVariant}
creationVariant
showConfiguredModelName={showConfiguredModelName}
showPrice={showOptionPrices && creditsEnabled}
disabledReason={disabledReason}
@@ -283,8 +281,8 @@ export function ModelPicker({
arrow={false}
content={content}
classNames={{
root: cn("canvas-model-picker-popover", creationVariant && "creation-model-picker-popover", popoverClassName),
container: cn("canvas-composer-popover-surface", creationVariant && "creation-model-picker-surface"),
root: cn("canvas-model-picker-popover", "creation-model-picker-popover", popoverClassName),
container: cn("canvas-composer-popover-surface", "creation-model-picker-surface"),
content: "canvas-composer-popover-content",
}}
>