84 lines
10 KiB
TypeScript
84 lines
10 KiB
TypeScript
/**
|
||
* 新版元素AI工具面板。配置、生成开始、取消和结果采用均通过engine命令进入既有状态与同步链路。
|
||
* 任务事实读取runtime.runs/ledger,局部state只保存输入、模型目录和界面错误;关闭面板不等于取消生成。
|
||
*/
|
||
"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";
|
||
|
||
/** 只接受非数组JSON对象作为配置/输出包络,其它值退为空对象,避免直接按对象读取标量。 */
|
||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||
/** 配置输入从元素当前ai字段初始化;模型输出模态按image/plain-image与其它组件区分。
|
||
* 元素数据来自父层投影,本组件直接读取engine根用于显示任务,不再创建额外轮询器。 */
|
||
export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) {
|
||
// getSnapshot 是本次 render 的读取,不建立订阅;任务状态更新是否触发重渲染由父层负责。
|
||
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);
|
||
// 输入 state 仅从初次 saved 初始化;同一挂载内外部 ai 配置变化不会自动覆盖本地草稿。
|
||
const image = ['image','plain-image'].includes(element.componentId);
|
||
// 用runContainsTarget核对目标身份,选择相关任务列表的最后一项;不是按时间戳重新排序。
|
||
const relevant = [...root.runtime.runs.values()].filter(run => runContainsTarget(run,element));
|
||
const run = relevant.at(-1);
|
||
// ledger里的run.start可尚未得到服务端run,必须单独显示等待/阻断,不能仅看runs来判定空闲。
|
||
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;
|
||
// 同一run未解决的cancel/apply也占忙碌状态;hasMore/pendingPage表示任务事件尚未完整读取。
|
||
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));
|
||
// 展示当前元素最后一个输出事件;是否可采用还要检查output_id、任务冲突状态及已应用集合。
|
||
const outputEvent = run?.outputEvents.filter(event => event.node_id === element.id).at(-1), output = object(outputEvent?.output);
|
||
// 按当前ownerCanvasId读取可用模型,保留仍兼容的选择,否则选首个available;目录不可用原因单独提示。
|
||
// 切作用域/卸载时abort;所有异步结果先检查取消状态,不把旧目录写回新面板。
|
||
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]);
|
||
/** 将同步命令的rejected结果显示为错误;返回布尔值供“先保存配置再启动”短路。 */
|
||
function check(result: Result) { if(result.status === 'rejected') { setError(errorText(new Error(result.code)));return false; }return true; }
|
||
/** 有模型且不忙时提交ai配置;否则不发修改并返回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]) : '结果将显示在当前元素';
|
||
// 保存配置与 startRun 是两个同步命令,没有合成一笔事务;配置成功而启动拒绝时,配置仍已修改。
|
||
// 命令接受也不等于服务端已受理或已持久确认,后续状态以 ledger/run 为准。
|
||
// 输入blur保存配置,执行按钮再保存一次且成功后startRun;忙碌时锁定配置和启动,关闭按钮仍可用。
|
||
// queued/running提供取消请求入口;冲突输出保留展示,只有事件读完且未采用时才允许显式替换。
|
||
// 跟踪重试调用engine.retry,不新建run.start;pointer/keydown止于面板,避免触发画布手势或快捷键。
|
||
return <section data-canvas-ui-view={viewId} 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>;
|
||
}
|