+ {!simpleMode && (mode === "image" || mode === "video") ?
e.stopPropagation()}>
+ {mode === "image" ? <>
+ updatePrompt(applyToolMention(prompt,{id,label,type:"style"},"Palette"))} onClear={() => updatePrompt(removeToolMentions(prompt,"style"))} />
+ updatePrompt(applyToolMention(prompt,{id,label,type:"nine_grid"},icon))} />
+ > : <>
+ updatePrompt(applyToolMention(prompt,{id,label,type:"effect"},"Sparkles"))} onClear={() => updatePrompt(removeToolMentions(prompt,"effect"))} />
+ t.toolId)} activeLabel={activeMotionTool?.label} onSelect={(id,label) => updatePrompt(applyToolMention(prompt,{id,label,type:"motion"},"Camera"))} onClear={removeMotionToolMention} />
+ >}
+
: null}
+ {showPromptTemplates ?
: null}
{canOptimizePrompt ? (
onAddReference(node.id, reference) : undefined}
includeAssetLibrary
onChange={updatePrompt}
@@ -565,6 +606,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
destroyOnHidden
onCancel={() => {
setExpandedPresetOpen(false);
+ setExpandedNineGridOpen(false);
setExpandedPromptOpen(false);
}}
styles={{
@@ -684,7 +726,7 @@ function ConnectedReferenceShelf({
onReplaceReference?: (oldReference: CanvasResourceReference, sourceNodeId: string) => void;
onReplaceReferenceFiles?: (oldReference: CanvasResourceReference, files: File[]) => void;
}) {
- const activeReferences = references.filter((item) => item.active && item.kind !== "skill");
+ const activeReferences = references.filter((item) => item.active && item.kind !== "skill" && item.kind !== "tool");
const [imagePreview, setImagePreview] = useState(null);
const [draggedReferenceId, setDraggedReferenceId] = useState(null);
const [dropTargetReferenceId, setDropTargetReferenceId] = useState(null);
diff --git a/web/src/components/canvas/canvas-node-toolbar.tsx b/web/src/components/canvas/canvas-node-toolbar.tsx
index 5dee9017b..46a542060 100644
--- a/web/src/components/canvas/canvas-node-toolbar.tsx
+++ b/web/src/components/canvas/canvas-node-toolbar.tsx
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { App, Button, Dropdown, Input, Modal, Tag, Tooltip } from "antd";
import type { MenuProps } from "antd";
-import { Camera, Check, ChevronDown, ChevronRight, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
+import { Camera, Check, ChevronDown, ChevronRight, Ellipsis, Grid3x3, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
@@ -62,6 +62,7 @@ type CanvasNodeToolbarProps = {
onToggleFreeResize: (node: CanvasNodeData) => void;
onToggleLocked: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void;
+ onNineGrid: (node: CanvasNodeData, toolId: number, label: string, icon: string) => void;
workspaceMode?: CanvasWorkspaceMode;
};
@@ -127,6 +128,7 @@ export function CanvasNodeToolbar({
onToggleFreeResize,
onToggleLocked,
onDelete,
+ onNineGrid,
workspaceMode = "professional",
}: CanvasNodeToolbarProps) {
const [openMenuId, setOpenMenuId] = useState(null);
@@ -229,7 +231,7 @@ export function CanvasNodeToolbar({
}
copyText(prompt, "提示词已复制");
};
- const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onAnnotationEdit, onTextEdit, onMaskEdit, onRemoveBackground, onLayerDecomposition, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
+ const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onAnnotationEdit, onTextEdit, onMaskEdit, onRemoveBackground, onLayerDecomposition, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt, onNineGrid });
// 构建 ToolContext——供注册表解析工具
const nodeHoverHandlers = {
@@ -292,6 +294,7 @@ export function CanvasNodeToolbar({
const viewpointLightingTools = compact ? [] : [...inGroup("viewpoint"), ...inGroup("lighting")];
const panoramaTools = compact ? [] : inGroup("panorama");
const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("lighting"), ...inGroup("panorama"), ...inGroup("process")] : inGroup("process");
+ const nineGridTools = compact ? [] : inGroup("nine_grid");
const workspaceTools = narrow ? [] : inGroup("workspace");
const utilityTools = inGroup("utility");
const moreTools = [...(narrow ? [...primary.slice(1), ...inGroup("workspace")] : []), ...inGroup("more")];
@@ -324,6 +327,7 @@ export function CanvasNodeToolbar({
style={{ ...dockStyle, border: 0 }}
>
{primaryTools.map((tool) => )}
+ {nineGridTools.length ? } tools={nineGridTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{panoramaTools.map((tool) => )}
{portraitTools.length ? } tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{viewpointLightingTools.length ? } tools={viewpointLightingTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
diff --git a/web/src/components/canvas/canvas-resource-mention-textarea.tsx b/web/src/components/canvas/canvas-resource-mention-textarea.tsx
index fdb4ef269..8f334e8d9 100644
--- a/web/src/components/canvas/canvas-resource-mention-textarea.tsx
+++ b/web/src/components/canvas/canvas-resource-mention-textarea.tsx
@@ -1,7 +1,8 @@
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react";
import { createPortal } from "react-dom";
-import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, UserRound, Video, Workflow } from "lucide-react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { ArrowLeft, Brush, Camera, Clapperboard, ChevronRight, Clock, Contrast, FastForward, FileText, Folder, Globe2, Grid2x2, Grid3x3, Image as ImageIcon, Music2, Package, Pencil, Palette, PersonStanding, Rewind, ScanFace, Search, SlidersHorizontal, Sparkles, Sun, UserRound, Video, Workflow } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
@@ -84,13 +85,16 @@ export const CanvasResourceMentionTextarea = forwardRef includeAssetLibrary ? buildAssetMentionReferences(assets) : [], [assets, includeAssetLibrary]);
const assetReferences = useResolvedCanvasResourceReferences(rawAssetReferences);
const activeCanvasReferences = useMemo(() => canvasReferences.filter((item) => item.active), [canvasReferences]);
- const availableReferences = useMemo(() => [...(onSelectReference ? canvasReferences : activeCanvasReferences), ...assetReferences], [onSelectReference, canvasReferences, activeCanvasReferences, assetReferences]);
+ // 工具标签只能经九宫格等面板入口插入,@ 引用菜单不再重复展示。
+ const mentionCanvasReferences = useMemo(() => canvasReferences.filter((item) => item.kind !== "tool"), [canvasReferences]);
+ const activeMentionCanvasReferences = useMemo(() => mentionCanvasReferences.filter((item) => item.active), [mentionCanvasReferences]);
+ const availableReferences = useMemo(() => [...(onSelectReference ? mentionCanvasReferences : activeMentionCanvasReferences), ...assetReferences], [onSelectReference, mentionCanvasReferences, activeMentionCanvasReferences, assetReferences]);
const candidates = useMemo(() => {
if (!mention) return [];
const query = mention.query.trim().toLowerCase();
- if (!query) return onSelectReference ? canvasReferences : activeCanvasReferences;
+ if (!query) return onSelectReference ? mentionCanvasReferences : activeMentionCanvasReferences;
return availableReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.category || ""} ${item.text || ""}`.toLowerCase().includes(query));
- }, [onSelectReference, canvasReferences, activeCanvasReferences, availableReferences, mention]);
+ }, [onSelectReference, mentionCanvasReferences, activeMentionCanvasReferences, availableReferences, mention]);
const activeReferences = useMemo(() => {
if (!highlightLabels) return [];
return [...activeCanvasReferences, ...assetReferences.filter((item) => value.includes(canvasResourceMentionToken(item)))];
@@ -327,7 +331,7 @@ export const CanvasResourceMentionTextarea = forwardRef> = {
+ Brush, Camera, Clapperboard, Clock, Contrast, FastForward, Globe2, Grid2x2, Grid3x3, Package, Palette, PersonStanding, Rewind, ScanFace, SlidersHorizontal, Sparkles, Sun,
+};
+
+function toolIconSvg(iconName: string): string {
+ const Icon = TOOL_ICON_MAP[iconName];
+ if (!Icon) return "🔧";
+ return renderToStaticMarkup();
+}
+
function createInlineMentionChip(reference: CanvasResourceReference, token: string) {
const chip = document.createElement("span");
chip.contentEditable = "false";
chip.dataset.mentionToken = token;
chip.dataset.mentionReferenceId = reference.id;
- chip.className = `canvas-resource-inline-mention ${reference.kind === "skill" ? "is-skill" : ""}`;
+ const isDecorated = reference.kind === "skill" || reference.kind === "tool";
+ chip.className = `canvas-resource-inline-mention ${isDecorated ? `is-${reference.kind}` : ""}`;
chip.title = "双击放大预览";
if (reference.kind === "skill") chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference));
+ if (reference.kind === "tool") chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference));
const prefix = document.createElement("span");
- prefix.className = reference.kind === "skill" ? "canvas-resource-inline-skill-icon" : "canvas-resource-inline-at";
- // “/” is an input command, not part of the selected Skill name. Keep the
- // command token in the serialized value, but render the chip as a normal
- // icon + label so it remains readable after selection and submission.
- prefix.textContent = reference.kind === "skill" ? "✦" : "@";
+ prefix.className = isDecorated ? "canvas-resource-inline-skill-icon" : "canvas-resource-inline-at";
+ if (reference.kind === "skill") {
+ prefix.textContent = "✦";
+ } else if (reference.kind === "tool") {
+ prefix.innerHTML = toolIconSvg(reference.toolIcon ?? "Grid3x3");
+ } else {
+ prefix.textContent = "@";
+ }
chip.appendChild(prefix);
- // Skill chip 的前缀已经承担图标职责,不再追加 fallback preview,避免出现两个星标。
- if (reference.kind !== "skill") chip.appendChild(createInlinePreview(reference));
+ // Skill/tool chip 的前缀已经承担图标职责,不再追加 fallback preview,避免出现两个图标。
+ if (!isDecorated) chip.appendChild(createInlinePreview(reference));
const label = document.createElement("span");
label.className = "canvas-resource-inline-label";
diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx
index 446986e77..270833367 100644
--- a/web/src/components/canvas/canvas-toolbar.tsx
+++ b/web/src/components/canvas/canvas-toolbar.tsx
@@ -51,6 +51,7 @@ export function CanvasToolbar({
onSaveAppearanceDefault,
onBackgroundModeChange,
onShowImageInfoChange,
+ onOpenWorkspace,
onOpenMyAssets,
onOpenProjectCharacters,
}: {
@@ -86,6 +87,7 @@ export function CanvasToolbar({
onSaveAppearanceDefault: (appearance: CanvasAppearance) => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void;
+ onOpenWorkspace?: () => void;
onOpenMyAssets: () => void;
onOpenProjectCharacters: () => void;
}) {
@@ -150,7 +152,8 @@ export function CanvasToolbar({
onChooseStyle,
onOpenDirector,
onUpload,
- onOpenMyAssets,
+ onOpenWorkspace,
+ onOpenMyAssets,
onOpenProjectCharacters,
onBackgroundModeChange,
onShowImageInfoChange,
diff --git a/web/src/components/canvas/canvas-workspace-asset-panel.tsx b/web/src/components/canvas/canvas-workspace-asset-panel.tsx
new file mode 100644
index 000000000..e4784e77b
--- /dev/null
+++ b/web/src/components/canvas/canvas-workspace-asset-panel.tsx
@@ -0,0 +1,142 @@
+import { useEffect, useState } from "react";
+import { App, Button, Select } from "antd";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { FileText, Images, Plus, RefreshCw } from "lucide-react";
+import { CachedResourceImage } from "@/components/cached-resource-image";
+import { WorkspaceState } from "@/components/layout/workspace-state";
+import { ASSET_CATEGORY_LABELS, type AssetCategory } from "@/lib/asset-category";
+import { resourceStorageLabel } from "@/lib/canvas/resource-storage-status";
+import { loadAssetLibraryPage } from "@/services/user-data-sync";
+import { useUserStore } from "@/stores/use-user-store";
+import type { Asset } from "@/stores/use-asset-store";
+import { assetPickerItemsToInsertPayloads, type InsertAssetPayload } from "./asset-picker-modal";
+
+const categories: AssetCategory[] = ["material", "character", "environment", "prop", "other"];
+export function CanvasWorkspaceAssetPanel({ onInsert, onManage, onProjectAssets }: { onInsert: (payloads: InsertAssetPayload[]) => Promise; onManage: () => void; onProjectAssets?: () => void }) {
+ const { message } = App.useApp();
+ const userId = useUserStore((state) => state.user?.id);
+ const hydrated = useUserStore((state) => state.hydrated);
+ const [category, setCategory] = useState("material");
+ const [kind, setKind] = useState("all");
+ const [input, setInput] = useState("");
+ const [search, setSearch] = useState("");
+ const [inserting, setInserting] = useState(null);
+ useEffect(() => {
+ const timer = window.setTimeout(() => setSearch(input.trim()), 250);
+ return () => window.clearTimeout(timer);
+ }, [input]);
+ const query = useInfiniteQuery({
+ queryKey: ["canvas-workspace-assets", userId, category, kind, search],
+ initialPageParam: 1,
+ queryFn: ({ signal, pageParam }) => loadAssetLibraryPage({ page: pageParam, pageSize: 40, category, kind: kind === "all" ? undefined : kind, status: "active", query: search, signal }),
+ getNextPageParam: (page, pages) => (pages.reduce((count, item) => count + item.assets.length, 0) < page.total ? pages.length + 1 : undefined),
+ enabled: Boolean(userId) && hydrated,
+ });
+ const assets = query.data?.pages.flatMap((page) => page.assets) ?? [];
+ const insert = async (asset: Asset) => {
+ if (inserting) return;
+ setInserting(asset.id);
+ try {
+ const payloads = assetPickerItemsToInsertPayloads([asset.id], [{ id: asset.id, title: asset.title, category: asset.category || "other", kindLabel: asset.kind, asset }]);
+ await onInsert(payloads);
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "插入素材失败");
+ } finally {
+ setInserting(null);
+ }
+ };
+ return (
+ <>
+
+
+ 资产
+ {query.data?.pages[0]?.total ?? 0}
+
+
+ {categories.map((id) => (
+
+ ))}
+
+
+
+
+ } onClick={() => void query.refetch()} />
+
+
setInput(event.target.value)} />
+
+
+ {!userId ? (
+
登录后查看资产。
+ ) : query.isPending ? (
+
+ 正在加载资产…
+
+ ) : null}
+ {query.isError ? (
+
+ 资产读取失败,已加载的内容仍保留。
+
+ ) : null}
+ {assets.map((asset) => {
+ const insertable = ["image", "video", "audio", "text"].includes(asset.kind);
+ const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
+ return (
+
+
+ } />
+
+
+
+ {asset.title}
+
+
+ {resourceStorageLabel("storageKey" in asset.data ? asset.data.storageKey : undefined)}
+ {asset.tags?.length ? ` · ${asset.tags[0]}` : ""}
+
+
+
}
+ onClick={() => void insert(asset)}
+ />
+
+ );
+ })}
+ {query.isSuccess && !assets.length ?
: null}
+ {query.hasNextPage ? (
+
+ ) : null}
+
+
+ >
+ );
+}
diff --git a/web/src/components/canvas/canvas-workspace-history-panel.tsx b/web/src/components/canvas/canvas-workspace-history-panel.tsx
new file mode 100644
index 000000000..6b95766fd
--- /dev/null
+++ b/web/src/components/canvas/canvas-workspace-history-panel.tsx
@@ -0,0 +1,35 @@
+import { History, RefreshCw } from "lucide-react";
+
+import { WorkspaceState } from "@/components/layout/workspace-state";
+import type { GenerationTask } from "@/services/api/task-center";
+import { TaskListItem } from "./canvas-workspace-task-list-item";
+
+// 历史面板:展示项目最近任务(含云端 Agent 根任务与步骤任务),与任务面板互不依赖。
+export function CanvasWorkspaceHistoryPanel({ tasks, refreshing, onRefresh, onCancelTask }: { tasks: GenerationTask[]; refreshing?: boolean; onRefresh?: () => void; onCancelTask?: (task: GenerationTask) => void }) {
+ return (
+ <>
+
+
+ 历史 · 最近 30 条(含 Agent)
+ {tasks.length.toLocaleString("zh-CN")}
+
+
+
+
+
+
+ {tasks.length ? (
+
+ {tasks.map((task) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/web/src/components/canvas/canvas-workspace-node-list-panel.tsx b/web/src/components/canvas/canvas-workspace-node-list-panel.tsx
new file mode 100644
index 000000000..ecbb42e13
--- /dev/null
+++ b/web/src/components/canvas/canvas-workspace-node-list-panel.tsx
@@ -0,0 +1,148 @@
+import { memo, useState } from "react";
+import { AudioLines, Clock3, FileText, Image, Layers, Pencil, Search, X } from "lucide-react";
+
+import { WorkspaceState } from "@/components/layout/workspace-state";
+import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes } from "@/lib/canvas/canvas-node-search";
+import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
+import { getNodeListLabel } from "@/lib/canvas/node-registry";
+import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
+import { Video as CanvasVideoIcon } from "lucide-react";
+
+export function CanvasWorkspaceNodeListPanel({
+ nodes,
+ results,
+ query,
+ deferredQuery,
+ selectedNodeIds,
+ onQueryChange,
+ onFocus,
+}: {
+ nodes: CanvasNodeData[];
+ results: CanvasNodeData[];
+ query: string;
+ deferredQuery: string;
+ selectedNodeIds: Set;
+ onQueryChange: (value: string) => void;
+ onFocus: (nodeId: string) => void;
+}) {
+ return (
+ <>
+
+
+ 画布节点
+ {nodes.length.toLocaleString("zh-CN")}
+
+
+
+ {deferredQuery ?
找到 {results.length.toLocaleString("zh-CN")} 个节点
: null}
+
+
+ {results.length ? (
+ results.map((node) => onFocus(node.id)} />)
+ ) : (
+
+ )}
+
+ >
+ );
+}
+
+const CanvasNodeListItem = memo(function CanvasNodeListItem({ node, active, onSelect }: { node: CanvasNodeData; active: boolean; onSelect: () => void }) {
+ const times = canvasNodeSearchTimes(node);
+ const materialSummary = canvasNodeMaterialSummary(node);
+ const context = canvasNodeSearchContext(node);
+ return (
+
+ );
+});
+
+function CanvasNodeListThumbnail({ node }: { node: CanvasNodeData }) {
+ const [failed, setFailed] = useState(false);
+ const mediaSource =
+ node.type === CanvasNodeType.Video
+ ? canvasNodeVideoPreviewUrl(node)
+ : node.metadata?.drawingPreviewUrl ||
+ node.metadata?.characterCoverUrl ||
+ node.metadata?.folder?.themeCover ||
+ (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.ColorGrade ? node.metadata?.content : undefined);
+ const commonClass = "h-9 w-11 rounded-[var(--r-sm)] border object-cover";
+ const commonStyle = { borderColor: "color-mix(in srgb, var(--foreground) 9%, transparent)", background: "color-mix(in srgb, var(--foreground) 5%, transparent)" };
+
+ if (mediaSource && !failed) {
+ return
setFailed(true)} />;
+ }
+
+ const textPreview = node.metadata?.previewContent || node.metadata?.composerContent || node.metadata?.prompt || node.metadata?.content;
+ if (textPreview && (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Markdown || node.type === CanvasNodeType.Script)) {
+ return (
+
+ {textPreview}
+
+ );
+ }
+
+ return (
+
+ {node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Panorama ? (
+
+ ) : node.type === CanvasNodeType.Video ? (
+
+ ) : node.type === CanvasNodeType.Audio ? (
+
+ ) : node.type === CanvasNodeType.Drawing ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function fullTime(value?: string) {
+ if (!value) return "时间未记录";
+ return new Date(value).toLocaleString("zh-CN", { hour12: false });
+}
diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx
index f7b558ce5..9bb404f03 100644
--- a/web/src/components/canvas/canvas-workspace-overlays.tsx
+++ b/web/src/components/canvas/canvas-workspace-overlays.tsx
@@ -48,7 +48,7 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre
toolbarRef.current.classList.toggle("-translate-y-full", placement === "above");
return;
}
- setAnchor((current) => current?.left === left && current.top === top && current.placement === placement ? current : { left, top, placement });
+ setAnchor((current) => (current?.left === left && current.top === top && current.placement === placement ? current : { left, top, placement }));
};
update();
@@ -79,15 +79,45 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
-
- 已选 {count}
+
+
+ 已选 {count}
+
{children}
);
}
-export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth, panelHeight = 190, dragOffset, isDragging = false, allowOverflow = false, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject