feat(*): 画布创作体验 - 完善 Agent 引导、生成防重、搜索快捷键、素材多选与分辨率兼容
This commit is contained in:
@@ -16,13 +16,14 @@ export type InsertAssetPayload =
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onInsert: (payload: InsertAssetPayload) => void;
|
||||
multiple?: boolean;
|
||||
onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const categoryLabels: Record<string, string> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
|
||||
|
||||
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: Props) {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const externalAssetSources = useExternalAssetSources(open);
|
||||
const insertableAssets = useMemo(() => assets.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio"), [assets]);
|
||||
@@ -38,22 +39,6 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
...externalAssetSources.items,
|
||||
], [externalAssetSources.items, insertableAssets]);
|
||||
|
||||
const insert = (id: string) => {
|
||||
const pickerItem = items.find((item) => item.id === id);
|
||||
if (pickerItem?.external) {
|
||||
onInsert(externalAssetToInsertPayload(pickerItem.external));
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const asset = insertableAssets.find((item) => item.id === id);
|
||||
if (!asset) throw new Error("所选素材已不存在,请重新选择");
|
||||
if (asset.kind === "text") onInsert({ kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id });
|
||||
else if (asset.kind === "audio") onInsert({ kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id });
|
||||
else if (asset.kind === "video") onInsert({ kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id });
|
||||
else onInsert({ kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AssetLibraryPickerModal
|
||||
open={open}
|
||||
@@ -61,15 +46,39 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
folders={externalAssetSources.folders}
|
||||
footerNote={externalAssetSources.error || undefined}
|
||||
multiple={false}
|
||||
confirmLabel={() => "插入所选素材"}
|
||||
multiple={multiple}
|
||||
confirmLabel={(count) => `插入已选素材${count ? `(${count})` : ""}`}
|
||||
emptyDescription="先在素材库中添加图片、视频、音频或文本。"
|
||||
onClose={onClose}
|
||||
onConfirm={(ids) => insert(ids[0])}
|
||||
onConfirm={async (ids) => {
|
||||
await onInsert(assetPickerItemsToInsertPayloads(ids, items));
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function assetPickerItemsToInsertPayloads(ids: string[], items: AssetLibraryPickerItem[]): InsertAssetPayload[] {
|
||||
const itemsById = new Map(items.map((item) => [item.id, item]));
|
||||
return ids.map((id) => {
|
||||
const pickerItem = itemsById.get(id);
|
||||
if (!pickerItem) throw new Error("所选素材已不存在,请重新选择");
|
||||
if (pickerItem.external) return externalAssetToInsertPayload(pickerItem.external);
|
||||
const asset = pickerItem.asset;
|
||||
if (!asset || (asset.kind !== "text" && asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio")) {
|
||||
throw new Error(`“${pickerItem.title}”不是可插入画布的素材`);
|
||||
}
|
||||
return localAssetToInsertPayload(asset);
|
||||
});
|
||||
}
|
||||
|
||||
function localAssetToInsertPayload(asset: InsertableAsset): InsertAssetPayload {
|
||||
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
|
||||
if (asset.kind === "audio") return { kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
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, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
|
||||
}
|
||||
|
||||
export function externalAssetToInsertPayload(reference: ExternalAssetPickerReference): InsertAssetPayload {
|
||||
const item = reference.item;
|
||||
const url = item.fileUrl || "";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnimatePresence, useReducedMotion } from "motion/react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react";
|
||||
|
||||
@@ -93,7 +93,6 @@ export function CanvasNodeContextMenu({
|
||||
onToggleFrame,
|
||||
}: CanvasNodeContextMenuProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
|
||||
@@ -148,8 +147,8 @@ export function CanvasNodeContextMenu({
|
||||
<SpotlightSurface
|
||||
spotlightColor={theme.toolbar.itemHover}
|
||||
data-canvas-context-menu
|
||||
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97, x: -3, y: -3 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
|
||||
className="aceternity-floating-panel fixed z-[var(--z-popover)] flex w-[224px] max-h-[calc(100vh-56px)] origin-top-left flex-col overflow-hidden rounded-xl border p-1.5 backdrop-blur-2xl"
|
||||
style={{ left: position.left, top: position.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
@@ -245,7 +244,6 @@ export function CanvasNodeContextMenu({
|
||||
parentPosition={position}
|
||||
workspaceMode={workspaceMode}
|
||||
isProjectLinked={isProjectLinked}
|
||||
reducedMotion={Boolean(reducedMotion)}
|
||||
onAddNode={(type) => runAction(() => onAddNode(type))}
|
||||
onAddFolder={() => runAction(onAddFolder)}
|
||||
onChooseStyle={() => runAction(onChooseStyle)}
|
||||
@@ -260,7 +258,7 @@ export function CanvasNodeContextMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
|
||||
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
const pluginStates = usePluginStore((state) => state.pluginStates);
|
||||
@@ -299,9 +297,9 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re
|
||||
return (
|
||||
<SpotlightSurface
|
||||
spotlightColor={theme.toolbar.itemHover}
|
||||
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, x: left > parentPosition.left ? -5 : 5, scale: 0.97 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, x: left > parentPosition.left ? -4 : 4, scale: 0.98 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
|
||||
className="aceternity-floating-panel fixed z-[var(--z-popover)] w-[260px] origin-top overflow-hidden rounded-[var(--dock-radius)] border p-2 backdrop-blur-2xl"
|
||||
style={{ left, top: parentPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
@@ -342,7 +340,7 @@ function MenuButton({ icon, label, detail, shortcut, badge, chevron = false, act
|
||||
<span className="canvas-menu-item-icon grid size-7 shrink-0 place-items-center rounded-md border opacity-75 group-hover:opacity-100 [&_svg]:size-3.5" style={{ background: danger ? `${theme.accent.danger}12` : theme.spatial.surface, borderColor: danger ? `${theme.accent.danger}33` : theme.toolbar.border, color: danger ? theme.accent.danger : theme.node.text }}>{icon}</span>
|
||||
<span className="min-w-0 flex-1"><span className="flex items-center gap-1 text-xs font-medium"><span className="truncate">{label}</span>{badge ? <span className="rounded-full border px-1 py-0.5 text-[var(--fs-nano)] font-bold" style={{ background: theme.toolbar.activeBg, borderColor: theme.toolbar.border, color: theme.node.muted }}>{badge}</span> : null}</span>{detail ? <span className="mt-0.5 block truncate text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{detail}</span> : null}</span>
|
||||
{shortcut ? <span className="shrink-0 text-[var(--fs-micro)] opacity-38">{shortcut}</span> : null}
|
||||
{chevron ? <ChevronRight className="size-3 shrink-0 opacity-45 transition-transform group-hover:translate-x-0.5" /> : null}
|
||||
{chevron ? <ChevronRight className="size-3 shrink-0 opacity-45" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { App, Button, Segmented, Tooltip } from "antd";
|
||||
import copyToClipboard from "copy-to-clipboard";
|
||||
import { Copy, FolderOpen, History, LoaderCircle, MessageSquareText, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
|
||||
import { CheckCircle2, Copy, ExternalLink, FolderOpen, History, LoaderCircle, PlugZap, Plus, RefreshCw, Terminal, Trash2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { canvasAgentPostconditionMessage, hashCanvasAgentSnapshot, previewCanvasAgentOps, summarizeCanvasAgentOps, verifyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
|
||||
import { buildCanvasAgentContext, findCanvasAgentNodes, getCanvasAgentConnection, getCanvasAgentGenerationTasks, getCanvasAgentNode, getCanvasAgentResources, validateCanvasAgentOps } from "@/lib/canvas/canvas-agent-context";
|
||||
import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
import { buildLocalAgentSetupCommands, detectLocalAgentSetupPlatform, type LocalAgentSetupPlatform } from "@/lib/canvas/local-agent-setup";
|
||||
import { resolveSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
|
||||
@@ -35,12 +36,6 @@ import { AgentChatEmptyState } from "./canvas-agent-panel-chrome";
|
||||
const PANEL_MOTION_SECONDS = 0.5;
|
||||
const MAX_ATTACHMENTS = 6;
|
||||
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
|
||||
const AGENT_CONNECT_STEPS = [
|
||||
{ title: "从仓库安装插件", text: "插件暂未上架公共目录,请按项目 README 添加仓库 marketplace;安装后新建 Codex 对话。" },
|
||||
{ title: "自动连接本机 Runtime", text: "首次使用时,已信任的 Framefield 网页会自动建立当前浏览器的本机安全会话。" },
|
||||
{ title: "打开画布连接", text: "回到这里点击连接,画布会复用同一个本机 Runtime 安全会话。" },
|
||||
];
|
||||
|
||||
type AgentEventPayload = {
|
||||
agent?: string;
|
||||
type?: string;
|
||||
@@ -755,7 +750,15 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{activeTab === "history" ? (
|
||||
{!connected ? (
|
||||
<AgentConnectView
|
||||
theme={theme}
|
||||
enabled={enabled}
|
||||
activity={activity}
|
||||
connectError={connectError}
|
||||
onToggleEnabled={toggleAgentConnection}
|
||||
/>
|
||||
) : activeTab === "history" ? (
|
||||
<AgentHistoryView
|
||||
theme={theme}
|
||||
threads={threads}
|
||||
@@ -925,55 +928,54 @@ function AgentLogView({
|
||||
function AgentConnectView({
|
||||
theme,
|
||||
enabled,
|
||||
connected,
|
||||
activity,
|
||||
connectError,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
activity: string;
|
||||
connectError: string;
|
||||
onToggleEnabled: () => void;
|
||||
}) {
|
||||
const statusText = canvasAgentConnectionStatusText({ enabled, connected, activity, connectError });
|
||||
const statusColor = statusText === "连接失败" ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
|
||||
const { message } = App.useApp();
|
||||
const [platform, setPlatform] = useState<LocalAgentSetupPlatform>(() => detectLocalAgentSetupPlatform());
|
||||
const origin = typeof window === "undefined" ? "http://localhost:3000" : window.location.origin;
|
||||
const commands = buildLocalAgentSetupCommands(origin, platform);
|
||||
const statusText = canvasAgentConnectionStatusText({ enabled, connected: false, activity, connectError });
|
||||
const statusColor = connectError ? "#dc2626" : enabled ? "#d97706" : theme.node.muted;
|
||||
const copyCommand = async (value: string, label: string) => {
|
||||
if (await copyToClipboard(value)) {
|
||||
message.success(`${label}已复制`);
|
||||
return;
|
||||
}
|
||||
message.warning("复制失败,请手动复制");
|
||||
};
|
||||
return (
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="mx-auto w-full max-w-[620px] space-y-5 pb-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold leading-6">连接本地 Agent</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
安装仓库自带的 Codex 插件后,画布会优先自动连接本机 Agent。
|
||||
在这台电脑启动 Canvas Agent 后,网页会通过 127.0.0.1 建立本机安全连接。连接成功后,这里会自动恢复原对话。
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{AGENT_CONNECT_STEPS.map((step) => (
|
||||
<div key={step.title} className="rounded-lg px-3 py-2.5">
|
||||
<div className="text-sm font-medium leading-5">{step.title}</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
{step.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-md p-3" style={{ background: theme.spatial.surface }}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-sm font-medium leading-5">网页连接</span>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[var(--fs-label)] leading-4" style={{ background: theme.node.fill, color: statusColor }}>
|
||||
<span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />
|
||||
{enabled ? <LoaderCircle className="size-3 shrink-0 animate-spin" /> : <span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />}
|
||||
<span className="truncate">{statusText}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
自动复用“设置 → 本机工具”建立的短期安全会话。
|
||||
{enabled ? "正在检测本机 127.0.0.1:17371;服务启动后会自动连接。" : "先完成下面两步,再点击连接。"}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="!h-8 !px-3" type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={onToggleEnabled}>
|
||||
{enabled ? "断开" : "连接"}
|
||||
{enabled ? "停止重试" : connectError ? "重新连接" : "开始连接"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
@@ -984,11 +986,88 @@ function AgentConnectView({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<section className="border-b pb-4" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-full text-xs font-semibold" style={{ background: theme.node.fill, color: theme.node.text }}>1</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium leading-5">安装本机 Runtime</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>需要 Git、Node.js 18+。当前版本从 GitHub 源码安装。</div>
|
||||
</div>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={platform}
|
||||
options={[
|
||||
{ label: "macOS / Linux", value: "unix" },
|
||||
{ label: "Windows", value: "windows" },
|
||||
]}
|
||||
onChange={(value) => setPlatform(value as LocalAgentSetupPlatform)}
|
||||
/>
|
||||
</div>
|
||||
<CommandBlock value={commands.install} theme={theme} onCopy={() => copyCommand(commands.install, "安装命令")} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b py-4" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-full text-xs font-semibold" style={{ background: theme.node.fill, color: theme.node.text }}>2</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium leading-5">启动并授权当前站点</div>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
命令已绑定 <span className="font-mono">{origin}</span>。保持终端运行,再回到上方开始连接。
|
||||
</div>
|
||||
<CommandBlock value={commands.start} theme={theme} onCopy={() => copyCommand(commands.start, "启动命令")} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-full" style={{ background: theme.node.fill, color: theme.node.text }}><CheckCircle2 className="size-3.5" /></span>
|
||||
<div className="min-w-0 flex-1 text-xs leading-5" style={{ color: theme.node.muted }}>
|
||||
<div className="text-sm font-medium" style={{ color: theme.node.text }}>当前支持范围</div>
|
||||
<div className="mt-1">网页侧边栏使用 Codex;外部 MCP 已提供 Codex 与 Claude Code 接入。Hermes、WorkBuddy 尚未验证。Codex 需要已登录或配置可用凭据。</div>
|
||||
<Button type="link" size="small" className="mt-1 !h-7 !px-0" href="https://github.com/ddcat-ai/open-ai-canvas/tree/main/canvas-agent" target="_blank" rel="noreferrer" icon={<ExternalLink className="size-3.5" />}>
|
||||
查看完整说明
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandBlock({
|
||||
value,
|
||||
theme,
|
||||
onCopy,
|
||||
}: {
|
||||
value: string;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onCopy: () => void | Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-3 overflow-hidden rounded-md" style={{ background: "rgba(0,0,0,.22)" }}>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-1.5" style={{ borderColor: theme.node.stroke }}>
|
||||
<span className="inline-flex items-center gap-1.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
|
||||
<Terminal className="size-3.5" />
|
||||
终端命令
|
||||
</span>
|
||||
<Button type="text" size="small" className="!h-6 !px-1.5" icon={<Copy className="size-3.5" />} onClick={() => void onCopy()} aria-label="复制终端命令">
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="thin-scrollbar overflow-x-auto whitespace-pre p-3 font-mono text-[11px] leading-5" style={{ color: theme.node.text }}>{value}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentHistoryView({
|
||||
theme,
|
||||
threads,
|
||||
|
||||
@@ -1,49 +1,160 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { memo, useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { Input, Modal } from "antd";
|
||||
import { BookOpenText, FileText, Image, Pencil, Search, Video } from "lucide-react";
|
||||
import { AudioLines, BookOpenText, Clock3, FileText, Image, Pencil, Search, Video } from "lucide-react";
|
||||
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes, searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
|
||||
import { getNodeListLabel } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
const RESULT_LIST_ID = "canvas-node-search-results";
|
||||
|
||||
export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open: boolean; nodes: CanvasNodeData[]; onClose: () => void; onFocus: (nodeId: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const results = useMemo(() => {
|
||||
const keyword = query.trim().toLocaleLowerCase();
|
||||
if (!keyword) return nodes.slice(0, 40);
|
||||
return nodes.filter((node) => [node.title, node.type, getNodeListLabel(node.type), node.metadata?.prompt, node.metadata?.composerContent, node.metadata?.model, node.metadata?.chapterTitle, node.metadata?.workflowTitle, node.metadata?.workflowDescription, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : "", ...(node.metadata?.assetTags || [])]
|
||||
.some((value) => typeof value === "string" && value.toLocaleLowerCase().includes(keyword))).slice(0, 80);
|
||||
}, [nodes, query]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const results = useMemo(() => searchCanvasNodes(nodes, query), [nodes, query]);
|
||||
|
||||
useEffect(() => setActiveIndex(0), [query, open]);
|
||||
useEffect(() => setActiveIndex((current) => Math.min(current, Math.max(0, results.length - 1))), [results.length]);
|
||||
|
||||
const focusNode = (node: CanvasNodeData | undefined) => {
|
||||
if (!node) return;
|
||||
onFocus(node.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) => Math.min(results.length - 1, current + 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) => Math.max(0, current - 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
focusNode(results[activeIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="搜索画布节点" open={open} footer={null} width="min(680px, 90vw)" onCancel={onClose} afterClose={() => setQuery("")} centered>
|
||||
<Input autoFocus allowClear value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-4 opacity-50" />} placeholder="搜索节点、章节、镜头、模型或标签" />
|
||||
<div className="thin-scrollbar mt-3 max-h-[50vh] overflow-y-auto border-t pt-2">
|
||||
{results.length ? results.map((node) => (
|
||||
<button
|
||||
<Modal
|
||||
title="搜索画布节点"
|
||||
open={open}
|
||||
footer={null}
|
||||
width="min(760px, calc(100vw - 32px))"
|
||||
onCancel={onClose}
|
||||
afterClose={() => { setQuery(""); setActiveIndex(0); }}
|
||||
styles={{ body: { paddingTop: 8 } }}
|
||||
centered
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
allowClear
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
prefix={<Search className="size-4 opacity-50" />}
|
||||
placeholder="搜索节点、章节、镜头、模型或标签…"
|
||||
aria-label="搜索画布节点"
|
||||
aria-controls={RESULT_LIST_ID}
|
||||
aria-activedescendant={results[activeIndex] ? `canvas-node-search-result-${results[activeIndex].id}` : undefined}
|
||||
/>
|
||||
<div className="mt-2 flex min-h-7 items-center justify-between gap-3 border-b pb-2 text-[11px] tracking-[0.015em] text-foreground/45">
|
||||
<span className="tabular-nums">{query.trim() ? `找到 ${results.length} 个节点` : `最近编辑 · ${results.length} 个节点`}</span>
|
||||
<span className="hidden sm:inline">↑↓ 选择 · Enter 定位 · Esc 关闭</span>
|
||||
</div>
|
||||
<div id={RESULT_LIST_ID} role="listbox" aria-label="画布节点搜索结果" className="thin-scrollbar max-h-[54vh] overflow-y-auto overscroll-contain py-1.5">
|
||||
{results.length ? results.map((node, index) => (
|
||||
<CanvasNodeSearchResult
|
||||
key={node.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition hover:bg-black/5 dark:hover:bg-white/10"
|
||||
onClick={() => { onFocus(node.id); onClose(); }}
|
||||
>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-black/5 dark:bg-white/10">
|
||||
{node.type === CanvasNodeType.Image ? <Image className="size-4" /> : node.type === CanvasNodeType.Video ? <Video className="size-4" /> : node.type === CanvasNodeType.Drawing ? <Pencil className="size-4" /> : <FileText className="size-4" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{node.title}</span>
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs opacity-55">
|
||||
{node.metadata?.chapterTitle ? <BookOpenText className="size-3 shrink-0" /> : null}
|
||||
<span className="truncate">{nodeContextLabel(node)}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
node={node}
|
||||
active={index === activeIndex}
|
||||
onActivate={() => setActiveIndex(index)}
|
||||
onSelect={() => focusNode(node)}
|
||||
/>
|
||||
)) : <WorkspaceState icon="canvas" compact title="没有匹配节点" description="换一个节点、章节、镜头、模型或标签继续搜索。" />}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function nodeContextLabel(node: CanvasNodeData) {
|
||||
const location = [node.metadata?.chapterTitle, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : ""].filter(Boolean).join(" · ");
|
||||
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || getNodeListLabel(node.type);
|
||||
const CanvasNodeSearchResult = memo(function CanvasNodeSearchResult({ node, active, onActivate, onSelect }: { node: CanvasNodeData; active: boolean; onActivate: () => void; onSelect: () => void }) {
|
||||
const times = canvasNodeSearchTimes(node);
|
||||
const materialSummary = canvasNodeMaterialSummary(node);
|
||||
const context = canvasNodeSearchContext(node);
|
||||
return (
|
||||
<button
|
||||
id={`canvas-node-search-result-${node.id}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className="grid min-h-14 w-full grid-cols-[64px_minmax(0,1fr)_auto] items-center gap-2.5 rounded-[var(--r-md)] px-2 py-1.5 text-left transition-[background-color,box-shadow] hover:bg-[var(--surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 max-sm:grid-cols-[56px_minmax(0,1fr)]"
|
||||
style={{
|
||||
background: active ? "var(--surface-active)" : undefined,
|
||||
boxShadow: active ? "inset 0 0 0 1px color-mix(in srgb, var(--foreground) 10%, transparent)" : undefined,
|
||||
contentVisibility: "auto",
|
||||
containIntrinsicSize: "56px",
|
||||
}}
|
||||
onMouseEnter={onActivate}
|
||||
onFocus={onActivate}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<CanvasNodeSearchThumbnail node={node} />
|
||||
<span className="min-w-0 self-center">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-foreground" title={node.title}>{node.title || getNodeListLabel(node.type)}</span>
|
||||
<span className="max-w-[210px] shrink truncate rounded-full bg-foreground/[0.055] px-2 py-0.5 text-[10px] font-medium tracking-[0.02em] text-foreground/50" title={materialSummary}>{materialSummary}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-foreground/45">
|
||||
{node.metadata?.chapterTitle ? <BookOpenText className="size-3 shrink-0" /> : null}
|
||||
<span className="truncate" title={context}>{context}</span>
|
||||
</span>
|
||||
<span className="mt-1 hidden items-center gap-3 text-[10px] tabular-nums tracking-[0.015em] text-foreground/40 max-sm:flex">
|
||||
<time dateTime={times.createdAt} title={fullTime(times.createdAt)}>创建 {times.createdLabel}</time>
|
||||
<time dateTime={times.updatedAt} title={fullTime(times.updatedAt)}>编辑 {times.updatedLabel}</time>
|
||||
</span>
|
||||
</span>
|
||||
<span className="grid min-w-[112px] gap-0.5 border-l pl-3 text-[10px] tabular-nums tracking-[0.015em] text-foreground/40 max-sm:hidden" style={{ borderColor: "color-mix(in srgb, var(--foreground) 8%, transparent)" }}>
|
||||
<span className="flex items-center gap-1.5"><Clock3 className="size-3" /><span>创建</span><time className="ml-auto" dateTime={times.createdAt} title={fullTime(times.createdAt)}>{times.createdLabel}</time></span>
|
||||
<span className="flex items-center gap-1.5"><Pencil className="size-3" /><span>编辑</span><time className="ml-auto" dateTime={times.updatedAt} title={fullTime(times.updatedAt)}>{times.updatedLabel}</time></span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
function CanvasNodeSearchThumbnail({ node }: { node: CanvasNodeData }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const mediaSource = node.metadata?.drawingPreviewUrl
|
||||
|| node.metadata?.characterCoverUrl
|
||||
|| node.metadata?.folder?.themeCover
|
||||
|| ((node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.ColorGrade) ? node.metadata?.content : undefined);
|
||||
const commonClass = "h-11 w-16 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 && node.type === CanvasNodeType.Video) {
|
||||
return <video src={mediaSource} muted playsInline preload="metadata" aria-label={`${node.title} 视频缩略图`} className={commonClass} style={commonStyle} onError={() => setFailed(true)} />;
|
||||
}
|
||||
if (mediaSource && !failed) {
|
||||
return <img src={mediaSource} alt="" width={64} height={44} loading="lazy" decoding="async" className={commonClass} style={commonStyle} onError={() => 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 <span aria-hidden="true" className="line-clamp-3 h-11 w-16 overflow-hidden rounded-[var(--r-sm)] border px-1.5 py-1 text-[8px] leading-[11px] text-foreground/55" style={commonStyle}>{textPreview}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span aria-hidden="true" className="grid h-11 w-16 place-items-center rounded-[var(--r-sm)] border text-foreground/48" style={commonStyle}>
|
||||
{node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Panorama ? <Image className="size-4" />
|
||||
: node.type === CanvasNodeType.Video ? <Video className="size-4" />
|
||||
: node.type === CanvasNodeType.Audio ? <AudioLines className="size-4" />
|
||||
: node.type === CanvasNodeType.Drawing ? <Pencil className="size-4" />
|
||||
: <FileText className="size-4" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function fullTime(value?: string) {
|
||||
if (!value) return "时间未记录";
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from "antd";
|
||||
|
||||
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import { modelCapabilityConfigFor, resolveVideoResolutionValue } from "@/lib/model-capabilities";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
@@ -22,9 +22,11 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
|
||||
const resolutionSupported = Boolean(modelCapabilityConfigFor(config, config.model).video?.resolutions.length);
|
||||
const videoProfile = modelCapabilityConfigFor(config, config.model).video;
|
||||
const resolutionSupported = Boolean(videoProfile?.resolutions.length);
|
||||
const resolution = videoProfile ? resolveVideoResolutionValue(videoProfile, config.vquality) : "";
|
||||
const summary = [
|
||||
...(resolutionSupported ? [videoResolutionLabel(config.vquality)] : []),
|
||||
...(resolutionSupported ? [videoResolutionLabel(resolution)] : []),
|
||||
videoSizeLabel(config.size),
|
||||
videoSecondsLabel(config.videoSeconds),
|
||||
].join(" · ");
|
||||
|
||||
@@ -4,8 +4,8 @@ import { Switch } from "antd";
|
||||
import { ImageSettingsTheme } from "@/components/image-settings-panel";
|
||||
import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceRatioOptions } from "@/lib/seedance-video";
|
||||
import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { formatVideoResolutionLabel, normalizeVideoDuration, videoResolutionComparisonKey, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, resolveVideoResolutionValue, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
const sizeOptions = [
|
||||
@@ -38,8 +38,8 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
const seconds = normalizeVideoDuration(config.videoSeconds);
|
||||
const size = normalizeVideoSizeValue(config.size);
|
||||
const dimensions = readSizeDimensions(size);
|
||||
const resolution = normalizeVideoResolutionValue(config.vquality);
|
||||
const configuredResolutions = profile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: value.toUpperCase() }));
|
||||
const resolution = resolveVideoResolutionValue(profile, config.vquality);
|
||||
const configuredResolutions = profile.resolutions.map((value) => ({ value, label: formatVideoResolutionLabel(value) }));
|
||||
const generateAudio = boolConfig(config.videoGenerateAudio, profile.generateAudio.default);
|
||||
const watermark = boolConfig(config.videoWatermark, profile.watermark.default);
|
||||
const updateDimension = (key: "width" | "height", value: number | null) => {
|
||||
@@ -182,7 +182,7 @@ function SeedanceVideoSettingsPanel({ config, profile, priceTiers, onConfigChang
|
||||
}
|
||||
|
||||
export function videoResolutionLabel(value: string) {
|
||||
return `${normalizeVideoResolutionValue(value)}P`;
|
||||
return formatVideoResolutionLabel(value);
|
||||
}
|
||||
|
||||
export function videoSizeLabel(value: string) {
|
||||
@@ -205,13 +205,9 @@ export function normalizeVideoSizeValue(value: string) {
|
||||
return ["9:16", "2:3", "3:4"].includes(value) ? "720x1280" : "1280x720";
|
||||
}
|
||||
|
||||
export function normalizeVideoResolutionValue(value: string) {
|
||||
return normalizeVideoResolution(value);
|
||||
}
|
||||
|
||||
function OptionPill({ selected, disabled = false, theme, onClick, children }: { selected: boolean; disabled?: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button type="button" disabled={disabled} className="h-8 cursor-pointer whitespace-nowrap rounded-md px-1 text-[var(--fs-label)] font-medium leading-none transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-35" style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }} onMouseDown={(event) => event.stopPropagation()} onClick={onClick}>
|
||||
<button type="button" disabled={disabled} aria-pressed={selected} className="h-8 cursor-pointer whitespace-nowrap rounded-md px-1 text-[var(--fs-label)] font-medium leading-none transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-35" style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }} onMouseDown={(event) => event.stopPropagation()} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -301,9 +297,7 @@ function hasPriceTierForVideoSelection(tiers: ReturnType<typeof modelPriceTiers>
|
||||
}
|
||||
|
||||
function normalizeTierResolution(value: string) {
|
||||
const normalized = normalizeVideoResolution(value);
|
||||
if (/^\d+$/.test(normalized)) return `${normalized}p`;
|
||||
return normalized.toLowerCase();
|
||||
return videoResolutionComparisonKey(value);
|
||||
}
|
||||
|
||||
function DurationRangeControl({ value, min, max, step, theme, onChange }: { value: number; min: number; max: number; step: number; theme: CanvasTheme; onChange: (value: number) => void }) {
|
||||
|
||||
Reference in New Issue
Block a user