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
2026-08-25 13:49:15 +08:00
committed by GitHub
parent 1758ba6062
commit 5b7f173102
70 changed files with 6863 additions and 1708 deletions

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 }) {