feat(*): 创作体验 - 支持资源技能引用并统一生成反馈与画布列表展示

This commit is contained in:
ddCat
2026-08-02 22:29:44 +08:00
parent e404d4f716
commit 0253072232
20 changed files with 589 additions and 130 deletions

View File

@@ -886,16 +886,16 @@ function AgentTextModelPicker({ config, value, onChange }: { config: AiConfig; v
value={current || undefined}
className="agent-text-model-select w-full"
popupMatchSelectWidth={288}
options={options.map((model) => ({ value: model, label: `${modelDisplayName(config, model)} ${modelOptionName(model)} ${resolveModelChannel(config, model).name}` }))}
options={options.map((model) => ({ value: model, label: `${modelDisplayName(config, model)} ${resolveModelChannel(config, model).name}` }))}
notFoundContent={<span className="block py-2 text-center text-xs text-foreground/48">暂无文本模型</span>}
optionRender={(option) => {
const model = String(option.value);
return <span className="flex min-w-0 items-center gap-2"><AgentModelIcon model={model} /><span className="min-w-0 flex-1"><span className="block truncate">{modelDisplayName(config, model)}</span><span className="block truncate text-[10px] opacity-45">{modelOptionName(model)}</span></span><span className="shrink-0 text-xs opacity-55">{resolveModelChannel(config, model).name}</span></span>;
return <span className="flex min-w-0 items-center gap-2"><AgentModelIcon model={model} /><span className="min-w-0 flex-1 truncate">{modelDisplayName(config, model)}</span><span className="shrink-0 text-xs opacity-55">{resolveModelChannel(config, model).name}</span></span>;
}}
labelRender={() => <span className="flex min-w-0 items-center gap-1.5"><AgentModelIcon model={current} /><span className="min-w-0 truncate">{current ? modelDisplayName(config, current) : "选择文本模型"}</span>{current ? <span className="shrink-0 opacity-55">{resolveModelChannel(config, current).name}</span> : null}</span>}
onChange={onChange}
aria-label="选择 Agent 文本模型"
title={current ? `${modelDisplayName(config, current)} · ${modelOptionName(current)} · ${resolveModelChannel(config, current).name}` : "选择文本模型"}
title={current ? `${modelDisplayName(config, current)} · ${resolveModelChannel(config, current).name}` : "选择文本模型"}
/>
</div>
);

View File

@@ -7,7 +7,7 @@ import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { useCopyText } from "@/hooks/use-copy-text";
import { useThemeStore } from "@/stores/use-theme-store";
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
@@ -546,7 +546,7 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
{node.metadata?.errorDetails ? (
<div className="rounded-2xl border p-3 text-red-500" style={{ borderColor: theme.node.stroke }}>
{node.metadata.errorDetails}
{generationErrorMessage(node.metadata.errorDetails)}
</div>
) : null}
</div>

View File

@@ -7,7 +7,7 @@ import { CometCard } from "@/components/ui/aceternity/comet-card";
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { formatBytes } from "@/lib/image-utils";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { useThemeStore } from "@/stores/use-theme-store";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache";
@@ -633,7 +633,7 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails);
return (
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
<div className="text-xs leading-5" style={{ color: theme.accent.danger }}>{node.metadata?.errorDetails || "生成失败"}</div>
<div className="text-xs leading-5" style={{ color: theme.accent.danger }}>{generationErrorMessage(node.metadata?.errorDetails)}</div>
{moderationFailure ? (
<div className="rounded-md border px-3 py-2 text-[11px] leading-4" style={{ background: theme.node.fill, borderColor: theme.toolbar.border, color: theme.node.muted }}>
修改节点提示词后,可重新点击生成。

View File

@@ -32,11 +32,11 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
const compact = variant === "recent";
return (
<article className={cn("app-canvas-project-card group h-full cursor-pointer", selected && "is-selected")} onClick={() => !editing && open()}>
<article className={cn("app-canvas-project-card group h-full cursor-pointer", compact ? "is-recent" : "is-library overflow-hidden rounded-lg border", selected && "is-selected")} onClick={() => !editing && open()}>
<div className="app-canvas-project-preview relative">
<button
type="button"
className="block aspect-[16/10] w-full overflow-hidden text-left"
className={cn("block w-full overflow-hidden text-left", compact ? "aspect-[16/10]" : "aspect-video")}
onClick={(event) => {
event.stopPropagation();
open();
@@ -48,20 +48,20 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
<span className="absolute bottom-2 right-2 rounded-md border border-white/15 bg-stone-950/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-xl">{project.nodes.length} 节点</span>
</div>
<div className="px-1 pb-1 pt-2.5">
<div className="flex items-center justify-between gap-3">
<div className={cn("app-canvas-project-body", compact ? "px-1 pb-1 pt-2.5" : "px-3 py-2.5")}>
<div className="flex min-h-7 items-center justify-between gap-2">
{editing ? (
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
) : (
<button
type="button"
className="min-w-0 flex-1 cursor-pointer text-left"
className="min-w-0 flex-1 cursor-pointer text-left focus-visible:outline-none"
onClick={(event) => {
event.stopPropagation();
open();
}}
>
<h2 className="line-clamp-1 text-[13px] font-semibold leading-5 text-foreground">{project.title}</h2>
<h2 className="truncate text-[13px] font-semibold leading-5 text-foreground">{project.title}</h2>
</button>
)}
{editing ? (
@@ -70,27 +70,30 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
<button type="button" className="grid size-7 place-items-center rounded-md hover:bg-black/5 dark:hover:bg-white/10" onClick={stopEditing} aria-label="取消重命名"><X className="size-3.5" /></button>
</div>
) : (
<Dropdown
trigger={["click"]}
menu={{
onClick: ({ domEvent }) => domEvent.stopPropagation(),
items: [
{ key: "export", icon: <Download className="size-3.5" />, label: "导出画布", onClick: () => void exportCanvasProjects([project], project.title || "影策画布") },
{ key: "rename", icon: <Pencil className="size-3.5" />, label: "重命名", onClick: () => startEditing(project.id, project.title) },
{ type: "divider" },
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: () => setDeleteIds([project.id]) },
],
}}
>
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md text-foreground/42 opacity-100 transition hover:bg-foreground/[.06] hover:text-foreground sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100 dark:hover:bg-white/10" onClick={(event) => event.stopPropagation()} aria-label={`${project.title} 画布操作`} title="画布操作"><MoreHorizontal className="size-4" /></button>
</Dropdown>
<div className="flex shrink-0 items-center" onClick={(event) => event.stopPropagation()}>
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/38 transition-colors hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-white/10" onClick={() => startEditing(project.id, project.title)} aria-label={`重命名 ${project.title}`} title="重命名"><Pencil className="size-3.5" /></button>
<Dropdown
trigger={["click"]}
menu={{
onClick: ({ domEvent }) => domEvent.stopPropagation(),
items: [
{ key: "export", icon: <Download className="size-3.5" />, label: "导出画布", onClick: () => void exportCanvasProjects([project], project.title || "影策画布") },
{ type: "divider" },
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: () => setDeleteIds([project.id]) },
],
}}
>
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/38 transition-colors hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-white/10" aria-label={`${project.title} 画布操作`} title="更多操作"><MoreHorizontal className="size-4" /></button>
</Dropdown>
</div>
)}
</div>
<div className="mt-0.5 flex min-w-0 items-center justify-between gap-2 text-[11px] leading-5 text-foreground/52">
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-foreground/52">
<span className="truncate">{projectName || "自由画布"}</span>
<span className="shrink-0">{project.connections.length} 条连线</span>
<span className="shrink-0 text-foreground/28" aria-hidden="true">·</span>
<span className="shrink-0 tabular-nums">{project.connections.length} 条连线</span>
</div>
<p className="text-[10px] tabular-nums text-foreground/38">更新于 {formatProjectTime(project.updatedAt)}</p>
<p className="mt-1 text-[10px] leading-4 tabular-nums text-foreground/38">更新于 {formatProjectTime(project.updatedAt)}</p>
</div>
</article>
);

View File

@@ -8,7 +8,7 @@ import { ModelPicker } from "@/components/model-picker";
import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { pipelineStatusLabel, type CanvasStoryboardPipelineProgress, type StoryboardPipelineStage } from "@/lib/canvas/canvas-storyboard-progress";
import { isContentModerationError } from "@/lib/generation-error";
import { generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { navigateToSettings } from "@/lib/settings-navigation";
import { canvasThemes } from "@/lib/canvas-theme";
import { useEffectiveConfig } from "@/stores/use-config-store";
@@ -106,7 +106,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
const hasActiveBatchItems = Boolean(batch?.items.some((item) => item.status === "waiting" || item.status === "submitting" || item.status === "queued" || item.status === "running"));
const taskFeedback = node.metadata?.status === "loading"
? `${node.metadata.taskStage || "正在创建任务"}${typeof node.metadata.taskProgress === "number" ? ` · ${node.metadata.taskProgress}%` : ""}`
: node.metadata?.status === "error" ? node.metadata.errorDetails : "";
: node.metadata?.status === "error" ? generationErrorMessage(node.metadata.errorDetails) : "";
const submitPrompt = () => {
const value = prompt.trim();
if (value && node.metadata?.status !== "loading") onGenerateScript(value);
@@ -344,7 +344,7 @@ function GenerationBatchDetails({ batch, rows, onRetryItem, onCancelItem }: { ba
const requiresPromptChange = isContentModerationError(item.errorDetails);
return <div key={item.id} className="flex min-h-9 items-center gap-2 border-t border-foreground/10 py-1.5 first:border-t-0">
<span className="w-14 shrink-0 text-xs font-medium">镜头 {shotByRowId.get(item.rowId) || "--"}</span>
<span className="min-w-0 flex-1 truncate text-xs text-foreground/60" title={item.errorDetails}>{generationBatchItemLabel(item)}{item.retryCount ? ` · 重试 ${item.retryCount}` : ""}</span>
<span className="min-w-0 flex-1 truncate text-xs text-foreground/60" title={item.errorDetails ? generationErrorMessage(item.errorDetails) : undefined}>{generationBatchItemLabel(item)}{item.retryCount ? ` · 重试 ${item.retryCount}` : ""}</span>
{item.status === "failed" ? <Tooltip title={requiresPromptChange ? "请先修改提示词,再重试这个镜头" : "只重试这个镜头"}><button type="button" className="grid size-7 shrink-0 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" onClick={() => onRetryItem(item.id)} aria-label={`重试镜头 ${shotByRowId.get(item.rowId) || ""}`}><RefreshCw className="size-3.5" /></button></Tooltip> : null}
{cancellable ? <Tooltip title="取消这个后台任务"><button type="button" className="grid size-7 shrink-0 place-items-center rounded outline-none transition hover:bg-red-500/10 focus-visible:ring-2" onClick={() => onCancelItem(item.id)} aria-label={`取消镜头 ${shotByRowId.get(item.rowId) || ""} 任务`}><X className="size-3.5" /></button></Tooltip> : null}
</div>;

View File

@@ -31,9 +31,9 @@ export function PageHeader({ title, description, meta, actions, icon }: { title:
);
}
export function ListToolbar({ children, trailing, active, onReset }: { children: ReactNode; trailing?: ReactNode; active?: boolean; onReset?: () => void }) {
export function ListToolbar({ children, trailing, active, onReset, className }: { children: ReactNode; trailing?: ReactNode; active?: boolean; onReset?: () => void; className?: string }) {
return (
<div className="mt-3 flex min-h-12 flex-col gap-2 border-b border-border/75 pb-3 lg:flex-row lg:items-center lg:justify-between">
<div className={cn("mt-3 flex min-h-12 flex-col gap-2 border-b border-border/75 pb-3 lg:flex-row lg:items-center lg:justify-between", className)}>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2.5">{children}</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{active && onReset ? <Button type="text" icon={<RotateCcw className="size-3.5" />} onClick={onReset}>重置</Button> : null}

View File

@@ -124,7 +124,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
window.requestAnimationFrame(() => triggerRef.current?.focus());
}}
>
<ModelLabel config={config} model={model} capability={capability} theme={theme} creationVariant={creationVariant} />
<ModelLabel config={config} model={model} capability={capability} theme={theme} />
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
</button>
);
@@ -182,7 +182,7 @@ function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
}
function ModelLabel({ config, model, capability, theme, creationVariant }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean }) {
function ModelLabel({ config, model, capability, theme }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const meta = modelMenuMeta(model, capability);
return (
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
@@ -191,7 +191,7 @@ function ModelLabel({ config, model, capability, theme, creationVariant }: { con
</span>
<span className="min-w-0 flex-1 overflow-hidden">
<span className="block min-w-0 truncate text-[11px] font-medium leading-none">{modelDisplayName(config, model)}</span>
<span className="mt-1 block truncate text-[10px]" style={{ color: theme.node.muted }} title={`${modelOptionName(model)} · ${meta.description}`}>{creationVariant ? meta.description : `${modelOptionName(model)} · ${meta.description}`}</span>
<span className="mt-1 block truncate text-[10px]" style={{ color: theme.node.muted }} title={meta.description}>{meta.description}</span>
</span>
<ModelPrice price={modelMenuPrice(config, model)} />
{!creationVariant && meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}