merge: official v1.5.6 with multimodal storyboard enhancements
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { resolveImageUrl } from "@/services/image-storage";
|
||||
|
||||
type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
|
||||
@@ -13,11 +12,13 @@ type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src">
|
||||
};
|
||||
|
||||
/**
|
||||
* 资源图片优先读取按用户隔离的本地 Blob 缓存,避免刷新后再次从对象存储下载。
|
||||
* 本地 image: 类型的 storageKey 也会自动从 LocalForage 恢复有效的 Object URL。
|
||||
* 远程资源图片统一使用稳定的资源文件地址。
|
||||
* Blob 缓存仍可用于导出、抽帧等字节处理,但不作为媒体展示 src,避免把
|
||||
* `blob:http(s)://...` 泄露到节点、素材库和浏览器媒体链路中。
|
||||
*/
|
||||
export function CachedResourceImage({ storageKey, src = "", fallback = null, loadingFallback = fallback, eager = false, onError, ...props }: CachedResourceImageProps) {
|
||||
const remoteResource = Boolean(resourceIdFromStorageKey(storageKey));
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
const remoteResource = Boolean(resourceId);
|
||||
const localImageResource = Boolean(storageKey && storageKey.startsWith("image:"));
|
||||
const targetRef = useRef<HTMLSpanElement>(null);
|
||||
const [nearViewport, setNearViewport] = useState(eager || !remoteResource);
|
||||
@@ -51,29 +52,14 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, loa
|
||||
let cancelled = false;
|
||||
setCacheFailed(false);
|
||||
|
||||
if (remoteResource && storageKey) {
|
||||
if (remoteResource && resourceId) {
|
||||
if (!nearViewport) {
|
||||
setCachedSrc("");
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
setCachedSrc("");
|
||||
const resolve = cacheResourceObjectUrl(storageKey);
|
||||
void resolve
|
||||
.then((url) => {
|
||||
if (!cancelled) {
|
||||
setCachedSrc(url || src);
|
||||
setCacheFailed(!url && !src);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// 缓存读取失败时仍允许原始地址加载,真正的解码失败再显示占位。
|
||||
setCacheFailed(!src);
|
||||
setCachedSrc(src);
|
||||
}
|
||||
});
|
||||
setCachedSrc(resourceFileUrl(resourceId));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -96,7 +82,7 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, loa
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [localImageResource, nearViewport, remoteResource, src, storageKey]);
|
||||
}, [localImageResource, nearViewport, remoteResource, resourceId, src, storageKey]);
|
||||
|
||||
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) {
|
||||
|
||||
47
web/src/components/canvas/canvas-agent-welcome.tsx
Normal file
47
web/src/components/canvas/canvas-agent-welcome.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ArrowUpRight, Clapperboard, Layers3, Sparkles } from "lucide-react";
|
||||
|
||||
type AgentWelcomeProps = {
|
||||
brandName: string;
|
||||
nodeCount: number;
|
||||
onChooseSkill: () => void;
|
||||
onDraftPrompt: (prompt: string) => void;
|
||||
};
|
||||
|
||||
export function AgentWelcome({ brandName, nodeCount, onChooseSkill, onDraftPrompt }: AgentWelcomeProps) {
|
||||
return (
|
||||
<section className="agent-welcome" aria-label="开始 Agent 创作">
|
||||
<div className="agent-welcome-intro">
|
||||
<span className="agent-welcome-orb" aria-hidden="true" />
|
||||
<h2>在这里,和{brandName}让灵感,慢慢成形</h2>
|
||||
<p>从一个想法开始,和 Agent 一起创作。</p>
|
||||
</div>
|
||||
<div className="agent-welcome-actions">
|
||||
<button type="button" onClick={onChooseSkill}>
|
||||
<Sparkles aria-hidden="true" />
|
||||
<span>
|
||||
<strong>选择技能,开始创作</strong>
|
||||
<small>为这次创作找到合适的帮手</small>
|
||||
</span>
|
||||
<ArrowUpRight className="agent-welcome-arrow" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" onClick={() => onDraftPrompt("我想创作一段短片,请先和我一起梳理故事方向。先询问我的想法,不要直接生成。")}>
|
||||
<Clapperboard aria-hidden="true" />
|
||||
<span>
|
||||
<strong>从灵感构思故事</strong>
|
||||
<small>聊聊主题、角色,或一个难忘的画面</small>
|
||||
</span>
|
||||
<ArrowUpRight className="agent-welcome-arrow" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" disabled={nodeCount === 0} onClick={() => onDraftPrompt("请先阅读当前画布,梳理素材与节点之间的关系,给出接下来的创作建议。先不要修改节点或提交生成任务。")}>
|
||||
<Layers3 aria-hidden="true" />
|
||||
<span>
|
||||
<strong>一起梳理当前画布</strong>
|
||||
<small>{nodeCount > 0 ? `${nodeCount} 个节点,看看下一步可以做什么` : "添加节点后,一起梳理创作思路"}</small>
|
||||
</span>
|
||||
<ArrowUpRight className="agent-welcome-arrow" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="agent-welcome-footnote">先聊想法,再决定下一步</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -166,7 +166,9 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
{
|
||||
id: "asset-tray-toggle",
|
||||
label: open ? "收起素材空间" : `打开素材空间,共 ${(showLibrary ? assetImages.length : 0) + canvasImages.length} 项`,
|
||||
icon: <span className="relative"><Images /><span className="absolute -right-1.5 -top-1.5 min-w-3 rounded-full px-0.5 text-center text-[var(--fs-nano)] font-bold leading-3" style={{ background: theme.accent.primary, color: theme.accent.onPrimary }}>{(showLibrary ? assetImages.length : 0) + canvasImages.length}</span></span>,
|
||||
displayLabel: "素材",
|
||||
badge: (showLibrary ? assetImages.length : 0) + canvasImages.length,
|
||||
icon: <Images />,
|
||||
active: open,
|
||||
onClick: () => {
|
||||
bringToFront();
|
||||
@@ -248,7 +250,7 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<FloatingDock items={dockItems} className="canvas-floating-dock" style={canvasDockStyle(theme)} ariaLabel="素材空间" />
|
||||
<FloatingDock items={dockItems} showLabels className="canvas-floating-dock" style={canvasDockStyle(theme)} ariaLabel="素材空间" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ export function AgentChatMessage({
|
||||
return (
|
||||
<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 } : {}) }}>
|
||||
<div className={`agent-message-body min-w-0 text-sm leading-6 ${isUser ? "agent-message-user max-w-[82%] px-4 py-3 text-right" : "max-w-[calc(100%-36px)] flex-1 text-left"}`} style={{ color }}>
|
||||
{item.interjection ? (
|
||||
<span
|
||||
className="mb-1 inline-flex items-center rounded-full px-1.5 py-[1px] text-[var(--fs-label)] leading-4"
|
||||
@@ -370,7 +370,7 @@ export function AgentPlanBar({ items, theme, minimized, onToggle }: {
|
||||
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 }}>
|
||||
<div className="agent-plan-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ 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>
|
||||
@@ -405,7 +405,7 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
|
||||
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="agent-question-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ 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>
|
||||
@@ -417,8 +417,8 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
|
||||
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 }}
|
||||
className="max-w-full rounded-md border-0 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={{ background: theme.toolbar.itemHover }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
@@ -600,13 +600,11 @@ export function AgentChatComposer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-0 shrink-0 px-3 pb-3 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
|
||||
<div className="agent-composer-wrap min-w-0 shrink-0" onWheelCapture={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
className="group/composer relative rounded-2xl px-3 pb-2.5 pt-3 transition-[background-color,box-shadow] duration-200"
|
||||
className="agent-composer-surface group/composer relative transition-[background-color,box-shadow] duration-200"
|
||||
style={{
|
||||
background: theme.node.fill,
|
||||
color: theme.accent.primary,
|
||||
boxShadow: `0 16px 40px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,0.045)`,
|
||||
}}
|
||||
>
|
||||
{sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
|
||||
@@ -757,8 +755,8 @@ export function AgentChatComposer({
|
||||
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` }}
|
||||
className="grid size-7 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 }}
|
||||
>
|
||||
{stopping ? <LoaderCircle className="size-4 animate-spin" /> : <Square className="size-3.5" fill="currentColor" />}
|
||||
</motion.button> : null}
|
||||
@@ -772,11 +770,10 @@ export function AgentChatComposer({
|
||||
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"
|
||||
className="agent-composer-send grid size-7 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: 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
|
||||
@@ -786,7 +783,7 @@ export function AgentChatComposer({
|
||||
transition={{ duration: reducedMotion ? 0 : 0.18, ease: "easeOut" }}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
{sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
|
||||
{sending ? <LoaderCircle className="size-3.5 animate-spin" /> : <ArrowUp className="size-3.5" />}
|
||||
</motion.span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type SetStateAction } from "react";
|
||||
import { Button, Dropdown, Input } from "antd";
|
||||
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 { ArrowLeft, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, MoveDiagonal2, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react";
|
||||
import { saveAs } from "file-saver";
|
||||
import { buildAgentDebugExport } from "@/lib/canvas/agent-debug-export";
|
||||
import { agentPlanVisible, latestAgentPlanItems, pendingAgentQuestion } from "@/lib/canvas/cloud-agent-plan";
|
||||
@@ -24,6 +24,7 @@ import { addSkill, listAddedSkills, listSkills, type Skill, type SkillCategory }
|
||||
import { clearCloudAgentPendingSubmission, cloudAgentConversationTitle, loadCloudAgentConversations, loadCloudAgentPendingSubmission, saveCloudAgentConversations, saveCloudAgentPendingSubmission, type CloudAgentConversation, type CloudAgentPendingSubmission } from "@/services/cloud-agent-conversations";
|
||||
import { logicalModelIDForConfig, modelOptionName, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
|
||||
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
|
||||
@@ -31,6 +32,7 @@ import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, Ag
|
||||
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";
|
||||
import { AgentWelcome } from "./canvas-agent-welcome";
|
||||
import "./canvas-cloud-agent.css";
|
||||
|
||||
type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; prefillPrompt?: string; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void };
|
||||
@@ -598,8 +600,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.985 }}
|
||||
transition={{ duration: reducedMotion ? 0 : 0.26, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="canvas-agent-panel fixed z-[var(--z-modal-overlay)] flex min-w-0 flex-col overflow-hidden rounded-2xl border max-sm:rounded-b-none"
|
||||
style={{ ...panelLayout.style, background: theme.node.panel, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 90px ${theme.spatial.shadow}` }}
|
||||
className="canvas-agent-panel fixed z-[var(--z-modal-overlay)] flex min-w-0 flex-col overflow-hidden"
|
||||
style={{ ...panelLayout.style, "--agent-surface-base": theme.node.panel, "--agent-ink": theme.node.text, "--agent-accent": theme.accent.primary, "--agent-shadow-color": theme.spatial.shadow } as CSSProperties & Record<`--${string}`, string>}
|
||||
aria-label="Agent 工作台"
|
||||
data-canvas-no-zoom
|
||||
data-canvas-wheel-scroll
|
||||
@@ -613,10 +615,11 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
aria-label="调整 Agent 面板大小"
|
||||
title="拖动调整宽高,也可用方向键调整"
|
||||
data-agent-resize="northwest"
|
||||
className="absolute left-0 top-0 z-10 hidden size-5 cursor-nw-resize touch-none opacity-50 transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-2 sm:block"
|
||||
className="agent-panel-resize-corner absolute left-2 top-2 z-10 hidden size-5 cursor-nw-resize touch-none place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-2 sm:grid"
|
||||
onKeyDown={panelLayout.onResizeKeyDown}
|
||||
style={{ color: theme.node.muted }}
|
||||
>
|
||||
<span className="absolute left-1.5 top-1.5 size-2 border-l-2 border-t-2 rounded-tl" style={{ borderColor: theme.node.muted }} />
|
||||
<MoveDiagonal2 className="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{view === "settings" ? (
|
||||
@@ -676,6 +679,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
<motion.div key="chat" className="flex min-h-0 flex-1 flex-col" initial={{ opacity: 0, x: -12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: reducedMotion ? 0 : 0.18 }}>
|
||||
<AgentHeader
|
||||
theme={theme}
|
||||
hasMessages={messages.length > 0}
|
||||
statusLabel={statusLabel}
|
||||
statusColor={statusColor}
|
||||
nodeCount={nodeCount}
|
||||
@@ -715,6 +719,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
|
||||
approval={approval}
|
||||
nodeCount={nodeCount}
|
||||
approvalSubmitting={approvalSubmitting || connectionStatus !== "connected"}
|
||||
onChooseSkill={() => setSkillsOpen(true)}
|
||||
onDraftPrompt={(draft) => setPrompt((current) => current.trim() ? `${current}\n\n${draft}` : draft)}
|
||||
onApprovalReasonChange={(reason) => setApproval((current) => (current ? { ...current, reason } : current))}
|
||||
onApprove={(settings) => void submitApproval("approve", settings)}
|
||||
onReject={() => void submitApproval("reject")}
|
||||
@@ -812,23 +818,20 @@ function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onO
|
||||
);
|
||||
}
|
||||
|
||||
function AgentHeader({ theme, statusLabel, statusColor, nodeCount, onNew, onHistory, onSettings, onCollapse, onExport, exporting }: { theme: CanvasTheme; statusLabel: string; statusColor: string; nodeCount: number; onNew: () => void; onHistory: () => void; onSettings: () => void; onCollapse: () => void; onExport: () => void; exporting: boolean }) {
|
||||
function AgentHeader({ theme, hasMessages, statusLabel, statusColor, nodeCount, onNew, onHistory, onSettings, onCollapse, onExport, exporting }: { theme: CanvasTheme; hasMessages: boolean; statusLabel: string; statusColor: string; nodeCount: number; onNew: () => void; onHistory: () => void; onSettings: () => void; onCollapse: () => void; onExport: () => void; exporting: boolean }) {
|
||||
return (
|
||||
<header data-agent-drag-handle className="flex h-[68px] shrink-0 items-center gap-3 px-4" style={{ boxShadow: `inset 0 -1px 0 ${theme.toolbar.border}` }}>
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-xl" style={{ color: theme.node.text, background: theme.node.fill }}>
|
||||
<Bot className="size-4" />
|
||||
</span>
|
||||
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">Agent</span>
|
||||
<span className="flex items-center gap-1 text-[11px]" style={{ color: statusColor }}>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="agent-panel-title">{hasMessages ? "画布助手" : "新对话"}</span>
|
||||
<span className="agent-panel-status flex items-center gap-1" style={{ color: statusColor }}>
|
||||
<CircleDot className="size-3" />
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] opacity-40">当前画布 · {nodeCount} 个节点</div>
|
||||
<div className="agent-panel-context">Agent · 当前画布 {nodeCount} 个节点</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="agent-header-actions flex items-center gap-0.5" style={{ color: theme.node.muted }}>
|
||||
<Button type="text" shape="circle" icon={<Download className="size-4" />} loading={exporting} onClick={onExport} aria-label="导出 Agent 调试记录" title="导出对话、工具参数、审批和错误(分享前请检查隐私)" />
|
||||
<Button type="text" shape="circle" icon={<MessageSquarePlus className="size-4" />} onClick={onNew} aria-label="新建对话" title="新建对话" />
|
||||
<Button type="text" shape="circle" icon={<History className="size-4" />} onClick={onHistory} aria-label="历史对话" title="历史对话" />
|
||||
@@ -842,7 +845,7 @@ function AgentHeader({ theme, statusLabel, statusColor, nodeCount, onNew, onHist
|
||||
function AgentHistory({ conversations, activeConversationId, theme, onBack, onNew, onOpen, onDelete }: { conversations: CloudAgentConversation[]; activeConversationId: string; theme: CanvasTheme; onBack: () => void; onNew: () => void; onOpen: (conversation: CloudAgentConversation) => void; onDelete: (id: string) => void }) {
|
||||
return (
|
||||
<div className="canvas-agent-history-root flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<header data-agent-drag-handle className="flex h-[68px] shrink-0 items-center gap-2 px-3" style={{ boxShadow: `inset 0 -1px 0 ${theme.toolbar.border}` }}>
|
||||
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-2">
|
||||
<Button type="text" shape="circle" icon={<ArrowLeft className="size-4" />} onClick={onBack} aria-label="返回对话" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold">历史对话</div>
|
||||
@@ -891,6 +894,8 @@ function AgentConversation({
|
||||
approval,
|
||||
approvalSubmitting,
|
||||
nodeCount,
|
||||
onChooseSkill,
|
||||
onDraftPrompt,
|
||||
onFocusNode,
|
||||
onApprovalReasonChange,
|
||||
onApprove,
|
||||
@@ -903,11 +908,14 @@ function AgentConversation({
|
||||
approval: ApprovalState | null;
|
||||
approvalSubmitting: boolean;
|
||||
nodeCount: number;
|
||||
onChooseSkill: () => void;
|
||||
onDraftPrompt: (prompt: string) => void;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
onApprovalReasonChange: (reason: string) => void;
|
||||
onApprove: (settings?: AgentMediaSettings) => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const followRef = useRef(true);
|
||||
@@ -934,12 +942,12 @@ function AgentConversation({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} data-agent-conversation className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-5" onScroll={(event) => {
|
||||
<div ref={scrollRef} data-agent-conversation className="agent-conversation thin-scrollbar min-h-0 flex-1 overflow-y-auto" onScroll={(event) => {
|
||||
const element = event.currentTarget;
|
||||
followRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
|
||||
}}>
|
||||
{!messages.length ? <AgentEmptyState theme={theme} nodeCount={nodeCount} /> : null}
|
||||
<div ref={contentRef} className="space-y-2.5">
|
||||
{!messages.length ? <AgentWelcome brandName={brandName} nodeCount={nodeCount} onChooseSkill={onChooseSkill} onDraftPrompt={onDraftPrompt} /> : null}
|
||||
<div ref={contentRef} className="agent-conversation-messages">
|
||||
{messages.map((item) => (
|
||||
<AgentChatMessage key={item.id} item={item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && item.streaming === true && item === messages.at(-1)} />
|
||||
))}
|
||||
@@ -952,18 +960,6 @@ function AgentConversation({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentEmptyState({ theme, nodeCount }: { theme: CanvasTheme; nodeCount: number }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-72 flex-col items-center justify-center px-8 pb-8 text-center">
|
||||
<div className="grid size-12 place-items-center rounded-2xl" style={{ background: theme.node.fill, color: theme.node.muted }}>
|
||||
<Bot className="size-6" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-lg font-semibold">现在想做什么?</h2>
|
||||
<p className="mt-2 max-w-64 text-xs leading-5" style={{ color: theme.node.muted }}>当前画布有 {nodeCount} 个节点。Agent 可以按权限读取画布、调用已启用 Skill、整理文本节点并提交受预算约束的生成任务。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerControls({
|
||||
reasoningMode,
|
||||
reasoningSupported,
|
||||
@@ -992,6 +988,7 @@ function ComposerControls({
|
||||
selectedSkillCount: number;
|
||||
}) {
|
||||
const permissionVisual = agentPermissionVisual(permissionMode);
|
||||
const PermissionIcon = permissionVisual.icon;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-0.5">
|
||||
<ModelPicker
|
||||
@@ -1000,7 +997,7 @@ function ComposerControls({
|
||||
capability="text"
|
||||
onChange={onModelChange}
|
||||
variant="creation"
|
||||
className="!h-8 !min-w-0 !w-36 !max-w-full !border-0 !bg-transparent !px-1.5 !shadow-none"
|
||||
className="!h-8 !min-w-0 !w-52 !max-w-full !border-0 !bg-transparent !px-1.5 !shadow-none"
|
||||
popoverClassName="agent-model-picker-popover"
|
||||
showSelectedPrice={false}
|
||||
showOptionPrices
|
||||
@@ -1014,12 +1011,12 @@ function ComposerControls({
|
||||
<Dropdown trigger={["click"]} placement="topLeft" menu={{ items: agentPermissionMenuItems(permissionMode, onPermissionChange) }}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25"
|
||||
className="grid size-8 shrink-0 place-items-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25"
|
||||
style={{ color: theme.node.muted, background: "transparent" }}
|
||||
aria-label="选择 Agent 执行权限"
|
||||
aria-label={`执行权限:${agentPermissionLabel(permissionMode)},点击切换`}
|
||||
title={`执行权限:${agentPermissionLabel(permissionMode)},点击切换`}
|
||||
>
|
||||
<ShieldCheck className="size-3.5" style={{ color: permissionVisual.color }} />
|
||||
<span className="max-w-20 truncate">{agentPermissionLabel(permissionMode)}</span>
|
||||
<PermissionIcon className="size-3.5" style={{ color: permissionVisual.color }} aria-hidden="true" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
<button
|
||||
@@ -1033,7 +1030,7 @@ function ComposerControls({
|
||||
onClick={() => onSkillsOpenChange(true)}
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
<span className="max-w-28 truncate">Skills技能包({selectedSkillCount})</span>
|
||||
<span className="max-w-28 truncate">Skills({selectedSkillCount})</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ export function CanvasCloudAgentSettings(props: AgentSettingsProps) {
|
||||
|
||||
return (
|
||||
<div className="canvas-agent-settings flex min-h-0 min-w-0 flex-1 flex-col" style={{ background: theme.node.panel }}>
|
||||
<header data-agent-drag-handle className="flex h-[68px] shrink-0 items-center gap-2 px-3" style={{ boxShadow: `inset 0 -1px 0 ${theme.toolbar.border}` }}>
|
||||
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-2">
|
||||
<Button type="text" shape="circle" icon={<ArrowLeft className="size-4" />} onClick={section === "home" ? props.onBack : goHome} aria-label={section === "home" ? "返回对话" : "返回设置"} />
|
||||
<div className="min-w-0 flex-1"><div className="text-sm font-semibold">{title}</div><div className="mt-0.5 text-[11px] opacity-40">{section === "home" ? "只影响下一次新运行" : sectionSubtitle(section)}</div></div>
|
||||
{section !== "home" ? <span className="rounded-full px-2 py-1 text-[10px] opacity-50" style={{ background: theme.node.fill }}>{section === "skills" ? `${props.selectedSkillIds.length} 已启用` : "当前 Agent"}</span> : null}
|
||||
@@ -317,5 +317,5 @@ function sectionTitle(section: SettingsSection) { return section === "profile" ?
|
||||
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` }; }
|
||||
export function agentPermissionVisual(mode: AgentPermissionMode) { const option = permissionOptions.find((item) => item.value === mode) || permissionOptions[0]; return { color: option.color, soft: `${option.color}1f`, icon: option.icon }; }
|
||||
export function agentPermissionMenuItems(mode: AgentPermissionMode, onChange: (mode: AgentPermissionMode) => void) { return permissionOptions.map((option) => ({ key: option.value, label: <span>{option.label}</span>, icon: mode === option.value ? <Check className="size-3.5" style={{ color: option.color }} /> : <option.icon className="size-3.5" style={{ color: option.color }} />, onClick: () => onChange(option.value) })); }
|
||||
|
||||
@@ -1,6 +1,242 @@
|
||||
.canvas-agent-panel {
|
||||
/* Canvas theme -> material tokens. Only the floating shell blurs the canvas. */
|
||||
--agent-material-solid: color-mix(in srgb, var(--agent-ink) 3%, var(--agent-surface-base));
|
||||
--agent-material-glass: color-mix(in srgb, var(--agent-material-solid) 68%, transparent);
|
||||
--agent-material-sheen: color-mix(in srgb, var(--color-neutral-0) 8%, transparent);
|
||||
--agent-surface-soft: color-mix(in srgb, var(--agent-ink) 4%, transparent);
|
||||
--agent-surface-hover: color-mix(in srgb, var(--agent-ink) 7%, transparent);
|
||||
--agent-surface-raised: color-mix(in srgb, var(--agent-ink) 6%, var(--agent-material-solid));
|
||||
--agent-caption: color-mix(in srgb, var(--agent-ink) 62%, var(--agent-surface-base));
|
||||
--agent-radius-shell: calc(var(--radius) * 3.5);
|
||||
--agent-radius-surface: calc(var(--radius) * 2.5);
|
||||
--agent-material-shadow: 0 24px 80px var(--agent-shadow-color);
|
||||
--agent-focus-ring: color-mix(in srgb, var(--agent-accent) 65%, var(--agent-ink));
|
||||
container-type: inline-size;
|
||||
overscroll-behavior: contain;
|
||||
isolation: isolate;
|
||||
border: 0;
|
||||
border-radius: var(--agent-radius-shell);
|
||||
background: var(--agent-material-solid);
|
||||
color: var(--agent-ink);
|
||||
box-shadow: var(--agent-material-shadow);
|
||||
}
|
||||
|
||||
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.canvas-agent-panel {
|
||||
background: linear-gradient(145deg, var(--agent-material-sheen), transparent 55%), var(--agent-material-glass);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(145%);
|
||||
backdrop-filter: blur(32px) saturate(145%);
|
||||
}
|
||||
}
|
||||
|
||||
.agent-panel-header {
|
||||
min-height: 80px;
|
||||
padding: var(--space-4) var(--space-5) var(--space-3) var(--space-6);
|
||||
}
|
||||
|
||||
.agent-panel-title {
|
||||
font-size: var(--fs-heading);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.agent-panel-context {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--agent-caption);
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
|
||||
.agent-panel-status {
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
|
||||
.agent-header-actions .ant-btn {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agent-conversation {
|
||||
padding: var(--space-4) var(--space-6) var(--space-6);
|
||||
scroll-padding-block: var(--space-6);
|
||||
}
|
||||
|
||||
.agent-conversation-messages > * + * {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.agent-message-user {
|
||||
border-radius: var(--agent-radius-surface) var(--agent-radius-surface) var(--space-2) var(--agent-radius-surface);
|
||||
background: var(--agent-surface-hover, color-mix(in srgb, var(--foreground) 7%, transparent));
|
||||
}
|
||||
|
||||
.agent-welcome {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
|
||||
.agent-welcome-intro {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-welcome-orb {
|
||||
display: inline-block;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin-bottom: var(--space-5);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 32% 24%, var(--color-neutral-0), transparent 36%),
|
||||
radial-gradient(circle at 70% 80%, var(--palette-node-audio), transparent 64%),
|
||||
radial-gradient(circle at 35% 65%, color-mix(in srgb, var(--agent-accent) 65%, var(--color-neutral-0)), var(--agent-accent));
|
||||
box-shadow: inset -4px -6px 14px color-mix(in srgb, var(--agent-accent) 28%, transparent), 0 8px 32px color-mix(in srgb, var(--agent-accent) 14%, transparent);
|
||||
}
|
||||
|
||||
.agent-welcome-intro h2 {
|
||||
margin: 0;
|
||||
color: var(--agent-ink);
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.5;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.agent-welcome-intro p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--agent-caption);
|
||||
font-size: var(--fs-caption);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.agent-welcome-actions {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.agent-welcome-actions button {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 0;
|
||||
border-radius: var(--space-4);
|
||||
background: transparent;
|
||||
color: var(--agent-caption);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.agent-welcome-actions button:hover:not(:disabled) {
|
||||
background: var(--agent-surface-soft);
|
||||
color: var(--agent-ink);
|
||||
}
|
||||
|
||||
.agent-welcome-actions button:focus-visible {
|
||||
outline: 2px solid var(--agent-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.agent-welcome-actions button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.agent-welcome-actions svg {
|
||||
width: var(--space-5);
|
||||
height: var(--space-5);
|
||||
flex: 0 0 auto;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.agent-welcome-actions button > span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-welcome-actions strong,
|
||||
.agent-welcome-actions small {
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.agent-welcome-actions strong {
|
||||
color: var(--agent-ink);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
.agent-welcome-actions small {
|
||||
margin-top: var(--space-half);
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
|
||||
.agent-welcome-actions .agent-welcome-arrow {
|
||||
width: var(--space-4);
|
||||
height: var(--space-4);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.agent-welcome-footnote {
|
||||
margin: var(--space-6) 0 0;
|
||||
color: var(--agent-caption);
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
|
||||
.agent-composer-wrap {
|
||||
padding: var(--space-2) var(--space-4) var(--space-4);
|
||||
}
|
||||
|
||||
.agent-composer-surface {
|
||||
padding: var(--space-4) var(--space-4) var(--space-3);
|
||||
border-radius: var(--agent-radius-surface, var(--radius-xl));
|
||||
background: var(--agent-surface-raised, var(--background));
|
||||
}
|
||||
|
||||
.agent-composer-surface:focus-within {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent-accent, var(--foreground)) 35%, transparent);
|
||||
}
|
||||
|
||||
.agent-plan-bar {
|
||||
border: 0;
|
||||
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 4%, transparent));
|
||||
}
|
||||
|
||||
.agent-question-bar {
|
||||
border: 0;
|
||||
background: var(--agent-surface-raised, var(--background));
|
||||
}
|
||||
|
||||
@container (max-width: 400px) {
|
||||
.agent-panel-header {
|
||||
gap: var(--space-2);
|
||||
padding-inline: var(--space-4);
|
||||
}
|
||||
.agent-panel-status { white-space: nowrap; }
|
||||
.agent-conversation { padding-inline: var(--space-4); }
|
||||
.agent-welcome-actions button { padding-inline: var(--space-2); }
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.canvas-agent-panel { border-end-start-radius: 0; border-end-end-radius: 0; }
|
||||
.agent-composer-wrap { padding-bottom: max(var(--space-4), env(safe-area-inset-bottom)); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.canvas-agent-panel { background: var(--agent-material-solid); -webkit-backdrop-filter: none; backdrop-filter: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-welcome-actions button { transition: none; }
|
||||
}
|
||||
|
||||
/* Agent 浮窗高于普通画布层,模型菜单必须叠在对话窗之上;菜单本身与全站二级选择器同一套。 */
|
||||
@@ -172,16 +408,16 @@
|
||||
|
||||
.agent-tool-action-link {
|
||||
min-width: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
padding: 4px 7px;
|
||||
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 3%, transparent));
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: color-mix(in srgb, currentColor 88%, transparent);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease;
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.agent-tool-action-link:not(:disabled) {
|
||||
@@ -189,7 +425,6 @@
|
||||
}
|
||||
|
||||
.agent-tool-action-link:not(:disabled):hover {
|
||||
border-color: color-mix(in srgb, var(--foreground) 26%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
@@ -412,7 +647,7 @@
|
||||
}
|
||||
|
||||
.agent-reasoning-card[open] {
|
||||
background: color-mix(in srgb, var(--agent-reasoning-accent) 3.5%, var(--background));
|
||||
background: var(--agent-surface-soft, color-mix(in srgb, var(--agent-reasoning-accent) 3.5%, var(--background)));
|
||||
}
|
||||
|
||||
.agent-reasoning-summary {
|
||||
@@ -590,11 +825,10 @@
|
||||
.canvas-agent-approval-card {
|
||||
--agent-approval-blue: #2563eb;
|
||||
--agent-approval-blue-hover: #1d4ed8;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 13%, transparent);
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--foreground) 4%, var(--background));
|
||||
padding: 16px;
|
||||
box-shadow: 0 10px 28px color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
border: 0;
|
||||
border-radius: var(--agent-radius-surface, var(--radius-xl));
|
||||
background: var(--agent-surface-raised, color-mix(in srgb, var(--foreground) 4%, var(--background)));
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.canvas-agent-approval-header {
|
||||
@@ -654,7 +888,7 @@
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
padding: 9px 10px;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-ge
|
||||
import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities";
|
||||
import { defaultImageParamsForModel, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
|
||||
import { canonicalGenerationMetadata } from "@/lib/canvas/generation-contract";
|
||||
import type { CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows";
|
||||
@@ -66,6 +67,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses);
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const mode = node.metadata?.generationMode === "video" || node.metadata?.generationMode === "audio" ? node.metadata.generationMode : "image";
|
||||
node = { ...node, metadata: canonicalGenerationMetadata(node, mode) };
|
||||
const simpleMode = workspaceMode === "simple";
|
||||
const resolvedProvider = resolveCanvasWorkflowProvider(node.metadata);
|
||||
const workflowProvider = resolvedProvider;
|
||||
@@ -74,16 +76,16 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
capability: mode,
|
||||
input: inputSummary,
|
||||
videoOperation: node.metadata?.videoEditOperation,
|
||||
videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined,
|
||||
videoSeconds: mode === "video" ? node.metadata?.seconds ?? globalConfig.videoSeconds : undefined,
|
||||
options: modelRequestOptions({
|
||||
...globalConfig,
|
||||
size: node.metadata?.size || globalConfig.size,
|
||||
quality: node.metadata?.quality || globalConfig.quality,
|
||||
count: String(node.metadata?.count || globalConfig.count),
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds,
|
||||
videoSeconds: node.metadata?.seconds ?? globalConfig.videoSeconds,
|
||||
vquality: node.metadata?.vquality || globalConfig.vquality,
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio,
|
||||
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark,
|
||||
videoGenerateAudio: node.metadata?.generateAudio ?? globalConfig.videoGenerateAudio,
|
||||
videoWatermark: node.metadata?.watermark ?? globalConfig.videoWatermark,
|
||||
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice,
|
||||
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat,
|
||||
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed,
|
||||
@@ -450,6 +452,7 @@ function WorkflowSelectedLabel({ kind, label }: { kind: "app" | "workflow"; labe
|
||||
}
|
||||
|
||||
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode, requirements: ModelRequirements): AiConfig {
|
||||
node = { ...node, metadata: canonicalGenerationMetadata(node, mode) };
|
||||
const workflowProvider = mode === "text" ? "model" : resolveCanvasWorkflowProvider(node.metadata);
|
||||
if (workflowProvider === "model") return buildModelNodeConfig(globalConfig, node, mode, requirements);
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
||||
@@ -476,7 +479,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
const workflowParameter = (source: string) => workflowParameters[`source:${source}`] === undefined ? "" : String(workflowParameters[`source:${source}`]);
|
||||
const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { size: workflowOutputSize || node.metadata?.size || globalConfig.size || defaultConfig.size, quality: node.metadata?.quality || workflowParameter("quality") || globalConfig.quality || defaultConfig.quality, transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, count: String(node.metadata?.count || globalConfig.canvasImageCount || globalConfig.count || defaultConfig.count) }) : undefined;
|
||||
const videoProfile = mode === "video" ? capabilityProfile.video! : undefined;
|
||||
const rawVideoSettings = { seconds: node.metadata?.seconds || workflowParameter("videoSeconds") || globalConfig.videoSeconds || defaultConfig.videoSeconds, ratio: workflowOutputSize || node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || workflowParameter("vquality") || globalConfig.vquality || defaultConfig.vquality };
|
||||
const rawVideoSettings = { seconds: node.metadata?.seconds ?? workflowParameter("videoSeconds") ?? globalConfig.videoSeconds ?? defaultConfig.videoSeconds, ratio: workflowOutputSize || node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || workflowParameter("vquality") || globalConfig.vquality || defaultConfig.vquality };
|
||||
const normalizedVideo = videoProfile
|
||||
? { seconds: String(rawVideoSettings.seconds), ratio: rawVideoSettings.ratio, resolution: rawVideoSettings.resolution }
|
||||
: undefined;
|
||||
@@ -489,10 +492,10 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
quality: workflowParameter("quality") || normalizedImage?.quality || node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: normalizedImage?.size || normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
transparentBackground: normalizedImage?.transparentBackground || ((node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false"),
|
||||
videoSeconds: normalizedVideo?.seconds || normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds),
|
||||
videoSeconds: normalizedVideo?.seconds ?? normalizeVideoDuration(node.metadata?.seconds ?? globalConfig.videoSeconds ?? defaultConfig.videoSeconds),
|
||||
vquality: String(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality),
|
||||
videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio || globalConfig.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false",
|
||||
videoWatermark: videoProfile?.watermark.supported ? node.metadata?.watermark || globalConfig.videoWatermark || String(videoProfile.watermark.default) : "false",
|
||||
videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio ?? globalConfig.videoGenerateAudio ?? String(videoProfile.generateAudio.default) : "false",
|
||||
videoWatermark: videoProfile?.watermark.supported ? node.metadata?.watermark ?? globalConfig.videoWatermark ?? String(videoProfile.watermark.default) : "false",
|
||||
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
|
||||
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
|
||||
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
|
||||
@@ -502,6 +505,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
}
|
||||
|
||||
function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode, requirements: ModelRequirements): AiConfig {
|
||||
node = { ...node, metadata: canonicalGenerationMetadata(node, mode) };
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const storedModel = node.metadata?.model;
|
||||
@@ -544,10 +548,10 @@ function buildModelNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode
|
||||
quality: generationDefaults.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: generationDefaults.size ?? globalConfig.size ?? defaultConfig.size,
|
||||
transparentBackground: generationDefaults.transparentBackground || "false",
|
||||
videoSeconds: generationDefaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
|
||||
videoSeconds: generationDefaults.videoSeconds ?? normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
|
||||
vquality: generationDefaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality),
|
||||
videoGenerateAudio: videoProfile?.generateAudio.supported ? generationDefaults.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false",
|
||||
videoWatermark: videoProfile?.watermark.supported ? generationDefaults.videoWatermark || String(videoProfile.watermark.default) : "false",
|
||||
videoGenerateAudio: videoProfile?.generateAudio.supported ? generationDefaults.videoGenerateAudio ?? String(videoProfile.generateAudio.default) : "false",
|
||||
videoWatermark: videoProfile?.watermark.supported ? generationDefaults.videoWatermark ?? String(videoProfile.watermark.default) : "false",
|
||||
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
|
||||
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
|
||||
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { uploadVeilOpacity } from "@/lib/canvas/canvas-file-upload";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { useCanvasNodeActions } from "./canvas-node-action-context";
|
||||
import "./canvas-file-upload.css";
|
||||
|
||||
export function CanvasFileUploadContent({ node, theme, reduceMotion = false }: { node: CanvasNodeData; theme: CanvasTheme; reduceMotion?: boolean }) {
|
||||
const { upload } = useCanvasNodeActions();
|
||||
|
||||
71
web/src/components/canvas/canvas-file-upload.css
Normal file
71
web/src/components/canvas/canvas-file-upload.css
Normal file
@@ -0,0 +1,71 @@
|
||||
/* Canvas file upload placeholder: a local component owns its visual state. */
|
||||
.canvas-file-upload {
|
||||
--upload-glow-blue: color-mix(in srgb, var(--status-loading) 22%, transparent);
|
||||
--upload-glow-mint: color-mix(in srgb, var(--status-success) 14%, transparent);
|
||||
--upload-glow-warm: color-mix(in srgb, var(--status-warning) 12%, transparent);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--upload-surface);
|
||||
color: var(--upload-text);
|
||||
}
|
||||
|
||||
.canvas-file-upload-glow {
|
||||
transition: opacity 180ms ease-out;
|
||||
position: absolute;
|
||||
inset: -45%;
|
||||
background: radial-gradient(ellipse at 28% 32%, var(--upload-glow-blue), transparent 48%), radial-gradient(ellipse at 75% 64%, var(--upload-glow-mint), transparent 46%), radial-gradient(ellipse at 55% 78%, var(--upload-glow-warm), transparent 42%);
|
||||
animation: canvas-file-upload-drift 5s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-file-upload-frost {
|
||||
transition: opacity 180ms ease-out;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--upload-surface) 24%, transparent);
|
||||
backdrop-filter: blur(20px) saturate(0.8);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-file-upload-frost::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -60%;
|
||||
background: linear-gradient(110deg, transparent 35%, color-mix(in srgb, var(--upload-text) 4%, transparent) 47%, color-mix(in srgb, var(--upload-surface) 40%, transparent) 51%, transparent 65%);
|
||||
animation: canvas-file-upload-sheen 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.canvas-file-upload-copy {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
height: 100%;
|
||||
padding: var(--space-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.canvas-file-upload-copy > svg { color: var(--upload-muted); }
|
||||
.canvas-file-upload-label { font-size: var(--fs-caption); font-weight: 500; }
|
||||
.canvas-file-upload-detail { max-width: 100%; font-size: var(--fs-tiny); color: var(--upload-muted); overflow-wrap: anywhere; }
|
||||
.canvas-file-upload-retry { margin-top: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--r-md); background: color-mix(in srgb, var(--upload-text) 8%, transparent); font-size: var(--fs-caption); cursor: pointer; }
|
||||
.canvas-file-upload-retry:hover { background: color-mix(in srgb, var(--upload-text) 12%, transparent); }
|
||||
.canvas-file-upload-retry:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
|
||||
|
||||
@keyframes canvas-file-upload-drift { from { transform: translate(-8%, -4%) rotate(-8deg); } to { transform: translate(8%, 4%) rotate(12deg); } }
|
||||
@keyframes canvas-file-upload-sheen { from { transform: translateX(-35%); } to { transform: translateX(35%); } }
|
||||
|
||||
.canvas-file-upload[data-static] .canvas-file-upload-glow,
|
||||
.canvas-file-upload[data-static] .canvas-file-upload-frost::after { animation: none; }
|
||||
.canvas-file-upload[data-static] .canvas-file-upload-glow,
|
||||
.canvas-file-upload[data-static] .canvas-file-upload-frost { transition: none; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.canvas-file-upload-glow, .canvas-file-upload-frost::after { animation: none; }
|
||||
.canvas-file-upload-glow, .canvas-file-upload-frost { transition: none; }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat
|
||||
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel, isGenerationTaskSubmissionUncertain } from "@/lib/generation-task-display";
|
||||
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
|
||||
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { canvasTextFontSize } from "@/lib/canvas/canvas-text-scale";
|
||||
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
|
||||
import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-preview";
|
||||
@@ -15,7 +16,7 @@ import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { cacheResourceObjectUrl, getCachedResourceObjectUrl, peekCachedResourceObjectUrl, scheduleResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
import { scheduleResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
@@ -306,7 +307,7 @@ function UnknownNodeContent({ theme }: Pick<CanvasNodeContentProps, "theme">) {
|
||||
}
|
||||
|
||||
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: CanvasNodeContentProps) {
|
||||
const fontSize = node.metadata?.fontSize || 14;
|
||||
const fontSize = canvasTextFontSize(node.width, node.height, node.metadata?.fontSize);
|
||||
const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as CSSProperties;
|
||||
const richTextHTML = useMemo(() => canvasRichTextHTML(node.metadata?.richText), [node.metadata?.richText]);
|
||||
|
||||
@@ -650,49 +651,27 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
|
||||
|| (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
const isRemoteResource = Boolean(resourceId);
|
||||
// 图片内容随资源 ID 不可变且后端允许磁盘强缓存:视口内的远程图片首帧直接上直链,
|
||||
// 走浏览器原生解码与磁盘缓存;Blob 缓存就绪后再平滑替换,避免刷新后满屏转圈。
|
||||
const synchronousUrl = eager && isRemoteResource && node.type === CanvasNodeType.Image ? peekCachedResourceObjectUrl(storageKey) || resourceFileUrl(resourceId) : "";
|
||||
// 远程资源展示统一走稳定的云端资源文件地址。Blob 缓存只服务于导出、抽帧和
|
||||
// 供应商输入等字节处理,不再把缓存生成的 blob: URL 写入媒体展示链路。
|
||||
const remoteUrl = isRemoteResource ? resourceFileUrl(resourceId) : "";
|
||||
// Inline data URLs are already local, but decoding thousands of them is
|
||||
// still expensive. Images must wait for the same viewport gate as remote
|
||||
// resources; otherwise DOM virtualization does not reduce image work.
|
||||
const isLazyVisual = node.type === CanvasNodeType.Image;
|
||||
const isHttpUrl = Boolean(fallback && !fallback.startsWith("data:"));
|
||||
const initialUrl = synchronousUrl || (eager && isLazyVisual && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback));
|
||||
const initialUrl = eager && isRemoteResource ? remoteUrl : (eager && isLazyVisual && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback));
|
||||
const [url, setUrl] = useState(() => initialUrl);
|
||||
const [loading, setLoading] = useState(() => !initialUrl && isRemoteResource && eager);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isRemoteResource) {
|
||||
setUrl(isLazyVisual && !eager ? "" : fallback);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const cachedSync = peekCachedResourceObjectUrl(storageKey);
|
||||
if (cachedSync) {
|
||||
setUrl(cachedSync);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!url && eager && isHttpUrl) {
|
||||
setUrl(fallback);
|
||||
setLoading(false);
|
||||
} else if (!url) {
|
||||
setLoading(eager);
|
||||
}
|
||||
// 只有进入视口或被激活的节点才下载远程媒体;缓存层会复用已有 Blob URL 和 in-flight 请求。
|
||||
const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey);
|
||||
void resolve.then((cached) => {
|
||||
if (!cancelled && cached) setUrl(cached);
|
||||
else if (!cancelled && eager && fallback) setUrl(fallback);
|
||||
}).catch(() => {
|
||||
if (!cancelled && eager) setUrl(synchronousUrl || fallback);
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [eager, fallback, isHttpUrl, isLazyVisual, isRemoteResource, storageKey]);
|
||||
setUrl(eager ? remoteUrl : "");
|
||||
setLoading(false);
|
||||
}, [eager, fallback, isLazyVisual, isRemoteResource, remoteUrl]);
|
||||
|
||||
return { url, loading };
|
||||
}
|
||||
|
||||
@@ -301,14 +301,14 @@ export function CanvasNodeLightingPanel({ dataUrl, onClose, onConfirm }: { dataU
|
||||
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover, color: theme.node.text, opacity: options.smartMode ? 1 : 0.55 }}
|
||||
/>
|
||||
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>预设</span>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
<div className="grid shrink-0 auto-rows-[60px] grid-cols-4 gap-1.5">
|
||||
{STYLE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
aria-pressed={options.stylePreset === preset.id}
|
||||
onClick={() => updateOption("stylePreset", options.stylePreset === preset.id ? "" : preset.id)}
|
||||
className={`relative h-[60px] overflow-hidden rounded-[var(--r-lg)] text-left transition-[box-shadow,filter,border-color,background-color] duration-150 ${options.stylePreset === preset.id ? "ring-2 ring-white/60 shadow-[0_0_0_1px_rgba(255,255,255,0.15)]" : "hover:ring-1 hover:ring-white/30"}`}
|
||||
className={`relative h-full min-h-0 overflow-hidden rounded-[var(--r-lg)] text-left transition-[box-shadow,filter,border-color,background-color] duration-150 ${options.stylePreset === preset.id ? "ring-2 ring-white/60 shadow-[0_0_0_1px_rgba(255,255,255,0.15)]" : "hover:ring-1 hover:ring-white/30"}`}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to top, rgba(0,0,0,0.78), rgba(0,0,0,0.08)), url(${preset.image})`,
|
||||
backgroundSize: "cover",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText,
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
|
||||
import { canonicalGenerationMetadata } from "@/lib/canvas/generation-contract";
|
||||
import { clampPromptEditorModalSize, PROMPT_EDITOR_VIEWPORT_MARGIN } from "@/lib/canvas/canvas-prompt-editor-size";
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -79,6 +80,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled));
|
||||
const simpleMode = workspaceMode === "simple";
|
||||
const mode = defaultMode(node.type);
|
||||
node = { ...node, metadata: canonicalGenerationMetadata(node, mode) };
|
||||
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
|
||||
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
|
||||
const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? "";
|
||||
@@ -107,17 +109,17 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
characterCount: activeReferences.filter((item) => item.kind === "character").length,
|
||||
},
|
||||
videoOperation: node.metadata?.videoEditOperation,
|
||||
videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined,
|
||||
videoSeconds: mode === "video" ? node.metadata?.seconds ?? globalConfig.videoSeconds : undefined,
|
||||
options: modelRequestOptions({
|
||||
...globalConfig,
|
||||
size: node.metadata?.size || globalConfig.size,
|
||||
quality: node.metadata?.quality || globalConfig.quality,
|
||||
count: String(node.metadata?.count || globalConfig.count),
|
||||
transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds,
|
||||
videoSeconds: node.metadata?.seconds ?? globalConfig.videoSeconds,
|
||||
vquality: node.metadata?.vquality || globalConfig.vquality,
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio,
|
||||
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark,
|
||||
videoGenerateAudio: node.metadata?.generateAudio ?? globalConfig.videoGenerateAudio,
|
||||
videoWatermark: node.metadata?.watermark ?? globalConfig.videoWatermark,
|
||||
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice,
|
||||
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat,
|
||||
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed,
|
||||
@@ -1054,6 +1056,7 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
|
||||
}
|
||||
|
||||
export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode, requirements: ModelRequirements): AiConfig {
|
||||
node = { ...node, metadata: canonicalGenerationMetadata(node, mode) };
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const preferredModel = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel;
|
||||
@@ -1094,7 +1097,7 @@ export function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mo
|
||||
quality: defaults.quality ?? globalConfig.quality ?? defaultConfig.quality,
|
||||
size: defaults.size ?? globalConfig.size ?? defaultConfig.size,
|
||||
transparentBackground: defaults.transparentBackground ?? "false",
|
||||
videoSeconds: defaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
|
||||
videoSeconds: defaults.videoSeconds ?? normalizeVideoDuration(globalConfig.videoSeconds ?? defaultConfig.videoSeconds),
|
||||
vquality: defaults.vquality ?? normalizeVideoResolution(globalConfig.vquality || defaultConfig.vquality),
|
||||
videoGenerateAudio: defaults.videoGenerateAudio ?? globalConfig.videoGenerateAudio ?? defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: defaults.videoWatermark ?? globalConfig.videoWatermark ?? defaultConfig.videoWatermark,
|
||||
|
||||
@@ -9,8 +9,6 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { useConfigStore, type AiConfig } from "@/stores/use-config-store";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { parseSrt, serializeSrtEntries } from "@/lib/timeline/srt-parser";
|
||||
import { DEFAULT_MAX_CHARS_PER_ENTRY, MAX_CHARS_PER_ENTRY_LIMIT, MIN_CHARS_PER_ENTRY, resegmentSrtEntries, splitLongEntry } from "@/lib/timeline/srt-resegment";
|
||||
import { buildFallbackHighlights, remapHighlightsAfterResegment } from "@/lib/timeline/subtitle-highlights";
|
||||
@@ -62,8 +60,7 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o
|
||||
return () => abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
// 打开弹窗时解析视频地址,用于字幕叠加预览。
|
||||
// 远端资源优先走节点同款缓存下载(对象 URL),失败再退回资源代理地址。
|
||||
// 打开弹窗时解析视频地址,用于字幕叠加预览;远端资源统一走稳定的云端地址。
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
@@ -72,25 +69,9 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o
|
||||
setVideoError(false);
|
||||
const storageKey = node.metadata?.storageKey || "";
|
||||
const fallback = node.metadata?.content || "";
|
||||
const applyUrl = (url: string) => {
|
||||
void resolveMediaUrl(storageKey, fallback).then((url) => {
|
||||
if (!cancelled) setVideoUrl(url);
|
||||
};
|
||||
if (resourceIdFromStorageKey(storageKey)) {
|
||||
void cacheResourceObjectUrl(storageKey)
|
||||
.then((cached) => {
|
||||
if (cancelled) return;
|
||||
if (cached) {
|
||||
setVideoUrl(cached);
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
});
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Pause, Play } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
|
||||
import { createDefaultSubtitleStyle } from "@/types/timeline";
|
||||
import type { TimelineClip } from "@/types/timeline";
|
||||
@@ -57,28 +55,12 @@ export function CanvasTimelinePreview({ clips, nodes, playheadMs, playing, theme
|
||||
setVideoSize(null);
|
||||
if (!node && !media) return;
|
||||
let cancelled = false;
|
||||
// 直连媒体片段(directMedia,不落画布)与画布节点走同一套缓存/回退解析策略
|
||||
// 直连媒体片段(directMedia,不落画布)与画布节点走同一套稳定资源地址解析策略。
|
||||
const storageKey = node?.metadata?.storageKey || media?.storageKey || "";
|
||||
const fallback = node?.metadata?.content || media?.url || "";
|
||||
const applyUrl = (url: string) => {
|
||||
void resolveMediaUrl(storageKey, fallback).then((url) => {
|
||||
if (!cancelled) setVideoUrl(url);
|
||||
};
|
||||
if (resourceIdFromStorageKey(storageKey)) {
|
||||
void cacheResourceObjectUrl(storageKey)
|
||||
.then((cached) => {
|
||||
if (cancelled) return;
|
||||
if (cached) {
|
||||
setVideoUrl(cached);
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
});
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -5,9 +5,7 @@ import { nanoid } from "nanoid";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatVideoFrameTime, normalizeVideoFrameTimes } from "@/lib/canvas/canvas-video-frame";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
@@ -49,22 +47,9 @@ export function CanvasVideoFrameDialog({ node, open, onClose, onConfirm }: Canva
|
||||
setFrames([]);
|
||||
const storageKey = node.metadata?.storageKey || "";
|
||||
const fallback = node.metadata?.content || "";
|
||||
const applyUrl = (url: string) => {
|
||||
void resolveMediaUrl(storageKey, fallback).then((url) => {
|
||||
if (!cancelled) setVideoUrl(url);
|
||||
};
|
||||
if (resourceIdFromStorageKey(storageKey)) {
|
||||
void cacheResourceObjectUrl(storageKey)
|
||||
.then((cached) => {
|
||||
if (cancelled) return;
|
||||
if (cached) setVideoUrl(cached);
|
||||
else void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
});
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -12,8 +12,6 @@ import { modelRequestOptions, resolveCompatibleModel, type ModelRequirements } f
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { modelDisplayName, type AiConfig } from "@/stores/use-config-store";
|
||||
import { type CanvasConnection, type CanvasNodeData, type CanvasVideoEditOperation } from "@/types/canvas";
|
||||
import type { TimelineProject } from "@/types/timeline";
|
||||
@@ -82,22 +80,9 @@ export function CanvasVideoSegmentDialog({ node, nodes, connections, open, mode,
|
||||
setOperation(profile?.operations.includes("extend") ? "extend" : (profile?.operations[0] as CanvasVideoEditOperation | undefined) || "extend");
|
||||
const storageKey = node.metadata?.storageKey || "";
|
||||
const fallback = node.metadata?.content || "";
|
||||
const applyUrl = (url: string) => {
|
||||
void resolveMediaUrl(storageKey, fallback).then((url) => {
|
||||
if (!cancelled) setVideoUrl(url);
|
||||
};
|
||||
if (resourceIdFromStorageKey(storageKey)) {
|
||||
void cacheResourceObjectUrl(storageKey)
|
||||
.then((cached) => {
|
||||
if (cancelled) return;
|
||||
if (cached) setVideoUrl(cached);
|
||||
else void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
});
|
||||
} else {
|
||||
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { Compass, Focus, HelpCircle, LayoutTemplate, Minus, Plus } from "lucide-react";
|
||||
import { Compass, Eye, EyeOff, Focus, HelpCircle, LayoutTemplate, Minus, Plus } from "lucide-react";
|
||||
|
||||
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
@@ -14,6 +14,8 @@ type CanvasZoomControlsProps = {
|
||||
onScaleChange: (scale: number) => void;
|
||||
onFitContent: () => void;
|
||||
onAutoArrange?: () => void;
|
||||
hideNodeConnections?: boolean;
|
||||
onHideNodeConnectionsChange?: (value: boolean) => void;
|
||||
isMiniMapOpen: boolean;
|
||||
onToggleMiniMap: () => void;
|
||||
onOpenShortcuts: () => void;
|
||||
@@ -22,7 +24,7 @@ type CanvasZoomControlsProps = {
|
||||
|
||||
const QUICK_ZOOM_LEVELS = [0.25, 0.5, 1, 2] as const;
|
||||
|
||||
export function CanvasZoomControls({ scale, onScaleChange, onFitContent, onAutoArrange, isMiniMapOpen, onToggleMiniMap, onOpenShortcuts, containerRef }: CanvasZoomControlsProps) {
|
||||
export function CanvasZoomControls({ scale, onScaleChange, onFitContent, onAutoArrange, hideNodeConnections = false, onHideNodeConnectionsChange, isMiniMapOpen, onToggleMiniMap, onOpenShortcuts, containerRef }: CanvasZoomControlsProps) {
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const liveScaleRef = useRef(scale);
|
||||
@@ -73,6 +75,7 @@ export function CanvasZoomControls({ scale, onScaleChange, onFitContent, onAutoA
|
||||
{ id: "zoom-minimap", label: isMiniMapOpen ? "关闭小地图" : "打开小地图", icon: <Compass />, active: isMiniMapOpen, onClick: onToggleMiniMap },
|
||||
{ id: "zoom-fit", label: "适应画布", icon: <Focus />, onClick: onFitContent },
|
||||
...(onAutoArrange ? [{ id: "zoom-auto-arrange", label: "自动整理节点", icon: <LayoutTemplate />, onClick: onAutoArrange }] : []),
|
||||
...(onHideNodeConnectionsChange ? [{ id: "zoom-hide-node-connections", label: hideNodeConnections ? "显示全部连线" : "设置隐藏节点连线", icon: hideNodeConnections ? <Eye /> : <EyeOff />, active: hideNodeConnections, onClick: () => onHideNodeConnectionsChange(!hideNodeConnections) }] : []),
|
||||
{ kind: "separator", id: "zoom-separator" },
|
||||
{ id: "zoom-out", label: "缩小画布", icon: <Minus />, onClick: () => commitScale(liveScaleRef.current - 0.1) },
|
||||
{
|
||||
|
||||
@@ -29,7 +29,6 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
|
||||
footer={null}
|
||||
centered
|
||||
onCancel={onClose}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
modalRender={(node) => (
|
||||
<motion.div initial={reducedMotion ? false : { opacity: 0, y: 14, scale: 0.975 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}>
|
||||
{node}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "antd";
|
||||
import { Button, ConfigProvider } from "antd";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
/** 内容库的浏览控件,不复用后台表格的筛选外框。 */
|
||||
export function CollectionToolbar({ children, trailing, active, onReset, label = "搜索与筛选" }: {
|
||||
@@ -10,8 +10,23 @@ export function CollectionToolbar({ children, trailing, active, onReset, label =
|
||||
onReset?: () => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return <section className="collection-toolbar" aria-label={label}>
|
||||
<div className="collection-toolbar-controls">{children}</div>
|
||||
const [inputModality, setInputModality] = useState<"keyboard" | "pointer">("keyboard");
|
||||
|
||||
return <section
|
||||
className="collection-toolbar"
|
||||
aria-label={label}
|
||||
data-input-modality={inputModality}
|
||||
onPointerDownCapture={() => setInputModality("pointer")}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (!event.altKey && !event.ctrlKey && !event.metaKey) setInputModality("keyboard");
|
||||
}}
|
||||
onBlurCapture={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setInputModality("keyboard");
|
||||
}}
|
||||
>
|
||||
<ConfigProvider select={{ variant: "filled", classNames: { popup: { root: "workspace-quiet-popup" } } }}>
|
||||
<div className="collection-toolbar-controls">{children}</div>
|
||||
</ConfigProvider>
|
||||
<div className="collection-toolbar-actions">
|
||||
{active && onReset ? <Button type="text" icon={<RotateCcw />} onClick={onReset}>重置</Button> : null}
|
||||
{trailing}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.workspace-account-popover .ant-popover-container { padding: 6px; border: 0 !important; border-radius: 20px; background: var(--user-surface-raised) !important; box-shadow: none !important; }
|
||||
.workspace-account-popover .ant-popover-container { padding: var(--space-2); }
|
||||
.workspace-account-popover .workspace-topbar-account-menu { width: auto; min-width: 0; padding: 0; }
|
||||
.workspace-account-card { width: min(304px, calc(100vw - 48px)); color: var(--user-ink); padding: 12px; }
|
||||
.workspace-account-card-identity { display: flex; align-items: center; gap: 12px; padding: 4px 0 16px; }
|
||||
@@ -8,7 +8,7 @@
|
||||
.workspace-account-card-identity strong { font-size: 15px; line-height: 24px; }
|
||||
.workspace-account-card-identity span { color: var(--user-ink-muted); font-size: 12px; }
|
||||
.workspace-account-card-identity em { font-style: normal; font-size: 10px; padding: 4px 8px; border-radius: 999px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
|
||||
.workspace-account-card-wallet { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; padding: 16px; margin-bottom: 12px; border-radius: 16px; background: linear-gradient(120deg, var(--user-accent-soft), color-mix(in srgb, var(--product-accent-blue) 10%, var(--user-surface))); }
|
||||
.workspace-account-card-wallet { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; padding: 16px; margin-bottom: 12px; border-radius: var(--user-radius-card); background: var(--user-surface-muted); }
|
||||
.workspace-account-card-balance > span { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--user-ink-muted); }
|
||||
.workspace-account-card-balance strong { display: block; margin-top: 4px; font-size: 26px; font-weight: 650; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
|
||||
.workspace-account-card-wallet > button { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--user-accent); white-space: nowrap; padding: 6px 0; }
|
||||
|
||||
@@ -27,6 +27,7 @@ export function WorkspaceAccountMenu() {
|
||||
<><Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
rootClassName="workspace-account-popover"
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
|
||||
@@ -81,7 +81,7 @@ function WorkspaceSidebarProfile({ collapsed, user }: { collapsed: boolean; user
|
||||
<WorkspaceSidebarCheckin collapsed={collapsed} />
|
||||
<WorkspaceSidebarStorageMeter collapsed={collapsed} />
|
||||
<div className={cn("app-workspace-sidebar-profile-row", collapsed && "is-collapsed")}>
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" rootClassName="workspace-account-popover" content={content}>
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" arrow={false} rootClassName="workspace-account-popover" content={content}>
|
||||
<button type="button" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="打开账户菜单" title={profileName}>
|
||||
<span className="app-workspace-sidebar-profile-avatar">{avatar}</span>
|
||||
{!collapsed ? <span className="app-workspace-sidebar-profile-copy"><strong>{profileName}</strong><span>创作工作台</span></span> : null}
|
||||
|
||||
@@ -350,7 +350,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
|
||||
}
|
||||
|
||||
function TextCapabilityEditor({ value, onChange, protocol, disabled, section }: Pick<Props, "value" | "onChange" | "protocol" | "disabled" | "section">) {
|
||||
const profile = value?.text || defaultModelCapabilityConfig(protocol).text!;
|
||||
const profile = normalizeModelCapabilityConfig(value || defaultModelCapabilityConfig(protocol)).text!;
|
||||
const update = (patch: Partial<TextCapabilityConfig>) => {
|
||||
onChange?.({ version: 1, text: { ...profile, ...patch } });
|
||||
};
|
||||
@@ -369,8 +369,17 @@ function TextCapabilityEditor({ value, onChange, protocol, disabled, section }:
|
||||
}
|
||||
|
||||
if (section === "references") {
|
||||
const contextWindowFields = (
|
||||
<CapabilityGroup title="上下文能力" description="这是上游文本模型的能力合同,决定 Agent 本轮可保留的输入预算;不会改变运行时 checkpoint 的持久化上限。">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NumberField label="上下文窗口 Token" value={profile.contextWindowTokens} min={4_096} max={10_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ contextWindowTokens: next || 4_096 })} />
|
||||
<NumberField label="最大输出 Token" value={profile.maxOutputTokens} min={256} max={1_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ maxOutputTokens: next || 256 })} />
|
||||
</div>
|
||||
</CapabilityGroup>
|
||||
);
|
||||
return (
|
||||
<div className="admin-capability-reference-editor">
|
||||
{contextWindowFields}
|
||||
<div className="admin-capability-reference-grid is-three">
|
||||
<ReferenceCard title="图片引用" description="文本模型可接收的图片范围">
|
||||
<NumberField label="最大参考图片数" value={profile.references.maxImages} min={0} max={100} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxImages: next || 0 })} />
|
||||
@@ -394,6 +403,12 @@ function TextCapabilityEditor({ value, onChange, protocol, disabled, section }:
|
||||
<div className="text-sm font-medium">文本理解能力</div>
|
||||
<div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">默认不假设支持图片或视频,只有明确配置后相关请求才会进入该模型。</div>
|
||||
</div>
|
||||
<CapabilityGroup title="上下文能力" description="这是上游文本模型的能力合同,决定 Agent 本轮可保留的输入预算;不会改变运行时 checkpoint 的持久化上限。">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NumberField label="上下文窗口 Token" value={profile.contextWindowTokens} min={4_096} max={10_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ contextWindowTokens: next || 4_096 })} />
|
||||
<NumberField label="最大输出 Token" value={profile.maxOutputTokens} min={256} max={1_000_000} disabled={Boolean(disabled)} onChange={(next) => update({ maxOutputTokens: next || 256 })} />
|
||||
</div>
|
||||
</CapabilityGroup>
|
||||
<CapabilityGroup title="图片" description="文本模型可接收的图片参考范围">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NumberField label="最大参考图片数" value={profile.references.maxImages} min={0} max={100} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxImages: next || 0 })} />
|
||||
|
||||
@@ -9,6 +9,7 @@ export type FloatingDockCommand = {
|
||||
id: string;
|
||||
label: string;
|
||||
displayLabel?: string;
|
||||
badge?: ReactNode;
|
||||
icon: ReactNode;
|
||||
wide?: boolean;
|
||||
quiet?: boolean;
|
||||
@@ -25,6 +26,7 @@ export type FloatingDockSwitchOption = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
value: string;
|
||||
displayLabel?: string;
|
||||
};
|
||||
|
||||
export type FloatingDockSwitch = {
|
||||
@@ -237,6 +239,7 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
|
||||
>
|
||||
<span className="grid size-3.5 shrink-0 place-items-center">{command.icon}</span>
|
||||
<span className="inline-flex h-4 items-center text-[var(--fs-label)] font-medium leading-none">{command.displayLabel || command.label}</span>
|
||||
{command.badge !== undefined ? <span className="aceternity-dock-command-badge inline-flex min-w-4 items-center justify-center rounded-full px-1 text-[var(--fs-micro)] font-bold leading-4">{command.badge}</span> : null}
|
||||
</motion.button>
|
||||
</motion.span>
|
||||
);
|
||||
@@ -292,15 +295,17 @@ function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { ent
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const selectedIndex = Math.max(0, entry.options.findIndex((option) => option.value === entry.value));
|
||||
const touch = metrics.base >= 40;
|
||||
const slot = touch ? 32 : compact ? 24 : 26;
|
||||
const gap = touch ? 10 : compact ? 8 : 10;
|
||||
const padX = touch ? 7 : compact ? 6 : 7;
|
||||
const labeled = showLabel || entry.options.some((option) => option.displayLabel);
|
||||
const slot = labeled ? (touch ? 68 : compact ? 58 : 64) : touch ? 32 : compact ? 24 : 26;
|
||||
const slotHeight = labeled ? metrics.base : slot;
|
||||
const gap = labeled ? (touch ? 4 : 3) : touch ? 10 : compact ? 8 : 10;
|
||||
const padX = labeled ? (touch ? 5 : 4) : touch ? 7 : compact ? 6 : 7;
|
||||
|
||||
return (
|
||||
<span
|
||||
role="radiogroup"
|
||||
aria-label={entry.label}
|
||||
className="aceternity-dock-switch relative flex shrink-0 self-end items-center"
|
||||
className={cn("aceternity-dock-switch relative flex shrink-0 self-end items-center", labeled && "is-labeled")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
|
||||
event.preventDefault();
|
||||
@@ -315,25 +320,25 @@ function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { ent
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="aceternity-dock-switch-thumb pointer-events-none absolute top-1/2 rounded-full"
|
||||
className={cn("aceternity-dock-switch-thumb pointer-events-none absolute top-1/2", labeled ? "rounded-[var(--dock-item-radius)]" : "rounded-full")}
|
||||
initial={false}
|
||||
animate={{ x: selectedIndex * (slot + gap), y: "-50%" }}
|
||||
transition={reducedMotion || !motionEnabled ? { duration: 0 } : aceternityMotion.spring.dock}
|
||||
style={{ width: slot, height: slot, left: padX }}
|
||||
style={{ width: slot, height: slotHeight, left: padX }}
|
||||
/>
|
||||
{entry.options.map((option) => {
|
||||
const checked = option.value === entry.value;
|
||||
const showTooltip = !showLabel && (hoveredId === option.id || focusedId === option.id);
|
||||
const showTooltip = !labeled && (hoveredId === option.id || focusedId === option.id);
|
||||
return (
|
||||
<span key={option.id} className="relative">
|
||||
<span key={option.id} className="relative inline-flex shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
aria-label={option.label}
|
||||
title={!motionEnabled ? option.label : undefined}
|
||||
className="aceternity-dock-switch-option relative z-[1] grid place-items-center rounded-full border-0 outline-none"
|
||||
style={{ width: slot, height: slot }}
|
||||
className={cn("aceternity-dock-switch-option relative z-[1] inline-flex items-center justify-center border-0 outline-none", labeled ? "gap-1 rounded-[var(--dock-item-radius)] px-2" : "rounded-full")}
|
||||
style={{ width: slot, height: slotHeight }}
|
||||
onMouseEnter={() => setHoveredId(option.id)}
|
||||
onMouseLeave={() => setHoveredId((current) => current === option.id ? null : current)}
|
||||
onFocus={(event) => {
|
||||
@@ -345,7 +350,8 @@ function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { ent
|
||||
if (!checked) entry.onChange(option.value);
|
||||
}}
|
||||
>
|
||||
<span className={cn("grid place-items-center", touch ? "[&_svg]:size-4" : "[&_svg]:size-[15px]")}>{option.icon}</span>
|
||||
<span className="grid size-4 shrink-0 place-items-center">{option.icon}</span>
|
||||
{labeled ? <span className="shrink-0 whitespace-nowrap text-[length:var(--fs-micro)] font-semibold leading-none">{option.displayLabel || option.label}</span> : null}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showTooltip ? (
|
||||
|
||||
Reference in New Issue
Block a user