/** * 旧版画布的节点AI浮层。通过useCanvasAI返回的控制接口管理生成,模型目录由旧画布AI接口读取。 * 配置草稿保存在本地state,save回调将配置写回旧节点;本组件不承担新版ledger调度与恢复。 */ "use client"; import { useEffect, useState } from "react"; import { NodeToolbar, Position } from "@xyflow/react"; import { ArrowUp, Square, Sparkles } from "lucide-react"; import { api, errorText } from "@/lib/api"; import { canvasPath } from "@/lib/canvas-sync"; import { CANVAS_ACCEPT, managedImageSource } from "@/lib/canvas-v2"; import type { CanvasNode } from "@/lib/canvas"; import type { CanvasModel, useCanvasAI } from "./use-canvas-ai"; import styles from "./canvas.module.css"; /** 以boardId+node.id定位run步骤,列表最后一项作为当前任务;NodeToolbar将面板附着在显示节点下方。 */ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string; node: CanvasNode; workspace: string; boardId: string; ai: ReturnType; save: (patch: NonNullable) => void }) { const [models, setModels] = useState([]); const [model, setModel] = useState(node.data.ai?.modelId || ""); const [prompt, setPrompt] = useState(node.data.ai?.prompt || ""); const [propagate, setPropagate] = useState(node.data.ai?.propagate || false); const [error, setError] = useState(""); const [loading, setLoading] = useState(true); const [starting, setStarting] = useState(false); const image = ["image", "plain-image"].includes(node.data.kind); const relevant = [...ai.runs.values()].filter((run) => run.steps?.some((step) => step.board_id === boardId && step.node_id === node.id)); const run = relevant.at(-1), step = run?.steps?.find((step) => step.board_id === boardId && step.node_id === node.id); // starting防止本地重复启动,之后以步骤queued/running判断生成忙碌;这不是整份画布的同步锁。 const busy = starting || step?.status === "queued" || step?.status === "running"; // 演示/非live路径不请求模型;真实路径按输出模态筛选,并保留仍兼容选择或回退到首个可用模型。 // 清理时abort,失败和finally检查signal再更新;成功分支直接采用api返回的结果。 useEffect(() => { if (!ai.live) { setLoading(false); return; } const controller = new AbortController(); setLoading(true); setError(""); api<{ models: CanvasModel[] }>(canvasPath(workspace, boardId) + "/ai/models", { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } }).then((result) => { const compatible = result.models.filter((model) => model.output_modalities.includes(image ? "image" : "text")); setModels(compatible); setModel((id) => compatible.some((model) => model.id === id) ? id : compatible.find((model) => model.available)?.id || ""); }).catch((error) => { if (!controller.signal.aborted) setError(errorText(error)); }).finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); }, [workspace, boardId, image, ai.live]); /** 构造当前输入配置,参数固定为空对象;状态切换事件需显式替换刚改变的值,避免读取旧React state。 */ const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} }); /** 输入blur时,有模型且live才调用save;仅持久化配置,不启动服务端任务。 */ const persist = () => { if (model && ai.live) save(config()); }; // output取当前步骤资源,文本内容则从最后一个step.output事件取;两者按同一board/node限定。 const output = run?.outputs?.find((item) => item.step_id === step?.step_id)?.output; const textOutput = run?.events?.filter((event) => event.type === "step.output" && event.node_id === node.id && event.board_id === boardId).at(-1)?.output?.text; // 模型和传播开关变化即时save,正文blur保存;执行由ai.run负责,starting在finally复位。 // 上传中、演示或没有可用模型禁止启动;与新版不同,此处未要求prompt非空,也未在busy时禁用textarea。 // 冲突时保留结果并给显式apply入口,错误优先显示本面板错误;关闭/取消任务的行为交由父层与ai接口决定。 return
event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
{image ? "图像创作" : "文本创作"}{node.data.title}