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:
QAyong authored and GitHub committed 2026-08-25 13:49:15 +08:00
1 parent 625a97560b
commit cf6c78d540
70 files changed
+6863 -1708

No files matched your search

+104 -86
View File
@@ -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 }) {
+60 -15
View File
@@ -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>
</>
);
+1 -1
View File
@@ -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,
+52 -2
View File
@@ -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
View File
@@ -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;
}
}
+5 -2
View File
@@ -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">
+4 -2
View File
@@ -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} />