Files
ThothII/frontend/src/shell/WorkspaceEditor.tsx
T

235 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useId, useMemo, useState } from "react";
import type { CanonicalWorkspace, PublishWorkspaceRequest } from "../api/workspaces";
import type { WorkspaceDraft } from "../workspaces/drafts";
import { Button } from "../components/ui/button";
type FieldErrors = Record<string, string>;
export interface WorkspaceEditorProps {
draft?: WorkspaceDraft;
onSaveDraft: (draft: WorkspaceDraft) => void;
/** Reserved for Task 10; saving a draft never publishes it. */
onPublish: (request: PublishWorkspaceRequest) => Promise<void>;
idLocked?: boolean;
}
const EMPTY_COMMIT = "0".repeat(40);
function emptyWorkspace(): CanonicalWorkspace {
return {
workspace: { schema_version: 2, id: "new-workspace", name: "New workspace", language: "en" },
dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] },
semantic_index: {
vector_store: {
engine: "pgvector", database: "vectors", schema: "public", collection: "documents",
dimensions: 768, distance: "cosine", supported_transports: ["pgvector_direct"],
},
embedding: { provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768 },
},
llm_policy: { allowed: ["zai/glm-5.2"] },
};
}
function positiveInteger(value: number | undefined, label: string, max = Number.MAX_SAFE_INTEGER): string | undefined {
if (value === undefined) return undefined;
if (!Number.isInteger(value) || value < 1 || value > max) {
return max === 65_535 ? "Port must be between 1 and 65535" : `${label} must be a positive whole number`;
}
return undefined;
}
function validate(workspace: CanonicalWorkspace): FieldErrors {
const errors: FieldErrors = {};
if (!/^[a-z][a-z0-9-]{2,62}$/.test(workspace.workspace.id)) {
errors["workspace.id"] = "Use 3–63 lowercase letters, numbers, or hyphens; start with a letter";
}
if (!workspace.workspace.name.trim()) errors["workspace.name"] = "Workspace name is required";
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.dwh.database)) errors["dwh.database"] = "Use a database identifier";
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.dwh.schema)) errors["dwh.schema"] = "Use a schema identifier";
if (!workspace.dwh.supported_transports.length) errors["dwh.transport"] = "Choose at least one DWH transport";
const dwhPort = positiveInteger(workspace.dwh.port, "DWH port", 65_535);
if (dwhPort) errors["dwh.port"] = dwhPort;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.database)) errors["vector.database"] = "Use a database identifier";
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.schema)) errors["vector.schema"] = "Use a schema identifier";
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.collection)) errors["vector.collection"] = "Use a collection identifier";
if (!workspace.semantic_index.vector_store.supported_transports.length) errors["vector.transport"] = "Choose at least one vector transport";
const vectorPort = positiveInteger(workspace.semantic_index.vector_store.port, "Vector port", 65_535);
if (vectorPort) errors["vector.port"] = vectorPort;
const dimensions = positiveInteger(workspace.semantic_index.vector_store.dimensions, "Dimensions", 32_768);
if (dimensions) errors["semantic.dimensions"] = dimensions;
if (workspace.semantic_index.embedding.dimensions !== workspace.semantic_index.vector_store.dimensions) {
errors["semantic.dimensions"] = "Vector and embedding dimensions must match";
}
if (!workspace.semantic_index.embedding.model.trim()) errors["embedding.model"] = "Embedding model is required";
if (!workspace.llm_policy.allowed.length || workspace.llm_policy.allowed.some((model) => !/^[^/\s]+\/[^/\s]+$/.test(model))) {
errors["llm.allowed"] = "Use provider/model entries separated by commas";
}
if (workspace.llm_policy.default && !workspace.llm_policy.allowed.includes(workspace.llm_policy.default)) {
errors["llm.default"] = "Default model must be in the allowlist";
}
return errors;
}
function selectedValues(event: React.ChangeEvent<HTMLSelectElement>): string[] {
return Array.from(event.currentTarget.selectedOptions, (option) => option.value);
}
function numberOrUndefined(value: string): number | undefined {
return value.trim() === "" ? undefined : Number(value);
}
function Field({
label, error, children, hint,
}: {
label: string;
error?: string;
hint?: string;
children: (props: { id: string; describedBy?: string; invalid: boolean }) => React.ReactNode;
}) {
const id = useId();
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
const describedBy = [hint ? hintId : undefined, error ? errorId : undefined].filter(Boolean).join(" ") || undefined;
return (
<div className="grid gap-1.5">
<label className="text-xs font-semibold text-foreground" htmlFor={id}>{label}</label>
{children({ id, describedBy, invalid: Boolean(error) })}
{hint && <p id={hintId} className="text-xs text-muted-foreground">{hint}</p>}
{error && <p id={errorId} role="alert" className="text-xs font-medium text-destructive">{error}</p>}
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="border-t border-border/70 pt-5 first:border-t-0 first:pt-0" aria-label={title}>
<h3 className="font-heading text-sm font-semibold text-foreground">{title}</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">{children}</div>
</section>
);
}
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 aria-invalid:border-destructive";
export function WorkspaceEditor({ draft, onSaveDraft, onPublish: _onPublish, idLocked = Boolean(draft?.baseBlob) }: WorkspaceEditorProps) {
const [workspace, setWorkspace] = useState<CanonicalWorkspace>(draft?.workspace ?? emptyWorkspace());
const [errors, setErrors] = useState<FieldErrors>({});
const allowedModels = useMemo(() => workspace.llm_policy.allowed.join(", "), [workspace.llm_policy.allowed]);
useEffect(() => {
setWorkspace(draft?.workspace ?? emptyWorkspace());
setErrors({});
}, [draft]);
function update(change: (previous: CanonicalWorkspace) => CanonicalWorkspace) {
setWorkspace((previous) => {
const next = change(previous);
setErrors(validate(next));
return next;
});
}
function saveDraft() {
const nextErrors = validate(workspace);
setErrors(nextErrors);
if (Object.keys(nextErrors).length) return;
onSaveDraft({
workspaceId: workspace.workspace.id,
baseCommit: draft?.baseCommit ?? EMPTY_COMMIT,
...(draft?.baseBlob ? { baseBlob: draft.baseBlob } : {}),
workspace,
updatedAt: new Date().toISOString(),
});
}
return (
<form className="grid gap-6" onSubmit={(event) => { event.preventDefault(); saveDraft(); }} noValidate>
<Section title="General">
<Field label="Workspace ID" error={errors["workspace.id"]} hint={idLocked ? "Technical IDs cannot change after publication." : "Lowercase, immutable technical identifier."}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Workspace ID" aria-describedby={describedBy} aria-invalid={invalid} disabled={idLocked} className={fieldClass} value={workspace.workspace.id} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, id: event.target.value } }))} />}
</Field>
<Field label="Workspace name" error={errors["workspace.name"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Workspace name" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.workspace.name} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, name: event.target.value } }))} />}
</Field>
<Field label="Description">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Description" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.workspace.description ?? ""} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, description: event.target.value || undefined } }))} />}
</Field>
<Field label="Workspace language">
{({ id, describedBy, invalid }) => <select id={id} aria-label="Workspace language" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.workspace.language} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, language: event.target.value as "en" | "it" } }))}><option value="en">English</option><option value="it">Italian</option></select>}
</Field>
</Section>
<Section title="DWH">
<Field label="DWH engine">
{({ id, describedBy, invalid }) => <select id={id} aria-label="DWH engine" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="postgres" disabled><option value="postgres">PostgreSQL</option></select>}
</Field>
<Field label="DWH transport" error={errors["dwh.transport"]} hint="Choose supported installation transports.">
{({ id, describedBy, invalid }) => <select id={id} multiple aria-label="DWH transport" aria-describedby={describedBy} aria-invalid={invalid} className={`${fieldClass} h-24 py-1`} value={workspace.dwh.supported_transports} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, supported_transports: selectedValues(event) as CanonicalWorkspace["dwh"]["supported_transports"] } }))}><option value="postgres_direct">postgres_direct</option><option value="rest_api">rest_api</option><option value="ssh_tunnel">ssh_tunnel</option></select>}
</Field>
<Field label="DWH database" error={errors["dwh.database"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="DWH database" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.database} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, database: event.target.value } }))} />}
</Field>
<Field label="DWH schema" error={errors["dwh.schema"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="DWH schema" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.schema} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, schema: event.target.value } }))} />}
</Field>
<Field label="DWH port" error={errors["dwh.port"]}>
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="65535" aria-label="DWH port" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.port ?? ""} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, port: numberOrUndefined(event.target.value) } }))} />}
</Field>
<Field label="DWH timeout (ms)">
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" aria-label="DWH timeout (ms)" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.timeout_ms ?? ""} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, timeout_ms: numberOrUndefined(event.target.value) } }))} />}
</Field>
</Section>
<Section title="Semantic index">
<Field label="Vector store engine">
{({ id, describedBy, invalid }) => <select id={id} aria-label="Vector store engine" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="pgvector" disabled><option value="pgvector">pgvector</option></select>}
</Field>
<Field label="Vector transport" error={errors["vector.transport"]}>
{({ id, describedBy, invalid }) => <select id={id} multiple aria-label="Vector transport" aria-describedby={describedBy} aria-invalid={invalid} className={`${fieldClass} h-24 py-1`} value={workspace.semantic_index.vector_store.supported_transports} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, supported_transports: selectedValues(event) as CanonicalWorkspace["semantic_index"]["vector_store"]["supported_transports"] } } }))}><option value="pgvector_direct">pgvector_direct</option><option value="rest_api">rest_api</option><option value="ssh_tunnel">ssh_tunnel</option></select>}
</Field>
<Field label="Vector database" error={errors["vector.database"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector database" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.database} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, database: event.target.value } } }))} />}
</Field>
<Field label="Vector schema" error={errors["vector.schema"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector schema" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.schema} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, schema: event.target.value } } }))} />}
</Field>
<Field label="Vector collection" error={errors["vector.collection"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector collection" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.collection} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, collection: event.target.value } } }))} />}
</Field>
<Field label="Vector port" error={errors["vector.port"]}>
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="65535" aria-label="Vector port" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.port ?? ""} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, port: numberOrUndefined(event.target.value) } } }))} />}
</Field>
<Field label="Vector distance">
{({ id, describedBy, invalid }) => <select id={id} aria-label="Vector distance" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.distance} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, distance: event.target.value as "cosine" | "l2" | "inner_product" } } }))}><option value="cosine">cosine</option><option value="l2">l2</option><option value="inner_product">inner_product</option></select>}
</Field>
<Field label="Semantic index dimensions" error={errors["semantic.dimensions"]} hint="One value is shared by the vector collection and embedding model.">
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="32768" aria-label="Semantic index dimensions" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.dimensions} onChange={(event) => update((value) => { const dimensions = numberOrUndefined(event.target.value) ?? 0; return { ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, dimensions }, embedding: { ...value.semantic_index.embedding, dimensions } } }; })} />}
</Field>
<Field label="Embedding provider">
{({ id, describedBy, invalid }) => <select id={id} aria-label="Embedding provider" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.embedding.provider} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, embedding: { ...value.semantic_index.embedding, provider: event.target.value as "ollama_compatible" | "openai_compatible" } } }))}><option value="ollama_compatible">ollama_compatible</option><option value="openai_compatible">openai_compatible</option></select>}
</Field>
<Field label="Embedding model" error={errors["embedding.model"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Embedding model" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.embedding.model} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, embedding: { ...value.semantic_index.embedding, model: event.target.value } } }))} />}
</Field>
</Section>
<Section title="LLM policy">
<Field label="Allowed models" error={errors["llm.allowed"]} hint="Comma-separated provider/model identifiers.">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Allowed models" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={allowedModels} onChange={(event) => update((value) => { const allowed = event.target.value.split(",").map((model) => model.trim()).filter(Boolean) as `${string}/${string}`[]; return { ...value, llm_policy: { allowed, ...(value.llm_policy.default && allowed.includes(value.llm_policy.default) ? { default: value.llm_policy.default } : {}) } }; })} />}
</Field>
<Field label="Default model" error={errors["llm.default"]} hint="Only models in the allowlist are selectable.">
{({ id, describedBy, invalid }) => <select id={id} aria-label="Default model" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.llm_policy.default ?? ""} onChange={(event) => update((value) => ({ ...value, llm_policy: { ...value.llm_policy, ...(event.target.value ? { default: event.target.value as `${string}/${string}` } : {}) } }))}><option value="">No default</option>{workspace.llm_policy.allowed.map((model) => <option key={model} value={model}>{model}</option>)}</select>}
</Field>
</Section>
<Section title="Installation requirements">
<p className="sm:col-span-2 text-sm text-muted-foreground">Hosts, users, secret-file paths, and credentials are installation bindings. They are intentionally not editable or stored in workspace drafts.</p>
</Section>
<div className="flex justify-end border-t border-border/70 pt-4">
<Button type="submit">Save draft</Button>
</div>
</form>
);
}