feat(platform): 开源发布 - 初始化 Open AI Canvas

This commit is contained in:
ddCat committed 2026-07-22 14:29:10 +08:00
commit aea35896eb
365 files changed
+66410

No files matched your search

@@ -0,0 +1,411 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { ArrowUp, AtSign, Boxes, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Square, Video } from "lucide-react";
import { Button, Modal, Tooltip } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resolveModelChannel, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools";
import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasWorkspaceMode } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
export type CanvasNodeGenerationMode = CanvasGenerationMode;
type CanvasNodePromptPanelProps = {
node: CanvasNodeData;
isRunning: boolean;
onPromptChange: (nodeId: string, prompt: string) => void;
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
onStop: (nodeId: string) => void;
mentionReferences?: CanvasResourceReference[];
onImageSettingsOpenChange?: (open: boolean) => void;
workspaceMode?: CanvasWorkspaceMode;
};
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const themeName = useThemeStore((state) => state.theme);
const theme = canvasThemes[themeName];
const isDark = themeName === "dark";
const simpleMode = workspaceMode === "simple";
const mode = defaultMode(node.type);
const config = buildNodeConfig(globalConfig, node, mode);
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent;
const savedPrompt = node.metadata?.composerContent ?? (isEditingExistingContent ? "" : node.metadata?.prompt || "");
const [prompt, setPrompt] = useState(savedPrompt);
const [presetOpen, setPresetOpen] = useState(false);
const [expandedPresetOpen, setExpandedPresetOpen] = useState(false);
const [expandedPromptOpen, setExpandedPromptOpen] = useState(false);
const [promptContentHeight, setPromptContentHeight] = useState(0);
const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const priceChannel = resolveModelChannel(config, config.model);
const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? generationCount : 1 });
const activeReferenceCount = mentionReferences.filter((item) => item.active && item.kind !== "skill").length;
const videoFrameOptions = mentionReferences
.filter((item) => item.active && item.kind === "image")
.map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
const composerSurface = isDark ? "rgba(255,255,255,.035)" : "rgba(17,24,39,.028)";
const referenceShelfHeight = activeReferenceCount ? 42 : 0;
const composerMinHeight = activeReferenceCount ? 82 : 58;
const composerHeight = Math.min(144, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight)));
const isSubmitDisabled = !isRunning && !prompt.trim();
const canExpandPrompt = mode === "image" || mode === "video";
const updatePromptContentHeight = useCallback((height: number) => {
setPromptContentHeight((current) => Math.abs(current - height) < 1 ? current : height);
}, []);
useEffect(() => {
setPrompt(node.metadata?.composerContent ?? (isEditingExistingContent ? "" : node.metadata?.prompt || ""));
}, [isEditingExistingContent, node.id, node.metadata?.composerContent, node.metadata?.prompt]);
useEffect(() => setPromptContentHeight(0), [node.id]);
useEffect(() => {
setExpandedPromptOpen(false);
setExpandedPresetOpen(false);
}, [node.id]);
const skillReferences = useMemo(() => mentionReferences.filter((item) => item.kind === "skill"), [mentionReferences]);
const updatePrompt = (value: string) => {
setPrompt(value);
onPromptChange(node.id, value);
if (/(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) {
if (expandedPromptOpen) setExpandedPresetOpen(true);
else setPresetOpen(true);
}
};
const applyPreset = (preset: CanvasPromptPreset) => {
const withoutSlash = prompt.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, "$1").trimEnd();
updatePrompt(withoutSlash ? `${withoutSlash}\n${preset.prompt}` : preset.prompt);
};
const insertPromptReference = (reference: CanvasResourceReference) => {
const insertText = `@${reference.label} `;
const pendingMentionMatch = /@[^\s@,。!?、,.!?;:]*\s*$/.exec(prompt);
if (pendingMentionMatch) {
const prefix = prompt.slice(0, pendingMentionMatch.index).replace(/\s*$/, "");
updatePrompt(prefix ? `${prefix} ${insertText}` : insertText);
return;
}
const basePrompt = prompt.replace(/\s*$/, "");
updatePrompt(basePrompt ? `${basePrompt} ${insertText}` : insertText);
};
const submit = () => {
const text = prompt.trim();
if (!text || isRunning) return false;
onGenerate(node.id, mode, text);
return true;
};
const submitExpandedPrompt = () => {
if (submit()) {
setExpandedPresetOpen(false);
setExpandedPromptOpen(false);
}
};
const renderComposerHeader = (expanded: boolean) => (
<div className="flex min-w-0 items-center gap-1 px-0.5">
<div className="flex h-6 min-w-0 items-center gap-1 rounded-md px-1.5" style={{ background: theme.toolbar.itemHover }}>
<span className="grid size-3.5 shrink-0 place-items-center" style={{ color: theme.accent.primary }}>
<GenerationModeIcon mode={mode} />
</span>
<span className="truncate text-[10px] font-medium">{modeDisplayName(mode)}创作</span>
</div>
{!simpleMode ? (
<CanvasPresetPicker
mode={mode}
skillReferences={skillReferences}
open={expanded ? expandedPresetOpen : presetOpen}
onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen}
onSelect={applyPreset}
dense
/>
) : null}
<div className="ml-auto flex shrink-0 items-center justify-end gap-1">
{activeReferenceCount ? <ComposerPill theme={theme} icon={<Boxes className="size-2.5" />} label={`已连接 ${activeReferenceCount} 个`} active /> : null}
{!expanded && canExpandPrompt ? (
<Tooltip title="放大编辑">
<button
type="button"
className="grid size-6 shrink-0 place-items-center rounded-md transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1"
style={{ background: theme.toolbar.itemHover, color: theme.node.muted, outlineColor: theme.accent.primary }}
onClick={() => setExpandedPromptOpen(true)}
aria-label="放大编辑提示词"
>
<Maximize2 className="size-3" />
</button>
</Tooltip>
) : null}
</div>
</div>
);
const renderComposerControls = (expanded: boolean) => simpleMode ? (
<div className="flex min-w-0 items-center justify-between gap-2 px-0.5">
<span className="min-w-0 truncate px-2 text-[10px]" style={{ color: theme.node.muted }}>
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"}
</span>
<Button
type="text"
className="!inline-flex !h-8 shrink-0 !items-center !gap-1 !rounded-md !px-2.5 !text-[10px] !font-medium"
danger={isRunning}
disabled={isSubmitDisabled}
style={{ background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : theme.node.activeStroke, color: isSubmitDisabled ? theme.node.faint : isRunning ? "#ffffff" : theme.canvas.background }}
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
aria-label={isRunning ? "停止生成" : "生成"}
>
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
{isRunning ? "停止" : "生成"}
</Button>
</div>
) : (
<div className="flex min-w-0 items-center justify-between gap-0.5 px-0.5">
<div className={`${expanded ? "max-w-[260px]" : mode === "video" ? "max-w-[160px]" : "max-w-[174px]"} min-w-[104px] flex-1`}>
<ModelPicker className="!h-7 !w-full !min-w-0 !rounded-md !border-0 !bg-transparent !px-1.5 !text-[10px] !font-normal !shadow-none [&_img]:!size-3 [&_.lucide]:!size-3 [&_.canvas-select-chevron]:!size-3" fullWidth config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => openConfigDialog(true)} showSelectedPrice={false} />
</div>
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-0.5">
{mode === "image" ? (
<CanvasImageSettingsPopover
config={config}
placement={expanded ? "topRight" : "topLeft"}
buttonClassName="!h-7 !w-[138px] !justify-start !rounded-md !border-0 !bg-transparent !px-1.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
onMissingConfig={() => openConfigDialog(true)}
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
/>
) : mode === "video" ? (
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-7 !w-[136px] !justify-start !rounded-md !border-0 !bg-transparent !px-1.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
) : mode === "audio" ? (
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-7 !w-[138px] !justify-start !rounded-md !border-0 !bg-transparent !px-1.5 !text-[10px] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
) : null}
<GenerationCostBadge credits={credits} theme={theme} />
<Button
type="text"
className="!inline-flex !h-8 !w-8 shrink-0 !items-center !justify-center !rounded-md !border-0 !p-0 !shadow-none"
danger={isRunning}
disabled={isSubmitDisabled}
style={{ background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : theme.accent.primary, borderColor: "transparent", color: isSubmitDisabled ? theme.node.faint : "#ffffff" }}
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
aria-label={isRunning ? "停止生成" : "生成"}
>
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
</Button>
</div>
</div>
);
return (
<div
className="aceternity-floating-panel overflow-hidden rounded-lg p-1.5 backdrop-blur-2xl"
style={{ background: theme.spatial.elevated, color: theme.node.text, boxShadow: `0 20px 64px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.07)` }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
{renderComposerHeader(false)}
<div
className="relative mt-1.5 flex max-h-36 flex-col overflow-hidden rounded-lg transition-[height,outline-color] duration-150 focus-within:outline focus-within:outline-1 motion-reduce:transition-none"
style={{ height: composerHeight, background: composerSurface, outlineColor: theme.accent.primary }}
>
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
<CanvasResourceMentionTextarea
value={prompt}
references={mentionReferences}
onChange={updatePrompt}
containerClassName="min-h-0 flex-1"
className="thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-2.5 py-2 text-[13px] leading-5 outline-none placeholder:text-current placeholder:opacity-35"
style={{ color: theme.node.text }}
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
onContentSizeChange={updatePromptContentHeight}
/>
</div>
{mode === "video" && !simpleMode ? (
<div className="mt-1.5 rounded-md p-0.5" style={{ background: composerSurface }}>
<CanvasVideoPromptTools metadata={node.metadata} frameOptions={videoFrameOptions} onMetadataChange={(patch) => onConfigChange(node.id, patch)} />
</div>
) : null}
<div className="mt-1.5">{renderComposerControls(false)}</div>
<Modal
className="canvas-prompt-editor-modal"
open={expandedPromptOpen}
title={null}
footer={null}
centered
width={760}
destroyOnHidden
onCancel={() => { setExpandedPresetOpen(false); setExpandedPromptOpen(false); }}
styles={{ container: { display: "flex", height: "min(440px, calc(100vh - 40px))", flexDirection: "column", borderRadius: 12, padding: 0, overflow: "hidden" }, body: { minHeight: 0, flex: 1, padding: 0 } }}
>
<div className="flex h-full min-h-0 flex-col gap-2.5 p-3" style={{ color: theme.node.text }}>
<div className="shrink-0 pr-8">{renderComposerHeader(true)}</div>
<div
className="flex min-h-[240px] flex-1 flex-col overflow-hidden rounded-lg border focus-within:outline focus-within:outline-1"
style={{ borderColor: theme.toolbar.border, outlineColor: theme.accent.primary }}
>
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
<CanvasResourceMentionTextarea
value={prompt}
references={mentionReferences}
onChange={updatePrompt}
containerClassName="min-h-0 flex-1"
className="thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-3 py-2.5 text-[15px] leading-6 outline-none placeholder:text-current placeholder:opacity-35"
style={{ color: theme.node.text }}
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
aria-label={`${modeDisplayName(mode)}提示词`}
/>
</div>
{mode === "video" && !simpleMode ? (
<div className="shrink-0 rounded-md p-0.5">
<CanvasVideoPromptTools metadata={node.metadata} frameOptions={videoFrameOptions} onMetadataChange={(patch) => onConfigChange(node.id, patch)} />
</div>
) : null}
<div className="shrink-0">{renderComposerControls(true)}</div>
</div>
</Modal>
</div>
);
}
function ComposerPill({ theme, icon, label, active = false }: { theme: CanvasTheme; icon: ReactNode; label: string; active?: boolean }) {
return (
<span
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 text-[9px] font-medium"
style={{ background: active ? theme.accent.primarySoft : theme.toolbar.itemHover, color: active ? theme.accent.primary : theme.node.muted }}
>
{icon}
{label}
</span>
);
}
function GenerationModeIcon({ mode }: { mode: CanvasNodeGenerationMode }) {
if (mode === "image") return <ImagePlus className="size-3" />;
if (mode === "video") return <Video className="size-3" />;
if (mode === "audio") return <Music2 className="size-3" />;
return <FileText className="size-3" />;
}
function modeDisplayName(mode: CanvasNodeGenerationMode) {
if (mode === "image") return "图片";
if (mode === "video") return "视频";
if (mode === "audio") return "音频";
return "文本";
}
function ConnectedReferenceShelf({ references, theme, onInsert }: { references: CanvasResourceReference[]; theme: CanvasTheme; onInsert: (reference: CanvasResourceReference) => void }) {
const activeReferences = references.filter((item) => item.active && item.kind !== "skill");
if (!activeReferences.length) return null;
return (
<div className="thin-scrollbar flex h-[42px] shrink-0 min-w-0 items-center gap-1.5 overflow-x-auto px-2.5 pt-1.5" role="group" aria-label="已连接素材">
{activeReferences.map((reference, index) => (
<button
key={reference.id}
type="button"
className="group relative size-[34px] shrink-0 overflow-hidden rounded-md text-left transition hover:-translate-y-0.5 hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
style={{ background: theme.toolbar.itemHover, color: theme.node.text, outlineColor: theme.accent.primary }}
title={`插入 @${reference.label}`}
aria-label={`插入 @${reference.label}`}
onClick={() => onInsert(reference)}
>
<span className="block size-full overflow-hidden rounded-md">
<ReferenceThumbnail reference={reference} />
</span>
<span className="absolute left-0.5 top-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-[8px] font-semibold text-white backdrop-blur-sm">{index + 1}</span>
<span className="absolute bottom-0.5 right-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm"><AtSign className="size-2" /></span>
</button>
))}
</div>
);
}
function ReferenceThumbnail({ reference }: { reference: CanvasResourceReference }) {
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-full object-cover" />;
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-full bg-black object-cover" muted preload="metadata" />;
const Icon = reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
return (
<span className="grid size-full place-items-center bg-black/10 text-current dark:bg-white/10">
<Icon className="size-3.5 opacity-75" />
</span>
);
}
function GenerationCostBadge({ credits, theme }: { credits: number | null; theme: CanvasTheme }) {
if (credits === null) return null;
return (
<span className="inline-flex h-6 shrink-0 items-center gap-0.5 px-1 text-[9px] font-medium tabular-nums" style={{ color: theme.node.muted }} title="本次生成消耗">
<CreditSymbol />
{credits.toLocaleString()}
</span>
);
}
function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
return type === CanvasNodeType.Text || type === CanvasNodeType.Skill ? "text" : type === CanvasNodeType.Video ? "video" : type === CanvasNodeType.Audio ? "audio" : "image";
}
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
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;
const model = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel;
return {
...globalConfig,
model,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
videoSeconds: normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds),
vquality: normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality),
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
count: String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
};
}
function promptPlaceholder(mode: CanvasNodeGenerationMode, hasImageContent: boolean, hasTextContent: boolean) {
if (mode === "video") return "描述要生成的视频内容";
if (mode === "audio") return "描述要生成的音频内容";
if (mode === "image") return hasImageContent ? "输入新提示词,重新生成当前图片" : "描述要生成的图片内容";
return hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容";
}
function videoConfigPatch(key: keyof AiConfig, value: string) {
if (key === "videoSeconds") return { seconds: value };
if (key === "videoGenerateAudio") return { generateAudio: value };
if (key === "videoWatermark") return { watermark: value };
return { [key]: value };
}
function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
if (key === "audioVoice") return { audioVoice: value };
if (key === "audioFormat") return { audioFormat: value };
if (key === "audioSpeed") return { audioSpeed: value };
return { audioInstructions: value };
}