feat(canvas): 创作助手 - 合并创作对话、能力发现与画布接续
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { ArrowUp, AtSign, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, RotateCcw, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
import { ArrowUp, AtSign, CheckCircle2, ChevronDown, CircleAlert, ImagePlus, LoaderCircle, RotateCcw, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops";
|
||||
@@ -100,10 +100,7 @@ export function AgentChatMessage({
|
||||
if (item.role === "tool") {
|
||||
if (objectField(item.detail, "status") === "pending") return <AgentPendingToolCard summary={item.text} detail={item.detail} theme={theme} onReject={() => onRejectTool?.(item.id)} onApprove={() => onApproveTool?.(item.id)} />;
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AgentAvatar theme={theme} />
|
||||
<AgentToolCard title={item.title || "工具调用"} text={item.text} detail={item.detail} theme={theme} />
|
||||
</div>
|
||||
<AgentToolCard title={item.title || "工具调用"} text={item.text} detail={item.detail} theme={theme} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -151,26 +148,26 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AgentAvatar theme={theme} />
|
||||
<div className="min-w-0 flex-1 rounded-md p-3.5" style={{ background: "rgba(217,119,6,.07)", color: theme.node.text }}>
|
||||
<div className="agent-pending-tool min-w-0 flex-1 rounded-md p-2.5" style={{ background: "rgba(217,119,6,.07)", color: theme.node.text }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-md" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>
|
||||
<span className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>
|
||||
<CircleAlert className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[13px] font-semibold leading-5">
|
||||
<span>确认工具调用</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: "#d97706", background: "rgba(217,119,6,.1)" }}>
|
||||
等待确认
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>
|
||||
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.text }}>
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{impact?.operationCount ? (
|
||||
<div className="mt-3 pt-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
<ImpactMetric label="操作" value={impact.operationCount} theme={theme} />
|
||||
<ImpactMetric label="涉及节点" value={impact.affectedNodeCount} theme={theme} />
|
||||
<ImpactMetric label="删除" value={impact.destructiveCount} attention={impact.destructiveCount > 0} theme={theme} />
|
||||
@@ -198,11 +195,11 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
|
||||
</details>
|
||||
) : null}
|
||||
{onReject || onApprove ? (
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<Button danger className="!h-9" icon={<XCircle className="size-4" />} onClick={() => onReject?.()}>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button danger size="small" className="!h-8 flex-1" icon={<XCircle className="size-3.5" />} onClick={() => onReject?.()}>
|
||||
拒绝执行
|
||||
</Button>
|
||||
<Button className="!h-9" icon={<CheckCircle2 className="size-4" />} style={{ borderColor: "rgba(22,163,74,.42)", color: "#16a34a", background: "transparent" }} onClick={() => onApprove?.()}>
|
||||
<Button size="small" className="!h-8 flex-1" icon={<CheckCircle2 className="size-3.5" />} style={{ borderColor: "rgba(22,163,74,.42)", color: "#16a34a", background: "transparent" }} onClick={() => onApprove?.()}>
|
||||
批准执行
|
||||
</Button>
|
||||
</div>
|
||||
@@ -242,27 +239,21 @@ function agentImpactFromDetail(detail: unknown) {
|
||||
export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const state = toolCardState(title, text, detail);
|
||||
return (
|
||||
<details className="min-w-0 flex-1 rounded-md px-3 py-3 text-left" style={{ background: theme.spatial.surface, color: theme.node.text }}>
|
||||
<summary className="cursor-pointer list-none">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-md" style={{ color: state.color, background: state.softBg }}>
|
||||
<details data-agent-tool-card className="agent-tool-row group min-w-0 flex-1 text-left" style={{ color: theme.node.text }}>
|
||||
<summary className="cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<span className="agent-tool-status" style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
{state.icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[13px] leading-5">
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[var(--fs-label)] font-medium" style={{ color: state.color, background: state.softBg }}>
|
||||
<span className="agent-tool-label" style={{ color: state.color }}>
|
||||
{state.label}
|
||||
</span>
|
||||
{detail ? (
|
||||
<span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}>
|
||||
详情
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6" style={{ color: state.isError ? state.color : theme.node.muted }}>
|
||||
{text}
|
||||
{detail ? <ChevronDown className="ml-auto size-3.5 shrink-0 transition-transform duration-200 group-open:rotate-180" style={{ color: theme.node.muted }} aria-hidden="true" /> : null}
|
||||
</div>
|
||||
<div className="truncate text-xs leading-5" style={{ color: state.isError ? state.color : theme.node.muted, maxWidth: "100%" }}>{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
@@ -443,10 +434,10 @@ export function AgentChatComposer({
|
||||
value={prompt}
|
||||
references={composerReferences}
|
||||
includeAssetLibrary={includeAssetLibrary}
|
||||
sendOnEnter={false}
|
||||
sendOnEnter
|
||||
disabled={disabled}
|
||||
onChange={handlePromptChange}
|
||||
onSubmit={onSubmit}
|
||||
onSubmit={() => { if (canSubmit) onSubmit(); }}
|
||||
className="w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
|
||||
containerClassName="min-h-[60px]"
|
||||
style={{ color: theme.node.text }}
|
||||
@@ -512,6 +503,7 @@ export function AgentChatComposer({
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
aria-label={sending ? "发送中" : "发送"}
|
||||
title="回车发送,Shift+回车换行"
|
||||
onClick={() => void onSubmit()}
|
||||
whileHover={canSubmit && !reducedMotion ? { scale: 1.06, y: -1 } : undefined}
|
||||
whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { buildOrderedCanvasResourceReferences, canvasResourceMentionToken } from
|
||||
import { buildCanvasWorkflowOps, looksLikeWorkflowRequest, type CanvasWorkflowInput } from "@/lib/canvas/canvas-agent-workflow";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
|
||||
export type OnlineToolResult = { ok: true; message: string; data?: unknown } | { ok: false; message: string };
|
||||
export type OnlineToolResult = { ok: true; message: string; data?: unknown; waitForUser?: boolean } | { ok: false; message: string };
|
||||
|
||||
export function objectDetail(value: unknown) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
@@ -230,6 +230,14 @@ export function summarizeToolCalls(calls: ResponseToolCall[]) {
|
||||
return calls.map((call) => toolCallLabel(call.function.name)).join(",") || "工具调用";
|
||||
}
|
||||
|
||||
/** 返回待确认工具批次的用户可读类别,避免主交互流程重复猜测工具语义。 */
|
||||
export function capabilityBatchTitle(calls: ResponseToolCall[]) {
|
||||
const names = calls.map((call) => call.function.name);
|
||||
if (names.some((name) => name.startsWith("canvas_") && (name.includes("skill") || name.includes("plugin")))) return "技能与插件";
|
||||
if (names.some((name) => name.startsWith("canvas_"))) return "画布操作";
|
||||
return "工具调用";
|
||||
}
|
||||
|
||||
export function previewOnlineToolCalls(calls: ResponseToolCall[], snapshot: CanvasAgentSnapshot, config: AiConfig): CanvasAgentOperationImpact {
|
||||
const ops: CanvasAgentOp[] = [];
|
||||
let deferredCinematicCount = 0;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { Button, Modal } from "antd";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { nanoid } from "nanoid";
|
||||
import { type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image";
|
||||
import { inspectAgentImage } from "@/services/agent-image-preview";
|
||||
import { listAgentCapabilities, readAgentPluginDocumentation, readAgentPluginNode } from "@/services/agent-capabilities";
|
||||
import { readAgentStoryboard } from "@/lib/canvas/canvas-agent-storyboard";
|
||||
import { canvasStylePresets, userStylePreset, type CanvasStylePreset } from "./canvas-style-picker-modal";
|
||||
import { listStyleProfiles } from "@/services/api/style-profiles";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { isPluginEffectivelyEnabled } from "@/stores/use-plugin-store";
|
||||
import { consumeGenerationTaskAgent } from "@/services/project-asset-sync";
|
||||
import { applyGenerationConsumerEffect, generationEffectApplied } from "@/services/generation-consumer-dedupe";
|
||||
import { activeGenerationConsumerController } from "@/services/generation-consumer-lifecycle";
|
||||
@@ -32,8 +39,11 @@ import { isWritableToolCall } from "@/lib/canvas/canvas-agent-protocol";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { buildSkillMentionReferences, skillRuntime } from "@/services/skill-runtime";
|
||||
import { handleCinematicContinuationFailure, canvasCinematicContinuationEntryAdapters, type CinematicContinuationFailureDisposition, type CinematicContinuationLiveSessionState } from "./canvas-cinematic-continuation";
|
||||
import { CanvasCreativeInteraction, canvasCreativeDetail } from "./canvas-creative-interaction";
|
||||
import { creativePlan } from "@/lib/creation/creative-agent-state";
|
||||
import { CreativePlanBar } from "@/components/creation/creative-agent-cards";
|
||||
import { AgentTextModelPicker, AssistantHistory, AssistantReferenceChip, MessageReferences, assistantImageReferenceLabel, assistantMessageToChatMessage, assistantReferencesToMentionReferences, promptAlreadyHasMention } from "./canvas-assistant-panel-views";
|
||||
import { backendAgentProviderConfig, buildAssistantReferences, buildToolAgentMessages, cinematicSessionMessageId, compactSnapshot, createSession, describeCanvasSnapshot, explainNoop, nodeToReference, objectDetail, onlineToolToOps, parseToolArguments, previewOnlineToolCalls, requestOnlineAgentModel, requireOps, requireString, snapshotSignature, summarizeToolCalls, toolCallToResponseInput, toolCallsFromDetail, toolResultText, upsertAssistantMessage, type OnlineToolResult } from "./canvas-assistant-online-tools";
|
||||
import { backendAgentProviderConfig, buildAssistantReferences, buildToolAgentMessages, capabilityBatchTitle, cinematicSessionMessageId, compactSnapshot, createSession, describeCanvasSnapshot, explainNoop, nodeToReference, objectDetail, onlineToolToOps, parseToolArguments, previewOnlineToolCalls, requestOnlineAgentModel, requireOps, requireString, snapshotSignature, summarizeToolCalls, toolCallToResponseInput, toolCallsFromDetail, toolResultText, upsertAssistantMessage, type OnlineToolResult } from "./canvas-assistant-online-tools";
|
||||
|
||||
export const CANVAS_AGENT_PANEL_MOTION_MS = 500;
|
||||
const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000;
|
||||
@@ -53,6 +63,9 @@ type CanvasAssistantPanelProps = {
|
||||
onSelectNodeIds: (ids: Set<string>) => void;
|
||||
onSessionsChange: (sessions: CanvasAssistantSession[], activeSessionId: string | null) => void;
|
||||
onApplyOps: (ops?: CanvasAgentOp[], context?: { conversationId?: string; messageId?: string; source?: "online" | "local" }) => Promise<CanvasAgentSnapshot>;
|
||||
onApplyStyle: (preset: CanvasStylePreset) => Promise<void>;
|
||||
onStartArtCritique: (nodeId: string, restart: boolean) => void;
|
||||
onGenerateStoryboard: (nodeId: string, prompt: string, signal?: AbortSignal) => Promise<boolean | undefined>;
|
||||
canUndoOps: boolean;
|
||||
undoOpsCount: number;
|
||||
onUndoOps: () => CanvasAgentSnapshot | null;
|
||||
@@ -79,6 +92,9 @@ export function CanvasAssistantPanel({
|
||||
onSelectNodeIds,
|
||||
onSessionsChange,
|
||||
onApplyOps,
|
||||
onApplyStyle,
|
||||
onStartArtCritique,
|
||||
onGenerateStoryboard,
|
||||
canUndoOps,
|
||||
undoOpsCount,
|
||||
onUndoOps,
|
||||
@@ -102,12 +118,12 @@ export function CanvasAssistantPanel({
|
||||
const setAgentState = useCanvasAgentStore((state) => state.setAgentState);
|
||||
const [view, setView] = useState<OnlineAgentTab>("chat");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [cinematicEntryActive, setCinematicEntryActive] = useState(cinematicEntry);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [creativeBusy, setCreativeBusy] = useState<Record<string, boolean>>({});
|
||||
const [deleteChatIds, setDeleteChatIds] = useState<string[]>([]);
|
||||
const [composerSkills, setComposerSkills] = useState<Skill[]>([]);
|
||||
const [attachedReferenceIds, setAttachedReferenceIds] = useState<Set<string>>(() => new Set());
|
||||
const [localSessions, setLocalSessions] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
|
||||
const [localSessions, setLocalSessionsState] = useState<CanvasAssistantSession[]>(() => (sessions.length ? sessions : [createSession()]));
|
||||
const localSessionsRef = useRef(localSessions);
|
||||
const [localActiveSessionId, setLocalActiveSessionIdState] = useState<string | null>(activeSessionId);
|
||||
const localActiveSessionIdRef = useRef(localActiveSessionId);
|
||||
@@ -115,10 +131,17 @@ export function CanvasAssistantPanel({
|
||||
localActiveSessionIdRef.current = activeId;
|
||||
setLocalActiveSessionIdState(activeId);
|
||||
};
|
||||
const applyingExternalSessionsRef = useRef(false);
|
||||
const setLocalSessions = (value: CanvasAssistantSession[] | ((previous: CanvasAssistantSession[]) => CanvasAssistantSession[])) => {
|
||||
const next = typeof value === "function" ? value(localSessionsRef.current) : value;
|
||||
localSessionsRef.current = next;
|
||||
setLocalSessionsState(next);
|
||||
onSessionsChange(next, localActiveSessionIdRef.current);
|
||||
};
|
||||
const chatListRef = useRef<HTMLDivElement>(null);
|
||||
const composerRef = useRef<HTMLDivElement>(null);
|
||||
const snapshotRef = useRef(snapshot);
|
||||
const pendingToolContextRef = useRef(new Map<string, PendingOnlineToolContext>());
|
||||
const inspectedImagesRef = useRef(new Map<string, { title: string; url: string; encodedBytes: number }>());
|
||||
const cinematicSessionControllersRef = useRef(new Map<string, AbortController>());
|
||||
const generationConsumerControllerRef = useRef(new AbortController());
|
||||
|
||||
@@ -137,13 +160,15 @@ export function CanvasAssistantPanel({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessions.length) return;
|
||||
if (sessions === localSessions && activeSessionId === localActiveSessionId) return;
|
||||
applyingExternalSessionsRef.current = true;
|
||||
if (!sessions.length) {
|
||||
onSessionsChange(localSessionsRef.current, localActiveSessionIdRef.current);
|
||||
return;
|
||||
}
|
||||
localSessionsRef.current = sessions;
|
||||
setLocalSessions(sessions);
|
||||
setLocalActiveSessionId(activeSessionId);
|
||||
}, [activeSessionId, sessions]);
|
||||
setLocalSessionsState(sessions);
|
||||
localActiveSessionIdRef.current = activeSessionId;
|
||||
setLocalActiveSessionIdState(activeSessionId);
|
||||
}, [activeSessionId, sessions, onSessionsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
snapshotRef.current = snapshot;
|
||||
@@ -156,24 +181,19 @@ export function CanvasAssistantPanel({
|
||||
cinematicSessionControllersRef.current.forEach((controller) => controller.abort());
|
||||
cinematicSessionControllersRef.current.clear();
|
||||
generationConsumerControllerRef.current.abort();
|
||||
inspectedImagesRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (applyingExternalSessionsRef.current) {
|
||||
applyingExternalSessionsRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (sessions === localSessions && activeSessionId === localActiveSessionId) return;
|
||||
onSessionsChange(localSessions, localActiveSessionId);
|
||||
}, [activeSessionId, localActiveSessionId, localSessions, onSessionsChange, sessions]);
|
||||
|
||||
const safeSessions = localSessions.length ? localSessions : [createSession()];
|
||||
const activeSession = useMemo(() => safeSessions.find((session) => session.id === localActiveSessionId) || safeSessions[0] || null, [localActiveSessionId, safeSessions]);
|
||||
const historySessions = safeSessions.filter((session) => session.messages.length > 0);
|
||||
const messages = activeSession?.messages || [];
|
||||
const hasMessages = messages.length > 0;
|
||||
const agentBusy = isRunning || safeSessions.some((session) => session.pendingBackendSession?.status === "pending");
|
||||
const agentBusy = isRunning || Object.values(creativeBusy).some(Boolean) || safeSessions.some((session) => session.pendingBackendSession?.status === "pending");
|
||||
const activeCreativeId = messages.findLast((message) => Boolean(canvasCreativeDetail(message)))?.id;
|
||||
const latestAssistantMessage = messages.findLast((message) => message.role === "assistant");
|
||||
const currentCreativeState = activeCreativeId ? canvasCreativeDetail(messages.find((message) => message.id === activeCreativeId)!)?.state : latestAssistantMessage ? canvasCreativeDetail(latestAssistantMessage)?.state : undefined;
|
||||
const selectedReferences = useMemo(() => buildAssistantReferences(nodes, attachedReferenceIds), [attachedReferenceIds, nodes]);
|
||||
const composerImageReferences = useMemo(() => assistantReferencesToMentionReferences(selectedReferences), [selectedReferences]);
|
||||
const resolvedComposerImageReferences = useResolvedCanvasResourceReferences(composerImageReferences);
|
||||
@@ -434,7 +454,7 @@ export function CanvasAssistantPanel({
|
||||
const toolMessage: CanvasAssistantMessage = {
|
||||
id: toolMessageId,
|
||||
role: "tool",
|
||||
title: "确认工具调用",
|
||||
title: `确认${capabilityBatchTitle(result.toolCalls)}`,
|
||||
text: summarizeToolCalls(result.toolCalls),
|
||||
detail: { status: "pending", step: loop.step, toolCalls: result.toolCalls, impact: previewOnlineToolCalls(result.toolCalls, snapshotRef.current, effectiveConfig) },
|
||||
};
|
||||
@@ -487,7 +507,7 @@ export function CanvasAssistantPanel({
|
||||
appendMessage(sessionId, {
|
||||
id: toolMessageId,
|
||||
role: "tool",
|
||||
title: "确认工具调用",
|
||||
title: `确认${capabilityBatchTitle(next.toolCalls)}`,
|
||||
text: summarizeToolCalls(next.toolCalls),
|
||||
detail: { status: "pending", step: step + 1, toolCalls: next.toolCalls, impact: previewOnlineToolCalls(next.toolCalls, snapshotRef.current, effectiveConfig) },
|
||||
});
|
||||
@@ -520,8 +540,64 @@ export function CanvasAssistantPanel({
|
||||
if (expectedRevision !== undefined && expectedRevision !== (current.revision ?? 0)) return { ok: false, message: "画布 revision 已变化,请重新读取 canvas_get_context 后再执行写操作。" };
|
||||
const expectedStateHash = typeof args.expectedStateHash === "string" ? args.expectedStateHash : "";
|
||||
if (expectedStateHash && expectedStateHash !== buildCanvasAgentContext(current).stateHash) return { ok: false, message: "画布状态已变化,请重新读取 canvas_get_context 后再执行写操作。" };
|
||||
const skillToolResult = await skillRuntime.executeAgentTool("onlineAgent", name, args, composerSkills);
|
||||
const currentSkills = skillRuntime.agentToolNames("onlineAgent").has(name) ? (await listAddedSkills()).skills : composerSkills;
|
||||
const skillToolResult = await skillRuntime.executeAgentTool("onlineAgent", name, args, currentSkills);
|
||||
if (skillToolResult) return skillToolResult;
|
||||
if (name === "canvas_generate_storyboard") {
|
||||
const { node, readiness } = readAgentStoryboard(current, requireString(args.nodeId, "nodeId"));
|
||||
if (!readiness.canGenerateStoryboard) throw new Error(readiness.blockingReason || "请先配置项目画风");
|
||||
const prompt = requireString(args.prompt ?? node.metadata?.composerContent, "剧本与拆镜要求");
|
||||
const completed = await onGenerateStoryboard(node.id, prompt, generationConsumerControllerRef.current.signal);
|
||||
if (!completed) return { ok: true, waitForUser: true, message: "分镜生成未完成或已取消,请查看节点提示;未完成不代表已生成,不要自动重复发起。", data: { nodeId: node.id, completed: false } };
|
||||
return { ok: true, message: "专业拆镜任务已完成并写入真实分镜表。请读取分镜表检查时长与镜头内容,再继续后续操作。", data: { nodeId: node.id, completed: true } };
|
||||
}
|
||||
if (name === "canvas_start_art_critique") {
|
||||
const nodeId = requireString(args.nodeId, "nodeId");
|
||||
const node = current.nodes.find((item) => item.id === nodeId && item.type === "ai-art-critique");
|
||||
if (!node || !isPluginEffectivelyEnabled("ai-art-critique")) throw new Error("审美插件未启用或节点不存在");
|
||||
const sources = current.nodes.filter((item) => item.type === "image" && current.connections.some((edge) => edge.fromNodeId === item.id && edge.toNodeId === nodeId));
|
||||
if (sources.length !== 1) throw new Error("审美分析需要明确连接一张图片,请先整理输入连线");
|
||||
const result = readAgentPluginNode(current, nodeId);
|
||||
if (args.retry !== true && result.data.status === "completed") return { ok: true, message: "已复用当前图片的审美报告。", data: result };
|
||||
onStartArtCritique(nodeId, args.retry === true);
|
||||
return { ok: true, waitForUser: true, message: "已打开审美分析,请在面板中核对并确认费用。分析完成后,可让我读取报告并继续处理。", data: { nodeId, status: "analysis_requested", paymentApproved: false } };
|
||||
}
|
||||
if (name === "canvas_read_plugin_node") return { ok: true, message: "已读取插件节点状态。", data: readAgentPluginNode(current, requireString(args.nodeId, "nodeId")) };
|
||||
if (name === "canvas_read_storyboard") {
|
||||
const { node, rows, readiness } = readAgentStoryboard(current, requireString(args.nodeId, "nodeId"));
|
||||
return { ok: true, message: "已读取分镜表及画风前置条件。", data: { nodeId: node.id, title: node.title, rows, readiness } };
|
||||
}
|
||||
if (name === "canvas_list_styles" || name === "canvas_apply_style") {
|
||||
const originScope = getActiveUserScope();
|
||||
const source = args.source || "system";
|
||||
if (source !== "system" && source !== "user") throw new Error("画风来源必须是 system 或 user");
|
||||
const presets = source === "system" ? canvasStylePresets.map((preset) => ({ id: preset.id, preset })) : (await listStyleProfiles()).profiles.flatMap((entity) => { const preset = userStylePreset(entity); return preset ? [{ id: entity.id, preset }] : []; });
|
||||
if (getActiveUserScope() !== originScope || snapshotRef.current.projectId !== current.projectId || generationConsumerControllerRef.current.signal.aborted) throw new Error("画布或账号已切换,请重新读取画风");
|
||||
if (name === "canvas_list_styles") {
|
||||
const query = typeof args.query === "string" ? args.query.toLocaleLowerCase().trim() : "";
|
||||
return { ok: true, message: "已读取画风目录。", data: presets.filter(({ preset }) => !query || `${preset.title} ${preset.description} ${preset.tags.join(" ")}`.toLocaleLowerCase().includes(query)).map(({ id, preset }) => ({ id, source, title: preset.title, description: preset.description, tags: preset.tags })) };
|
||||
}
|
||||
const selected = presets.find((item) => item.id === args.id);
|
||||
if (!selected) throw new Error("画风不存在或已移除,请先读取真实画风目录");
|
||||
await onApplyStyle(selected.preset);
|
||||
return { ok: true, message: `已应用画风“${selected.preset.title}”。`, data: { source, id: selected.id, presetId: selected.preset.id, prompt: selected.preset.prompt } };
|
||||
}
|
||||
if (name === "canvas_read_plugin") return { ok: true, message: "已读取插件用法。", data: readAgentPluginDocumentation(requireString(args.pluginId, "pluginId")) };
|
||||
if (name === "canvas_list_capabilities") return { ok: true, message: "已读取当前能力目录。", data: listAgentCapabilities(typeof args.query === "string" ? args.query : "") };
|
||||
if (name === "canvas_inspect_image") {
|
||||
const readSignal = generationConsumerControllerRef.current.signal;
|
||||
const node = current.nodes.find((item) => item.id === args.id && item.type === "image");
|
||||
if (!node || !messageId) throw new Error("未找到当前画布中的图片节点,请先检索真实节点 id。");
|
||||
const detail = args.detail;
|
||||
if (detail !== "preview" && detail !== "original" && detail !== "crop") throw new Error("请指定 preview、original 或 crop 看图方式。");
|
||||
const crop = args.crop as { x: number; y: number; width: number; height: number } | undefined;
|
||||
const image = await inspectAgentImage({ storageKey: node.metadata?.storageKey, dataUrl: node.metadata?.content }, detail, crop);
|
||||
if (readSignal.aborted) throw new DOMException("页面已关闭", "AbortError");
|
||||
if (snapshotRef.current.projectId !== current.projectId || !snapshotRef.current.nodes.some((item) => item.id === node.id && item.metadata?.storageKey === node.metadata?.storageKey && item.metadata?.content === node.metadata?.content)) throw new Error("画布素材已变化,请重新读取。");
|
||||
if ([...inspectedImagesRef.current.values()].reduce((sum, item) => sum + item.encodedBytes, image.encodedBytes) > 6 * 1024 * 1024) throw new Error("本轮看图预算已满,请先分析已读取图片,下一轮再读其他图片。");
|
||||
inspectedImagesRef.current.set(messageId, { title: `${node.title}(${detail}${crop ? ` ${JSON.stringify(crop)}` : ""})`, url: image.storageKey, encodedBytes: image.encodedBytes });
|
||||
return { ok: true, message: `已读取图片《${node.title}》,将以${detail === "preview" ? "构图预览" : detail === "crop" ? "原始像素局部" : "原图"}提供给模型。`, data: { nodeId: node.id, detail, crop } };
|
||||
}
|
||||
if (name === "canvas_get_state") return { ok: true, message: describeCanvasSnapshot(current), data: compactSnapshot(current) };
|
||||
if (name === "canvas_get_context") return { ok: true, message: "已读取语义化画布上下文。", data: buildCanvasAgentContext(current) };
|
||||
if (name === "canvas_find_nodes") return { ok: true, message: "已按条件检索真实节点。", data: findCanvasAgentNodes(current, args as Parameters<typeof findCanvasAgentNodes>[1]) };
|
||||
@@ -592,15 +668,16 @@ export function CanvasAssistantPanel({
|
||||
|
||||
const executeOnlineToolCalls = async (sessionId: string, toolCalls: ResponseToolCall[]) => {
|
||||
const results: OnlineExecutedToolCall[] = [];
|
||||
let stopped = false;
|
||||
let stopReason = "";
|
||||
for (const toolCall of toolCalls) {
|
||||
if (stopped) {
|
||||
results.push({ toolCallId: toolCall.id, name: toolCall.function.name, result: { ok: false, message: "前一个工具调用失败,未继续执行。" } });
|
||||
if (stopReason) {
|
||||
results.push({ toolCallId: toolCall.id, name: toolCall.function.name, result: { ok: false, message: stopReason } });
|
||||
continue;
|
||||
}
|
||||
const result = await executeOnlineToolCall(sessionId, toolCall);
|
||||
results.push(result);
|
||||
if (!result.result.ok) stopped = true;
|
||||
if (!result.result.ok) stopReason = "前一个工具调用失败,未继续执行。";
|
||||
else if (result.result.waitForUser) stopReason = "已打开待确认面板,同批后续操作暂不执行。";
|
||||
}
|
||||
return results;
|
||||
};
|
||||
@@ -642,7 +719,7 @@ export function CanvasAssistantPanel({
|
||||
const restored = onUndoOps();
|
||||
if (!restored) return;
|
||||
snapshotRef.current = restored;
|
||||
if (activeSession) appendMessage(activeSession.id, { id: nanoid(), role: "tool", title: "已撤销 Agent 批次", text: "已恢复到本次写回前的画布状态", detail: { status: "completed", remainingUndoCount: Math.max(0, undoOpsCount - 1) } });
|
||||
if (activeSession) appendMessage(activeSession.id, { id: nanoid(), role: "tool", title: "已撤销最近修改", text: "画布已恢复。已提交的生成任务和费用不受影响。", detail: { status: "completed", remainingUndoCount: Math.max(0, undoOpsCount - 1) } });
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
@@ -658,73 +735,10 @@ export function CanvasAssistantPanel({
|
||||
|
||||
useEffect(() => {
|
||||
if (!cinematicEntry) return;
|
||||
setCinematicEntryActive(true);
|
||||
setView("chat");
|
||||
setPrompt("");
|
||||
onCinematicEntryConsumed?.();
|
||||
}, [cinematicEntry, onCinematicEntryConsumed]);
|
||||
|
||||
const submitCinematicProject = async (text: string) => {
|
||||
const value = text.trim();
|
||||
if (!value || agentBusy) return;
|
||||
const requestConfig = { ...effectiveConfig, model: effectiveConfig.textModel || effectiveConfig.model };
|
||||
if (!isAiConfigReady(requestConfig, requestConfig.model)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
return;
|
||||
}
|
||||
const session = activeSession || createSession();
|
||||
if (!activeSession) {
|
||||
setLocalSessions([session]);
|
||||
setLocalActiveSessionId(session.id);
|
||||
}
|
||||
appendMessage(session.id, { id: nanoid(), role: "user", text: value });
|
||||
setPrompt("");
|
||||
setIsRunning(true);
|
||||
let backendSessionId = "";
|
||||
let continuationFailureDisposition: CinematicContinuationFailureDisposition | undefined;
|
||||
try {
|
||||
const cinematic = await runCinematicSession(session.id, value, snapshotRef.current, effectiveConfig, (createdId) => {
|
||||
backendSessionId = createdId;
|
||||
});
|
||||
const applyContinuation = async ({ effectKey, signal }: { effectKey?: string; signal?: AbortSignal } = {}) => {
|
||||
if (hasAgentGenerationEffect(session.id, effectKey)) return;
|
||||
await canvasCinematicContinuationEntryAdapters["submit-cinematic"]({
|
||||
projectId,
|
||||
effectKey,
|
||||
signal,
|
||||
readSnapshot: () => snapshotRef.current,
|
||||
executeOps: async () => {
|
||||
const next = await onApplyOps(cinematic.ops);
|
||||
snapshotRef.current = next;
|
||||
return next;
|
||||
},
|
||||
completeSession: (key) => completeCinematicSession(session.id, cinematic.backendSessionId, cinematic.ops, false, key),
|
||||
readLiveSessionState: readCinematicSessionState,
|
||||
restoreLiveSessions: restoreCinematicSessions,
|
||||
restoreLiveSnapshot: restoreCinematicSnapshot,
|
||||
failProvider: (failure) => failCinematicSession(session.id, cinematic.backendSessionId, failure),
|
||||
onFailureDisposition: (disposition) => {
|
||||
continuationFailureDisposition = disposition;
|
||||
},
|
||||
});
|
||||
setCinematicEntryActive(false);
|
||||
};
|
||||
if (cinematic.continuationTask) {
|
||||
await consumeGenerationTaskAgent(cinematic.continuationTask, cinematic.backendSessionId, applyContinuation, { signal: generationConsumerControllerRef.current.signal });
|
||||
} else {
|
||||
await applyContinuation();
|
||||
}
|
||||
} catch (error) {
|
||||
if (continuationFailureDisposition) return;
|
||||
handleCinematicContinuationFailure(error, (failure) => {
|
||||
if (backendSessionId) failCinematicSession(session.id, backendSessionId, failure);
|
||||
else appendMessage(session.id, { id: nanoid(), role: "error", title: "影视项目生成失败", text: failure instanceof Error ? failure.message : "影视项目生成失败" });
|
||||
});
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resumePendingCinematicSession = async (sessionId: string, pending: CanvasAssistantPendingBackendSession) => {
|
||||
if (cinematicSessionControllersRef.current.has(pending.id)) return;
|
||||
const controller = new AbortController();
|
||||
@@ -811,13 +825,29 @@ export function CanvasAssistantPanel({
|
||||
<div ref={chatListRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||
{messages.length ? (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="space-y-2">
|
||||
<AgentChatMessage item={assistantMessageToChatMessage(message)} theme={theme} user={user} isStreaming={agentBusy && message.id === messages.at(-1)?.id && message.role === "assistant"} onRejectTool={rejectOnlineTool} onApproveTool={approveOnlineTool} onQuickAction={submitQuickAction} />
|
||||
{messages.map((message, index) => (
|
||||
<Fragment key={message.id}>
|
||||
{agentBusy && index === messages.findLastIndex((item) => item.role === "user") + 1 ? <div className="creative-agent-thinking-wrap"><AgentWorkingMessage theme={theme} /></div> : null}
|
||||
<div className={message.role === "tool" ? "agent-tool-message" : "space-y-2"}>
|
||||
<AgentChatMessage item={assistantMessageToChatMessage(message)} theme={theme} user={user} isStreaming={agentBusy && message.id === messages.at(-1)?.id && message.role === "assistant" && !canvasCreativeDetail(message)} onRejectTool={rejectOnlineTool} onApproveTool={approveOnlineTool} onQuickAction={submitQuickAction} />
|
||||
{canvasCreativeDetail(message) && <CanvasCreativeInteraction
|
||||
message={message} sessionId={activeSession!.id} active={message.id === activeCreativeId && !isRunning && agentMode === "online"} config={effectiveConfig}
|
||||
superseded={message.id !== activeCreativeId}
|
||||
canvas={{ canvasId: projectId, read: () => snapshotRef.current, apply: async (ops) => { const next = await onApplyOps(ops, { source: "online", conversationId: activeSession!.id, messageId: message.id }); snapshotRef.current = next; return next; } }}
|
||||
onUpdate={(next) => upsertMessage(activeSession!.id, next)}
|
||||
onContinue={(text) => { void sendMessage(text, localSessionsRef.current.find((session) => session.id === activeSession!.id)?.messages || []); }}
|
||||
onReview={() => { void sendMessage("继续当前创作,在我已授权的范围内完成下一步。根据真实执行结果保留已完成作品,必要时自行读取素材;只有缺少关键信息或需要新的方案、费用确认时再停下来。", localSessionsRef.current.find((session) => session.id === activeSession!.id)?.messages || []); }}
|
||||
onEditPrompt={(text) => {
|
||||
setPrompt(text);
|
||||
requestAnimationFrame(() => composerRef.current?.querySelector<HTMLElement>('textarea, [contenteditable="true"]')?.focus());
|
||||
}}
|
||||
onBusy={(id, busy) => setCreativeBusy((current) => current[id] === busy ? current : { ...current, [id]: busy })}
|
||||
/>}
|
||||
{message.references?.length ? <MessageReferences message={message} /> : null}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{agentBusy ? <AgentWorkingMessage theme={theme} /> : null}
|
||||
{agentBusy && messages.findLastIndex((item) => item.role === "user") === messages.length - 1 ? <div className="creative-agent-thinking-wrap"><AgentWorkingMessage theme={theme} /></div> : null}
|
||||
</>
|
||||
) : (
|
||||
<AgentChatEmptyState
|
||||
@@ -835,8 +865,8 @@ export function CanvasAssistantPanel({
|
||||
{view === "chat" ? (
|
||||
<>
|
||||
{selectedReferences.length ? (
|
||||
<div className="thin-scrollbar flex max-w-full items-center gap-1.5 overflow-x-auto px-3 py-1.5">
|
||||
<span className="shrink-0 text-[var(--fs-micro)] font-medium opacity-55">参考 · {selectedReferences.length}</span>
|
||||
<div className="thin-scrollbar flex max-w-full items-center gap-1.5 overflow-x-auto px-3 pb-1" role="group" aria-label="待发送引用">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">待发送引用</span>
|
||||
{selectedReferences.map((item, index) => (
|
||||
<AssistantReferenceChip
|
||||
key={item.id}
|
||||
@@ -859,28 +889,28 @@ export function CanvasAssistantPanel({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{currentCreativeState && <div className="creative-agent-plan-docked" data-canvas-no-zoom data-canvas-wheel-scroll>
|
||||
<CreativePlanBar plan={creativePlan(currentCreativeState)} onLocateNode={(id) => onSelectNodeIds(new Set([id]))} />
|
||||
</div>}
|
||||
<div ref={composerRef}>
|
||||
<AgentChatComposer
|
||||
prompt={prompt}
|
||||
sending={agentBusy}
|
||||
placeholder={cinematicEntryActive ? "一句话描述题材、角色和核心冲突" : "描述你想让 Agent 如何操作画布"}
|
||||
placeholder={currentCreativeState?.modificationRequested ? "在这里告诉我想改哪里,调整后再确认…" : "描述创作需求,或告诉我如何调整画布…"}
|
||||
theme={theme}
|
||||
references={composerReferences}
|
||||
slashSkills={composerSkills}
|
||||
onPromptChange={setPrompt}
|
||||
onSubmit={cinematicEntryActive ? () => submitCinematicProject(prompt) : submit}
|
||||
onSubmit={submit}
|
||||
onAddFiles={addImagesToCanvas}
|
||||
left={
|
||||
<>
|
||||
<VoiceRecordingButton disabled={agentBusy} onTranscribed={(text) => setPrompt((prev) => (prev.trim() ? `${prev} ${text}` : text))} />
|
||||
<AgentTextModelPicker config={effectiveConfig} value={effectiveConfig.textModel} onChange={(model) => updateConfig("textModel", model)} />
|
||||
{cinematicEntryActive ? (
|
||||
<span className="ml-2 inline-flex h-6 items-center rounded-md px-2 text-[var(--fs-tiny)] font-medium" style={{ background: theme.spatial.surface, color: theme.node.muted }}>
|
||||
影视项目
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
||||
192
web/src/components/canvas/canvas-creative-interaction.tsx
Normal file
192
web/src/components/canvas/canvas-creative-interaction.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Dropdown } from "antd";
|
||||
import localforage from "localforage";
|
||||
import type { CanvasAssistantMessage } from "@/types/canvas";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { assertCreativeBriefSpecifications, initialCreativeState, normalizeCreativeProposal, type CreativeAgentState } from "@/lib/creation/creative-agent-state";
|
||||
import type { CreativeAnswers, CreativeQuote } from "@/lib/creation/creative-agent-contract";
|
||||
import { CreativeAgentController, type CreativeCanvasAdapter, type CreativeControllerView } from "@/services/creative-agent-controller";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { CreativeProposalCard, CreativeQuestionCard, CreativeQuoteCard } from "@/components/creation/creative-agent-cards";
|
||||
import { snapshotCreativePlan } from "@/lib/creation/creative-plan";
|
||||
|
||||
export type CanvasCreativeDetail = {
|
||||
kind: "creative-interaction";
|
||||
input: Record<string, unknown>;
|
||||
runId?: string;
|
||||
status?: string;
|
||||
quotes?: CreativeQuote[];
|
||||
approvedProposalVersion?: number;
|
||||
state: CreativeAgentState;
|
||||
};
|
||||
export function canvasCreativeDetail(message: CanvasAssistantMessage): CanvasCreativeDetail | undefined {
|
||||
const detail = message.detail as Partial<CanvasCreativeDetail> | undefined;
|
||||
return detail?.kind === "creative-interaction" && detail.state?.schemaVersion === 1 ? detail as CanvasCreativeDetail : undefined;
|
||||
}
|
||||
|
||||
// 历史中的失败方案也按当前模型目录校验,将真实错误反馈给原 Agent,而不是丢掉失败的工具参数。
|
||||
export function creativeProposalFailure(detail: CanvasCreativeDetail, config: AiConfig): string | undefined {
|
||||
if (!detail.input.proposal || detail.state.proposal || detail.state.questions?.questions.length) return;
|
||||
try {
|
||||
const proposal = normalizeCreativeProposal(detail.input.proposal, "validation", 1, config, detail.state.references);
|
||||
assertCreativeBriefSpecifications(proposal, detail.state.brief);
|
||||
} catch (cause) {
|
||||
return cause instanceof Error ? cause.message : "方案结构未通过校验";
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
message: CanvasAssistantMessage; sessionId: string; active: boolean; superseded?: boolean; config: AiConfig; canvas: CreativeCanvasAdapter;
|
||||
onUpdate: (message: CanvasAssistantMessage) => void;
|
||||
onContinue: (text: string) => void;
|
||||
onReview: () => void;
|
||||
onEditPrompt: (text: string) => void;
|
||||
onBusy: (id: string, busy: boolean) => void;
|
||||
};
|
||||
|
||||
// 嵌入原助手消息,不拥有输入框、会话列表或模型选择。detail 保存回看快照,run 保存执行事实。
|
||||
export function CanvasCreativeInteraction(props: Props) {
|
||||
const detail = canvasCreativeDetail(props.message)!;
|
||||
const [view, setView] = useState<CreativeControllerView>({ state: detail.state, busy: false, hasControl: false });
|
||||
const [error, setError] = useState("");
|
||||
const [answers, setAnswers] = useState<CreativeAnswers>(detail.state.answers || {});
|
||||
const controller = useRef<CreativeAgentController | null>(null);
|
||||
const latest = useRef(props); latest.current = props;
|
||||
const saved = useRef("");
|
||||
const quoteHistory = useRef<CreativeQuote[]>(detail.quotes || []);
|
||||
const answersEdited = useRef(false);
|
||||
const presentationAttempted = useRef(false);
|
||||
const hadControl = useRef(false);
|
||||
const scope = getActiveUserScope();
|
||||
const invoke = (promise?: Promise<unknown>) => { if (promise) { setError(""); void promise.catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(cause instanceof Error ? cause.message : "操作未完成"); }); } };
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
presentationAttempted.current = false;
|
||||
hadControl.current = false;
|
||||
const original = canvasCreativeDetail(latest.current.message)!;
|
||||
setView({ state: original.state, busy: false, hasControl: false });
|
||||
const instance = new CreativeAgentController({
|
||||
clientKey: `canvas-chat:${props.sessionId}:${props.message.id}`,
|
||||
config: () => latest.current.config,
|
||||
canvas: () => latest.current.canvas,
|
||||
onOpenCanvas: () => setError("当前会话仅操作所在画布,请在对应画布打开历史会话。"),
|
||||
onChange: (next) => {
|
||||
if (!live) return;
|
||||
// Recovery clears the controller error and the rejected action's local UI error together.
|
||||
if (next.hasControl && !hadControl.current && !next.error) setError("");
|
||||
hadControl.current = next.hasControl;
|
||||
setView(next); latest.current.onBusy(props.message.id, next.busy);
|
||||
if (!next.run || !latest.current.active) return;
|
||||
if (next.quote) quoteHistory.current = [...quoteHistory.current.filter((quote) => quote.id !== next.quote!.id), next.quote];
|
||||
const nextDetail: CanvasCreativeDetail = { kind: "creative-interaction", input: original.input, state: next.state, runId: next.run.id, status: next.run.status, quotes: quoteHistory.current, approvedProposalVersion: next.run.approvedProposalVersion };
|
||||
const signature = JSON.stringify(nextDetail);
|
||||
if (signature !== saved.current) {
|
||||
saved.current = signature;
|
||||
latest.current.onUpdate({ ...latest.current.message, detail: nextDetail });
|
||||
}
|
||||
},
|
||||
});
|
||||
controller.current = instance;
|
||||
const initialize = async () => {
|
||||
if (!original.runId && !props.active) return;
|
||||
await instance.load(original.runId, original.state, !props.active);
|
||||
};
|
||||
invoke(initialize());
|
||||
return () => { live = false; instance.dispose(); latest.current.onBusy(props.message.id, false); if (controller.current === instance) controller.current = null; };
|
||||
}, [props.message.id, props.sessionId, props.canvas.canvasId, props.active, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.active || !view.hasControl || view.busy || view.state.externalInteractionPresented || presentationAttempted.current) return;
|
||||
presentationAttempted.current = true;
|
||||
invoke(controller.current?.presentInteraction(detail.input));
|
||||
}, [props.active, view.hasControl, view.busy, view.state.externalInteractionPresented]);
|
||||
|
||||
const question = view.state.questions;
|
||||
const answerKey = `canvas-creative-answers:${scope}:${props.sessionId}:${props.message.id}:${question?.interactionId || ""}`;
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
answersEdited.current = false;
|
||||
setAnswers(view.state.answers || {});
|
||||
if (question?.status === "pending") void localforage.getItem<CreativeAnswers>(answerKey).then((value) => { if (live && !answersEdited.current && value) setAnswers(value); }).catch(() => undefined);
|
||||
return () => { live = false; };
|
||||
}, [answerKey, question?.status]);
|
||||
const blocked = !props.active || !view.hasControl || view.busy;
|
||||
const status = view.run?.status || detail.status;
|
||||
const awaitingProposal = status === "waiting_proposal" && !view.state.modificationRequested && !view.state.pendingEdits;
|
||||
const proposalFailure = creativeProposalFailure({ ...detail, state: view.state }, props.config);
|
||||
const modify = (text: string) => {
|
||||
if (!props.active) { props.onEditPrompt(text); return; }
|
||||
invoke(controller.current?.requestModification().then(() => props.onEditPrompt(text)));
|
||||
};
|
||||
const continueWork = async () => {
|
||||
const instance = controller.current;
|
||||
if (!instance) return;
|
||||
await instance.takeControl();
|
||||
if (proposalFailure) { props.onContinue("请根据当前真实素材和校验反馈调整方案。能自行修正的请修正,需要我提供信息时请直接提问,仍须确认后才制作。"); return; }
|
||||
if (detail.input.proposal && !view.state.proposal) { await instance.presentInteraction(detail.input, true); return; }
|
||||
if (view.quote && Date.parse(view.quote.expiresAt || "") <= Date.now()) { await instance.refreshQuotes(); return; }
|
||||
if (["running", "paused", "waiting_canvas", "waiting_task"].includes(status || "")) { await instance.resume(); return; }
|
||||
props.onContinue("保留已确认的要求,在已授权范围内继续当前任务,自行处理常规选择和技术问题;确实阻塞时再询问。");
|
||||
};
|
||||
const rawError = proposalFailure || error || view.error;
|
||||
const failedGenerations = view.state.media.filter((item) => item.status === "failed" && item.failureKind !== "observation");
|
||||
const onlyFailedRemaining = failedGenerations.length > 0 && !view.state.media.some((item) => ["queued", "running", "write_failed"].includes(item.status) || item.failureKind === "observation" && item.status === "failed");
|
||||
const feedback = onlyFailedRemaining ? "部分作品生成失败。请在失败作品下方重新生成,先核对费用;已成功的作品会保留。" : rawError ? /恢复|连接|网络|接管|执行权|代次/.test(rawError) ? "状态读取未完成,可以继续处理重试。已有作品会保留。"
|
||||
: /报价|费用|价格|过期/.test(rawError) ? "生成费用需要重新确认,继续后会显示最新价格。"
|
||||
: /素材|参考|引用/.test(rawError) ? "参考素材还需要核对,助手会继续处理,必要时请你选择。"
|
||||
: "这一步还没有完成。已有内容已保留,可以继续处理或修改要求。" : undefined;
|
||||
const submitAnswers = async (submitted: CreativeAnswers) => {
|
||||
await controller.current!.answer(submitted, false);
|
||||
await localforage.removeItem(answerKey).catch(() => undefined);
|
||||
const summary = question!.questions.map((item) => {
|
||||
const answer = submitted[item.id];
|
||||
const options = item.type === "asset" ? view.state.references.map((ref) => ({ id: ref.id, label: ref.title })) : item.options;
|
||||
return `${item.title}:${[...(answer?.selected || []).map((id) => options.find((option) => option.id === id)?.label || id), answer?.custom].filter(Boolean).join("、") || "由助手建议"}`;
|
||||
}).join("\n");
|
||||
latest.current.onContinue(summary);
|
||||
};
|
||||
|
||||
return <div className="space-y-3" data-canvas-no-zoom data-canvas-wheel-scroll>
|
||||
{question && <CreativeQuestionCard request={props.superseded && question.status === "pending" ? { ...question, status: "superseded" } : question} answers={answers} assets={view.state.references.map((ref) => ({ id: ref.id, label: ref.title }))} busy={view.busy} disabled={!props.active || !view.hasControl} onModify={() => modify(`我想修改之前的回答(${question.questions.map((item) => item.title).join("、")}):`)} onAnswersChange={(next) => { answersEdited.current = true; setAnswers(next); invoke(localforage.setItem(answerKey, next)); }} onSubmit={submitAnswers} />}
|
||||
{view.state.proposal && <CreativeProposalCard proposal={view.state.proposal} archived={!props.active || !awaitingProposal} modifiable={!view.busy} disabled={props.active ? blocked : false} busy={view.busy} onApprove={() => invoke(controller.current?.approveProposal())} onModify={() => modify(`请修改方案《${view.state.proposal!.title}》:`)} onRedirect={() => modify("保留已确认的要求和素材,换一个创意方向。")} />}
|
||||
{view.state.pendingEdits && <div className="creative-agent-card"><p>将调整 {view.state.pendingEdits.length} 个节点的指定字段。</p><Button disabled={blocked} onClick={() => invoke(controller.current?.approveEdits())}>确认节点调整</Button></div>}
|
||||
{props.active && view.quote && <CreativeQuoteCard quote={view.quote} busy={view.busy} disabled={blocked} onApprove={() => invoke(controller.current?.approvePayment())} onRefresh={() => invoke(controller.current?.refreshQuotes())} />}
|
||||
{(detail.quotes || []).filter((quote) => !props.active || quote.id !== view.quote?.id).map((quote) => <details key={quote.id} className="creative-agent-receipt"><summary>{quote.approvedQuantity ? "已确认生成" : "历史报价"} · {quote.amountLabel} · {quote.items.length} 项</summary><p>{quote.basis}</p><ul>{quote.items.map((item) => <li key={item.id}>{item.label} · {item.model} · {item.specification}</li>)}</ul></details>)}
|
||||
{view.state.media.filter((item) => item.taskId || item.storageKey || item.error).map((item) => {
|
||||
const resource = resourceIdFromStorageKey(item.storageKey), url = resource ? resourceFileUrl(resource) : "";
|
||||
const video = view.state.proposal?.generationItems.find((entry) => entry.ref === item.ref)?.mode === "video";
|
||||
return <div className="creative-agent-card" key={item.ref}>
|
||||
<strong>{view.state.proposal?.workflow.nodes.find((node) => node.ref === item.ref)?.title || item.ref}</strong>
|
||||
{!item.error && !url && item.taskId && <p className="creative-agent-muted">{view.busy ? item.status === "running" ? "正在生成,完成后自动显示。" : "已提交,正在等待生成结果。" : "任务已提交。点击下方“继续处理”读取最新结果,无需重复确认费用。"}</p>}
|
||||
{url && (video ? <video className="w-full max-h-80 object-contain" src={url} controls preload="metadata" /> : <img className="w-full max-h-80 object-contain" src={url} alt={item.ref} />)}
|
||||
{item.error && <p className="creative-agent-muted">这项作品还需要处理,已有结果会保留。</p>}
|
||||
{item.error && <details className="creative-agent-receipt"><summary>查看失败原因</summary><p>{item.error}</p></details>}
|
||||
{props.active && item.status === "failed" && item.failureKind !== "observation" && <Button type="primary" disabled={blocked} onClick={() => invoke(controller.current?.redo(item.ref))}>重新生成此项 · 先确认费用</Button>}
|
||||
<div className="creative-agent-actions">{url && <a href={url} target="_blank" rel="noreferrer">查看作品</a>}{props.active && <Dropdown trigger={["click"]} disabled={blocked} menu={{ items: [{ key: "adjust", label: "调整作品" }, { key: "redo", label: "重新生成(费用另行确认)" }], onClick: ({ key }) => key === "redo" ? invoke(controller.current?.redo(item.ref)) : modify(`请调整《${view.state.proposal?.workflow.nodes.find((entry) => entry.ref === item.ref)?.title || item.ref}》,保留其他作品:`) }}><Button type="text" disabled={blocked}>更多</Button></Dropdown>}</div>
|
||||
</div>;
|
||||
})}
|
||||
{!props.active && rawError && <details className="creative-agent-receipt"><summary>这一步未完成,后续对话中可继续调整</summary><p>{feedback}</p></details>}
|
||||
{props.active && <div className="creative-agent-next" role="status">
|
||||
<p>{view.busy ? "正在处理,请稍候…" : !view.hasControl ? rawError ? "暂时无法恢复当前创作,可以重试连接。已有作品会保留。" : "正在恢复当前创作,请稍候…" : view.state.modificationRequested ? "在下方告诉我想改哪里,调整后再确认。" : feedback || (view.quote ? "确认费用后,就开始本批制作。" : awaitingProposal ? "看看方案是否符合你的想法,确认后继续。" : question?.status === "pending" ? "选择一个答案,也可以在下方直接补充。" : status === "completed" ? "本阶段已完成。可以查看作品,或告诉我想改哪里。" : "准备好了,可以继续。")}</p>
|
||||
<div className="creative-agent-actions">
|
||||
{!view.busy && !view.hasControl && <Button onClick={() => invoke(view.run ? controller.current?.takeControl() : controller.current?.load(detail.runId, detail.state))}>重试连接</Button>}
|
||||
{view.busy && ["running", "waiting_task", "waiting_canvas"].includes(status || "") ? <Button onClick={() => invoke(controller.current?.pause())}>停止后续制作</Button>
|
||||
: !view.busy && view.hasControl && !view.state.modificationRequested && !view.quote && !awaitingProposal && !view.state.pendingEdits && question?.status !== "pending" ? <>
|
||||
{!onlyFailedRemaining && <Button type="primary" onClick={() => status === "completed" && !feedback ? props.onReview() : invoke(continueWork())}>{status === "completed" && !feedback ? "继续创作" : "继续处理"}</Button>}
|
||||
<Button onClick={() => modify("我想调整当前创作要求:")}>修改</Button>
|
||||
</> : null}
|
||||
</div>
|
||||
{status === "paused" && <small className="creative-agent-muted">已停止后续制作。已提交的生成可能仍在处理,已有作品不会删除。</small>}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function creativeInteractionSeed(history: CanvasAssistantMessage[]): CreativeAgentState {
|
||||
const previous = history.findLast((message) => canvasCreativeDetail(message));
|
||||
const state = previous ? canvasCreativeDetail(previous)!.state : initialCreativeState();
|
||||
return { ...initialCreativeState(), scene: state.scene, brief: state.brief, references: state.references, dynamicPlan: snapshotCreativePlan(state),
|
||||
messages: history.filter((message) => message.role === "user" || message.role === "assistant").slice(-30).map((message) => ({ id: message.id, role: message.role as "user" | "assistant", text: message.text })),
|
||||
};
|
||||
}
|
||||
@@ -310,7 +310,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
props.onDrop?.(event as unknown as React.DragEvent<HTMLTextAreaElement>);
|
||||
}}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
|
||||
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current || event.keyCode === 229)) return;
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
@@ -413,7 +413,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
props.onCompositionEnd?.(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
|
||||
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current || event.keyCode === 229)) return;
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -393,7 +393,7 @@ export function CanvasScriptNodeContent({
|
||||
className="thin-scrollbar h-full min-h-0 w-full touch-pan-y resize-none overflow-y-auto overflow-x-hidden overscroll-contain rounded-md border bg-transparent px-3 py-2 text-sm leading-5 outline-none transition placeholder:opacity-45 focus:ring-1"
|
||||
style={{ borderColor: theme.node.stroke, color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties}
|
||||
value={prompt}
|
||||
placeholder="描述想生成的脚本或视频内容"
|
||||
placeholder="输入剧本或剧情,以及总时长等要求,自动拆分为镜头"
|
||||
onContentSizeChange={resizePrompt}
|
||||
onChange={(value) => {
|
||||
setPrompt(value);
|
||||
@@ -447,11 +447,11 @@ export function CanvasScriptNodeContent({
|
||||
value={shotDuration}
|
||||
disabled={node.metadata?.status === "loading"}
|
||||
options={[
|
||||
{ value: "auto", label: "时长自动" },
|
||||
{ value: "5", label: "5 秒" },
|
||||
{ value: "10", label: "10 秒" },
|
||||
{ value: "15", label: "15 秒" },
|
||||
{ value: "30", label: "30 秒" },
|
||||
{ value: "auto", label: "单镜时长自动" },
|
||||
{ value: "5", label: "每镜 5 秒" },
|
||||
{ value: "10", label: "每镜 10 秒" },
|
||||
{ value: "15", label: "每镜 15 秒" },
|
||||
{ value: "30", label: "每镜 30 秒" },
|
||||
]}
|
||||
popupMatchSelectWidth={false}
|
||||
onChange={onShotDurationChange}
|
||||
|
||||
@@ -655,7 +655,7 @@ function EmptyStyleCenter({ tab, loading, failed, color, onCreate, onBrowse }: {
|
||||
return <div className="col-span-full grid min-h-64 place-items-center text-center"><div><Search className="mx-auto size-5" style={{ color }} /><p className="mt-2 text-xs font-medium">{loading ? "正在加载风格库" : failed && tab !== "system" ? "我的风格加载失败,请检查登录状态或后端服务" : tab === "mine" ? "还没有自己的风格" : tab === "favorites" ? "还没有收藏风格" : tab === "recent" ? "还没有使用记录" : "没有匹配的系统风格"}</p><div className="mt-3 flex justify-center gap-2">{tab === "mine" && !failed ? <Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreate}>新建风格</Button> : null}<Button size="small" onClick={onBrowse}>浏览系统风格</Button></div></div></div>;
|
||||
}
|
||||
|
||||
function userStylePreset(entity: UserStyleProfile): CanvasStylePreset | null {
|
||||
export function userStylePreset(entity: UserStyleProfile): CanvasStylePreset | null {
|
||||
const profile = parseStyleProfile(entity.profileJson);
|
||||
if (!profile) return null;
|
||||
return { id: profile.presetId, title: profile.title, category: "我的风格", description: profile.description, tags: [...profile.tags], prompt: profile.prompt, imageUrl: profile.coverUrl || entity.coverUrl || "/short-drama-styles/real-life.jpg", profile };
|
||||
|
||||
Reference in New Issue
Block a user