refactor: make browser workspace writes bootstrap-only
This commit is contained in:
@@ -1,34 +1,30 @@
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import type { CanonicalWorkspace, PublishWorkspaceRequest } from "../api/workspaces";
|
||||
import type { WorkspaceDraft } from "../workspaces/drafts";
|
||||
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 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;
|
||||
}
|
||||
export type WorkspaceEditorMode =
|
||||
| { kind: "bootstrap"; catalog: WorkspaceSummary; draft: WorkspaceBootstrapDraft }
|
||||
| { kind: "read_only"; catalog: WorkspaceSummary; record: WorkspaceRecord };
|
||||
|
||||
const EMPTY_COMMIT = "0".repeat(40);
|
||||
type BootstrapEditorProps = {
|
||||
mode: Extract<WorkspaceEditorMode, { kind: "bootstrap" }>;
|
||||
onSaveDraft?: (draft: WorkspaceBootstrapDraft) => void;
|
||||
onRequestCreate?: (request: PublishWorkspaceRequest, draft: WorkspaceBootstrapDraft) => void;
|
||||
};
|
||||
|
||||
function emptyWorkspace(): CanonicalWorkspace {
|
||||
return {
|
||||
workspace: { schema_version: 3, id: "new-workspace", name: "New workspace", language: "en" },
|
||||
dwh: { engine: "postgres", database: "database", schema: "public", supported_transports: ["postgres_direct"] },
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "qdrant", collection: "documents",
|
||||
dimensions: 1024, distance: "cosine",
|
||||
},
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
};
|
||||
}
|
||||
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;
|
||||
@@ -52,14 +48,6 @@ function validate(workspace: CanonicalWorkspace): FieldErrors {
|
||||
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.semantic_index.vector_store.engine !== "qdrant") errors["semantic.engine"] = "Vector store engine is fixed to qdrant";
|
||||
if (workspace.semantic_index.vector_store.dimensions !== 1024) errors["semantic.dimensions"] = "Semantic index dimensions are fixed to 1024";
|
||||
if (workspace.semantic_index.vector_store.distance !== "cosine") errors["semantic.distance"] = "Vector distance is fixed to cosine";
|
||||
if (workspace.semantic_index.embedding.provider !== "ollama_internal") errors["embedding.provider"] = "Embedding provider is fixed to ollama_internal";
|
||||
if (workspace.semantic_index.embedding.model !== "qwen3-embedding:0.6b") errors["embedding.model"] = "Embedding model is fixed to qwen3-embedding:0.6b";
|
||||
if (workspace.semantic_index.embedding.dimensions !== 1024 || workspace.semantic_index.embedding.dimensions !== workspace.semantic_index.vector_store.dimensions) {
|
||||
errors["semantic.dimensions"] = "Vector and embedding dimensions are fixed to 1024";
|
||||
}
|
||||
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";
|
||||
}
|
||||
@@ -69,7 +57,7 @@ function validate(workspace: CanonicalWorkspace): FieldErrors {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function selectedValues(event: React.ChangeEvent<HTMLSelectElement>): string[] {
|
||||
function selectedValues(event: ChangeEvent<HTMLSelectElement>): string[] {
|
||||
return Array.from(event.currentTarget.selectedOptions, (option) => option.value);
|
||||
}
|
||||
|
||||
@@ -77,13 +65,28 @@ 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, children, hint,
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
children: (props: { id: string; describedBy?: string; invalid: boolean }) => React.ReactNode;
|
||||
children: (props: { id: string; describedBy?: string; invalid: boolean }) => ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const errorId = `${id}-error`;
|
||||
@@ -99,7 +102,7 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
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>
|
||||
@@ -117,72 +120,81 @@ function EvidenceSummary({ evidence }: { evidence: NonNullable<CanonicalWorkspac
|
||||
<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")} characters</dd></div>
|
||||
<div><dt className="font-medium text-foreground">Retention</dt><dd className="text-muted-foreground">{evidence.policy.retain_published_generations.toLocaleString("en-US")} published generations</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 is managed by the registry descriptor in P1.</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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, idLocked = Boolean(draft?.baseBlob) }: WorkspaceEditorProps) {
|
||||
const [workspace, setWorkspace] = useState<CanonicalWorkspace>(draft?.workspace ?? emptyWorkspace());
|
||||
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(draft?.workspace ?? emptyWorkspace());
|
||||
setWorkspace(props.mode.kind === "bootstrap"
|
||||
? coerceCatalogMetadata(props.mode.draft.workspace, props.mode.catalog)
|
||||
: props.mode.record.workspace);
|
||||
setErrors({});
|
||||
}, [draft]);
|
||||
}, [bootstrapMode, props.mode]);
|
||||
|
||||
function update(change: (previous: CanonicalWorkspace) => CanonicalWorkspace) {
|
||||
if (readOnly) return;
|
||||
setWorkspace((previous) => {
|
||||
const next = change(previous);
|
||||
const next = coerceCatalogMetadata(change(previous), props.mode.catalog);
|
||||
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,
|
||||
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 publishDraft() {
|
||||
function saveDraft() {
|
||||
if (props.mode.kind !== "bootstrap") return;
|
||||
const nextErrors = validate(workspace);
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
const baseCommit = draft?.baseCommit ?? EMPTY_COMMIT;
|
||||
const request: PublishWorkspaceRequest = draft?.baseBlob
|
||||
? { action: "update", workspace, baseCommit, baseBlob: draft.baseBlob }
|
||||
: { action: "create", workspace, baseCommit };
|
||||
void onPublish(request);
|
||||
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={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 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"]}>
|
||||
{({ 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 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} className={fieldClass} value={workspace.workspace.description ?? ""} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, description: event.target.value || undefined } }))} />}
|
||||
{({ 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" } }))}><option value="en">English</option><option value="it">Italian</option></select>}
|
||||
{({ 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>
|
||||
|
||||
@@ -191,19 +203,19 @@ export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Bool
|
||||
{({ 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>}
|
||||
{({ 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} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, database: event.target.value } }))} />}
|
||||
{({ 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} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, schema: event.target.value } }))} />}
|
||||
{({ 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 ?? ""} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, port: numberOrUndefined(event.target.value) } }))} />}
|
||||
{({ 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 ?? ""} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, timeout_ms: numberOrUndefined(event.target.value) } }))} />}
|
||||
{({ 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>
|
||||
|
||||
@@ -212,41 +224,50 @@ export function WorkspaceEditor({ draft, onSaveDraft, onPublish, idLocked = Bool
|
||||
{({ 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} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, collection: event.target.value } } }))} />}
|
||||
{({ 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" error={errors["semantic.dimensions"]} hint="One value is shared by the vector collection and embedding model.">
|
||||
<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" error={errors["embedding.model"]}>
|
||||
<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} 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 } : {}) } }; })} />}
|
||||
{({ 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}` } : {}) } }))}><option value="">No default</option>{workspace.llm_policy.allowed.map((model) => <option key={model} value={model}>{model}</option>)}</select>}
|
||||
{({ 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 workspace drafts.</p>
|
||||
<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>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border/70 pt-4">
|
||||
<Button type="submit" variant="outline">Save draft</Button>
|
||||
<Button type="button" onClick={publishDraft}>Publish draft</Button>
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user