Merge branch 'dev'

# Conflicts:
#	.gitignore
#	CHANGELOG.md
#	web/bun.lock
This commit is contained in:
HouYunFei
2026-07-15 15:32:32 +08:00
112 changed files with 5611 additions and 384 deletions
@@ -16,7 +16,7 @@ type Props = {
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
return (
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<Modal title="选择资产" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<MyAssetsTab onInsert={onInsert} />
</Modal>
);
@@ -90,7 +90,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材"
placeholder="搜索资产"
value={keyword}
allowClear
onChange={(e) => {
@@ -122,7 +122,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有资产" className="py-12" />
)}
{filtered.length > PAGE_SIZE && (
@@ -116,7 +116,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-baseline gap-2">
<div className="shrink-0 text-xs font-semibold"></div>
<div className="truncate text-[11px] opacity-55">@ </div>
<div className="truncate text-[11px] opacity-55">@ </div>
</div>
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
</div>
@@ -146,9 +146,9 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
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 currentModel = node.metadata?.model;
const model = currentModel && modelMatchesCapability(currentModel, mode)
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
? currentModel
: defaultModel && modelMatchesCapability(defaultModel, mode)
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
? defaultModel
: fallbackModel;
return {
@@ -927,8 +927,8 @@ function siteToolSummary(name: string, result: unknown) {
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
if (name === "canvas_list_projects") return `${numberField(data, "total")} 个画布`;
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
if (name === "assets_list") return `${numberField(data, "total")}素材`;
if (name === "assets_add") return "已加入我的素材";
if (name === "assets_list") return `${numberField(data, "total")}资产`;
if (name === "assets_add") return "已加入我的资产";
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
return "已完成";
@@ -1,8 +1,9 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
import { type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; onViewportChange: (viewport: ViewportTransform) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -113,7 +114,7 @@ export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { n
>
{nodes.map((node) => {
const pos = toMinimap(node.position.x, node.position.y);
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : node.type === CanvasNodeType.Group ? "#94a3b8" : theme.node.muted;
const color = getNodeDefinition(node.type)?.minimapColor || theme.node.muted;
return (
<div
key={node.id}
@@ -7,6 +7,7 @@ import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { useCopyText } from "@/hooks/use-copy-text";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
import type { CanvasNodeToolbarItem } from "@/types/canvas-plugin";
import { ImageToolSettingsModal, type ImageToolbarSettingsTool } from "./canvas-image-toolbar-settings-modal";
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
@@ -35,6 +36,7 @@ type CanvasNodeHoverToolbarProps = {
onRetry: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void;
extraTools?: CanvasNodeToolbarItem[];
};
type ToolbarTool = {
@@ -72,6 +74,7 @@ export function CanvasNodeHoverToolbar({
onRetry,
onToggleFreeResize,
onDelete,
extraTools = [],
}: CanvasNodeHoverToolbarProps) {
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
@@ -137,7 +140,7 @@ export function CanvasNodeHoverToolbar({
];
const nodeToolbarTools: ToolbarTool[] = [
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的资产", label: "存资产", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
@@ -150,7 +153,7 @@ export function CanvasNodeHoverToolbar({
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
];
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools];
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools, ...extraTools];
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => tool.id !== "retry") as ImageToolbarSettingsTool[];
const closeImageToolSettings = () => {
@@ -134,9 +134,9 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
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 currentModel = node.metadata?.model;
const model = currentModel && modelMatchesCapability(currentModel, mode)
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
? currentModel
: defaultModel && modelMatchesCapability(defaultModel, mode)
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
? defaultModel
: fallbackModel;
return {
+33 -9
View File
@@ -1,12 +1,15 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { ChevronRight, Group, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
@@ -25,6 +28,8 @@ type CanvasNodeProps = {
showImageInfo: boolean;
resourceLabel?: CanvasResourceReference;
mentionReferences?: CanvasResourceReference[];
pluginHost?: CanvasPluginHost;
registryVersion?: number;
renderPanel?: (node: CanvasNodeData) => ReactNode;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
batchCount?: number;
@@ -61,6 +66,7 @@ type NodeContentRendererProps = {
batchOpening: boolean;
batchRecovering: boolean;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
pluginContext?: CanvasNodeContext | null;
onContentChange: (nodeId: string, content: string) => void;
onStopEditing: () => void;
mentionReferences: CanvasResourceReference[];
@@ -84,6 +90,7 @@ export const CanvasNode = React.memo(function CanvasNode({
showImageInfo,
resourceLabel,
mentionReferences = [],
pluginHost,
renderPanel,
renderNodeContent,
batchCount = 0,
@@ -110,6 +117,8 @@ export const CanvasNode = React.memo(function CanvasNode({
}: CanvasNodeProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [hovered, setHovered] = useState(false);
const definition = getNodeDefinition(data.type);
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale) : null), [pluginHost, data, theme, scale]);
const [isEditingContent, setIsEditingContent] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(data.title || "");
@@ -259,7 +268,7 @@ export const CanvasNode = React.memo(function CanvasNode({
startTop: data.position.y,
startWidth: data.width,
startHeight: data.height,
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video || Boolean(definition?.keepAspectRatio?.(data)),
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
};
window.addEventListener("mousemove", handleResizeMove);
@@ -343,6 +352,10 @@ export const CanvasNode = React.memo(function CanvasNode({
onToggleBatch?.(data.id);
return;
}
if (definition?.onDoubleClick && pluginContext) {
if (definition.onDoubleClick(pluginContext)) event.stopPropagation();
return;
}
if (data.type === CanvasNodeType.Image && hasImageContent) {
event.stopPropagation();
onViewImage?.(data);
@@ -377,6 +390,7 @@ export const CanvasNode = React.memo(function CanvasNode({
batchOpening={batchOpening}
batchRecovering={batchRecovering}
renderNodeContent={renderNodeContent}
pluginContext={pluginContext}
mentionReferences={mentionReferences}
onContentChange={onContentChange}
onStopEditing={() => setIsEditingContent(false)}
@@ -400,7 +414,7 @@ export const CanvasNode = React.memo(function CanvasNode({
</div>
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
{!isGroup ? <ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
{!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
</div>
@@ -413,8 +427,16 @@ function NodeContent(props: NodeContentRendererProps) {
if (props.node.metadata?.status === "loading") return <LoadingContent theme={props.theme} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
const Renderer = nodeContentRenderers[props.node.type];
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
const Renderer = nodeContentRenderers[props.node.type as CanvasNodeType];
if (Renderer) return <Renderer {...props} />;
// 插件节点:有注册渲染器则渲染,否则展示缺少插件占位
const definition = getNodeDefinition(props.node.type);
if (definition?.Content && props.pluginContext) {
const PluginContent = definition.Content;
return <PluginContent ctx={props.pluginContext} />;
}
return <MissingPluginContent theme={props.theme} type={props.node.type} />;
}
const nodeContentRenderers = {
@@ -473,10 +495,12 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
);
}
function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
function MissingPluginContent({ theme, type }: Pick<NodeContentRendererProps, "theme"> & { type: string }) {
return (
<div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.placeholder }}>
<Puzzle className="size-7 opacity-40" />
<span className="text-sm"></span>
<span className="text-[11px] opacity-70"> {type} </span>
</div>
);
}
@@ -0,0 +1,204 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Button, Input, Modal, Popconfirm, Switch, Tabs } from "antd";
import { AlertTriangle, Download, Puzzle, RefreshCw, Trash2 } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { installPluginFromUrl, setPluginEnabled, uninstallPlugin, updatePlugin } from "@/lib/canvas/plugin-loader";
import { fetchOfficialPlugins, type OfficialPluginEntry } from "@/lib/canvas/plugin-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { message } = App.useApp();
const plugins = usePluginStore((state) => state.plugins);
const [url, setUrl] = useState("");
const [installing, setInstalling] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [official, setOfficial] = useState<OfficialPluginEntry[]>([]);
const [loadingOfficial, setLoadingOfficial] = useState(false);
const [officialError, setOfficialError] = useState<string | null>(null);
const recordById = useMemo(() => new Map(plugins.map((item) => [item.id, item])), [plugins]);
const localPlugins = useMemo(() => plugins.filter((item) => item.local), [plugins]);
const thirdPartyPlugins = useMemo(() => plugins.filter((item) => !item.local && !item.official), [plugins]);
const loadOfficial = useCallback(async () => {
setLoadingOfficial(true);
setOfficialError(null);
try {
setOfficial(await fetchOfficialPlugins());
} catch (error) {
setOfficialError(error instanceof Error ? error.message : String(error));
} finally {
setLoadingOfficial(false);
}
}, []);
// 打开面板时拉取官方清单(仅在尚未加载过时,避免重复请求)
useEffect(() => {
if (open && official.length === 0 && !loadingOfficial && !officialError) void loadOfficial();
}, [open, official.length, loadingOfficial, officialError, loadOfficial]);
const handleInstallUrl = async () => {
const target = url.trim();
if (!target) return;
setInstalling(true);
try {
const plugin = await installPluginFromUrl(target);
message.success(`已安装插件 ${plugin.name}`);
setUrl("");
} catch (error) {
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
setInstalling(false);
}
};
const handleInstallOfficial = async (entry: OfficialPluginEntry) => {
setBusyId(entry.id);
try {
const plugin = await installPluginFromUrl(entry.url, { official: true });
message.success(`已安装 ${plugin.name}`);
} catch (error) {
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
setBusyId(null);
}
};
const runOnPlugin = async (record: InstalledPlugin, action: () => Promise<void>, successText: string) => {
setBusyId(record.id);
try {
await action();
message.success(successText);
} catch (error) {
message.error(`${error instanceof Error ? error.message : String(error)}`);
} finally {
setBusyId(null);
}
};
// 已安装插件的操作区:启用开关 +(非本地)更新/卸载
const installedControls = (record: InstalledPlugin) => (
<>
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")} />
{!record.local && (
<>
<Button type="text" size="small" icon={<RefreshCw className="size-4" />} loading={busyId === record.id} title="从来源更新" onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} />
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
</Popconfirm>
</>
)}
</>
);
const versionTag = (version: string) => (
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
v{version}
</span>
);
const emptyHint = (text: string) => (
<div className="py-10 text-center text-sm" style={{ color: theme.node.muted }}>
{text}
</div>
);
// 通用插件行:图标 + 标题(名称 + 版本)+ 描述 + 右侧操作
const row = (key: string, icon: ReactNode, name: string, version: string, subtitle: string | undefined, right: ReactNode) => (
<div key={key} className="flex items-center gap-3 rounded-xl border px-3 py-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}>
<span className="grid size-9 shrink-0 place-items-center rounded-lg text-base" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium" style={{ color: theme.node.text }}>
<span className="truncate">{name}</span>
{versionTag(version)}
</div>
{subtitle ? (
<div className="mt-0.5 truncate text-xs" style={{ color: theme.node.muted }}>
{subtitle}
</div>
) : null}
</div>
{right}
</div>
);
const officialTab = (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs" style={{ color: theme.node.muted }}>
,
</div>
<Button type="text" size="small" icon={<RefreshCw className={`size-4 ${loadingOfficial ? "animate-spin" : ""}`} />} onClick={loadOfficial} disabled={loadingOfficial}>
</Button>
</div>
{officialError ? (
<div className="rounded-lg border px-3 py-2 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
{officialError}
</div>
) : loadingOfficial && official.length === 0 ? (
emptyHint("正在获取官方插件…")
) : official.length === 0 ? (
emptyHint("暂无官方插件")
) : (
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
{official.map((entry) => {
const record = recordById.get(entry.id);
return row(
entry.id,
entry.icon || <Puzzle className="size-4" />,
entry.name,
entry.version,
entry.description,
record ? (
installedControls(record)
) : (
<Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}>
</Button>
),
);
})}
</div>
)}
</div>
);
const localTab = <div className="thin-scrollbar max-h-[52vh] space-y-2 overflow-auto">{localPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div>;
const thirdPartyTab = (
<div className="space-y-3">
<div className="flex gap-2">
<Input placeholder="输入插件 JS 文件 URL,例如 https://.../plugin.js" value={url} onChange={(event) => setUrl(event.target.value)} onPressEnter={handleInstallUrl} allowClear />
<Button type="primary" loading={installing} onClick={handleInstallUrl} icon={<Puzzle className="size-4" />}>
</Button>
</div>
<div className="thin-scrollbar max-h-[42vh] space-y-2 overflow-auto">{thirdPartyPlugins.length === 0 ? emptyHint("还没有安装第三方插件") : thirdPartyPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div>
</div>
);
const tabs = [
{ key: "official", label: "官方插件", children: officialTab },
...(localPlugins.length > 0 ? [{ key: "local", label: "本地插件", children: localTab }] : []),
{ key: "third", label: "第三方插件", children: thirdPartyTab },
];
return (
<Modal title="节点插件" open={open} onCancel={onClose} footer={null} centered width={640}>
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs leading-5" style={{ borderColor: "#f59e0b55", background: "#f59e0b14", color: theme.node.text }}>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-500" />
<span>访 AI API Key</span>
</div>
<Tabs defaultActiveKey="official" items={tabs} />
</div>
</Modal>
);
}
@@ -0,0 +1,224 @@
import { useMemo, useState } from "react";
import { Empty, Input, Select, Tag } from "antd";
import { ChevronRight, FileText, Image as ImageIcon, Music2, Search, Settings2, Square, Type, Video } from "lucide-react";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { cn } from "@/lib/utils";
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { InsertAssetPayload } from "./asset-picker-modal";
type PanelTab = "canvas" | "assets";
type Props = {
nodes: CanvasNodeData[];
selectedNodeIds: Set<string>;
onFocusNode: (nodeId: string) => void;
onInsertAsset: (payload: InsertAssetPayload) => void;
};
const NODE_TYPE_ICON: Record<string, typeof Square> = {
[CanvasNodeType.Image]: ImageIcon,
[CanvasNodeType.Video]: Video,
[CanvasNodeType.Audio]: Music2,
[CanvasNodeType.Text]: Type,
[CanvasNodeType.Config]: Settings2,
[CanvasNodeType.Group]: Square,
};
const STATUS_COLOR: Record<string, string> = {
success: "#22c55e",
loading: "#f59e0b",
error: "#ef4444",
idle: "transparent",
};
export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertAsset }: Props) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [tab, setTab] = useState<PanelTab>("canvas");
return (
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden border-r" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} data-canvas-no-zoom>
<div className="flex items-center gap-1 px-3 pt-3">
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
</div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden">{tab === "canvas" ? <CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} theme={theme} /> : <CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />}</div>
</aside>
);
}
function TabButton({ label, active, theme, onClick }: { label: string; active: boolean; theme: CanvasTheme; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className={cn("rounded-lg px-3 py-1.5 text-sm font-semibold transition", active ? "" : "opacity-55 hover:opacity-90")} style={active ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { color: theme.node.text }}>
{label}
</button>
);
}
// ---------------------------------------------------------------------------
// 画布 Tab —— 列出节点,点击居中放大并选中
// ---------------------------------------------------------------------------
const NODE_FILTER_OPTIONS = [
{ label: "全部", value: "all" },
{ label: "图片", value: CanvasNodeType.Image },
{ label: "视频", value: CanvasNodeType.Video },
{ label: "文本", value: CanvasNodeType.Text },
{ label: "音频", value: CanvasNodeType.Audio },
{ label: "配置", value: CanvasNodeType.Config },
{ label: "分组", value: CanvasNodeType.Group },
];
function nodePreviewText(node: CanvasNodeData) {
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
return getNodeDefinition(node.type)?.title || node.type;
}
function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; theme: CanvasTheme }) {
const [keyword, setKeyword] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return nodes.filter((node) => (typeFilter === "all" || node.type === typeFilter) && (!query || [node.title, node.metadata?.content, node.metadata?.prompt].filter(Boolean).join(" ").toLowerCase().includes(query)));
}, [nodes, keyword, typeFilter]);
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-3 pb-2.5 pt-1">
<span className="text-xs font-medium opacity-60"></span>
{filtered.length ? <span className="text-xs opacity-35">{filtered.length}</span> : null}
<Select size="small" variant="borderless" className="ml-auto w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} />
</div>
<div className="px-3 pb-2.5">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{filtered.length ? (
<div className="space-y-1.5">
{filtered.map((node) => {
const Icon = NODE_TYPE_ICON[node.type] || FileText;
const isImage = node.type === CanvasNodeType.Image && node.metadata?.content;
const active = selectedNodeIds.has(node.id);
return (
<button
key={node.id}
type="button"
onClick={() => onFocusNode(node.id)}
className={cn("flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")}
style={active ? { background: theme.toolbar.activeBg } : undefined}
>
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md" style={{ background: theme.node.fill }}>
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-4.5 opacity-70" />}
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
<span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span>
</span>
{node.metadata?.status && node.metadata.status !== "idle" ? <span className="size-1.5 shrink-0 rounded-full" style={{ background: STATUS_COLOR[node.metadata.status] || "transparent" }} /> : null}
</button>
);
})}
</div>
) : (
<div className="pt-16 text-center text-sm opacity-40"></div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
// ---------------------------------------------------------------------------
const ASSET_GROUPS: { kind: AssetKind; label: string; icon: typeof Square }[] = [
{ kind: "image", label: "图片", icon: ImageIcon },
{ kind: "video", label: "视频", icon: Video },
{ kind: "text", label: "文本", icon: FileText },
];
function buildInsertPayload(asset: Asset): InsertAssetPayload {
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title };
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height };
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title };
}
function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const assets = useAssetStore((state) => state.assets);
const [keyword, setKeyword] = useState("");
const [tagFilter, setTagFilter] = useState<string>("all");
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const allTags = useMemo(() => Array.from(new Set(assets.flatMap((asset) => asset.tags || []))).slice(0, 20), [assets]);
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return assets.filter((asset) => (tagFilter === "all" || (asset.tags || []).includes(tagFilter)) && (!query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query)));
}, [assets, keyword, tagFilter]);
const groups = useMemo(() => ASSET_GROUPS.map((group) => ({ ...group, items: filtered.filter((asset) => asset.kind === group.kind) })).filter((group) => group.items.length > 0), [filtered]);
return (
<div className="flex h-full flex-col">
<div className="px-3 pb-2">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div>
{allTags.length ? (
<div className="flex flex-wrap gap-1.5 px-3 pb-2">
<Tag.CheckableTag checked={tagFilter === "all"} className={cn("prompt-filter-tag", tagFilter === "all" && "is-active")} onChange={() => setTagFilter("all")}>
</Tag.CheckableTag>
{allTags.map((tag) => (
<Tag.CheckableTag key={tag} checked={tagFilter === tag} className={cn("prompt-filter-tag", tagFilter === tag && "is-active")} onChange={() => setTagFilter((prev) => (prev === tag ? "all" : tag))}>
{tag}
</Tag.CheckableTag>
))}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{groups.length ? (
<div className="space-y-1">
{groups.map((group) => {
const isCollapsed = collapsed[group.kind];
return (
<div key={group.kind}>
<button type="button" onClick={() => setCollapsed((prev) => ({ ...prev, [group.kind]: !prev[group.kind] }))} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
<ChevronRight className={cn("size-3.5 transition-transform", !isCollapsed && "rotate-90")} />
<group.icon className="size-3.5" />
<span>{group.label}</span>
<span className="opacity-50">{group.items.length}</span>
</button>
{isCollapsed ? null : (
<div className="grid grid-cols-2 gap-2 px-1 pb-2 pt-1">
{group.items.map((asset) => (
<AssetCard key={asset.id} asset={asset} theme={theme} onClick={() => onInsert(buildInsertPayload(asset))} />
))}
</div>
)}
</div>
);
})}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无资产" className="pt-16" />
)}
</div>
</div>
);
}
function AssetCard({ asset, theme, onClick }: { asset: Asset; theme: CanvasTheme; onClick: () => void }) {
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
return (
<button type="button" onClick={onClick} className="group relative overflow-hidden rounded-lg border text-left transition hover:shadow-md" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
{cover ? <img src={cover} alt={asset.title} className="aspect-square w-full object-cover" /> : <div className="flex aspect-square items-center justify-center p-2 text-center text-[11px] leading-4 opacity-60">{asset.title}</div>}
<div className="truncate px-2 py-1.5 text-[11px] font-medium">{asset.title}</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-xs font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100"></div>
</button>
);
}
+79 -11
View File
@@ -1,9 +1,10 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Button, Segmented, Switch } from "antd";
import { CircleDot, Eraser, FolderOpen, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { CircleDot, Eraser, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Puzzle, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
@@ -19,6 +20,7 @@ export function CanvasToolbar({
onAddText,
onAddConfig,
onAddGroup,
onAddExtensionNode,
onUndo,
onRedo,
onUpload,
@@ -27,7 +29,6 @@ export function CanvasToolbar({
onDeselect,
onBackgroundModeChange,
onShowImageInfoChange,
onOpenMyAssets,
}: {
selectedCount: number;
canUndo: boolean;
@@ -40,6 +41,7 @@ export function CanvasToolbar({
onAddText: () => void;
onAddConfig: () => void;
onAddGroup: () => void;
onAddExtensionNode: (type: string) => void;
onUndo: () => void;
onRedo: () => void;
onUpload: () => void;
@@ -48,9 +50,9 @@ export function CanvasToolbar({
onDeselect: () => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void;
onOpenMyAssets: () => void;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
@@ -58,13 +60,31 @@ export function CanvasToolbar({
const [tipX, setTipX] = useState(0);
const [appearanceOpen, setAppearanceOpen] = useState(false);
const [panelX, setPanelX] = useState(0);
const [extensionsOpen, setExtensionsOpen] = useState(false);
const [extPanelX, setExtPanelX] = useState(0);
// 扩展(插件)节点,随注册表变化实时更新
useNodeRegistryVersion();
const extensionDefs = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false && getNodePluginId(def.type) !== "builtin");
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const tip = hovered ? toolLabel(hovered) : "";
// 点击工具栏(含弹出面板)以外的地方,关闭弹出的扩展节点/画布外观面板
useEffect(() => {
if (!extensionsOpen && !appearanceOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setExtensionsOpen(false);
setAppearanceOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
}, [extensionsOpen, appearanceOpen]);
return (
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
<div ref={rootRef} className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
@@ -95,13 +115,30 @@ export function CanvasToolbar({
<ToolbarButton id="tool-group" label="组" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
<Group className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-upload" label="上传素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
{extensionDefs.length ? (
<ToolbarButton
id="tool-extensions"
label="扩展节点"
active={extensionsOpen}
hovered={hovered}
activeStyle={activeStyle}
hoverStyle={hoverStyle}
wrapRef={wrapRef}
onTipX={setTipX}
onHover={setHovered}
onClick={(event) => {
setExtPanelX(getTipX(wrapRef.current, event.currentTarget));
setAppearanceOpen(false);
setExtensionsOpen((value) => !value);
}}
>
<Puzzle className="size-4.5" />
</ToolbarButton>
) : null}
<ToolbarButton id="tool-upload" label="上传资产" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<Upload className="size-4.5" />
</ToolbarButton>
<Divider theme={theme} />
<ToolbarButton id="tool-assets" label="我的素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onOpenMyAssets}>
<FolderOpen className="size-4.5" />
</ToolbarButton>
<ToolbarButton
id="tool-style"
label="画布外观"
@@ -114,6 +151,7 @@ export function CanvasToolbar({
onHover={setHovered}
onClick={(event) => {
setPanelX(getTipX(wrapRef.current, event.currentTarget));
setExtensionsOpen(false);
setAppearanceOpen((value) => !value);
}}
>
@@ -133,6 +171,36 @@ export function CanvasToolbar({
</ToolbarButton>
</div>
{extensionsOpen && extensionDefs.length ? (
<div
className="thin-scrollbar pointer-events-auto absolute bottom-[72px] z-30 max-h-[50vh] w-[240px] -translate-x-1/2 overflow-y-auto rounded-xl border p-2 shadow-xl backdrop-blur"
style={{ left: extPanelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
>
<div className="px-1.5 pb-1.5 text-[11px] font-medium opacity-50"></div>
<div className="grid gap-0.5">
{extensionDefs.map((def) => (
<button
key={def.type}
type="button"
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm transition"
style={{ color: theme.toolbar.item }}
onMouseEnter={(event) => (event.currentTarget.style.background = theme.toolbar.itemHover)}
onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}
onClick={() => {
onAddExtensionNode(def.type);
setExtensionsOpen(false);
}}
>
<span className="grid size-7 shrink-0 place-items-center rounded-md text-base" style={{ background: theme.toolbar.itemHover }}>
{def.icon}
</span>
<span className="min-w-0 flex-1 truncate">{def.title}</span>
</button>
))}
</div>
</div>
) : null}
{appearanceOpen ? (
<div
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
@@ -287,8 +355,8 @@ function toolLabel(id: string) {
if (id === "tool-audio") return "音频";
if (id === "tool-config") return "生成配置";
if (id === "tool-group") return "组";
if (id === "tool-upload") return "上传素材";
if (id === "tool-assets") return "我的素材";
if (id === "tool-extensions") return "扩展节点";
if (id === "tool-upload") return "上传资产";
if (id === "tool-style") return "画布外观";
if (id === "tool-delete") return "删除选中";
if (id === "tool-clear") return "清空画布";
@@ -167,7 +167,12 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
const container = containerRef.current;
if (!container) return;
const preventWheelScroll = (event: WheelEvent) => event.preventDefault();
// 阻止画布滚动导致页面滚动;但浮层(创建菜单/弹窗等)内允许原生滚动
const preventWheelScroll = (event: WheelEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
event.preventDefault();
};
container.addEventListener("wheel", preventWheelScroll, { passive: false });
return () => container.removeEventListener("wheel", preventWheelScroll);
}, [containerRef]);
@@ -0,0 +1,37 @@
import { FileText, Group, Image as ImageIcon, Music2, Settings2, Video } from "lucide-react";
import { NODE_SPECS } from "@/constant/canvas";
import { registerNodeDefinitions } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { CanvasNodeDefinition, CanvasNodeResource } from "@/types/canvas-plugin";
// 内置节点的可扩展元数据(尺寸/初始 metadata 复用 NODE_SPECS)。
// 渲染仍由 canvas-node 内部渲染器负责,故不提供 Content。
function builtinResource(node: CanvasNodeData): CanvasNodeResource | null {
if (node.type === CanvasNodeType.Image && node.metadata?.content) return { kind: "image", url: node.metadata.content };
if (node.type === CanvasNodeType.Video && node.metadata?.content) return { kind: "video", url: node.metadata.content };
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return { kind: "audio", url: node.metadata.content };
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return { kind: "text", text: node.metadata.content || node.metadata.prompt };
return null;
}
const iconClass = "size-5";
const BUILTIN_DEFINITIONS: CanvasNodeDefinition[] = [
{ type: CanvasNodeType.Text, title: "文本", icon: <FileText className={iconClass} />, minimapColor: undefined, resource: builtinResource },
{ type: CanvasNodeType.Image, title: "图片", icon: <ImageIcon className={iconClass} />, minimapColor: "#10b981", keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize, resource: builtinResource },
{ type: CanvasNodeType.Video, title: "视频", icon: <Video className={iconClass} />, minimapColor: "#f97316", keepAspectRatio: () => true, resource: builtinResource },
{ type: CanvasNodeType.Audio, title: "音频", icon: <Music2 className={iconClass} />, minimapColor: "#a855f7", resource: builtinResource },
{ type: CanvasNodeType.Config, title: "生成配置", icon: <Settings2 className={iconClass} />, minimapColor: "#60a5fa", hasSourceHandle: false },
{ type: CanvasNodeType.Group, title: "组", icon: <Group className={iconClass} />, minimapColor: "#94a3b8" },
].map((def) => {
const spec = NODE_SPECS[def.type];
return { ...def, title: spec.title, defaultSize: { width: spec.width, height: spec.height }, defaultMetadata: spec.metadata };
});
let registered = false;
export function registerBuiltinNodes() {
if (registered) return;
registered = true;
registerNodeDefinitions(BUILTIN_DEFINITIONS, "builtin");
}
+57 -185
View File
@@ -1,20 +1,18 @@
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
import { CircleAlert, Cloud, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
import { Cloud, Pencil, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
import { useEffect, useState } from "react";
import { ModelPicker } from "@/components/model-picker";
import { fetchChannelModels } from "@/services/api/image";
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import { createModelChannel, defaultBaseUrlForApiFormat, filterModelsByCapability, modelOptionLabel, modelOptionsFromChannels, normalizeModelOptionValue, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { createModelChannel, modelOptionsFromChannels, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
type ModelGroup = {
capability: ModelCapability;
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
modelsKey: "imageModels" | "videoModels" | "textModels" | "audioModels";
defaultLabel: string;
optionsLabel: string;
};
type WebdavDomainProgress = {
@@ -26,21 +24,16 @@ type WebdavDomainProgress = {
};
const modelGroups: ModelGroup[] = [
{ capability: "image", modelKey: "imageModel", modelsKey: "imageModels", defaultLabel: "默认生图模型", optionsLabel: "生图模型可选项" },
{ capability: "video", modelKey: "videoModel", modelsKey: "videoModels", defaultLabel: "默认视频模型", optionsLabel: "视频模型可选项" },
{ capability: "text", modelKey: "textModel", modelsKey: "textModels", defaultLabel: "默认文本模型", optionsLabel: "文本模型可选项" },
{ capability: "audio", modelKey: "audioModel", modelsKey: "audioModels", defaultLabel: "默认音频模型", optionsLabel: "音频模型可选项" },
];
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" },
{ capability: "image", modelKey: "imageModel", defaultLabel: "默认生图模型" },
{ capability: "video", modelKey: "videoModel", defaultLabel: "默认视频模型" },
{ capability: "text", modelKey: "textModel", defaultLabel: "默认文本模型" },
{ capability: "audio", modelKey: "audioModel", defaultLabel: "默认音频模型" },
];
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
const webdavDomainLabels: Record<AppSyncDomainKey, string> = {
canvas: "画布",
assets: "我的素材",
assets: "我的资产",
"image-workbench": "生图工作台",
"video-workbench": "视频创作台",
};
@@ -58,7 +51,7 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
const { message } = App.useApp();
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
const [loadingChannelId, setLoadingChannelId] = useState("");
const [editingChannelId, setEditingChannelId] = useState("");
const [testingWebdav, setTestingWebdav] = useState(false);
const [syncingWebdav, setSyncingWebdav] = useState(false);
const [webdavSyncStatus, setWebdavSyncStatus] = useState("");
@@ -70,8 +63,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const modelOptions = config.models.map((model) => ({ label: modelOptionLabel(config, model), value: model }));
const webdavReady = Boolean(webdav.url.trim());
const editingChannel = config.channels.find((channel) => channel.id === editingChannelId) || null;
useEffect(() => setActiveTab(initialTab), [initialTab]);
const saveConfig = (nextConfig: AiConfig) => {
@@ -86,22 +79,12 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
clearPromptContinue();
};
const updateChannels = (channels: ModelChannel[]) => {
const nextConfig = withChannels(config, channels);
saveConfig(nextConfig);
};
const updateChannel = (id: string, patch: Partial<ModelChannel>) => {
updateChannels(config.channels.map((channel) => (channel.id === id ? { ...channel, ...patch, models: patch.models ? uniqueModels(patch.models) : channel.models } : channel)));
};
const updateChannelApiFormat = (channel: ModelChannel, apiFormat: ApiCallFormat) => {
const baseUrl = !channel.baseUrl.trim() || channel.baseUrl.trim() === defaultBaseUrlForApiFormat(channel.apiFormat) ? defaultBaseUrlForApiFormat(apiFormat) : channel.baseUrl;
updateChannel(channel.id, { apiFormat, baseUrl });
};
const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels));
const addChannel = () => {
updateChannels([...config.channels, createModelChannel({ name: `渠道 ${config.channels.length + 1}` })]);
const channel = createModelChannel({ name: `渠道 ${config.channels.length + 1}` });
updateChannels([...config.channels, channel]);
setEditingChannelId(channel.id);
};
const deleteChannel = (id: string) => {
@@ -112,46 +95,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
updateChannels(config.channels.filter((channel) => channel.id !== id));
};
const refreshChannelModels = async (channel: ModelChannel) => {
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
message.error("请先填写该渠道的 Base URL 和 API Key");
return;
}
setLoadingChannelId(channel.id);
try {
const models = await fetchChannelModels(channel);
updateChannels(config.channels.map((item) => (item.id === channel.id ? { ...item, models } : item)));
message.success(`${channel.name} 模型列表已更新`);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
} finally {
setLoadingChannelId("");
}
};
const refreshAllModels = async () => {
const runnable = config.channels.filter((channel) => channel.baseUrl.trim() && channel.apiKey.trim());
if (!runnable.length) {
message.error("请先填写至少一个渠道的 Base URL 和 API Key");
return;
}
setLoadingChannelId("all");
try {
const entries = await Promise.all(runnable.map(async (channel) => [channel.id, await fetchChannelModels(channel)] as const));
const modelMap = new Map(entries);
updateChannels(config.channels.map((channel) => (modelMap.has(channel.id) ? { ...channel, models: modelMap.get(channel.id) || [] } : channel)));
message.success("模型列表已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
} finally {
setLoadingChannelId("");
}
};
const updateCapabilityModels = (group: ModelGroup, models: string[]) => {
const next = uniqueModels(models.map((model) => normalizeModelOptionValue(model, config.channels)).filter(Boolean));
updateConfig(group.modelsKey, next);
if (!next.includes(config[group.modelKey])) updateConfig(group.modelKey, next[0] || "");
const saveChannel = (channel: ModelChannel) => {
updateChannels(config.channels.map((item) => (item.id === channel.id ? channel : item)));
};
const testWebdav = async () => {
@@ -196,7 +141,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
try {
const result = await syncAppDataToWebdav(webdav, updateWebdavProgress);
updateWebdavConfig("lastSyncedAt", result.syncedAt);
message.success(`同步完成:${result.projects} 个画布,${result.assets}素材${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`);
message.success(`同步完成:${result.projects} 个画布,${result.assets}资产${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`);
} catch (error) {
setWebdavSyncStatus(error instanceof Error ? error.message : "WebDAV 同步失败");
message.error(error instanceof Error ? error.message : "WebDAV 同步失败");
@@ -215,107 +160,48 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
key: "channels",
label: "渠道",
children: (
<Form layout="vertical" requiredMark={false}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="min-w-0 flex-1">
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2.5 py-1.5 text-xs text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/30 dark:text-amber-100">
<CircleAlert className="size-3.5 shrink-0" />
<span className="font-semibold"></span>
<span>Tab </span>
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold text-amber-900 dark:text-amber-100" onClick={() => setActiveTab("models")}>
</Button>
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button icon={<RefreshCw className="size-4" />} loading={Boolean(loadingChannelId)} onClick={() => void refreshAllModels()}>
</Button>
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
</Button>
</div>
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-stone-500">///</div>
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
</Button>
</div>
<div className="space-y-3">
<div className="space-y-2">
{config.channels.map((channel) => (
<section key={channel.id} className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
<div className="mt-1 text-xs text-stone-500">
{apiFormatLabel(channel.apiFormat)} · {channel.models.length}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button size="small" loading={loadingChannelId === channel.id} onClick={() => void refreshChannelModels(channel)}>
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
<div key={channel.id} className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
<div className="mt-1 truncate text-xs text-stone-500">
{apiFormatLabel(channel.apiFormat)} · {channel.models.length} · {channel.baseUrl || "未填写接口地址"}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="渠道名称" className="mb-0">
<Input value={channel.name} onChange={(event) => updateChannel(channel.id, { name: event.target.value })} />
</Form.Item>
<Form.Item label="调用格式" className="mb-0">
<Select value={channel.apiFormat} options={apiFormatOptions} onChange={(value: ApiCallFormat) => updateChannelApiFormat(channel, value)} />
</Form.Item>
<Form.Item label="Base URL" className="mb-0">
<Input value={channel.baseUrl} onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} />
</Form.Item>
<Form.Item label="API Key" className="mb-0">
<Input.Password value={channel.apiKey} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} />
</Form.Item>
<Form.Item label="模型列表" className="mb-0 md:col-span-2">
<Select mode="tags" showSearch allowClear maxTagCount="responsive" placeholder="输入模型名,或点击拉取模型" value={channel.models} onChange={(models) => updateChannel(channel.id, { models })} />
</Form.Item>
<div className="flex shrink-0 gap-2">
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingChannelId(channel.id)}>
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
</div>
</section>
</div>
))}
</div>
</Form>
</div>
),
},
{
key: "models",
label: "模型",
key: "preferences",
label: "偏好设置",
children: (
<Form layout="vertical" requiredMark={false}>
<div className="mb-4 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="text-sm font-semibold"></div>
<div className="mt-1 text-xs leading-5 text-stone-500"></div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{modelGroups.map((group) => (
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
<Select
mode="tags"
showSearch
allowClear
maxTagCount="responsive"
placeholder={config.models.length ? `请选择或输入${group.optionsLabel}` : "先到渠道里填写或拉取模型"}
value={config[group.modelsKey]}
options={modelOptions}
onChange={(models) => updateCapabilityModels(group, models)}
/>
</Form.Item>
))}
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="mb-2 text-sm font-semibold"></div>
<div className="mb-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{modelGroups.map((group) => (
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-0">
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
</Form.Item>
))}
</div>
</Form>
),
},
{
key: "preferences",
label: "生成偏好",
children: (
<Form layout="vertical" requiredMark={false}>
<div className="mb-2 text-sm font-semibold"></div>
<div className="grid gap-4 md:grid-cols-4">
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
<Input
@@ -366,7 +252,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
<Cloud className="size-4" />
WebDAV
</div>
<div className="mt-1 text-xs text-stone-500"> AI API Key WebDAV </div>
<div className="mt-1 text-xs text-stone-500"> AI API Key WebDAV </div>
</div>
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
</div>
@@ -407,6 +293,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
</Button>
</div>
) : null}
<ChannelEditorDrawer open={Boolean(editingChannel)} channel={editingChannel} onSave={saveChannel} onClose={() => setEditingChannelId("")} />
</>
);
}
@@ -420,7 +307,7 @@ export function AppConfigModal() {
title={
<div>
<div className="text-lg font-semibold"></div>
<div className="mt-1 text-xs font-normal text-stone-500"></div>
<div className="mt-1 text-xs font-normal text-stone-500"></div>
</div>
}
open={isConfigOpen}
@@ -436,48 +323,33 @@ export function AppConfigModal() {
}
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
const models = modelOptionsFromChannels(channels);
const imageModels = keepOrSuggest(config.imageModels, filterModelsByCapability(models, "image"), models);
const videoModels = keepOrSuggest(config.videoModels, filterModelsByCapability(models, "video"), models);
const textModels = keepOrSuggest(config.textModels, filterModelsByCapability(models, "text"), models);
const audioModels = keepOrSuggest(config.audioModels, filterModelsByCapability(models, "audio"), models);
return {
const next: AiConfig = {
...config,
channels,
models,
models: modelOptionsFromChannels(channels),
baseUrl: channels[0]?.baseUrl || config.baseUrl,
apiKey: channels[0]?.apiKey || config.apiKey,
apiFormat: channels[0]?.apiFormat || config.apiFormat,
imageModels,
videoModels,
textModels,
audioModels,
imageModel: normalizeDefaultModel(config.imageModel, imageModels),
videoModel: normalizeDefaultModel(config.videoModel, videoModels),
textModel: normalizeDefaultModel(config.textModel, textModels),
audioModel: normalizeDefaultModel(config.audioModel, audioModels),
};
return {
...next,
imageModel: pickDefaultModel(next, "image", config.imageModel),
videoModel: pickDefaultModel(next, "video", config.videoModel),
textModel: pickDefaultModel(next, "text", config.textModel),
audioModel: pickDefaultModel(next, "audio", config.audioModel),
};
}
function keepOrSuggest(current: string[], suggested: string[], allModels: string[]) {
const available = new Set(allModels);
const kept = uniqueModels(current).filter((model) => available.has(model));
return kept.length ? kept : suggested;
}
function normalizeDefaultModel(value: string, options: string[]) {
if (options.includes(value)) return value;
return options[0] || value;
function pickDefaultModel(config: AiConfig, capability: ModelCapability, current: string) {
const options = selectableModelsByCapability(config, capability);
const normalized = normalizeModelOptionValue(current, config.channels);
return options.includes(normalized) ? normalized : options[0] || "";
}
function normalizeImageCount(value: string) {
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
}
function uniqueModels(models: string[]) {
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
}
function apiFormatLabel(apiFormat: ApiCallFormat) {
return apiFormat === "gemini" ? "Gemini" : "OpenAI";
}
@@ -0,0 +1,134 @@
import { Button, Drawer, Input, Segmented, Select, Space } from "antd";
import { ListPlus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { defaultBaseUrlForApiFormat, guessCapability, normalizeChannelModels, type ApiCallFormat, type ChannelModel, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { ModelScriptEditor } from "./model-script-editor";
import { ModelSelectModal } from "./model-select-modal";
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" },
];
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [
{ label: "生图", value: "image" },
{ label: "视频", value: "video" },
{ label: "文本", value: "text" },
{ label: "音频", value: "audio" },
];
type ScriptTarget = { name: string; capability: ModelCapability; value: string };
export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open: boolean; channel: ModelChannel | null; onSave: (channel: ModelChannel) => void; onClose: () => void }) {
const [draft, setDraft] = useState<ModelChannel | null>(channel);
const [selectOpen, setSelectOpen] = useState(false);
const [scriptTarget, setScriptTarget] = useState<ScriptTarget | null>(null);
useEffect(() => {
if (open && channel) setDraft(channel);
}, [open, channel]);
if (!draft) return null;
const patch = (value: Partial<ModelChannel>) => setDraft((current) => (current ? { ...current, ...value } : current));
const setModels = (models: ChannelModel[]) => patch({ models });
const changeApiFormat = (apiFormat: ApiCallFormat) => {
const baseUrl = !draft.baseUrl.trim() || draft.baseUrl.trim() === defaultBaseUrlForApiFormat(draft.apiFormat) ? defaultBaseUrlForApiFormat(apiFormat) : draft.baseUrl;
patch({ apiFormat, baseUrl });
};
const applySelection = (names: string[]) => {
const map = new Map(draft.models.map((model) => [model.name, model]));
setModels(names.map((name) => map.get(name) || { name, capability: guessCapability(name) }));
};
const setCapability = (name: string, capability: ModelCapability) => setModels(draft.models.map((model) => (model.name === name ? { ...model, capability } : model)));
const setScript = (name: string, script: string) => setModels(draft.models.map((model) => (model.name === name ? { ...model, script: script || undefined } : model)));
const removeModel = (name: string) => setModels(draft.models.filter((model) => model.name !== name));
const save = () => {
onSave({ ...draft, name: draft.name.trim() || "未命名渠道", models: normalizeChannelModels(draft.models) });
onClose();
};
return (
<Drawer
open={open}
width={640}
title="编辑渠道"
onClose={onClose}
styles={{ body: { paddingTop: 16 } }}
extra={
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" onClick={save}>
</Button>
</Space>
}
>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-medium"></span>
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} />
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium"></span>
<Select className="w-full" value={draft.apiFormat} options={apiFormatOptions} onChange={changeApiFormat} />
</label>
<label className="block md:col-span-2">
<span className="mb-1 block text-sm font-medium"></span>
<Input value={draft.baseUrl} onChange={(event) => patch({ baseUrl: event.target.value })} placeholder="https://api.example.com" />
</label>
<label className="block md:col-span-2">
<span className="mb-1 block text-sm font-medium">API Key</span>
<Input.Password value={draft.apiKey} onChange={(event) => patch({ apiKey: event.target.value })} placeholder="sk-..." />
</label>
</div>
<div className="mt-6 mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold"></div>
<div className="mt-0.5 text-xs text-stone-500"> {draft.models.length} </div>
</div>
<Button type="primary" icon={<ListPlus className="size-4" />} onClick={() => setSelectOpen(true)}>
</Button>
</div>
<div className="space-y-2 rounded-lg border border-stone-200 p-2 dark:border-stone-800">
{draft.models.length ? (
draft.models.map((model) => (
<div key={model.name} className="flex flex-wrap items-center gap-3 rounded-md px-2 py-1.5 hover:bg-stone-50 dark:hover:bg-stone-900/40">
<span className="min-w-0 flex-1 truncate text-sm" title={model.name}>
{model.name}
</span>
<div className="flex shrink-0 items-center gap-2">
<Segmented size="small" value={model.capability} options={capabilityOptions} onChange={(value) => setCapability(model.name, value as ModelCapability)} />
<Button size="small" type={model.script ? "primary" : "default"} ghost={Boolean(model.script)} onClick={() => setScriptTarget({ name: model.name, capability: model.capability, value: model.script || "" })}>
{model.script ? "脚本已设" : "调用脚本"}
</Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} onClick={() => removeModel(model.name)} />
</div>
</div>
))
) : (
<div className="px-2 py-8 text-center text-sm text-stone-500"></div>
)}
</div>
<ModelSelectModal open={selectOpen} channel={draft} selectedNames={draft.models.map((model) => model.name)} onConfirm={applySelection} onClose={() => setSelectOpen(false)} />
<ModelScriptEditor
open={Boolean(scriptTarget)}
capability={scriptTarget?.capability || "text"}
modelName={scriptTarget?.name || ""}
value={scriptTarget?.value || ""}
onSave={(script) => scriptTarget && setScript(scriptTarget.name, script)}
onClose={() => setScriptTarget(null)}
/>
</Drawer>
);
}
@@ -0,0 +1,112 @@
import { javascript } from "@codemirror/lang-javascript";
import CodeMirror from "@uiw/react-codemirror";
import { Button, Modal } from "antd";
import { useEffect, useState } from "react";
import { PLUGIN_RETURNS, PLUGIN_TEMPLATES, PLUGIN_VARIABLES } from "@/services/api/model-plugin";
import type { ModelCapability } from "@/stores/use-config-store";
const capabilityLabels: Record<ModelCapability, string> = { image: "生图", video: "视频", text: "文本", audio: "音频" };
function isDarkMode() {
return typeof document !== "undefined" && document.documentElement.classList.contains("dark");
}
export function ModelScriptEditor({ open, capability, modelName, value, onSave, onClose }: { open: boolean; capability: ModelCapability; modelName: string; value: string; onSave: (script: string) => void; onClose: () => void }) {
const [draft, setDraft] = useState(value);
useEffect(() => {
if (open) setDraft(value);
}, [open, value]);
const variables = PLUGIN_VARIABLES.filter((variable) => !variable.capabilities || variable.capabilities.includes(capability));
return (
<Modal
open={open}
title={
<div>
<div className="text-base font-semibold">
{capabilityLabels[capability]}
{modelName ? ` - ${modelName}` : ""}
</div>
<div className="mt-1 text-xs font-normal text-stone-500">使 return 使</div>
</div>
}
width={1080}
centered
onCancel={onClose}
styles={{ body: { padding: 0 } }}
footer={
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
{PLUGIN_TEMPLATES[capability].map((template) => (
<Button key={template.label} size="small" onClick={() => setDraft(template.script)}>
{template.label}
</Button>
))}
<Button size="small" danger onClick={() => setDraft("")}>
</Button>
</div>
<div className="flex items-center gap-2">
<Button onClick={onClose}></Button>
<Button
type="primary"
onClick={() => {
onSave(draft.trim());
onClose();
}}
>
</Button>
</div>
</div>
}
>
<div className="flex h-[60vh] min-h-[420px] border-t border-stone-200 dark:border-stone-800">
<aside className="flex w-[320px] shrink-0 flex-col overflow-y-auto border-r border-stone-200 bg-stone-50/80 dark:border-stone-800 dark:bg-stone-900/40">
<div className="border-b border-stone-200/70 px-4 py-3 dark:border-stone-800/70">
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-stone-400"></div>
<div className="text-xs leading-6 text-stone-600 dark:text-stone-300">{PLUGIN_RETURNS[capability]}</div>
</div>
<div className="px-4 py-3">
<div className="mb-2.5 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400"></span>
<span className="text-[10px] text-stone-400"></span>
</div>
<div className="space-y-1.5">
{variables.map((variable) => (
<button
key={variable.name}
type="button"
onClick={() => setDraft((current) => (current ? `${current}\n${variable.name}` : variable.name))}
className="group block w-full rounded-lg border border-transparent px-2.5 py-2 text-left transition-colors hover:border-stone-200 hover:bg-white dark:hover:border-stone-700 dark:hover:bg-stone-800/60"
>
<div className="flex flex-wrap items-baseline gap-1.5">
<code className="rounded bg-stone-200/80 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-stone-800 group-hover:bg-blue-100 group-hover:text-blue-700 dark:bg-stone-800 dark:text-stone-100 dark:group-hover:bg-blue-950 dark:group-hover:text-blue-300">
{variable.name}
</code>
<span className="font-mono text-[10px] text-stone-400">{variable.type}</span>
</div>
<div className="mt-1 text-xs leading-5 text-stone-500 dark:text-stone-400">{variable.desc}</div>
</button>
))}
</div>
</div>
</aside>
<div className="min-w-0 flex-1 overflow-hidden bg-white dark:bg-stone-950">
<CodeMirror
value={draft}
onChange={setDraft}
height="100%"
theme={isDarkMode() ? "dark" : "light"}
extensions={[javascript()]}
placeholder={"// 留空使用系统默认调用;点击右下角「插入模板」查看示例。"}
style={{ height: "100%", fontSize: 13 }}
className="h-full [&_.cm-editor]:h-full [&_.cm-gutters]:border-none [&_.cm-scroller]:overflow-auto"
/>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,153 @@
import { App, Button, Checkbox, Input, Modal, Tabs } from "antd";
import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { fetchChannelModels } from "@/services/api/image";
import type { ModelChannel } from "@/stores/use-config-store";
// 选择渠道模型弹窗:拉取上游模型列表或手动增加,勾选后才会进入渠道模型列表。
export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onClose }: { open: boolean; channel: ModelChannel | null; selectedNames: string[]; onConfirm: (names: string[]) => void; onClose: () => void }) {
const { message } = App.useApp();
const [existing, setExisting] = useState<string[]>([]);
const [fetched, setFetched] = useState<string[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState("new");
const [search, setSearch] = useState("");
const [manual, setManual] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
setExisting(selectedNames);
setFetched([]);
setSelected(new Set(selectedNames));
setActiveTab(selectedNames.length ? "existing" : "new");
setSearch("");
setManual("");
}, [open, selectedNames]);
const currentList = activeTab === "new" ? fetched : existing;
const visibleList = useMemo(() => {
const keyword = search.trim().toLowerCase();
return keyword ? currentList.filter((name) => name.toLowerCase().includes(keyword)) : currentList;
}, [currentList, search]);
const visibleSelectedCount = visibleList.filter((name) => selected.has(name)).length;
const toggle = (name: string, checked: boolean) =>
setSelected((current) => {
const next = new Set(current);
if (checked) next.add(name);
else next.delete(name);
return next;
});
const selectVisible = (checked: boolean) =>
setSelected((current) => {
const next = new Set(current);
visibleList.forEach((name) => (checked ? next.add(name) : next.delete(name)));
return next;
});
const addManual = () => {
const name = manual.trim();
if (!name) return;
if (!fetched.includes(name) && !existing.includes(name)) setFetched((current) => [name, ...current]);
setSelected((current) => new Set(current).add(name));
setManual("");
setActiveTab("new");
};
const fetchModels = async () => {
if (!channel) return;
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
message.error("请先填写接口地址和 API Key");
return;
}
setLoading(true);
try {
const models = await fetchChannelModels(channel);
setFetched(models);
setActiveTab("new");
message.success(`已拉取 ${models.length} 个模型`);
} catch (error) {
message.error(error instanceof Error ? error.message : "拉取模型失败");
} finally {
setLoading(false);
}
};
const confirm = () => {
const ordered = [...existing, ...fetched].filter((name, index, list) => list.indexOf(name) === index).filter((name) => selected.has(name));
onConfirm(ordered);
onClose();
};
return (
<Modal
open={open}
width={880}
centered
onCancel={onClose}
title={
<span>
<span className="ml-2 text-xs font-normal text-stone-500"> {selected.size} / {new Set([...existing, ...fetched]).size}</span>
</span>
}
styles={{ body: { maxHeight: "62vh", overflowY: "auto" } }}
footer={[
<Button key="cancel" onClick={onClose}>
</Button>,
<Button key="confirm" type="primary" onClick={confirm}>
</Button>,
]}
>
<div className="flex flex-wrap items-center gap-3">
<Input className="min-w-[200px] flex-1" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索模型" prefix={<Search className="size-4 text-stone-400" />} allowClear />
<Input className="min-w-[180px] flex-1" value={manual} onChange={(event) => setManual(event.target.value)} onPressEnter={addManual} placeholder="输入模型名称" />
<Button onClick={addManual}></Button>
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void fetchModels()}>
</Button>
</div>
<div className="mt-2 text-xs text-stone-500"> OpenAI /models </div>
<Tabs
className="mt-3"
activeKey={activeTab}
onChange={setActiveTab}
items={[
{ key: "new", label: `新获取的模型 (${fetched.length})` },
{ key: "existing", label: `已有的模型 (${existing.length})` },
]}
/>
<div className="mb-3 flex items-center justify-between gap-2">
<span className="text-xs text-stone-500"> {visibleSelectedCount} / {visibleList.length}</span>
<div className="flex gap-2">
<Button size="small" disabled={!visibleList.length} onClick={() => selectVisible(true)}>
</Button>
<Button size="small" disabled={!visibleSelectedCount} onClick={() => selectVisible(false)}>
</Button>
</div>
</div>
{visibleList.length ? (
<div className="grid grid-cols-1 gap-x-8 gap-y-3 md:grid-cols-2">
{visibleList.map((name) => (
<Checkbox key={name} checked={selected.has(name)} onChange={(event) => toggle(name, event.target.checked)}>
<span className="truncate" title={name}>
{name}
</span>
</Checkbox>
))}
</div>
) : (
<div className="py-8 text-center text-sm text-stone-500">{activeTab === "new" ? "点击「拉取模型列表」获取上游模型,或手动增加模型名称。" : "暂无已选择的模型。"}</div>
)}
</Modal>
);
}
@@ -1,5 +1,5 @@
import type { CSSProperties } from "react";
import { BookOpen, Keyboard, Settings2 } from "lucide-react";
import { BookOpen, Keyboard, Puzzle, Settings2 } from "lucide-react";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { GitHubLink } from "@/components/layout/github-link";
@@ -14,9 +14,10 @@ type UserStatusActionsProps = {
showConfig?: boolean;
variant?: "default" | "canvas";
onOpenShortcuts?: () => void;
onOpenPlugins?: () => void;
};
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts }: UserStatusActionsProps) {
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, onOpenPlugins }: UserStatusActionsProps) {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
@@ -29,6 +30,11 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
return (
<div className="inline-flex shrink-0 items-center gap-1">
{onOpenPlugins ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenPlugins} aria-label="节点插件" title="节点插件">
<Puzzle className="size-4" />
</button>
) : null}
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={naturalIconClass} style={iconStyle} aria-label="文档" title="文档">
<BookOpen className="size-4" />
</a>
+1 -1
View File
@@ -83,7 +83,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
if (capability && config.models.length) return "请先在上方配置可选模型";
if (capability && config.models.length) return `请先在渠道里为${label}指定模型`;
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
}