feat: 完善创作工作台与用户诊断支持 (#295)
* feat(canvas): 参考素材 - 增加移除按钮并删除对应连线 * feat(canvas): 完善节点面板交互与拖拽反馈 * fix(canvas): 画布交互与小地图 - 统一浮层层级并完善节点识别 * feat(prompt-optimizer): 增加 AI 提示词优化器及宿主接入 * feat(canvas): 画布 - 增加自动整理节点按钮并补充文档 * chore(web): 前端 - 修正提示词优化器格式 * chore(backend): 后端 - 修正 Go 格式检查 * feat(workspace): 完善诊断导出与本地排障支持 * chore(web): 格式化诊断导出文件 * fix(web-test): 兼容 Bun 测试运行时 * fix(web-test): 对齐模型目录协议断言
This commit is contained in:
1 parent
625a97560b
commit
cf6c78d540
70 files changed
+6863
-1708
No files matched your search
@@ -2,7 +2,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Bot, Check, ChevronDown, Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Button, Dropdown, Modal, Tooltip } from "antd";
|
||||
import { Button, Dropdown, Modal } from "antd";
|
||||
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
@@ -102,38 +102,40 @@ export function CanvasTopBar({
|
||||
<>
|
||||
<div className="canvas-topbar pointer-events-none absolute left-0 right-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5">
|
||||
<div className="canvas-topbar-cluster canvas-topbar-project-cluster pointer-events-auto flex min-w-0 items-center gap-2" style={dockStyle}>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "home", icon: <Home className="size-4" />, label: <Link to="/">主页</Link> },
|
||||
{ key: "projects", icon: <LayoutGrid className="size-4" />, label: <Link to="/canvas">画布</Link> },
|
||||
{ type: "divider" },
|
||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
||||
{ type: "divider" },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
|
||||
{ key: "search", icon: <Search className="size-4" />, label: <MenuLabel text="搜索节点" shortcut="⌘ K" />, onClick: onOpenSearch },
|
||||
{
|
||||
key: "performance",
|
||||
icon: <Gauge className="size-4" />,
|
||||
label: "媒体性能",
|
||||
children: [
|
||||
{ key: "performance-auto", label: "自动性能", onClick: () => onMediaPerformanceModeChange("auto") },
|
||||
{ key: "performance-quality", label: "画质优先", onClick: () => onMediaPerformanceModeChange("quality") },
|
||||
{ key: "performance-fast", label: "性能优先", onClick: () => onMediaPerformanceModeChange("performance") },
|
||||
],
|
||||
},
|
||||
{ type: "divider" },
|
||||
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
||||
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="canvas-topbar-action grid size-9 place-items-center rounded-full" style={{ color: theme.node.text }} aria-label="打开画布菜单">
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
<CanvasTopBarTooltip label="打开画布菜单">
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "home", icon: <Home className="size-4" />, label: <Link to="/">主页</Link> },
|
||||
{ key: "projects", icon: <LayoutGrid className="size-4" />, label: <Link to="/canvas">画布</Link> },
|
||||
{ type: "divider" },
|
||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
||||
{ type: "divider" },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
|
||||
{ key: "search", icon: <Search className="size-4" />, label: <MenuLabel text="搜索节点" shortcut="⌘ K" />, onClick: onOpenSearch },
|
||||
{
|
||||
key: "performance",
|
||||
icon: <Gauge className="size-4" />,
|
||||
label: "媒体性能",
|
||||
children: [
|
||||
{ key: "performance-auto", label: "自动性能", onClick: () => onMediaPerformanceModeChange("auto") },
|
||||
{ key: "performance-quality", label: "画质优先", onClick: () => onMediaPerformanceModeChange("quality") },
|
||||
{ key: "performance-fast", label: "性能优先", onClick: () => onMediaPerformanceModeChange("performance") },
|
||||
],
|
||||
},
|
||||
{ type: "divider" },
|
||||
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
||||
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="canvas-topbar-action grid size-9 place-items-center rounded-full" style={{ color: theme.node.text }} aria-label="打开画布菜单">
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</CanvasTopBarTooltip>
|
||||
|
||||
<div ref={titleRef} className="canvas-topbar-title-block flex min-w-0 flex-auto flex-col items-start overflow-hidden">
|
||||
{isTitleEditing ? (
|
||||
@@ -156,11 +158,11 @@ export function CanvasTopBar({
|
||||
<button type="button" className="min-w-0 flex-1 truncate text-left text-base font-semibold tracking-normal transition-opacity hover:opacity-75" onClick={onStartTitleEditing} title="点击修改画布名称">
|
||||
{title}
|
||||
</button>
|
||||
<Tooltip title="重命名画布">
|
||||
<CanvasTopBarTooltip label="重命名画布">
|
||||
<button type="button" className="canvas-topbar-action grid size-7 shrink-0 place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2" style={{ color: theme.node.text }} onClick={onStartTitleEditing} aria-label="重命名画布">
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</CanvasTopBarTooltip>
|
||||
</div>
|
||||
)}
|
||||
{projectContext && !isTitleEditing ? (
|
||||
@@ -181,49 +183,57 @@ export function CanvasTopBar({
|
||||
</div>
|
||||
|
||||
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5" style={dockStyle}>
|
||||
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "libtv", icon: <CopyPlus className="size-4" />, label: "导入 LibTV 画布", onClick: onImportLibTV },
|
||||
{ key: "tapnow", icon: <CloudDownload className="size-4" />, label: "导入 TapNow 画布", onClick: onImportTapNow },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" className="canvas-topbar-action canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} aria-label="导入第三方画布" title="导入第三方画布">
|
||||
<span className="hidden lg:inline">导入第三方画布</span>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
selectable: true,
|
||||
selectedKeys: [mediaPerformanceMode],
|
||||
onClick: ({ key }) => onMediaPerformanceModeChange(key as CanvasMediaPerformanceMode),
|
||||
items: [
|
||||
{ key: "auto", label: "自动性能" },
|
||||
{ key: "quality", label: "画质优先" },
|
||||
{ key: "performance", label: "性能优先" },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" title="媒体性能模式" />
|
||||
</Dropdown>
|
||||
<CanvasTopBarTooltip label="搜索画布节点">
|
||||
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" />
|
||||
</CanvasTopBarTooltip>
|
||||
<CanvasTopBarTooltip label="导入第三方画布">
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "libtv", icon: <CopyPlus className="size-4" />, label: "导入 LibTV 画布", onClick: onImportLibTV },
|
||||
{ key: "tapnow", icon: <CloudDownload className="size-4" />, label: "导入 TapNow 画布", onClick: onImportTapNow },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" className="canvas-topbar-action canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} aria-label="导入第三方画布">
|
||||
<span className="hidden lg:inline">导入第三方画布</span>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</CanvasTopBarTooltip>
|
||||
<CanvasTopBarTooltip label="媒体性能模式">
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
selectable: true,
|
||||
selectedKeys: [mediaPerformanceMode],
|
||||
onClick: ({ key }) => onMediaPerformanceModeChange(key as CanvasMediaPerformanceMode),
|
||||
items: [
|
||||
{ key: "auto", label: "自动性能" },
|
||||
{ key: "quality", label: "画质优先" },
|
||||
{ key: "performance", label: "性能优先" },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" />
|
||||
</Dropdown>
|
||||
</CanvasTopBarTooltip>
|
||||
{compactAgentStatus ? <CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} /> : null}
|
||||
{user && creditsEnabled ? (
|
||||
<Link
|
||||
to="/wallet"
|
||||
className="canvas-topbar-action inline-flex h-9 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium tabular-nums"
|
||||
style={{ color: theme.node.text }}
|
||||
title="查看积分明细"
|
||||
>
|
||||
{refreshing && availableMicrocredits === null ? <LoaderCircle className="size-3.5 animate-spin opacity-60" /> : <Coins className="size-3.5" />}
|
||||
<span>{availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 })}</span>
|
||||
</Link>
|
||||
<CanvasTopBarTooltip label="查看积分明细">
|
||||
<Link
|
||||
to="/wallet"
|
||||
className="canvas-topbar-action inline-flex h-9 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium tabular-nums"
|
||||
style={{ color: theme.node.text }}
|
||||
aria-label="查看积分明细"
|
||||
>
|
||||
{refreshing && availableMicrocredits === null ? <LoaderCircle className="size-3.5 animate-spin opacity-60" /> : <Coins className="size-3.5" />}
|
||||
<span>{availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 })}</span>
|
||||
</Link>
|
||||
</CanvasTopBarTooltip>
|
||||
) : null}
|
||||
<Tooltip title="进入专注模式(⇧⌘F)">
|
||||
<CanvasTopBarTooltip label="进入专注模式(⇧⌘F)">
|
||||
<Button
|
||||
type="text"
|
||||
className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0"
|
||||
@@ -232,9 +242,9 @@ export function CanvasTopBar({
|
||||
onClick={onEnterFocusMode}
|
||||
aria-label="进入专注模式"
|
||||
/>
|
||||
</Tooltip>
|
||||
</CanvasTopBarTooltip>
|
||||
{shortDramaGuide ? (
|
||||
<Tooltip title={shortDramaGuide.collapsed ? "展开短剧流程" : "收起短剧流程"}>
|
||||
<CanvasTopBarTooltip label={shortDramaGuide.collapsed ? "展开短剧流程" : "收起短剧流程"}>
|
||||
<Button
|
||||
type="text"
|
||||
className="canvas-topbar-action !h-10 !rounded-xl !px-2.5 !font-medium"
|
||||
@@ -246,9 +256,11 @@ export function CanvasTopBar({
|
||||
>
|
||||
<span className="tabular-nums">{shortDramaGuide.progress.completedCount}/5</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</CanvasTopBarTooltip>
|
||||
) : null}
|
||||
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" title="分享画布" />
|
||||
<CanvasTopBarTooltip label="分享画布">
|
||||
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" />
|
||||
</CanvasTopBarTooltip>
|
||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<Button
|
||||
type="text"
|
||||
@@ -271,7 +283,7 @@ export function CanvasTopBar({
|
||||
<Shortcut keys={["工具栏「框选」", "左键拖动"]} value="框选多个节点,完成后自动回到「移动与选择」" />
|
||||
<Shortcut keys={["Shift / Ctrl / Cmd", "点击"]} value="追加选择节点" />
|
||||
<Shortcut keys={["Alt", "点击 / 框选"]} value="移除选择节点" />
|
||||
<Shortcut keys={["Ctrl / Cmd", "1 / 2 / 3"]} value="100% / 适应全部 / 适应选择" />
|
||||
<Shortcut keys={["Ctrl / Cmd", "1 / 2 / 3"]} value="100% / 适应画布 / 适应选择" />
|
||||
<Shortcut keys={["?"]} value="打开快捷键" />
|
||||
<Shortcut keys={["Shift / Ctrl / Cmd", "F"]} value="进入 / 退出专注模式" />
|
||||
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" />
|
||||
@@ -366,6 +378,17 @@ export function CanvasWorkspaceModeSwitch({ mode, onChange }: { mode: CanvasWork
|
||||
|
||||
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
|
||||
function CanvasTopBarTooltip({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className="group relative inline-flex">
|
||||
{children}
|
||||
<span role="tooltip" className="aceternity-dock-tooltip pointer-events-none absolute left-1/2 top-[calc(100%+8px)] z-[var(--dock-tooltip-z)] -translate-x-1/2 translate-y-1 whitespace-nowrap rounded-md border px-2 py-1 text-[var(--fs-tiny)] font-medium opacity-0 shadow-xl backdrop-blur-xl transition-all duration-150 motion-reduce:transition-none group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeOption({ active, motionEnabled, icon, title, description, theme, onClick }: { active: boolean; motionEnabled: boolean; icon: ReactNode; title: string; description: string; theme: CanvasTheme; onClick: () => void }) {
|
||||
return (
|
||||
<motion.button
|
||||
@@ -403,12 +426,7 @@ function CompactAgentStatus({ status, onClick }: { status: { connected: boolean;
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const label = status.connected ? "已连接到本地 Codex" : status.enabled ? status.activity || "连接中" : "正在连接本地 Codex";
|
||||
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
|
||||
return (
|
||||
<button type="button" className="canvas-topbar-action flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium" style={{ background: "transparent", color: theme.node.text }} onClick={onClick} title="打开本地 Codex 面板">
|
||||
<span className="size-2 rounded-full" style={{ background: dotColor }} />
|
||||
<span className="max-w-[180px] truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
return <CanvasTopBarTooltip label="打开本地 Codex 面板"><button type="button" className="canvas-topbar-action flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium" style={{ background: "transparent", color: theme.node.text }} onClick={onClick} aria-label="打开本地 Codex 面板"><span className="size-2 rounded-full" style={{ background: dotColor }} /><span className="max-w-[180px] truncate">{label}</span></button></CanvasTopBarTooltip>;
|
||||
}
|
||||
|
||||
function Shortcut({ keys, value }: { keys: string[]; value: string }) {
|
||||
|
||||
@@ -64,8 +64,9 @@ import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-co
|
||||
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";
|
||||
import { useFocusMode } from "@/hooks/use-focus-mode";
|
||||
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
|
||||
import { getContextResourceNodes, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getContextResourceNodes, removeCanvasResourceMention, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
|
||||
import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer";
|
||||
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
|
||||
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
|
||||
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
@@ -500,7 +501,6 @@ function InfiniteCanvasPage() {
|
||||
handleViewportChange,
|
||||
handleViewportPreviewChange,
|
||||
previewViewport,
|
||||
resetViewport,
|
||||
screenToCanvas,
|
||||
setZoomScale,
|
||||
zoomCanvasIn,
|
||||
@@ -825,6 +825,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
const {
|
||||
alignSelectedNodes,
|
||||
autoArrangeCanvasNodes,
|
||||
arrangeSelectedNodes,
|
||||
copyNodesToClipboard,
|
||||
copySelectedNodes,
|
||||
@@ -898,7 +899,6 @@ function InfiniteCanvasPage() {
|
||||
|
||||
const handleCanvasSelectionStart = useCallback(() => {
|
||||
setContextMenu(null);
|
||||
setDialogNodeId(null);
|
||||
}, []);
|
||||
|
||||
const handleNodeInteractionStart = useCallback((selectionModifier: boolean) => {
|
||||
@@ -925,7 +925,6 @@ function InfiniteCanvasPage() {
|
||||
setContextMenu(null);
|
||||
setHoveredNodeId(null);
|
||||
setToolbarNodeId(null);
|
||||
setDialogNodeId(null);
|
||||
}, []);
|
||||
|
||||
const { alignmentGuides, cancelSelectionBox, deselectCanvas, dragPreview, frameDropTargetId, handleCanvasMouseDown, handleNodeMouseDown, isNodeDragging, nodeDraggingRef, selectionBoundsElementRef, selectionBox } = useCanvasSelectionController({
|
||||
@@ -997,6 +996,29 @@ function InfiniteCanvasPage() {
|
||||
setHoveredNodeId,
|
||||
});
|
||||
|
||||
const handleRemoveNodeReference = useCallback((targetNodeId: string, reference: CanvasResourceReference) => {
|
||||
const referenceNodeId = reference.nodeId;
|
||||
if (!referenceNodeId) return;
|
||||
// 生成节点可能通过配置节点接收参考,只移除参考来源边,保留目标到配置节点的主链。
|
||||
const configNodeId = connectionsRef.current.find((connection) => {
|
||||
if (connection.fromNodeId !== targetNodeId) return false;
|
||||
return nodesRef.current.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config;
|
||||
})?.toNodeId;
|
||||
const removedConnectionIds = new Set(
|
||||
connectionsRef.current
|
||||
.filter((connection) => connection.fromNodeId === referenceNodeId && (connection.toNodeId === targetNodeId || connection.toNodeId === configNodeId))
|
||||
.map((connection) => connection.id),
|
||||
);
|
||||
const targetNode = nodesRef.current.find((node) => node.id === targetNodeId);
|
||||
const currentPrompt = targetNode?.metadata?.composerContent ?? targetNode?.metadata?.prompt ?? "";
|
||||
const nextPrompt = removeCanvasResourceMention(currentPrompt, reference);
|
||||
if (nextPrompt !== currentPrompt) handleNodePromptChange(targetNodeId, nextPrompt);
|
||||
if (!removedConnectionIds.size) return;
|
||||
connectionsRef.current = connectionsRef.current.filter((connection) => !removedConnectionIds.has(connection.id));
|
||||
setConnections(connectionsRef.current);
|
||||
setSelectedConnectionId((current) => current && removedConnectionIds.has(current) ? null : current);
|
||||
}, [connectionsRef, handleNodePromptChange, nodesRef, setConnections, setSelectedConnectionId]);
|
||||
|
||||
const handleProjectFolderInsert = useCallback((folderId: string) => {
|
||||
const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId);
|
||||
if (!folder || !linkedProjectId) throw new Error("素材文件夹已不存在,请刷新后重试");
|
||||
@@ -1574,6 +1596,9 @@ function InfiniteCanvasPage() {
|
||||
onPromptChange={handleNodePromptChange}
|
||||
onConfigChange={handleConfigNodeChange}
|
||||
onGenerate={handleGenerateNode}
|
||||
onRemoveReference={handleRemoveNodeReference}
|
||||
onClose={() => setDialogNodeId(null)}
|
||||
onNodeMouseDown={handleNodeMouseDown}
|
||||
workspaceMode={workspaceMode}
|
||||
onImageSettingsOpenChange={(open) => {
|
||||
setNodeImageSettingsOpen(open);
|
||||
@@ -1582,7 +1607,7 @@ function InfiniteCanvasPage() {
|
||||
/>
|
||||
);
|
||||
},
|
||||
[configInputsById, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, mentionReferencesByNodeId, runningNodeId, skillMentionReferences, workspaceMode],
|
||||
[configInputsById, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, handleRemoveNodeReference, mentionReferencesByNodeId, runningNodeId, skillMentionReferences, workspaceMode],
|
||||
);
|
||||
|
||||
const renderCanvasNodeContent = useCallback(
|
||||
@@ -1793,7 +1818,8 @@ function InfiniteCanvasPage() {
|
||||
{!focusMode && shortDramaEnabled && currentProject?.projectId ? (
|
||||
<CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} />
|
||||
) : null}
|
||||
<section className="relative min-w-0 flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CanvasOverlayLayerProvider>
|
||||
<section className="relative min-w-0 flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{!focusMode ? (
|
||||
<CanvasTopBar
|
||||
title={currentProject?.title || "未命名画布"}
|
||||
@@ -2001,7 +2027,7 @@ function InfiniteCanvasPage() {
|
||||
onExit={exitFocusMode}
|
||||
onZoomIn={zoomCanvasIn}
|
||||
onZoomOut={zoomCanvasOut}
|
||||
onFit={resetViewport}
|
||||
onFit={fitCanvasContent}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -2079,7 +2105,15 @@ function InfiniteCanvasPage() {
|
||||
</div>
|
||||
|
||||
{angleNode?.metadata?.content ? (
|
||||
<CanvasNodePanelOverlay node={angleNode} viewport={viewport} containerRef={containerRef} panelWidth={580} panelHeight={350}>
|
||||
<CanvasNodePanelOverlay
|
||||
node={angleNode}
|
||||
viewport={viewport}
|
||||
containerRef={containerRef}
|
||||
panelWidth={580}
|
||||
panelHeight={350}
|
||||
dragOffset={dragPreview?.nodeIds.has(angleNode.id) ? { x: dragPreview.x, y: dragPreview.y } : null}
|
||||
isDragging={isNodeDragging && Boolean(dragPreview?.nodeIds.has(angleNode.id))}
|
||||
>
|
||||
<CanvasNodeAnglePanel
|
||||
dataUrl={angleNode.metadata.content}
|
||||
onClose={() => setAngleNodeId(null)}
|
||||
@@ -2095,6 +2129,8 @@ function InfiniteCanvasPage() {
|
||||
node={emotionNode}
|
||||
viewport={viewport}
|
||||
containerRef={containerRef}
|
||||
dragOffset={dragPreview?.nodeIds.has(emotionNode.id) ? { x: dragPreview.x, y: dragPreview.y } : null}
|
||||
isDragging={isNodeDragging && Boolean(dragPreview?.nodeIds.has(emotionNode.id))}
|
||||
onClose={() => setEmotionNodeId(null)}
|
||||
onConfirm={(payload: CanvasImageEmotionPayload) => {
|
||||
void generateEmotionNode(emotionNode, payload);
|
||||
@@ -2103,7 +2139,13 @@ function InfiniteCanvasPage() {
|
||||
) : null}
|
||||
|
||||
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox ? (
|
||||
<CanvasNodePanelOverlay node={dialogNode} viewport={viewport} containerRef={containerRef}>
|
||||
<CanvasNodePanelOverlay
|
||||
node={dialogNode}
|
||||
viewport={viewport}
|
||||
containerRef={containerRef}
|
||||
dragOffset={dragPreview?.nodeIds.has(dialogNode.id) ? { x: dragPreview.x, y: dragPreview.y } : null}
|
||||
isDragging={isNodeDragging && Boolean(dragPreview?.nodeIds.has(dialogNode.id))}
|
||||
>
|
||||
{renderCanvasNodePanel(dialogNode)}
|
||||
</CanvasNodePanelOverlay>
|
||||
) : null}
|
||||
@@ -2201,9 +2243,10 @@ function InfiniteCanvasPage() {
|
||||
{isMiniMapOpen && !focusMode ? <Minimap nodes={nodes} viewport={viewport} viewportSize={size} canvasContainerRef={containerRef} onViewportPreviewChange={previewViewport} onViewportChange={handleViewportChange} /> : null}
|
||||
|
||||
{!focusMode ? (
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
className="absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] left-[var(--canvas-inset-x)] z-[var(--z-panel)] flex items-end gap-2 lg:bottom-[var(--canvas-inset-y)]"
|
||||
<CanvasOverlayLayerContainer
|
||||
overlayId="asset-tray"
|
||||
fallbackZIndex="var(--z-panel)"
|
||||
className="absolute bottom-[calc(var(--canvas-inset-y)+var(--space-16))] left-[var(--canvas-inset-x)] flex items-end gap-2 lg:bottom-[var(--canvas-inset-y)]"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
@@ -2212,7 +2255,8 @@ function InfiniteCanvasPage() {
|
||||
scale={viewport.k}
|
||||
containerRef={containerRef}
|
||||
onScaleChange={setZoomScale}
|
||||
onReset={resetViewport}
|
||||
onFitContent={fitCanvasContent}
|
||||
onAutoArrange={autoArrangeCanvasNodes}
|
||||
isMiniMapOpen={isMiniMapOpen}
|
||||
onToggleMiniMap={() => setIsMiniMapOpen((value) => !value)}
|
||||
onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)}
|
||||
@@ -2225,7 +2269,7 @@ function InfiniteCanvasPage() {
|
||||
onInsertAssetImage={(asset) => void createImageAssetNode(asset)}
|
||||
onFocusCanvasImage={focusCanvasImageNode}
|
||||
/>
|
||||
</div>
|
||||
</CanvasOverlayLayerContainer>
|
||||
) : null}
|
||||
|
||||
<CanvasProjectContextMenu
|
||||
@@ -2478,7 +2522,8 @@ function InfiniteCanvasPage() {
|
||||
{codexCompactAgent && !assistantMounted ? (
|
||||
<CanvasLocalAgentPanel headless snapshot={agentSnapshot} canUndoOps={canUndoAgentOps} undoOpsCount={agentUndoCount} onApplyOps={applyAgentOps} onUndoOps={undoAgentOps} autoConnect={codexAutoConnect} />
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
</CanvasOverlayLayerProvider>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -258,7 +258,7 @@ export default function SharedCanvasPage() {
|
||||
|
||||
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onViewImage={unauthorized} onExtractVideoLastFrame={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoRegenerate={unauthorized} extractingVideoFrame={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
|
||||
|
||||
<div className="absolute bottom-5 left-5 z-[var(--z-panel-floating)]"><CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoom} onReset={resetViewport} isMiniMapOpen={false} onToggleMiniMap={unauthorized} onOpenShortcuts={unauthorized} /></div>
|
||||
<div className="absolute bottom-5 left-5 z-[var(--z-panel-floating)]"><CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoom} onFitContent={resetViewport} isMiniMapOpen={false} onToggleMiniMap={unauthorized} onOpenShortcuts={unauthorized} /></div>
|
||||
<div className="pointer-events-none absolute bottom-5 right-5 z-[var(--z-panel-floating)] max-w-[340px] text-right text-xs leading-5" style={{ color: theme.node.muted }}>访客操作仅在当前页面临时生效</div>
|
||||
|
||||
{contextMenu ? <SharedContextMenu menu={contextMenu} onAdd={addNode} onInfo={() => { if (contextMenu.nodeId) setInfoNodeId(contextMenu.nodeId); setContextMenu(null); }} onUnauthorized={() => { setContextMenu(null); unauthorized(); }} /> : null}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { nanoid } from "nanoid";
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
|
||||
import { createCanvasNode, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
|
||||
import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
|
||||
import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
|
||||
@@ -164,6 +164,28 @@ export function useCanvasNodeOperations({
|
||||
message.success(mode === "flow" ? "已按连线整理" : "已整理选中节点");
|
||||
}, [commitNodes, connectionsRef, message, nodesRef, selectedNodeIdsRef]);
|
||||
|
||||
const autoArrangeCanvasNodes = useCallback(() => {
|
||||
const currentNodes = nodesRef.current;
|
||||
const selectedIds = selectedNodeIdsRef.current;
|
||||
const hasSelection = selectedIds.size > 0;
|
||||
const candidates = currentNodes.filter((node) => {
|
||||
if (node.metadata?.locked || isFrameNode(node) || isHiddenBatchChild(node, currentNodes)) return false;
|
||||
if (hasSelection) return selectedIds.has(node.id);
|
||||
return !node.parentId;
|
||||
});
|
||||
if (candidates.length < 2) {
|
||||
message.info(hasSelection ? "请至少选择两个可整理节点" : "画布中至少需要两个可整理节点");
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateIds = new Set(candidates.map((node) => node.id));
|
||||
const hasConnections = connectionsRef.current.some((connection) => candidateIds.has(connection.fromNodeId) && candidateIds.has(connection.toNodeId));
|
||||
const mode = hasConnections ? "flow" : "grid";
|
||||
const positions = mode === "flow" ? layoutCanvasFlow(candidates, connectionsRef.current) : layoutCanvasNodes(candidates, "grid");
|
||||
commitNodes(currentNodes.map((node) => positions.has(node.id) ? { ...node, position: positions.get(node.id)! } : node));
|
||||
message.success(mode === "flow" ? (hasSelection ? "已按连线整理选中节点" : "已按连线整理画布") : (hasSelection ? "已网格整理选中节点" : "已网格整理画布"));
|
||||
}, [commitNodes, connectionsRef, message, nodesRef, selectedNodeIdsRef]);
|
||||
|
||||
const alignSelectedNodes = useCallback((mode: CanvasAlignmentMode) => {
|
||||
const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node));
|
||||
if (selected.length < 2 || ((mode === "distributeX" || mode === "distributeY") && selected.length < 3)) return;
|
||||
@@ -485,6 +507,7 @@ export function useCanvasNodeOperations({
|
||||
|
||||
return {
|
||||
alignSelectedNodes,
|
||||
autoArrangeCanvasNodes,
|
||||
arrangeSelectedNodes,
|
||||
copyNodesToClipboard,
|
||||
copySelectedNodes,
|
||||
|
||||
@@ -19,7 +19,7 @@ type UseCanvasSelectionControllerOptions = {
|
||||
onCanvasSelectionStart: () => void;
|
||||
onNodeInteractionStart: (selectionModifier: boolean) => void;
|
||||
onNodeClick: (node: CanvasNodeData) => void;
|
||||
onBatchConnectionTarget?: (event: ReactMouseEvent, nodeId: string) => boolean;
|
||||
onBatchConnectionTarget?: (event: ReactMouseEvent | ReactPointerEvent, nodeId: string) => boolean;
|
||||
onLinkedFolderDrop?: (folder: CanvasNodeData, nodes: CanvasNodeData[]) => void;
|
||||
onDeselect: () => void;
|
||||
onSelectionBoxEnd?: () => void;
|
||||
@@ -127,7 +127,7 @@ export function useCanvasSelectionController({
|
||||
setSelectedConnectionId(null);
|
||||
}, [cancelPendingConnectionCreate, nodesRef, onCanvasSelectionStart, screenToCanvas, selectedNodeIdsRef, setSelectedConnectionId]);
|
||||
|
||||
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
|
||||
const handleNodeMouseDown = useCallback((event: ReactMouseEvent | ReactPointerEvent, nodeId: string) => {
|
||||
event.stopPropagation();
|
||||
if (event.button !== 0) return;
|
||||
if (onBatchConnectionTarget?.(event, nodeId)) return;
|
||||
@@ -227,7 +227,7 @@ export function useCanvasSelectionController({
|
||||
}
|
||||
}, [historyPausedRef, nodesRef, onLinkedFolderDrop, onNodeClick, setNodes, viewportRef]);
|
||||
|
||||
const handleMouseMove = useCallback((event: MouseEvent) => {
|
||||
const handleNodeDragMove = useCallback((event: MouseEvent | PointerEvent) => {
|
||||
if (!dragRef.current.isDraggingNode) return;
|
||||
const currentViewport = viewportRef.current;
|
||||
pendingNodeDragRef.current = { x: (event.clientX - dragRef.current.startX) / currentViewport.k, y: (event.clientY - dragRef.current.startY) / currentViewport.k };
|
||||
@@ -256,6 +256,10 @@ export function useCanvasSelectionController({
|
||||
}, [nodesRef, viewportRef]);
|
||||
|
||||
const handlePointerMove = useCallback((event: PointerEvent) => {
|
||||
if (dragRef.current.isDraggingNode) {
|
||||
handleNodeDragMove(event);
|
||||
return;
|
||||
}
|
||||
const currentSelection = selectionBoxRef.current;
|
||||
if (!currentSelection) return;
|
||||
if (event.buttons === 0) {
|
||||
@@ -299,7 +303,7 @@ export function useCanvasSelectionController({
|
||||
selectedNodeIdsRef.current = nextSelected;
|
||||
setSelectedNodeIds(nextSelected);
|
||||
});
|
||||
}, [cancelSelectionBox, containerRef, screenToCanvas, selectedNodeIdsRef, setSelectedNodeIds, viewportRef]);
|
||||
}, [cancelSelectionBox, containerRef, handleNodeDragMove, screenToCanvas, selectedNodeIdsRef, setSelectedNodeIds, viewportRef]);
|
||||
|
||||
const finishSelection = useCallback(() => {
|
||||
const hadPendingSelection = Boolean(selectionBoxRef.current);
|
||||
@@ -316,7 +320,7 @@ export function useCanvasSelectionController({
|
||||
};
|
||||
const handlePointerUp = (event: PointerEvent) => finishNodeDrag(event.clientX, event.clientY);
|
||||
const cancel = () => finishNodeDrag();
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mousemove", handleNodeDragMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
@@ -325,14 +329,14 @@ export function useCanvasSelectionController({
|
||||
return () => {
|
||||
if (dragFrameRef.current) cancelAnimationFrame(dragFrameRef.current);
|
||||
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mousemove", handleNodeDragMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", cancel);
|
||||
window.removeEventListener("blur", cancel);
|
||||
};
|
||||
}, [finishNodeDrag, finishSelection, handleMouseMove, handlePointerMove]);
|
||||
}, [finishNodeDrag, finishSelection, handleNodeDragMove, handlePointerMove]);
|
||||
|
||||
return {
|
||||
alignmentGuides,
|
||||
|
||||
@@ -122,11 +122,6 @@ export function useCanvasViewportController({
|
||||
setDialogNodeId(node.type === CanvasNodeType.Drawing ? null : node.id);
|
||||
}, [nodesRef, selectFocusedNode, setDialogNodeId, size.height, size.width, transitionViewportTo, viewportRef]);
|
||||
|
||||
const resetViewport = useCallback(() => {
|
||||
transitionViewportTo({ x: size.width / 2, y: size.height / 2, k: 1 });
|
||||
setContextMenu(null);
|
||||
}, [setContextMenu, size.height, size.width, transitionViewportTo]);
|
||||
|
||||
const setZoomScale = useCallback((scale: number) => {
|
||||
cancelViewportTransition();
|
||||
const next = viewportAtScale(viewportRef.current, size, scale);
|
||||
@@ -164,7 +159,6 @@ export function useCanvasViewportController({
|
||||
handleViewportChange,
|
||||
handleViewportPreviewChange,
|
||||
previewViewport,
|
||||
resetViewport,
|
||||
screenToCanvas,
|
||||
setZoomScale,
|
||||
zoomCanvasIn,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react";
|
||||
import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
|
||||
import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, WandSparkles, X } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
@@ -8,6 +8,7 @@ import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/g
|
||||
import { MessageReasoning } from "@/components/ai/message-reasoning";
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||||
import { CanvasPromptOptimizerDrawer } from "@/components/canvas/canvas-prompt-optimizer-drawer";
|
||||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
@@ -37,6 +38,10 @@ import { recoverCreationTextTask } from "@/services/creation-text-task-recovery"
|
||||
import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types";
|
||||
import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
|
||||
import { createPluginHostContext } from "@/services/plugin-host";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references";
|
||||
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
|
||||
|
||||
@@ -128,6 +133,11 @@ function completedCreationGenerationTask(input: { taskId: string; task?: Generat
|
||||
export default function CreatePage() {
|
||||
const { message: toast, modal } = App.useApp();
|
||||
const config = useEffectiveConfig();
|
||||
const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID));
|
||||
const promptOptimizerProvider = useMemo<PromptOptimizerProvider | null>(() => {
|
||||
if (!promptOptimizerInstallation?.enabled || !promptOptimizerPlugin.createPromptOptimizer) return null;
|
||||
return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, config));
|
||||
}, [config, promptOptimizerInstallation]);
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
@@ -839,6 +849,7 @@ export default function CreatePage() {
|
||||
setVideoQuality,
|
||||
count,
|
||||
setCount,
|
||||
promptOptimizerProvider,
|
||||
composerFocusRef,
|
||||
placeholderOverride: viewMode === "storyboard" && composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined,
|
||||
onSubmit: () => void submit(),
|
||||
@@ -1105,6 +1116,7 @@ type ComposerProps = {
|
||||
setVideoQuality: (value: string) => void;
|
||||
count: string;
|
||||
setCount: (value: string) => void;
|
||||
promptOptimizerProvider: PromptOptimizerProvider | null;
|
||||
composerFocusRef: RefObject<HTMLTextAreaElement | null>;
|
||||
placeholderOverride?: string;
|
||||
onSubmit: () => void;
|
||||
@@ -1113,9 +1125,12 @@ type ComposerProps = {
|
||||
function CreationComposer(props: ComposerProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
|
||||
const canSubmit = Boolean(props.prompt.trim()) && !props.busy;
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const priceChannel = resolveModelChannel(props.config, props.model);
|
||||
const canOptimizePrompt = Boolean(props.promptOptimizerProvider) && (props.mode === "image" || props.mode === "video");
|
||||
const optimizerReferences = props.references.filter((reference) => reference.active && reference.kind !== "skill");
|
||||
const credits = requestCreditCost({
|
||||
channelMode: priceChannel.scope === "system" ? "remote" : "local",
|
||||
modelCosts: priceChannel.modelCosts,
|
||||
@@ -1141,7 +1156,11 @@ function CreationComposer(props: ComposerProps) {
|
||||
setPreviewType(type);
|
||||
setPreviewUrl(url);
|
||||
};
|
||||
return <section className={`creation-chat-composer is-${props.variant}`}>
|
||||
useEffect(() => {
|
||||
if (!canOptimizePrompt) setPromptOptimizerOpen(false);
|
||||
}, [canOptimizePrompt]);
|
||||
|
||||
const composer = <section className={`creation-chat-composer is-${props.variant}`}>
|
||||
<div className="creation-chat-writing-surface">
|
||||
<input ref={props.fileInputRef} type="file" hidden accept={creationUploadAccept(props.mode)} multiple onChange={props.onFileChange} />
|
||||
{primaryAttachment ? <CreationAttachmentThumbnail item={primaryAttachment} primary canAddMore={canAddMoreReferences && !props.busy} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} onAdd={props.onOpenLibrary} /> : <Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-reference is-paper" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"><Plus /><span>参考内容</span></button></Tooltip>}
|
||||
@@ -1157,6 +1176,19 @@ function CreationComposer(props: ComposerProps) {
|
||||
onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)}
|
||||
/>
|
||||
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
|
||||
{canOptimizePrompt ? <Tooltip title="用 AI 优化提示词">
|
||||
<button
|
||||
type="button"
|
||||
className="creation-chat-control"
|
||||
onClick={() => setPromptOptimizerOpen(true)}
|
||||
aria-label="优化提示词"
|
||||
aria-expanded={promptOptimizerOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<WandSparkles />
|
||||
<span>优化</span>
|
||||
</button>
|
||||
</Tooltip> : null}
|
||||
<Tooltip title="从本机上传附件"><button type="button" className="creation-chat-control" onClick={() => props.fileInputRef.current?.click()} disabled={props.busy || !referencesSupported} aria-label="从本机上传附件"><Paperclip /><span>附件</span></button></Tooltip>
|
||||
<Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-control" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"><FolderOpen /><span>素材库</span></button></Tooltip>
|
||||
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} requirements={props.modelRequirements} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice variant="creation" />
|
||||
@@ -1182,6 +1214,24 @@ function CreationComposer(props: ComposerProps) {
|
||||
</footer>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</section>;
|
||||
|
||||
return (
|
||||
<CanvasPromptOptimizerDrawer
|
||||
open={promptOptimizerOpen}
|
||||
prompt={props.prompt}
|
||||
generationMode={props.mode === "video" ? "video" : "image"}
|
||||
targetModel={modelOptionName(props.model) || props.model}
|
||||
targetProtocol={priceChannel.modelCosts?.find((item) => item.model === modelOptionName(props.model))?.protocol || priceChannel.interfaceType}
|
||||
config={props.config}
|
||||
optimizerModel={props.config.textModel}
|
||||
references={optimizerReferences}
|
||||
provider={props.promptOptimizerProvider}
|
||||
onClose={() => setPromptOptimizerOpen(false)}
|
||||
onApply={props.setPrompt}
|
||||
>
|
||||
{composer}
|
||||
</CanvasPromptOptimizerDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNavigate } from "react-router";
|
||||
import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
|
||||
import "@/lib/plugins/builtin";
|
||||
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
|
||||
import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
|
||||
import type { PluginManifest, RegisteredPlugin } from "@/lib/plugins/plugin-types";
|
||||
import { getEagleLibrary, type EagleFolder } from "@/services/api/eagle";
|
||||
import { fetchPlugins, setPluginEnabled, uploadPlugin, type BackendPlugin } from "@/services/api/plugins";
|
||||
@@ -522,6 +523,11 @@ export default function PluginsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : settingsPlugin.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID ? (
|
||||
<div className="rounded-[var(--r-md)] border border-border/60 bg-muted/25 px-3 py-3 text-[var(--fs-body)] leading-6 text-foreground/70">
|
||||
<p>在创作页或图片、视频节点的提示词编辑器中使用“优化”按钮,即可让当前文本模型整理提示词。</p>
|
||||
<p className="mt-2 text-[var(--fs-micro)] text-foreground/50">插件不会自动覆盖原提示词,只有点击“采用”后才会回填到当前输入框。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="plugin-settings-empty">
|
||||
{settingsPlugin.manifest.kind === "protocol"
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { App, Button, Input, Select } from "antd";
|
||||
import { Activity, CheckCircle2, Clock3, Download, FileText, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { exportDiagnosticBundle, downloadDiagnosticBundle, previewDiagnosticBundle, type DiagnosticExportInput, type DiagnosticPreview } from "@/services/diagnostics/diagnostics-api";
|
||||
import { getClientDiagnosticEvents, getDiagnosticRuntime } from "@/services/diagnostics/client-diagnostics";
|
||||
|
||||
type DiagnosticsPanelProps = {
|
||||
taskId?: string;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
type DiagnosticRange = "15m" | "30m" | "1h" | "24h";
|
||||
|
||||
const rangeOptions = [
|
||||
{ value: "15m", label: "最近 15 分钟" },
|
||||
{ value: "30m", label: "最近 30 分钟" },
|
||||
{ value: "1h", label: "最近 1 小时" },
|
||||
{ value: "24h", label: "最近 24 小时" },
|
||||
];
|
||||
|
||||
export default function DiagnosticsPanel({ taskId, projectId }: DiagnosticsPanelProps) {
|
||||
const { message } = App.useApp();
|
||||
const [range, setRange] = useState<DiagnosticRange>("30m");
|
||||
const [description, setDescription] = useState("");
|
||||
const [preview, setPreview] = useState<DiagnosticPreview | null>(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [bundleId, setBundleId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingPreview(true);
|
||||
void previewDiagnosticBundle(buildInput(range, undefined, taskId, projectId))
|
||||
.then((result) => {
|
||||
if (!cancelled) setPreview(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPreview(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingPreview(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, range, taskId]);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const download = await exportDiagnosticBundle(buildInput(range, description, taskId, projectId));
|
||||
downloadDiagnosticBundle(download);
|
||||
setBundleId(download.bundleId);
|
||||
message.success("诊断包已下载,请连同诊断编号提交给支持人员");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "导出诊断包失败");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-pane diagnostics-page">
|
||||
<div className="settings-section max-w-4xl pb-8">
|
||||
<header className="border-b border-border/60 pb-6 pt-1">
|
||||
<div className="mb-3 flex items-center gap-2 text-[var(--fs-tiny)] font-semibold tracking-[0.12em] text-foreground/42">
|
||||
<span className="h-px w-6 bg-[var(--workspace-accent)]" aria-hidden="true" />
|
||||
<span>排障工具</span>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_minmax(240px,0.72fr)] md:items-end md:gap-10">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold tracking-[-0.02em] text-foreground sm:text-2xl">问题诊断</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-foreground/55">遇到报错、任务失败或生成卡住时,导出一份给开发人员排查。</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-xl border border-emerald-500/20 bg-emerald-500/[.06] px-3.5 py-3">
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-emerald-500/[.12] text-emerald-600 dark:text-emerald-400">
|
||||
<ShieldCheck className="size-[18px]" strokeWidth={1.8} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-foreground/85">默认已脱敏</div>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/55">不包含 API Key、Cookie、完整提示词或原始媒体。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<section className="rounded-xl border border-border/70 bg-background/55 p-4 sm:p-5" aria-labelledby="diagnostic-window-heading">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/55 pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground/85">
|
||||
<Clock3 className="size-4 text-foreground/55" strokeWidth={1.8} aria-hidden="true" />
|
||||
<h3 id="diagnostic-window-heading">收集范围</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/48">选择问题发生前后的日志时间窗口。</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-foreground/[.045] px-2.5 py-1 text-[var(--fs-tiny)] font-medium text-foreground/48">最长 24 小时</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-[minmax(0,230px)_minmax(0,1fr)] md:items-end">
|
||||
<label className="block" htmlFor="diagnostic-range">
|
||||
<span className="mb-2 block text-xs font-semibold text-foreground/65">时间范围</span>
|
||||
<Select id="diagnostic-range" className="w-full" value={range} options={rangeOptions} onChange={setRange} />
|
||||
</label>
|
||||
<div className="rounded-lg border border-border/55 bg-foreground/[.025] px-3.5 py-3" aria-live="polite">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground/78">
|
||||
<Activity className="size-4 text-emerald-500" strokeWidth={1.8} aria-hidden="true" />
|
||||
<span>当前账号的可用记录</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 divide-x divide-border/55">
|
||||
<DiagnosticMetric label="前端事件" value={loadingPreview ? "读取中" : "最多 500"} />
|
||||
<DiagnosticMetric label="任务" value={loadingPreview ? "读取中" : preview ? String(preview.taskCount) : "待统计"} />
|
||||
<DiagnosticMetric label="上游调用" value={loadingPreview ? "读取中" : preview ? String(preview.apiCallCount) : "待统计"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/70 bg-background/55 p-4 sm:p-5" aria-labelledby="diagnostic-description-heading">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="mt-0.5 size-4 text-foreground/55" strokeWidth={1.8} aria-hidden="true" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground/85">
|
||||
<h3 id="diagnostic-description-heading">问题描述</h3>
|
||||
<span className="rounded-full bg-foreground/[.045] px-2 py-0.5 text-[var(--fs-tiny)] font-medium text-foreground/42">可选</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/48">用一句话说明现象,帮助开发人员更快定位。</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-4 block" htmlFor="diagnostic-description">
|
||||
<span className="sr-only">遇到了什么问题?</span>
|
||||
<Input.TextArea id="diagnostic-description" rows={4} maxLength={1000} showCount value={description} onChange={(event) => setDescription(event.target.value)} placeholder="例如:点击生成后一直显示处理中,刷新页面也没有结果。" />
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="mt-5 flex flex-col-reverse gap-4 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="max-w-md text-xs leading-5 text-foreground/48">下载后请把 ZIP 文件和诊断编号一起提交。诊断包不会自动上传到服务器。</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5 sm:justify-end">
|
||||
{bundleId ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg border border-emerald-500/25 bg-emerald-500/[.06] px-2.5 py-2 text-xs font-medium text-emerald-700 dark:text-emerald-300" role="status">
|
||||
<CheckCircle2 className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
诊断编号:{bundleId}
|
||||
</span>
|
||||
) : null}
|
||||
<Button size="large" type="primary" icon={<Download className="size-4" strokeWidth={2} />} loading={exporting} onClick={() => void handleExport()}>
|
||||
导出诊断包
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnosticMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 px-3 first:pl-0 last:pr-0">
|
||||
<div className="truncate text-[var(--fs-tiny)] text-foreground/45">{label}</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold tabular-nums text-foreground/78">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildInput(range: DiagnosticRange, description: string | undefined, taskId?: string, projectId?: string): DiagnosticExportInput {
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - rangeMilliseconds(range));
|
||||
return {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
taskId: taskId || undefined,
|
||||
projectId: projectId || undefined,
|
||||
description: description || undefined,
|
||||
runtime: getDiagnosticRuntime(),
|
||||
clientEvents: getClientDiagnosticEvents({ from, to }),
|
||||
};
|
||||
}
|
||||
|
||||
function rangeMilliseconds(range: DiagnosticRange) {
|
||||
switch (range) {
|
||||
case "15m":
|
||||
return 15 * 60 * 1000;
|
||||
case "1h":
|
||||
return 60 * 60 * 1000;
|
||||
case "24h":
|
||||
return 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 30 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App, Button, Form, Input, InputNumber, Select } from "antd";
|
||||
import { ArrowLeft, Boxes, Cloud, MessageSquareText, RadioTower, SlidersHorizontal, SquareTerminal } from "lucide-react";
|
||||
import { ArrowLeft, Boxes, Bug, Cloud, MessageSquareText, RadioTower, SlidersHorizontal, SquareTerminal } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
@@ -13,8 +13,9 @@ export { UserLocalChannelFields, UserLocalChannelSwitch, userLocalChannelChangeP
|
||||
import { ModelDefaultGrid } from "./model-default-grid";
|
||||
import { LocalCliSettings } from "./local-cli-settings";
|
||||
import { PromptPreferencesPane } from "./prompt-preferences-pane";
|
||||
import DiagnosticsPanel from "./diagnostics-panel";
|
||||
|
||||
type ConfigSectionKey = "local-cli" | "channels" | "models" | "preferences" | "prompts" | "storage";
|
||||
type ConfigSectionKey = "local-cli" | "channels" | "models" | "preferences" | "prompts" | "storage" | "diagnostics";
|
||||
|
||||
const configSections: Array<{ key: ConfigSectionKey; label: string; description: string; icon: ReactNode }> = [
|
||||
{ key: "local-cli", label: "本机工具", description: "连接 Runtime 与官方 CLI", icon: <SquareTerminal className="size-4" /> },
|
||||
@@ -23,6 +24,7 @@ const configSections: Array<{ key: ConfigSectionKey; label: string; description:
|
||||
{ key: "preferences", label: "生成偏好", description: "画布、视频与音频默认值", icon: <SlidersHorizontal className="size-4" /> },
|
||||
{ key: "prompts", label: "提示词偏好", description: "按任务定制平台模板", icon: <MessageSquareText className="size-4" /> },
|
||||
{ key: "storage", label: "我的对象存储", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
|
||||
{ key: "diagnostics", label: "问题诊断", description: "导出日志协助排查", icon: <Bug className="size-4" /> },
|
||||
];
|
||||
|
||||
export function isConfigSection(value: string | null): value is ConfigSectionKey {
|
||||
@@ -172,6 +174,7 @@ export default function SettingsPage() {
|
||||
</SettingsPane>
|
||||
),
|
||||
prompts: <SettingsPane fill><PromptPreferencesPane /></SettingsPane>,
|
||||
diagnostics: <SettingsPane><DiagnosticsPanel taskId={searchParams.get("taskId") || undefined} projectId={searchParams.get("projectId") || undefined} /></SettingsPane>,
|
||||
storage: (
|
||||
<SettingsPane>
|
||||
<div className="settings-section">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { App, Button, Drawer, Form, Input, Modal, Select, Switch, Tooltip, Typography } from "antd";
|
||||
import { LayoutGrid, List, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { Bug, LayoutGrid, List, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { MediaPreview } from "@/components/media-preview";
|
||||
import { ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
@@ -53,6 +53,7 @@ function taskStatusFilter(value: string | null): TaskStatusFilter {
|
||||
|
||||
export default function TasksPage() {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
@@ -537,6 +538,7 @@ export default function TasksPage() {
|
||||
</Button>
|
||||
) : null}
|
||||
{canQueryProviderTask(detailTask) ? <Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void queryProviderTask(detailTask)}>手动查询任务</Button> : null}
|
||||
{isTaskFailed(detailTask) ? <Button icon={<Bug className="size-4" />} onClick={() => navigate(`/settings?section=diagnostics&taskId=${encodeURIComponent(detailTask.id)}${detailTask.projectId ? `&projectId=${encodeURIComponent(detailTask.projectId)}` : ""}`)}>导出诊断包</Button> : null}
|
||||
</div>
|
||||
{detailTask.error ? <pre className="task-detail-error max-h-28 overflow-auto whitespace-pre-wrap px-3 py-2 text-xs">{generationErrorMessage(detailTask.error)}</pre> : null}
|
||||
<TaskResultMedia value={detailTask.resultJson} taskType={detailTask.type} />
|
||||
|
||||
Reference in new issue
Block a user