69 lines
7.9 KiB
TypeScript
69 lines
7.9 KiB
TypeScript
/**
|
|
* 旧版画布的节点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<typeof useCanvasAI>; save: (patch: NonNullable<CanvasNode["data"]["ai"]>) => void }) {
|
|
// 配置只用初次挂载的 node 初始化,不随外部 data.ai 自动重置;切换目标时应由宿主重建/隔离面板。
|
|
const [models, setModels] = useState<CanvasModel[]>([]);
|
|
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 是 React 状态,按钮禁用在重渲染后生效,不是函数入口的同步互斥标记。
|
|
// 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。 */
|
|
// 每次保存都显式 parameters:{},不会保留节点先前的自定义参数。
|
|
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 <NodeToolbar nodeId={id} isVisible position={Position.Bottom} offset={26} style={{ zIndex: 110 }}><section className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具"
|
|
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
|
<header><Sparkles size={14} /><strong>{image ? "图像创作" : "文本创作"}</strong><span>{node.data.title}</span></header>
|
|
<textarea aria-label="AI 指令" placeholder={image ? "描述你希望生成或修改的画面…" : "希望如何处理当前内容?"} value={prompt} maxLength={16000} onChange={(event) => setPrompt(event.target.value)} onBlur={persist} />
|
|
<footer><select aria-label="AI 模型" disabled={loading || !ai.live || busy} value={model} onChange={(event) => { setModel(event.target.value); if (ai.live) save({ ...config(), modelId: event.target.value }); }}>{!model && <option value="">{loading ? "正在读取模型…" : "暂无可用模型"}</option>}{models.map((model) => <option key={model.id} value={model.id} disabled={!model.available}>{model.label || model.display_name || model.id}{!model.available ? " · 不可用" : ""}</option>)}</select>
|
|
<label><input type="checkbox" aria-label="继续向下传播" disabled={!!busy} checked={propagate} onChange={(event) => { setPropagate(event.target.checked); if (model && ai.live) save({ ...config(), propagate: event.target.checked }); }} />继续向下传播</label>
|
|
{busy && run ? <button aria-label="取消生成" title="取消生成" onClick={() => void ai.cancel(run).catch((error) => setError(errorText(error)))}><Square size={15} /></button> : <button aria-label="执行 AI" title="在当前元素生成结果" disabled={!!node.pendingUpload || starting || !ai.live || !model || loading || !models.some((item) => item.id === model && item.available)} onClick={async () => {
|
|
setStarting(true); setError(""); try { await ai.run(boardId, node.id, model, prompt, propagate); } catch (error) { setError(errorText(error)); } finally { setStarting(false); }
|
|
}}><ArrowUp size={18} /></button>}
|
|
</footer>
|
|
<p role="status">{node.pendingUpload ? "图片正在上传,可继续编辑;上传完成后即可生成" : !ai.live ? "演示画布暂不执行生成" : ai.conflicts.has(boardId) ? "生成已完成,本地草稿存在冲突,尚未替换当前内容" : busy ? "正在生成,结果将显示在当前元素…" : step ? ({ succeeded: "已更新当前元素", conflict: "内容已改变,生成结果已保留", failed: "生成未完成,原内容已保留", cancelled: "已取消生成", skipped: "此步骤未执行" }[step.status] || step.status) : "结果将显示在当前元素"}</p>
|
|
{(step?.status === "conflict" || ai.conflicts.has(boardId)) && output && run && <div className={styles.aiConflict}>{textOutput && <pre>{textOutput}</pre>}{output.src && <img src={managedImageSource(output.src)} alt="待应用的生成结果" />}<button onClick={() => void ai.apply(run, node.id, output.output_id, boardId).catch((error) => setError(errorText(error)))}>使用此结果替换当前内容</button></div>}
|
|
{(error || ai.error) && <p role="alert">{error || errorText(new Error(ai.error))}</p>}
|
|
</section></NodeToolbar>;
|
|
}
|