60 lines
7.3 KiB
TypeScript
60 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { ArrowUp, Sparkles, Square, X } from "lucide-react";
|
|
import type { ElementRecord, Json, Result } from "@/lib/canvas-core/types";
|
|
import { runContainsTarget } from "@/lib/canvas-runtime/runs";
|
|
import type { RunModel } from "@/lib/canvas-runtime/runs";
|
|
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
|
import { errorText } from "@/lib/api";
|
|
import { managedImageSource } from "@/lib/canvas-v2";
|
|
import styles from "./canvas.module.css";
|
|
|
|
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
export function HeadlessAIPanel({ engine, element, close }: { engine: ProjectEngine; element: ElementRecord; close(): void }) {
|
|
const saved = object(element.data.ai), root = engine.getSnapshot();
|
|
const [models, setModels] = useState<readonly RunModel[]>([]), [loading, setLoading] = useState(true), [error, setError] = useState('');
|
|
const [model, setModel] = useState(typeof saved.modelId === 'string' ? saved.modelId : '');
|
|
const [prompt, setPrompt] = useState(typeof saved.prompt === 'string' ? saved.prompt : '');
|
|
const [propagate, setPropagate] = useState(saved.propagate === true);
|
|
const image = ['image','plain-image'].includes(element.componentId);
|
|
const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element));
|
|
const run = relevant.at(-1);
|
|
const starts = [...root.runtime.ledger.operations.values()].filter(record => record.intent.operationType === 'run.start' && record.intent.run.target.id === element.id && record.intent.run.target.incarnation === element.incarnation);
|
|
const pending=starts.filter(record=>record.phase!=='resolved'), rejectedStart=starts.at(-1)?.resolution==='not-applied'?starts.at(-1):undefined;
|
|
const actions=[...root.runtime.ledger.operations.values()].filter(record=>(record.intent.operationType==='run.cancel'||record.intent.operationType==='run.apply')&&record.intent.action.runId===run?.id&&record.phase!=='resolved');
|
|
const cancelling=actions.some(record=>record.intent.operationType==='run.cancel'),applying=actions.some(record=>record.intent.operationType==='run.apply');
|
|
const busy = pending.length > 0 || actions.length > 0 || !!run && (run.hasMore || !!run.pendingPage || ['queued','running'].includes(run.status));
|
|
const outputEvent = run?.outputEvents.filter(event => event.node_id === element.id).at(-1), output = object(outputEvent?.output);
|
|
useEffect(() => {
|
|
const controller = new AbortController();setLoading(true);setError('');
|
|
engine.models(element.ownerCanvasId,controller.signal).then(catalog => {
|
|
if(controller.signal.aborted)return;
|
|
const compatible = catalog.models.filter(model => model.output_modalities.includes(image ? 'image' : 'text'));
|
|
setModels(compatible);setModel(current => compatible.some(model => model.id === current) ? current : compatible.find(model => model.available)?.id ?? '');
|
|
if(catalog.unavailableReason)setError(errorText(new Error(catalog.unavailableReason)));
|
|
}).catch(error => { if(!controller.signal.aborted)setError(errorText(error)); }).finally(() => { if(!controller.signal.aborted)setLoading(false); });
|
|
return () => controller.abort();
|
|
},[engine,element.ownerCanvasId,image]);
|
|
function check(result: Result) { if(result.status === 'rejected') { setError(errorText(new Error(result.code)));return false; }return true; }
|
|
function save() {
|
|
if(!model || busy)return true;
|
|
return check(engine.editor.dispatch({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{ai:{enabled:true,modelId:model,prompt,propagate,parameters:{}}}},{label:'Configure generation'}));
|
|
}
|
|
const appliedOutput=typeof output.output_id==='string'&&run?.appliedOutputIds?.includes(output.output_id);
|
|
const message = cancelling ? '取消请求已保存,等待确认…' : applying ? '正在应用生成结果…' : rejectedStart ? `生成未受理:${rejectedStart.blockers.join('、')}` : appliedOutput ? '此生成结果已应用' : pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在同步任务状态…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素';
|
|
return <section className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具" style={{position:'absolute',right:24,bottom:90,zIndex:110,maxWidth:'calc(100% - 48px)',maxHeight:'70%',overflow:'auto'}} onPointerDown={event=>event.stopPropagation()} onKeyDown={event=>event.stopPropagation()}>
|
|
<header><Sparkles size={14}/><strong>{image ? '图像创作' : '文本创作'}</strong><span>{element.data.title as string}</span><button aria-label="关闭 AI 工具" onClick={close}><X size={14}/></button></header>
|
|
<textarea aria-label="AI 指令" placeholder="希望如何处理当前内容?" maxLength={16000} disabled={busy} value={prompt} onChange={event=>setPrompt(event.target.value)} onBlur={save}/>
|
|
<footer><select aria-label="AI 模型" value={model} disabled={loading || busy} onChange={event=>setModel(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}</option>)}</select>
|
|
<label><input type="checkbox" aria-label="继续向下传播" checked={propagate} disabled={busy} onChange={event=>setPropagate(event.target.checked)}/>继续向下传播</label>
|
|
<button aria-label="执行 AI" title="在当前元素生成结果" disabled={busy || loading || !prompt.trim() || !models.some(item=>item.id===model && item.available)} onClick={()=>{setError('');if(save())check(engine.startRun({target:{id:element.id,incarnation:element.incarnation},boardId:element.ownerCanvasId,modelId:model,prompt,parameters:{},propagate}));}}><ArrowUp size={18}/></button>
|
|
{run && ['queued','running'].includes(run.status) && <button aria-label="取消生成" disabled={cancelling} onClick={()=>check(engine.cancelRun(run.id))}><Square size={15}/></button>}
|
|
</footer>
|
|
<p role="status">{message}</p>
|
|
{(run?.blocked || run?.status==='conflict') && <div className={styles.aiConflict}>{typeof output.text==='string' && <pre>{output.text}</pre>}{typeof output.src==='string' && <img src={managedImageSource(output.src)} alt="保留的生成结果"/>}{run?.status==='conflict'&&!run.blocked&&!run.hasMore&&!appliedOutput&&typeof output.output_id==='string'&&<button disabled={actions.length>0} onClick={()=>check(engine.applyRunOutput(run.id,{id:element.id,incarnation:element.incarnation},output.output_id as string))}>使用此结果替换当前内容</button>}</div>}
|
|
{(run?.blocked || run?.error || pending.some(record=>record.phase==='blocked')) && <button onClick={()=>void engine.retry().catch(error=>setError(errorText(error)))}>重试任务跟踪</button>}
|
|
{error && <p role="alert">{error}</p>}
|
|
</section>;
|
|
}
|