feat: store workspace preferences and drafts locally
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import type { CanonicalDiagnostics, CanonicalWorkspace, RestDiagnosticRequest } from "../api/workspaces";
|
||||
export { workspacePreferences, type WorkspacePreference } from "./preferences";
|
||||
|
||||
export interface WorkspaceDraft {
|
||||
workspaceId: string;
|
||||
baseCommit: string;
|
||||
baseBlob?: string;
|
||||
workspace: CanonicalWorkspace;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const PREFIX = "thothii.workspace-registry.v1";
|
||||
const DRAFT_PREFIX = `${PREFIX}.draft.`;
|
||||
|
||||
function storage(): Storage | undefined {
|
||||
try { return window.localStorage; } catch { return undefined; }
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function copyRequest(value: unknown): RestDiagnosticRequest | undefined {
|
||||
const source = record(value);
|
||||
if (
|
||||
!source
|
||||
|| (source.method !== "GET" && source.method !== "POST")
|
||||
|| typeof source.path !== "string"
|
||||
|| (source.auth !== "none" && source.auth !== "bearer" && source.auth !== "x-api-key")
|
||||
) return undefined;
|
||||
return { method: source.method, path: source.path, auth: source.auth };
|
||||
}
|
||||
|
||||
function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined {
|
||||
const source = record(value);
|
||||
if (!source) return undefined;
|
||||
const diagnostics: CanonicalDiagnostics = {};
|
||||
if (source.dwh_rest !== undefined) {
|
||||
const request = copyRequest(source.dwh_rest);
|
||||
const response = record(record(source.dwh_rest)?.response);
|
||||
if (!request || !response || typeof response.database !== "string" || typeof response.schema !== "string") return undefined;
|
||||
diagnostics.dwh_rest = { ...request, response: { database: response.database, schema: response.schema } };
|
||||
}
|
||||
if (source.vector_rest !== undefined) {
|
||||
const vector = record(source.vector_rest);
|
||||
const request = copyRequest(vector?.metadata);
|
||||
const response = record(record(vector?.metadata)?.response);
|
||||
if (!vector || !request || !response || typeof response.collection !== "string" || typeof response.dimensions !== "string" || typeof response.distance !== "string") return undefined;
|
||||
const metadata = { ...request, response: { collection: response.collection, dimensions: response.dimensions, distance: response.distance } };
|
||||
let reversibleProbe: NonNullable<CanonicalDiagnostics["vector_rest"]>["reversible_probe"] | undefined;
|
||||
if (vector.reversible_probe !== undefined) {
|
||||
const probe = copyRequest(vector.reversible_probe);
|
||||
const probeResponse = record(record(vector.reversible_probe)?.response);
|
||||
if (!probe || probe.method !== "POST" || probe.auth === "none" || !probeResponse || typeof probeResponse.operation !== "string") return undefined;
|
||||
reversibleProbe = {
|
||||
method: "POST",
|
||||
path: probe.path,
|
||||
auth: probe.auth as "bearer" | "x-api-key",
|
||||
response: { operation: probeResponse.operation },
|
||||
};
|
||||
}
|
||||
diagnostics.vector_rest = { metadata, ...(reversibleProbe ? { reversible_probe: reversibleProbe } : {}) };
|
||||
}
|
||||
if (source.embedding !== undefined) {
|
||||
const request = copyRequest(source.embedding);
|
||||
const response = record(record(source.embedding)?.response);
|
||||
if (!request || !response || typeof response.model !== "string" || typeof response.dimensions !== "string") return undefined;
|
||||
diagnostics.embedding = { ...request, response: { model: response.model, dimensions: response.dimensions } };
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function copyWorkspace(value: unknown): CanonicalWorkspace | undefined {
|
||||
const source = record(value);
|
||||
const metadata = record(source?.workspace);
|
||||
const dwh = record(source?.dwh);
|
||||
const semanticIndex = record(source?.semantic_index);
|
||||
const vectorStore = record(semanticIndex?.vector_store);
|
||||
const embedding = record(semanticIndex?.embedding);
|
||||
const policy = record(source?.llm_policy);
|
||||
const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics);
|
||||
if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined;
|
||||
if (
|
||||
typeof metadata.schema_version !== "number" || typeof metadata.id !== "string" || typeof metadata.name !== "string"
|
||||
|| typeof metadata.language !== "string" || typeof dwh.database !== "string" || typeof dwh.schema !== "string"
|
||||
|| typeof vectorStore.database !== "string" || typeof vectorStore.schema !== "string"
|
||||
|| typeof vectorStore.collection !== "string" || typeof vectorStore.dimensions !== "number"
|
||||
|| typeof embedding.model !== "string" || typeof embedding.dimensions !== "number"
|
||||
|| !Array.isArray(dwh.supported_transports) || !Array.isArray(vectorStore.supported_transports) || !Array.isArray(policy.allowed)
|
||||
) return undefined;
|
||||
if (source?.diagnostics !== undefined && !diagnostics) return undefined;
|
||||
return {
|
||||
workspace: {
|
||||
schema_version: metadata.schema_version as 2,
|
||||
id: metadata.id,
|
||||
name: metadata.name,
|
||||
...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
|
||||
language: metadata.language as "en" | "it",
|
||||
},
|
||||
dwh: {
|
||||
engine: dwh.engine as "postgres",
|
||||
database: dwh.database,
|
||||
schema: dwh.schema,
|
||||
...(typeof dwh.port === "number" ? { port: dwh.port } : {}),
|
||||
...(typeof dwh.timeout_ms === "number" ? { timeout_ms: dwh.timeout_ms } : {}),
|
||||
supported_transports: dwh.supported_transports.filter((transport): transport is "postgres_direct" | "rest_api" | "ssh_tunnel" =>
|
||||
transport === "postgres_direct" || transport === "rest_api" || transport === "ssh_tunnel"),
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: vectorStore.engine as "pgvector",
|
||||
database: vectorStore.database,
|
||||
schema: vectorStore.schema,
|
||||
collection: vectorStore.collection,
|
||||
dimensions: vectorStore.dimensions,
|
||||
distance: vectorStore.distance as "cosine" | "l2" | "inner_product",
|
||||
...(typeof vectorStore.port === "number" ? { port: vectorStore.port } : {}),
|
||||
...(typeof vectorStore.timeout_ms === "number" ? { timeout_ms: vectorStore.timeout_ms } : {}),
|
||||
supported_transports: vectorStore.supported_transports.filter((transport): transport is "pgvector_direct" | "rest_api" | "ssh_tunnel" =>
|
||||
transport === "pgvector_direct" || transport === "rest_api" || transport === "ssh_tunnel"),
|
||||
},
|
||||
...(record(semanticIndex.vector_writer) ? { vector_writer: {} } : {}),
|
||||
embedding: {
|
||||
provider: embedding.provider as "ollama_compatible" | "openai_compatible",
|
||||
model: embedding.model,
|
||||
dimensions: embedding.dimensions,
|
||||
...(typeof embedding.timeout_ms === "number" ? { timeout_ms: embedding.timeout_ms } : {}),
|
||||
},
|
||||
},
|
||||
llm_policy: {
|
||||
...(typeof policy.default === "string" ? { default: policy.default as `${string}/${string}` } : {}),
|
||||
allowed: policy.allowed.filter((model): model is `${string}/${string}` => typeof model === "string"),
|
||||
},
|
||||
...(diagnostics ? { diagnostics } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(value: unknown): WorkspaceDraft | undefined {
|
||||
const source = record(value);
|
||||
const workspace = copyWorkspace(source?.workspace);
|
||||
if (!source || !workspace || typeof source.workspaceId !== "string" || typeof source.baseCommit !== "string" || typeof source.updatedAt !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
workspaceId: source.workspaceId,
|
||||
baseCommit: source.baseCommit,
|
||||
...(typeof source.baseBlob === "string" ? { baseBlob: source.baseBlob } : {}),
|
||||
workspace,
|
||||
updatedAt: source.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function key(id: string): string {
|
||||
return `${DRAFT_PREFIX}${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
/** Browser-only workspace drafts. Saving or editing one never calls the server. */
|
||||
export const workspaceDrafts = {
|
||||
load(id: string): WorkspaceDraft | undefined {
|
||||
try {
|
||||
const raw = storage()?.getItem(key(id));
|
||||
return raw ? normalize(JSON.parse(raw)) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
save(draft: WorkspaceDraft): void {
|
||||
const safe = normalize(draft);
|
||||
if (!safe) return;
|
||||
try { storage()?.setItem(key(safe.workspaceId), JSON.stringify(safe)); } catch { /* storage is optional */ }
|
||||
},
|
||||
|
||||
discard(id: string): void {
|
||||
try { storage()?.removeItem(key(id)); } catch { /* storage is optional */ }
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user