219 lines
5.9 KiB
TypeScript
219 lines
5.9 KiB
TypeScript
import { readJsonFile } from "../utils/fileStore.js";
|
|
import {
|
|
type ResultReferenceKind,
|
|
type ResultReferenceRecord,
|
|
type ResultReferenceSource,
|
|
type RetrievalContext,
|
|
RESULT_REFERENCE_KIND,
|
|
RESULT_REFERENCE_SOURCE,
|
|
type ResultReferenceStore,
|
|
} from "./store.js";
|
|
|
|
type ResolveOptions = {
|
|
expectedKind?: ResultReferenceKind;
|
|
};
|
|
|
|
type RegisterResultReferenceInput = {
|
|
actorKey: string;
|
|
clientSessionId: string;
|
|
data: unknown;
|
|
kind: ResultReferenceKind;
|
|
projectId?: string;
|
|
projectKey: string;
|
|
schemaVersion: number;
|
|
sessionId: string;
|
|
source: ResultReferenceSource;
|
|
traceId: string;
|
|
};
|
|
|
|
export type RenderJunctionPayload = {
|
|
node_area_map: Record<string, string>;
|
|
area_ids?: string[];
|
|
area_colors?: Record<string, string>;
|
|
};
|
|
|
|
export class ResultReferenceResolver {
|
|
constructor(private readonly store: ResultReferenceStore) {}
|
|
|
|
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
|
|
async register(input: RegisterResultReferenceInput) {
|
|
const normalizedData = normalizeDataForKind(
|
|
input.kind,
|
|
input.data,
|
|
input.schemaVersion,
|
|
);
|
|
if (!normalizedData) {
|
|
throw new Error(`invalid payload for result ref kind '${input.kind}'`);
|
|
}
|
|
return this.store.store({
|
|
actorKey: input.actorKey,
|
|
clientSessionId: input.clientSessionId,
|
|
data: normalizedData,
|
|
kind: input.kind,
|
|
projectId: input.projectId,
|
|
projectKey: input.projectKey,
|
|
schemaVersion: input.schemaVersion,
|
|
sessionId: input.sessionId,
|
|
source: input.source,
|
|
traceId: input.traceId,
|
|
});
|
|
}
|
|
|
|
async registerRenderPayloadFile(
|
|
filePath: string,
|
|
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
|
|
) {
|
|
const raw = await readJsonFile<unknown>(filePath);
|
|
if (raw === null) {
|
|
throw new Error(`render payload file not found: ${filePath}`);
|
|
}
|
|
|
|
const wrapper = normalizeRenderPayloadFile(raw, filePath);
|
|
if (!wrapper) {
|
|
throw new Error("render payload file must use the wrapped { metadata, location, data } format");
|
|
}
|
|
|
|
const payload = extractRenderJunctionPayload(wrapper.data);
|
|
if (!payload) {
|
|
throw new Error("render payload file does not contain a valid junction render payload");
|
|
}
|
|
|
|
return this.register({
|
|
...input,
|
|
data: payload,
|
|
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
|
schemaVersion: 1,
|
|
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
|
});
|
|
}
|
|
|
|
async getFullAuthorized(
|
|
resultRef: string,
|
|
context: RetrievalContext,
|
|
options: ResolveOptions = {},
|
|
) {
|
|
const record = await this.getResolvedRecord(resultRef, context, options);
|
|
if (!record) {
|
|
return null;
|
|
}
|
|
return {
|
|
ok: true,
|
|
result_ref: record.resultRef,
|
|
result_size_bytes: record.sizeBytes,
|
|
stored_at: record.createdAt,
|
|
data: record.data,
|
|
preview: record.preview,
|
|
kind: record.kind,
|
|
schema_version: record.schemaVersion,
|
|
source: record.source,
|
|
};
|
|
}
|
|
|
|
private async getResolvedRecord(
|
|
resultRef: string,
|
|
context: RetrievalContext,
|
|
options: ResolveOptions,
|
|
): Promise<ResultReferenceRecord | null> {
|
|
const record = await this.store.getAuthorizedRecord(resultRef, context);
|
|
if (!record) {
|
|
return null;
|
|
}
|
|
if (options.expectedKind && record.kind !== options.expectedKind) {
|
|
return null;
|
|
}
|
|
const normalizedData = normalizeDataForKind(
|
|
record.kind,
|
|
record.data,
|
|
record.schemaVersion,
|
|
);
|
|
if (!normalizedData) {
|
|
return null;
|
|
}
|
|
return {
|
|
...record,
|
|
data: normalizedData,
|
|
};
|
|
}
|
|
}
|
|
|
|
export const extractRenderJunctionPayload = (
|
|
value: unknown,
|
|
): RenderJunctionPayload | null => {
|
|
const candidate = unwrapReferencePayload(value);
|
|
if (!candidate || !isRecord(candidate.node_area_map)) {
|
|
return null;
|
|
}
|
|
|
|
// 节点渲染结果只保留前端真正需要的映射字段,剔除空值并统一转为字符串。
|
|
const nodeAreaMap = normalizeStringRecord(candidate.node_area_map);
|
|
if (Object.keys(nodeAreaMap).length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const areaIds = Array.isArray(candidate.area_ids)
|
|
? candidate.area_ids.map((entry) => String(entry).trim()).filter(Boolean)
|
|
: undefined;
|
|
|
|
const areaColors = isRecord(candidate.area_colors)
|
|
? normalizeStringRecord(candidate.area_colors)
|
|
: undefined;
|
|
|
|
return {
|
|
node_area_map: nodeAreaMap,
|
|
...(areaIds && areaIds.length > 0 ? { area_ids: areaIds } : {}),
|
|
...(areaColors && Object.keys(areaColors).length > 0
|
|
? { area_colors: areaColors }
|
|
: {}),
|
|
};
|
|
};
|
|
|
|
const normalizeDataForKind = (
|
|
kind: ResultReferenceKind,
|
|
data: unknown,
|
|
schemaVersion: number,
|
|
): unknown | null => {
|
|
if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
|
|
return null;
|
|
}
|
|
if (kind === RESULT_REFERENCE_KIND.renderJunctionsPayload) {
|
|
return extractRenderJunctionPayload(data);
|
|
}
|
|
return data;
|
|
};
|
|
|
|
const normalizeRenderPayloadFile = (
|
|
value: unknown,
|
|
filePath: string,
|
|
): { data: unknown } | null => {
|
|
if (!isRecord(value) || !("data" in value)) {
|
|
return null;
|
|
}
|
|
if (!isRecord(value.metadata) || !isRecord(value.location)) {
|
|
return null;
|
|
}
|
|
if (value.location.file_path !== filePath) {
|
|
return null;
|
|
}
|
|
return { data: value.data };
|
|
};
|
|
|
|
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
if ("data" in value && value.data !== undefined && value.data !== null) {
|
|
return isRecord(value.data) ? value.data : null;
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const normalizeStringRecord = (value: Record<string, unknown>) =>
|
|
Object.fromEntries(
|
|
Object.entries(value)
|
|
.map(([key, entry]) => [String(key), String(entry ?? "").trim()])
|
|
.filter(([, entry]) => entry.length > 0),
|
|
);
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|