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

274 lines
17 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, type ChangeEvent, type ReactNode } from "react";
import type { CanonicalWorkspace, PublishWorkspaceRequest, WorkspaceRecord, WorkspaceSummary } from "../api/workspaces";
import type { WorkspaceBootstrapDraft } from "../workspaces/drafts";
import { Button } from "../components/ui/button";
type FieldErrors = Record<string, string>;
export type WorkspaceEditorMode =
| { kind: "bootstrap"; catalog: WorkspaceSummary; draft: WorkspaceBootstrapDraft }
| { kind: "read_only"; catalog: WorkspaceSummary; record: WorkspaceRecord };
type BootstrapEditorProps = {
mode: Extract<WorkspaceEditorMode, { kind: "bootstrap" }>;
onSaveDraft?: (draft: WorkspaceBootstrapDraft) => void;
onRequestCreate?: (request: PublishWorkspaceRequest, draft: WorkspaceBootstrapDraft) => void;
};
type ReadOnlyEditorProps = {
mode: Extract<WorkspaceEditorMode, { kind: "read_only" }>;
/** Absent in read-only mode; declared so the union is uniformly addressable. */
onSaveDraft?: never;
onRequestCreate?: never;
};
export type WorkspaceEditorProps = BootstrapEditorProps | ReadOnlyEditorProps;
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";
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;
const dwhTimeout = positiveInteger(workspace.dwh.timeout_ms, "DWH timeout");
if (dwhTimeout) errors["dwh.timeout"] = dwhTimeout;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.semantic_index.vector_store.collection)) errors["vector.collection"] = "Use a collection identifier";
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: 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 coerceCatalogMetadata(workspace: CanonicalWorkspace, catalog: WorkspaceSummary): CanonicalWorkspace {
return {
...workspace,
workspace: {
...workspace.workspace,
id: catalog.id,
name: catalog.displayName,
description: catalog.description,
},
};
}
function Field({
label,
error,
hint,
children,
}: {
label: string;
error?: string;
hint?: string;
children: (props: { id: string; describedBy?: string; invalid: boolean }) => 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: 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>
);
}
function EvidenceSummary({ evidence }: { evidence: NonNullable<CanonicalWorkspace["evidence"]> }) {
const sourceIdentity = evidence.source.type === "http"
? `${evidence.source.uris.length} canonical URI${evidence.source.uris.length === 1 ? "" : "s"}`
: evidence.source.uri;
return (
<Section title="Evidence">
<dl className="grid gap-2 text-sm sm:col-span-2 sm:grid-cols-2">
<div><dt className="font-medium text-foreground">Source type</dt><dd className="text-muted-foreground">{evidence.source.type}</dd></div>
<div><dt className="font-medium text-foreground">Source</dt><dd className="break-all text-muted-foreground">{sourceIdentity}</dd></div>
<div><dt className="font-medium text-foreground">Chunk size</dt><dd className="text-muted-foreground">{evidence.policy.max_chunk_chars.toLocaleString("en-US")}</dd></div>
<div><dt className="font-medium text-foreground">Retention</dt><dd className="text-muted-foreground">{evidence.policy.retain_published_generations.toLocaleString("en-US")}</dd></div>
</dl>
<p className="sm:col-span-2 text-sm text-muted-foreground">Evidence summary is read-only. Curate source files or URIs in Git.</p>
</Section>
);
}
export function WorkspaceEditor(props: WorkspaceEditorProps) {
const bootstrapMode = props.mode.kind === "bootstrap";
const initialWorkspace = props.mode.kind === "bootstrap"
? coerceCatalogMetadata(props.mode.draft.workspace, props.mode.catalog)
: props.mode.record.workspace;
const [workspace, setWorkspace] = useState<CanonicalWorkspace>(initialWorkspace);
const [errors, setErrors] = useState<FieldErrors>({});
const readOnly = !bootstrapMode;
const allowedModels = useMemo(() => workspace.llm_policy.allowed.join(", "), [workspace.llm_policy.allowed]);
useEffect(() => {
setWorkspace(props.mode.kind === "bootstrap"
? coerceCatalogMetadata(props.mode.draft.workspace, props.mode.catalog)
: props.mode.record.workspace);
setErrors({});
}, [bootstrapMode, props.mode]);
function update(change: (previous: CanonicalWorkspace) => CanonicalWorkspace) {
if (readOnly) return;
setWorkspace((previous) => {
const next = coerceCatalogMetadata(change(previous), props.mode.catalog);
setErrors(validate(next));
return next;
});
}
function currentDraft(): WorkspaceBootstrapDraft {
if (props.mode.kind !== "bootstrap") throw new Error("Read-only workspaces cannot create drafts");
return {
workspaceId: props.mode.catalog.id,
baseCommit: props.mode.draft.baseCommit,
workspace: coerceCatalogMetadata(workspace, props.mode.catalog),
updatedAt: new Date().toISOString(),
};
}
function saveDraft() {
if (props.mode.kind !== "bootstrap") return;
const nextErrors = validate(workspace);
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;
props.onSaveDraft?.(currentDraft());
}
function requestCreate() {
if (props.mode.kind !== "bootstrap") return;
const nextErrors = validate(workspace);
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;
const draft = currentDraft();
props.onRequestCreate?.({ action: "create", workspace: draft.workspace, baseCommit: draft.baseCommit }, draft);
}
return (
<form className="grid gap-6" onSubmit={(event) => { event.preventDefault(); saveDraft(); }} noValidate>
<Section title="General">
<Field label="Workspace ID" error={errors["workspace.id"]} hint="Catalog-owned technical identifier.">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Workspace ID" aria-describedby={describedBy} aria-invalid={invalid} disabled={bootstrapMode} readOnly={readOnly} 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"]} hint={bootstrapMode ? "Catalog-owned display metadata." : undefined}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Workspace name" aria-describedby={describedBy} aria-invalid={invalid} disabled={bootstrapMode} readOnly={readOnly} 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} disabled={bootstrapMode} readOnly={readOnly} 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" } }))} disabled={readOnly}><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"] } }))} disabled={readOnly}><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} readOnly={readOnly} 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} readOnly={readOnly} 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 ?? ""} readOnly={readOnly} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, port: numberOrUndefined(event.target.value) } }))} />}
</Field>
<Field label="DWH timeout (ms)" error={errors["dwh.timeout"]}>
{({ 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 ?? ""} readOnly={readOnly} 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 }) => <input id={id} aria-label="Vector store engine" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="qdrant" readOnly />}
</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} readOnly={readOnly} 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 distance">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector distance" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="cosine" readOnly />}
</Field>
<Field label="Semantic index 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} readOnly />}
</Field>
<Field label="Embedding provider">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Embedding provider" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="ollama_internal" readOnly />}
</Field>
<Field label="Embedding model">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Embedding model" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="qwen3-embedding:0.6b" readOnly />}
</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} readOnly={readOnly} 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}` } : {}) } }))} disabled={readOnly}><option value="">No default</option>{workspace.llm_policy.allowed.map((model) => <option key={model} value={model}>{model}</option>)}</select>}
</Field>
</Section>
{workspace.evidence && <EvidenceSummary evidence={workspace.evidence} />}
<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 browser drafts.</p>
</Section>
{readOnly ? (
<Section title="Curator workflow">
<p className="sm:col-span-2 text-sm text-muted-foreground">To change this workspace, edit {workspace.workspace.id}/workspace.yaml, commit/push, then Pull.</p>
</Section>
) : (
<div className="flex justify-end gap-2 border-t border-border/70 pt-4">
<Button type="submit">Save draft</Button>
{"onRequestCreate" in props && props.onRequestCreate && <Button type="button" variant="outline" onClick={requestCreate}>Create workspace</Button>}
</div>
)}
</form>
);
}