45 lines
4.4 KiB
TypeScript
45 lines
4.4 KiB
TypeScript
/**
|
|
* Foundry 模型读取适配器,把一次服务端读取转换成 runtime LoadPort 的带作用域分块结果。
|
|
* 优先协商 NDJSON 流,旧服务器明确返回 JSON 时复用完整模型解释器;不会为了回退再发一次相同请求。
|
|
*/
|
|
import { decodeModelStream } from './model-stream.ts';
|
|
import type { Id, Registry } from '../canvas-core/types.ts';
|
|
import { fault } from '../canvas-core/values.ts';
|
|
import type { CanonicalReadUpdate } from '../canvas-runtime/contracts.ts';
|
|
import { loadQueryKey, type LoadPort } from '../canvas-runtime/loader.ts';
|
|
import { loadFoundryModel, type ServiceTransport } from './foundry.ts';
|
|
|
|
/** Negotiate real streaming; older servers may explicitly return complete JSON. */
|
|
/** 创建惰性异步生成器端口;开始迭代才请求,匹配 workspace/format/source/viewKey 后读取 scope.canvasId 的项目模型。
|
|
* scope/options 按不可变合同借用;authEpoch/queryKey 标记来自宿主 scope,不能代替 transport 的账号/权限检查。
|
|
* 同一次响应二选一解释 stream 或 JSON,不建立缓存/共享在途任务;这些由 runtime ScopedLoader 持有。 */
|
|
export function createFoundryModelLoader(options: { workspaceId: Id; registry: Registry; transport: ServiceTransport }): LoadPort<CanonicalReadUpdate> {
|
|
return async function* (scope, signal) {
|
|
// query scope 是缓存/权限隔离依据,不能用某工作区的 transport 为另一种查询来源提供结果。
|
|
if (scope.workspaceId !== options.workspaceId || scope.format !== 'canonical-model-v1' || scope.source !== 'foundry' || scope.viewKey !== 'canonical-project') throw fault('invalid_model_load_scope');
|
|
const response=await options.transport({method:'GET',path:`workspaces/${encodeURIComponent(options.workspaceId)}/materials/${encodeURIComponent(scope.canvasId)}/canvas/model`,signal,responseFormat:'model-stream-v1'});
|
|
// 若 response.stream 存在即进入流路径(须200),解析失败不再偷偷改用 body 或发第二次请求。
|
|
// decoder 控序列/token/EOF,适配器逐块补 scope.authEpoch/queryKey 和 versions 外壳,供上层做迟到结果准入。
|
|
if(response.stream){
|
|
if(response.status!==200)throw fault('invalid_model_stream');
|
|
for await(const chunk of decodeModelStream(response.stream,scope.canvasId,options.registry,signal))yield Object.freeze({...chunk,serverVersions:Object.freeze(Object.fromEntries(chunk.versions)),permissionEpoch:scope.authEpoch,queryKey:loadQueryKey(scope)});
|
|
return;
|
|
}
|
|
// JSON 回退仅把已有 response 注入解释器,ready 与 migration-required 是不同结果,不能把迁移要求当空文档。
|
|
const value = await loadFoundryModel(async()=>response, options.workspaceId, scope.canvasId, options.registry, signal);
|
|
signal.throwIfAborted();
|
|
const projectId = value.status === 'ready' ? value.document.projectId : value.rootId;
|
|
// 对版本排序后用 [projectId,versions] 生成 token,不包含元素内容,不能把它当成源字节摘要/真实性证明。
|
|
const versions = Object.fromEntries([...(value.status === 'ready' ? value.versions : value.sourceVersions)].sort(([a], [b]) => a.localeCompare(b)));
|
|
const token = JSON.stringify([projectId, versions]);
|
|
const payload = value.status === 'ready' ? { projectId, canvases: [...value.document.canvases.values()], elements: [...value.document.elements.values()], tombstones: [...value.document.tombstones], versions } : { projectId, versions, migrationRequired: true };
|
|
// A serialized payload cost, not a claim about pre-parse network/heap limits.
|
|
// byteLength 是已解析后重新序列化的 payload 成本,不能据此宣称 JSON.parse 前已有网络/堆内存硬上限。
|
|
const byteLength = new TextEncoder().encode(JSON.stringify(payload)).byteLength;
|
|
// JSON 完整结果一次 sequence=0/complete=true;迁移要求用 migration coverage 标记,仍保留其区别于 ready 的状态。
|
|
// 本端口不把内容发布进 editor,消费者还需模型预览/加载协调以及作用域检查。
|
|
yield Object.freeze({ queryKey: loadQueryKey(scope), projectId, snapshotToken: token, serverVersions: Object.freeze(versions), permissionEpoch: scope.authEpoch,
|
|
sequence: 0, coverage: Object.freeze(value.status === 'ready' ? [...value.document.canvases.keys()].map(id => `canvas:${id}`) : [`migration:${scope.canvasId}`]), complete: true, byteLength, value });
|
|
};
|
|
}
|