const PACKAGE_ID_PREFIX = 'cp_'; const ARTIFACT_ID_PREFIX = 'ca_'; export const CONVERSATION_PACKAGE_SCHEMA_VERSION = 1; export const CONVERSATION_ARTIFACT_KINDS = Object.freeze([ 'input_image', 'input_file', 'generated_image', 'generated_file', 'page', 'public_html', 'long_image', 'thumbnail', 'docx', 'pdf', ]); const MYSQL_SIGNED_INT_MAX = 2147483647; const MYSQL_SIGNED_INT_MIN = -2147483648; function requiredString(value, field) { const normalized = String(value ?? '').trim(); if (!normalized) { throw Object.assign(new Error(`${field} is required`), { code: 'invalid_conversation_package', field, }); } return normalized; } function optionalString(value) { const normalized = String(value ?? '').trim(); return normalized || null; } function optionalNumber(value) { if (value == null || value === '') return null; const number = Number(value); return Number.isFinite(number) ? number : null; } export function buildConversationDisplayFolderName(title) { return optionalString(title) ?? '本次对话文件夹'; } function safeSortOrder(value) { const number = Number(value); if (!Number.isFinite(number)) return 0; let normalized = Math.trunc(number); if (normalized > MYSQL_SIGNED_INT_MAX && Math.abs(normalized) >= 1_000_000_000_000) { normalized = Math.trunc(normalized / 1000); } if (normalized > MYSQL_SIGNED_INT_MAX) return MYSQL_SIGNED_INT_MAX; if (normalized < MYSQL_SIGNED_INT_MIN) return MYSQL_SIGNED_INT_MIN; return normalized; } function safeStorageSegment(value, field) { const normalized = requiredString(value, field); const encoded = encodeURIComponent(normalized); if (encoded.includes('%2F') || encoded.includes('%5C')) { throw Object.assign(new Error(`${field} must not contain path separators`), { code: 'invalid_storage_segment', field, }); } return encoded; } export function buildConversationStoragePrefix({ userId, sessionId }) { return `users/${safeStorageSegment(userId, 'userId')}/conversations/${safeStorageSegment( sessionId, 'sessionId', )}`; } export function buildConversationPackageUri({ userId, sessionId }) { return `mindspace://users/${safeStorageSegment(userId, 'userId')}/conversations/${safeStorageSegment( sessionId, 'sessionId', )}`; } export function createConversationPackageRecord(input) { const userId = requiredString(input?.userId, 'userId'); const sessionId = requiredString(input?.sessionId, 'sessionId'); const now = Number.isFinite(Number(input?.now)) ? Number(input.now) : Date.now(); const id = optionalString(input?.id) ?? `${PACKAGE_ID_PREFIX}${sessionId}`; const storagePrefix = optionalString(input?.storagePrefix) ?? buildConversationStoragePrefix({ userId, sessionId }); return { id, userId, sessionId, title: optionalString(input?.title), status: optionalString(input?.status) ?? 'active', storagePrefix, manifestAssetId: optionalString(input?.manifestAssetId), createdAt: Number.isFinite(Number(input?.createdAt)) ? Number(input.createdAt) : now, updatedAt: Number.isFinite(Number(input?.updatedAt)) ? Number(input.updatedAt) : now, }; } export function assertConversationArtifactKind(kind) { const normalized = requiredString(kind, 'artifactKind'); if (!CONVERSATION_ARTIFACT_KINDS.includes(normalized)) { throw Object.assign(new Error(`unsupported conversation artifact kind: ${normalized}`), { code: 'unsupported_conversation_artifact_kind', field: 'artifactKind', }); } return normalized; } export function createConversationArtifact(input) { const packageId = requiredString(input?.packageId, 'packageId'); const artifactKind = assertConversationArtifactKind(input?.artifactKind ?? input?.kind); const now = Number.isFinite(Number(input?.now)) ? Number(input.now) : Date.now(); const id = optionalString(input?.id) ?? `${ARTIFACT_ID_PREFIX}${packageId}_${artifactKind}_${now}`; return { id, packageId, artifactKind, role: optionalString(input?.role), assetId: optionalString(input?.assetId), pageId: optionalString(input?.pageId), publicationId: optionalString(input?.publicationId), agentRunId: optionalString(input?.agentRunId), messageId: optionalString(input?.messageId), displayName: optionalString(input?.displayName), mimeType: optionalString(input?.mimeType), sizeBytes: optionalNumber(input?.sizeBytes), storageKey: optionalString(input?.storageKey), canonicalUrl: optionalString(input?.canonicalUrl), sortOrder: safeSortOrder(input?.sortOrder), createdAt: Number.isFinite(Number(input?.createdAt)) ? Number(input.createdAt) : now, }; } function artifactManifestEntry(artifact) { return { artifactId: artifact.id, kind: artifact.artifactKind, ...(artifact.role ? { role: artifact.role } : {}), ...(artifact.assetId ? { assetId: artifact.assetId } : {}), ...(artifact.pageId ? { pageId: artifact.pageId } : {}), ...(artifact.publicationId ? { publicationId: artifact.publicationId } : {}), ...(artifact.agentRunId ? { agentRunId: artifact.agentRunId } : {}), ...(artifact.messageId ? { messageId: artifact.messageId } : {}), ...(artifact.displayName ? { displayName: artifact.displayName } : {}), ...(artifact.mimeType ? { mimeType: artifact.mimeType } : {}), ...(artifact.sizeBytes !== null ? { sizeBytes: artifact.sizeBytes } : {}), ...(artifact.storageKey ? { storageKey: artifact.storageKey } : {}), ...(artifact.canonicalUrl ? { canonicalUrl: artifact.canonicalUrl } : {}), sortOrder: artifact.sortOrder, createdAt: artifact.createdAt, }; } function groupedManifestEntries(artifacts, keyField, idField) { const groups = new Map(); for (const artifact of artifacts) { const key = artifact[keyField]; if (!key) continue; const existing = groups.get(key) ?? { [idField]: key, artifactIds: [], createdAt: artifact.createdAt, updatedAt: artifact.createdAt, }; existing.artifactIds.push(artifact.id); if (!existing.displayName && artifact.displayName) existing.displayName = artifact.displayName; if (!existing.canonicalUrl && artifact.canonicalUrl) existing.canonicalUrl = artifact.canonicalUrl; existing.createdAt = Math.min(existing.createdAt, artifact.createdAt); existing.updatedAt = Math.max(existing.updatedAt, artifact.createdAt); groups.set(key, existing); } return [...groups.values()].sort((a, b) => a.createdAt - b.createdAt || a[idField].localeCompare(b[idField])); } function provenanceManifestEntries(artifacts, keyField, idField) { const groups = new Map(); for (const artifact of artifacts) { const key = artifact[keyField]; if (!key) continue; const existing = groups.get(key) ?? { [idField]: key, artifactIds: [], kinds: [], createdAt: artifact.createdAt, updatedAt: artifact.createdAt, }; existing.artifactIds.push(artifact.id); if (!existing.kinds.includes(artifact.artifactKind)) existing.kinds.push(artifact.artifactKind); existing.createdAt = Math.min(existing.createdAt, artifact.createdAt); existing.updatedAt = Math.max(existing.updatedAt, artifact.createdAt); groups.set(key, existing); } return [...groups.values()].sort((a, b) => a.createdAt - b.createdAt || a[idField].localeCompare(b[idField])); } export function buildConversationPackageManifest({ packageRecord, artifacts = [] }) { const record = createConversationPackageRecord(packageRecord); const normalizedArtifacts = artifacts .map((artifact) => createConversationArtifact({ ...artifact, packageId: record.id })) .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt || a.id.localeCompare(b.id)); return { schemaVersion: CONVERSATION_PACKAGE_SCHEMA_VERSION, packageId: record.id, userId: record.userId, sessionId: record.sessionId, uri: buildConversationPackageUri({ userId: record.userId, sessionId: record.sessionId }), title: record.title, displayFolderName: buildConversationDisplayFolderName(record.title), status: record.status, storagePrefix: record.storagePrefix, manifestAssetId: record.manifestAssetId, createdAt: record.createdAt, updatedAt: record.updatedAt, artifacts: normalizedArtifacts.map(artifactManifestEntry), pages: groupedManifestEntries(normalizedArtifacts, 'pageId', 'pageId'), publications: groupedManifestEntries(normalizedArtifacts, 'publicationId', 'publicationId'), messages: provenanceManifestEntries(normalizedArtifacts, 'messageId', 'messageId'), agentRuns: provenanceManifestEntries(normalizedArtifacts, 'agentRunId', 'agentRunId'), }; } export const mindspaceConversationPackageInternals = { requiredString, optionalNumber, optionalString, provenanceManifestEntries, safeStorageSegment, safeSortOrder, };