feat: store workspace preferences and drafts locally

This commit is contained in:
2026-08-04 05:28:13 +02:00
parent 6c09adc05e
commit cee4cfa63d
8 changed files with 537 additions and 21 deletions
+179
View File
@@ -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 */ }
},
};