feat(canvas): 创作助手 - 合并创作对话、能力发现与画布接续
This commit is contained in:
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"
|
||||
|
||||
Reference in new issue
Block a user