feat(canvas): 创作助手 - 合并创作对话、能力发现与画布接续

This commit is contained in:
ddCat committed 2026-09-09 22:46:45 +08:00
commit 96166f32d4
97 files changed
+8434 -326

No files matched your search

@@ -1,10 +1,10 @@
import { canvasThemes } from "@/lib/canvas-theme";
import { Button, Tag } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { EmptyState } from "@/components/ui/product/empty-state";
import { ArrowLeft, ArrowRight, CheckCircle2, ChevronDown, ChevronRight, Copy, FileText, Image as ImageIcon, LoaderCircle, RefreshCw, Sparkles, Target, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import {
ART_CRITIQUE_SCHEMA_VERSION,
ART_CRITIQUE_PLUGIN_ID,
@@ -22,9 +22,12 @@ import {
type ArtCritiqueReport,
} from "@/lib/art-critique/contracts";
import { ART_CRITIQUE_CATEGORY_COLORS, ART_CRITIQUE_SEVERITY_COLORS, layoutArtCritiqueLabels, repairIssueTarget, targetBounds } from "@/lib/art-critique/annotation";
import { runArtCritiquePipeline } from "@/lib/art-critique/pipeline";
import { executeArtCritique } from "@/services/art-critique-execution";
import { ApprovedToolExecution, type ApprovedToolExecutionState } from "@/services/approved-tool-execution";
import { CreativeQuoteCard } from "@/components/creation/creative-agent-cards";
import { getActiveUserScope } from "@/lib/user-scope";
import { useCopyText } from "@/hooks/use-copy-text";
import { imageToDataUrl, resolveImageUrl } from "@/services/image-storage";
import { resolveImageUrl } from "@/services/image-storage";
import { modelOptionLabel, useEffectiveConfig } from "@/stores/use-config-store";
import { usePluginStore } from "@/stores/use-plugin-store";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -33,6 +36,9 @@ import { IconButton } from "@/components/ui/base/buttons";
import { Callout } from "@/components/ui/product/callout";
type AiArtCritiqueModalProps = {
startRequestId?: string;
restartRequested?: boolean;
onRunningChange?: (running: boolean) => void;
node: CanvasNodeData | null;
upstreamNodes: CanvasNodeData[];
open: boolean;
@@ -56,16 +62,14 @@ type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
type AiArtCritiqueView = "overview" | "detail";
type ArtCritiquePromptStatus = "ready" | "pending" | "unavailable";
export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdateState }: AiArtCritiqueModalProps) {
export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdateState, startRequestId, restartRequested, onRunningChange }: AiArtCritiqueModalProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const effectiveConfig = useEffectiveConfig();
const selectedCritiqueModel = effectiveConfig.textModel.trim();
const configuredCritiqueModelLabel = selectedCritiqueModel ? modelOptionLabel(effectiveConfig, selectedCritiqueModel) : "未配置文本/视觉理解模型";
const installations = usePluginStore((state) => state.installations);
const input = useMemo(() => upstreamNodes.find(isArtCritiqueImageInput), [upstreamNodes]);
const enabled = usePluginStore((state) => state.pluginStates[ART_CRITIQUE_PLUGIN_ID]?.effectiveEnabled ?? (ART_CRITIQUE_PLUGIN_ID in state.runtimeStatuses ? state.runtimeStatuses[ART_CRITIQUE_PLUGIN_ID] === "enabled" : Boolean(state.installations.find((item) => item.manifest.id === ART_CRITIQUE_PLUGIN_ID)?.enabled)));
const input = useMemo(() => { const images = upstreamNodes.filter(isArtCritiqueImageInput); return images.length === 1 ? images[0] : undefined; }, [upstreamNodes]);
const state = node?.metadata?.artCritique || createDefaultArtCritiqueState();
const installation = installations.find((item) => item.manifest.id === ART_CRITIQUE_PLUGIN_ID);
const enabled = installation?.enabled === true;
const currentFingerprint = input ? artCritiqueSourceFingerprint(input) : "";
const stale = Boolean(state.report && currentFingerprint && state.report.sourceFingerprint !== currentFingerprint);
const visibleState: ArtCritiqueNodeState = stale && state.status !== "running" ? { ...state, status: "stale" } : state;
@@ -79,7 +83,11 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
const [hoveredIssueId, setHoveredIssueId] = useState<string | null>(null);
const [draftReportVisible, setDraftReportVisible] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const preparedImageRef = useRef<{ fingerprint: string; dataUrl: string } | null>(null);
const approvalRef = useRef<ApprovedToolExecution | null>(null);
const [payment, setPayment] = useState<ApprovedToolExecutionState>({ busy: false });
const startedRequestRef = useRef<string | undefined>(undefined);
const currentRunTargetRef = useRef("");
currentRunTargetRef.current = `${getActiveUserScope()}:${node?.id || ""}:${currentFingerprint}`;
useEffect(() => {
if (!open || !input) {
@@ -111,6 +119,8 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
}, [node?.id, currentFingerprint, open]);
useEffect(() => () => abortRef.current?.abort(), []);
useEffect(() => () => abortRef.current?.abort(), [node?.id, currentFingerprint]);
useEffect(() => { if (!enabled) abortRef.current?.abort(); }, [enabled]);
const issues = visibleState.report?.issues || [];
const reportOptions = visibleState.report?.options || [];
@@ -144,23 +154,35 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
setView("overview");
};
const runReview = async () => {
const runReview = async (resume = false) => {
if (abortRef.current) return;
if (!node || !input) {
setLocalError("请先连接一张已有图片");
setLocalError("请连接且仅连接一张已有图片");
return;
}
if (!enabled) {
setLocalError("插件尚未启用,请先到插件管理中开启 AI 审美批改");
return;
}
if (resume && (!state.billingRunId || state.sourceFingerprint !== artCritiqueSourceFingerprint(input) || state.lastRunModel !== selectedCritiqueModel)) {
setLocalError("原分析的图片或模型已变化,请开始新的分析");
return;
}
setView("overview");
setSelectedIssueId(null);
setHoveredIssueId(null);
setDraftReportVisible(false);
const sourceFingerprint = artCritiqueSourceFingerprint(input);
const runId = `art-critique-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const runId = resume && state.lastRunId ? state.lastRunId : `art-critique-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
let billingRunId = resume ? state.billingRunId : undefined;
const controller = new AbortController();
abortRef.current = controller;
onRunningChange?.(true);
const runTarget = currentRunTargetRef.current;
const runScope = getActiveUserScope();
const publishState = (next: ArtCritiqueNodeState) => {
if (getActiveUserScope() === runScope && currentRunTargetRef.current === runTarget && abortRef.current === controller) onUpdateState(node.id, { ...next, billingRunId, lastRunModel: selectedCritiqueModel });
};
const baseState: ArtCritiqueNodeState = {
...state,
schemaVersion: ART_CRITIQUE_SCHEMA_VERSION,
@@ -169,41 +191,41 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
sourceNodeId: input.id,
sourceFingerprint,
lastRunId: runId,
stageTaskIds: resume ? state.stageTaskIds : {},
errorCode: undefined,
errorMessage: undefined,
updatedAt: new Date().toISOString(),
};
onUpdateState(node.id, baseState);
publishState(baseState);
setRunning(true);
setActiveStage("preparing");
setLocalError("");
let latestStage: ArtCritiquePipelineStage = "preparing";
let runningReport: ArtCritiqueReport | undefined = state.report;
const stageTaskIds: Record<string, string> = resume ? { ...state.stageTaskIds } : {};
setPayment({ busy: false });
const approval = new ApprovedToolExecution({
clientKey: `art-critique:${runId}`, runId: billingRunId,
identity: { kind: "art-critique", nodeId: node.id, sourceFingerprint, model: selectedCritiqueModel }, signal: controller.signal,
onRun: (id) => { billingRunId = id; publishState({ ...baseState, stageTaskIds: { ...stageTaskIds } }); },
onState: (next) => { if (currentRunTargetRef.current === runTarget) setPayment(next); },
});
approvalRef.current = approval;
try {
const source = input.metadata?.content || input.metadata?.previewContent || "";
const cachedImage = preparedImageRef.current;
const dataUrl =
cachedImage?.fingerprint === sourceFingerprint
? cachedImage.dataUrl
: await imageToDataUrl({
dataUrl: source.startsWith("data:") ? source : undefined,
url: source.startsWith("data:") ? undefined : source,
storageKey: input.metadata?.storageKey,
name: input.title,
mimeType: input.metadata?.mimeType,
});
if (!dataUrl) throw new Error("无法读取输入图片");
if (!controller.signal.aborted) preparedImageRef.current = { fingerprint: sourceFingerprint, dataUrl };
const report = await runArtCritiquePipeline(
effectiveConfig,
{ dataUrl, title: input.title, sourceFingerprint },
{
const report = await executeArtCritique({
nodeId: node.id, runId, source: input, config: effectiveConfig,
signal: controller.signal,
submitStage: approval.execute,
onTaskCreated: (stage, taskId) => {
stageTaskIds[stage] = taskId;
publishState({ ...baseState, stageTaskIds: { ...stageTaskIds }, analysisStage: latestStage, updatedAt: new Date().toISOString() });
},
onStage: (analysisStage) => {
latestStage = analysisStage;
setActiveStage(analysisStage);
onUpdateState(node.id, {
publishState({
...baseState,
stageTaskIds: { ...stageTaskIds },
...(runningReport ? { report: { ...runningReport, modelLabel: modelOptionLabel(effectiveConfig, selectedCritiqueModel) } } : {}),
analysisStage,
updatedAt: new Date().toISOString(),
@@ -213,19 +235,20 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
if (controller.signal.aborted || abortRef.current !== controller) return;
runningReport = draftReport;
setDraftReportVisible(true);
onUpdateState(node.id, {
publishState({
...baseState,
stageTaskIds: { ...stageTaskIds },
status: "running",
analysisStage: latestStage,
report: { ...draftReport, modelLabel: modelOptionLabel(effectiveConfig, selectedCritiqueModel) },
updatedAt: new Date().toISOString(),
});
},
},
);
});
setDraftReportVisible(false);
onUpdateState(node.id, {
publishState({
...baseState,
stageTaskIds: { ...stageTaskIds },
status: "completed",
analysisStage: "completed",
report: { ...report, modelLabel: modelOptionLabel(effectiveConfig, selectedCritiqueModel) },
@@ -236,22 +259,31 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
if (controller.signal.aborted) {
setActiveStage(undefined);
setDraftReportVisible(false);
onUpdateState(node.id, { ...baseState, status: "idle", analysisStage: undefined, errorCode: undefined, errorMessage: undefined, updatedAt: new Date().toISOString() });
publishState({ ...baseState, stageTaskIds: { ...stageTaskIds }, status: "failed", analysisStage: "failed", errorCode: "analysis_interrupted", errorMessage: "分析观察已停止;已提交阶段保留在任务中心,不会自动重复提交。", updatedAt: new Date().toISOString() });
return;
}
const errorMessage = error instanceof Error ? error.message : "AI 批改失败,请稍后重试";
setActiveStage("failed");
setDraftReportVisible(false);
setLocalError(errorMessage);
onUpdateState(node.id, { ...baseState, status: "failed", analysisStage: "failed", errorCode: error instanceof Error ? error.message : "art_critique_failed", errorMessage, updatedAt: new Date().toISOString() });
publishState({ ...baseState, stageTaskIds: { ...stageTaskIds }, status: "failed", analysisStage: "failed", errorCode: error instanceof Error ? error.message : "art_critique_failed", errorMessage, updatedAt: new Date().toISOString() });
} finally {
approval.dispose();
if (approvalRef.current === approval) { approvalRef.current = null; setPayment({ busy: false }); }
if (abortRef.current === controller) abortRef.current = null;
setRunning(false);
onRunningChange?.(false);
}
};
const progressStage = activeStage || visibleState.analysisStage || "preparing";
const displayedModelLabel = visibleState.report?.modelLabel || configuredCritiqueModelLabel;
useEffect(() => {
if (!open || !node || !startRequestId || running || startedRequestRef.current === startRequestId) return;
startedRequestRef.current = startRequestId;
void runReview(Boolean(!restartRequested && state.billingRunId && state.status !== "completed" && state.sourceFingerprint === currentFingerprint && state.lastRunModel === selectedCritiqueModel));
}, [open, node?.id, startRequestId, running]);
const modelStatusLabel = running ? "正在使用" : visibleState.report?.modelLabel ? "报告使用" : selectedCritiqueModel ? "待使用" : "需要配置";
const modelStatusColor = selectedCritiqueModel || visibleState.report?.modelLabel ? theme.node.muted : "var(--status-warning)";
const modelInlineLabel = selectedCritiqueModel || visibleState.report?.modelLabel ? `${modelStatusLabel} · ${displayedModelLabel}` : "请到设置中选择支持图片理解的文本模型";
@@ -336,6 +368,8 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
<aside className="min-h-0 flex-1 overflow-y-auto" data-canvas-wheel-scroll>
<div className="flex min-h-full flex-col gap-4 p-4">
{payment.quote ? <CreativeQuoteCard quote={payment.quote} busy={payment.busy} onApprove={() => void approvalRef.current?.approve(payment.quote!.items.map((item) => item.id))} onRefresh={() => void approvalRef.current?.refresh()} /> : null}
{payment.error ? <Callout tone="warning" title="费用确认未完成">{payment.error}</Callout> : null}
{running || visibleState.status === "running" ? <ArtCritiqueProgress stage={progressStage} theme={theme} /> : null}
{draftReportVisible && visibleState.status === "running" && visibleState.report ? (
<Callout tone="info" title="报告初稿已生成">
@@ -449,6 +483,7 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat
取消分析
</Button>
) : null}
{!running && state.billingRunId && state.status !== "completed" && !stale && state.lastRunModel === selectedCritiqueModel ? <Button size="small" disabled={!input || !enabled} onClick={() => void runReview(true)}>继续上次分析</Button> : null}
<Button
type="primary"
size="small"