Files
Endoleap/src/components/canvas/ai-panel.tsx
T

52 lines
5.9 KiB
TypeScript

"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";
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 }) {
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);
const busy = starting || step?.status === "queued" || step?.status === "running";
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]);
const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} });
const persist = () => { if (model && ai.live) save(config()); };
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;
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={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">{!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>;
}