feat: add workspace management editor

This commit is contained in:
2026-08-04 06:27:59 +02:00
parent b686ffe271
commit 48fd316b90
10 changed files with 744 additions and 7 deletions
+234
View File
@@ -0,0 +1,234 @@
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>
);
}