feat: establish unified administration and model context baseline
This commit is contained in:
@@ -17,6 +17,7 @@ frontend/vite.database-management-prototype.config.ts
|
||||
!deploy/env/*.env.example
|
||||
deploy/thothii.env
|
||||
deploy/secrets/
|
||||
deploy/psd/
|
||||
harness/workspaces/*.yaml
|
||||
!harness/workspaces/local.yaml
|
||||
!harness/workspaces/tht.example.yaml
|
||||
|
||||
@@ -574,6 +574,11 @@ può osservare, come tabelle, colonne, relazioni, indici o enum. Una capability
|
||||
|
||||
## Amministrazione e integrazione
|
||||
|
||||
**Workspace Readiness** — La preparazione di uno specifico Workspace per l'uso nel
|
||||
workflow, comprensiva della disponibilità degli artefatti derivati dai suoi metadati
|
||||
Database e dalle sue Evidence. Il preprocessing appartiene a questa preparazione;
|
||||
la configurazione e la sincronizzazione del catalogo restano responsabilità Database.
|
||||
|
||||
**Administration Surface** — Una superficie amministrativa autonoma per configurare o curare una
|
||||
parte dell'installazione. Workspace, Evidence, Memory, Database e Pi sono superfici peer e non
|
||||
dipendono dall'esistenza di una sessione attiva.
|
||||
|
||||
+40
-2
@@ -1,6 +1,6 @@
|
||||
# ThothII — Project State
|
||||
|
||||
Last updated: 2026-09-10.
|
||||
Last updated: 2026-09-12.
|
||||
|
||||
This file is the short operational snapshot. Stable commands and the architecture mental model
|
||||
live in `AGENTS.md`; current design and runtime contracts live under `docs/architecture/`,
|
||||
@@ -14,6 +14,23 @@ requirements as mandatory; do not replace the running server stack in place.
|
||||
|
||||
## Current product shape
|
||||
|
||||
The latest context-shelf A and five Administration pages are implemented locally.
|
||||
At the owner's request, local Docker project `thothii-18998cca7b0a` was rebuilt and
|
||||
its core/frontend recreated on 2026-09-12. The real UI at `http://127.0.0.1:8080`
|
||||
serves shelf A; both services and their existing dependencies are healthy.
|
||||
Runtime model projections were regenerated as schema v2 with the single
|
||||
`zai/glm-5.3` interaction default. Persistent services/volumes were not recreated.
|
||||
The local launcher `/private/tmp/thothii-memory-preview.sh` now adds
|
||||
`/private/tmp/thothii-context-a.compose.yaml` last, building from this checkout
|
||||
instead of the earlier Memory worktree. Previous images are retained under
|
||||
`thothii-core:before-context-a-20260912` and
|
||||
`thothii-frontend:before-context-a-20260912`. Remote server deployment remains pending.
|
||||
Core/session behavior is retained; one independently remembered workspace/model
|
||||
pair controls both Core and Admin. Unsaved Admin changes block navigation and
|
||||
context changes; operation activity locks the selectors. See
|
||||
`docs/reports/2026-09-12-context-shelf-a-implementation.md` for verification and the
|
||||
remaining server/Omics integration gate. Prototype alternatives remain untouched.
|
||||
|
||||
ThothII is a human-in-the-loop datamart builder with three independently built layers:
|
||||
|
||||
```text
|
||||
@@ -252,7 +269,28 @@ regenerates the backend catalog, Pi `models.json`/`settings.json`, and Compose o
|
||||
installation-local `generated/` directory. Those projections are replaceable runtime adapters:
|
||||
they are not edited, backed up, or treated as configuration.
|
||||
|
||||
Session and metadata defaults use canonical `provider/model` IDs. Provider authentication declares
|
||||
Core and Administration now share one canonical `modelCatalog.defaults.interaction` per installation,
|
||||
independent of workspace. Runtime catalog schema v2 contains only `defaultInteraction`; apply the host,
|
||||
backend, and regenerated projections together. Equal legacy defaults normalize on read; conflicting
|
||||
ones require an explicit operator choice. With Admin AI configured, selectable models are the
|
||||
intersection of the Pi and LiteLLM adapters; Core-only installations remain supported without Admin AI.
|
||||
The existing Core and Database controls share the operational selection. Explicit model choices are
|
||||
remembered per authenticated user/application mount in this browser, not in installation settings.
|
||||
Resume uses that selection/default while preserving the historical workspace/revision and manifest.
|
||||
Every model must be manually exercised in both Core and Admin as documented in
|
||||
`docs/general/pi-configuration.md`; validation is not a live model certification.
|
||||
These changes and shelf A are now deployed to local Docker; remote deployment remains pending.
|
||||
|
||||
PSD DeepSeek Pro/Flash now share canonical `deepseek/...` identities across native Pi and LiteLLM,
|
||||
using the existing `DEEPSEEK_API_KEY` bundle entry. Its value was confirmed identical to the working
|
||||
Pi key without exposing it; no secret files were changed. The duplicate `deepseek-metadata` descriptor
|
||||
is removed locally and the tracked example uses the shared provider. `secret_env` overrides legacy
|
||||
Pi auth only inside temporary runtime snapshots and fails closed if the bundle key is missing.
|
||||
The original auth store/history and `zai/glm-5.3` default are preserved. Local Docker projections
|
||||
and core/frontend were updated together on 2026-09-12. Regenerate projections with
|
||||
the matching release for the separate server deployment.
|
||||
|
||||
Provider authentication declares
|
||||
one explicit mode (`secret_env`, `pi_auth`, or `none`); `secret_env` names a protected bundle key.
|
||||
The backend settings store now owns only the selected workspace and thinking level. Existing v1
|
||||
installations use the explicit catalog migration command; schema-v3 workspace descriptors are
|
||||
|
||||
@@ -102,7 +102,7 @@ export function loadMetadataGenerationModels(options: {
|
||||
...(apiKeyEnv ? { apiKeyEnv, apiKey } : {}),
|
||||
}));
|
||||
}
|
||||
const result = new RestartLoadedMetadataGenerationModels(models, catalog.defaultMetadataGeneration);
|
||||
const result = new RestartLoadedMetadataGenerationModels(models, models.size ? catalog.defaultInteraction : null);
|
||||
const safe = result.catalog();
|
||||
return {
|
||||
catalog: () => ({
|
||||
|
||||
@@ -47,9 +47,8 @@ const runtimeModelSchema = z.object({
|
||||
}).strict();
|
||||
|
||||
const catalogSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
defaultSession: canonicalId,
|
||||
defaultMetadataGeneration: canonicalId.optional(),
|
||||
schemaVersion: z.literal(2),
|
||||
defaultInteraction: canonicalId,
|
||||
embedding: z.object({ id: canonicalId, dimensions: z.number().int().positive() }).strict(),
|
||||
models: z.array(runtimeModelSchema).max(64),
|
||||
}).strict();
|
||||
@@ -57,8 +56,7 @@ const catalogSchema = z.object({
|
||||
export type RuntimeModel = z.infer<typeof runtimeModelSchema>;
|
||||
|
||||
export interface RuntimeModelCatalog {
|
||||
readonly defaultSession: string | null;
|
||||
readonly defaultMetadataGeneration: string | null;
|
||||
readonly defaultInteraction: string | null;
|
||||
readonly embedding: Readonly<{ id: string; dimensions: number }> | null;
|
||||
sessionModels(): readonly RuntimeModel[];
|
||||
metadataModels(): readonly RuntimeModel[];
|
||||
@@ -66,19 +64,21 @@ export interface RuntimeModelCatalog {
|
||||
}
|
||||
|
||||
class RestartLoadedRuntimeModelCatalog implements RuntimeModelCatalog {
|
||||
readonly defaultSession: string | null;
|
||||
readonly defaultMetadataGeneration: string | null;
|
||||
readonly defaultInteraction: string | null;
|
||||
readonly embedding: Readonly<{ id: string; dimensions: number }> | null;
|
||||
readonly #sessions: readonly RuntimeModel[];
|
||||
readonly #metadata: readonly RuntimeModel[];
|
||||
readonly #sessionIds: ReadonlySet<string>;
|
||||
|
||||
constructor(catalog?: z.infer<typeof catalogSchema>) {
|
||||
this.defaultSession = catalog?.defaultSession ?? null;
|
||||
this.defaultMetadataGeneration = catalog?.defaultMetadataGeneration ?? null;
|
||||
this.defaultInteraction = catalog?.defaultInteraction ?? null;
|
||||
this.embedding = catalog ? Object.freeze({ ...catalog.embedding }) : null;
|
||||
this.#sessions = Object.freeze((catalog?.models ?? []).filter((model) => model.session !== undefined));
|
||||
this.#metadata = Object.freeze((catalog?.models ?? []).filter((model) => model.metadataGeneration !== undefined));
|
||||
// One operational list. Session-only installations can still run Core, but once Admin
|
||||
// LLM models are configured every selectable model must support both adapters.
|
||||
const hasMetadata = catalog?.models.some((model) => model.metadataGeneration !== undefined);
|
||||
this.#sessions = Object.freeze((catalog?.models ?? []).filter((model) =>
|
||||
model.session !== undefined && (!hasMetadata || model.metadataGeneration !== undefined)));
|
||||
this.#metadata = Object.freeze(this.#sessions.filter((model) => model.metadataGeneration !== undefined));
|
||||
this.#sessionIds = new Set(this.#sessions.map((model) => model.id));
|
||||
}
|
||||
|
||||
@@ -129,11 +129,9 @@ export function loadRuntimeModelCatalog(file?: string): RuntimeModelCatalog {
|
||||
if (ids.size !== parsed.data.models.length) throw new Error("runtime model catalog contains duplicate models");
|
||||
const sessions = parsed.data.models.filter((model) => model.session !== undefined).map((model) => model.id);
|
||||
const metadata = parsed.data.models.filter((model) => model.metadataGeneration !== undefined).map((model) => model.id);
|
||||
if (!sessions.includes(parsed.data.defaultSession)) throw new Error("runtime model catalog session default is invalid");
|
||||
if ((metadata.length > 0) !== (parsed.data.defaultMetadataGeneration !== undefined)
|
||||
|| (parsed.data.defaultMetadataGeneration !== undefined
|
||||
&& !metadata.includes(parsed.data.defaultMetadataGeneration))) {
|
||||
throw new Error("runtime model catalog metadata default is invalid");
|
||||
if (!sessions.includes(parsed.data.defaultInteraction)
|
||||
|| (metadata.length > 0 && !metadata.includes(parsed.data.defaultInteraction))) {
|
||||
throw new Error("runtime model catalog interaction default is invalid");
|
||||
}
|
||||
return new RestartLoadedRuntimeModelCatalog(parsed.data);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
validateDeclarativePiConfig,
|
||||
} from "./managed-config.js";
|
||||
import type { RuntimeModelCatalog } from "../models/runtime-model-catalog.js";
|
||||
import { secretValue } from "../config/secret-bundle.js";
|
||||
|
||||
export interface PiModel {
|
||||
provider: string;
|
||||
@@ -64,6 +65,14 @@ export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): ListModels
|
||||
}
|
||||
|
||||
const env = buildPiChildEnv({});
|
||||
// Pi's availability enumeration also needs the catalog-owned credentials for built-in
|
||||
// providers. It must keep working after their obsolete Pi auth entries are removed.
|
||||
for (const model of opts.modelCatalog?.sessionModels() ?? []) {
|
||||
const name = model.authentication.mode === "secret_env" ? model.authentication.apiKeyEnv : undefined;
|
||||
if (!name) continue;
|
||||
const value = secretValue(cfg, name);
|
||||
if (value) env[name] = value;
|
||||
}
|
||||
delete env.THT_DATA_ROOT;
|
||||
if (cfg.dataRoot !== undefined) env.THT_DATA_ROOT = cfg.dataRoot;
|
||||
const child = spawnFn(cfg.piBin, ["--mode", "rpc"], { cwd: cfg.harnessDir, env });
|
||||
|
||||
@@ -138,7 +138,7 @@ export interface PiRuntimeAgentSnapshot {
|
||||
* Bind a session Pi process to the exact managed auth/model bytes validated at spawn time.
|
||||
* Other agent resources remain live through symlinks, while session storage stays persistent.
|
||||
*/
|
||||
export function createPiRuntimeAgentSnapshot(): PiRuntimeAgentSnapshot {
|
||||
export function createPiRuntimeAgentSnapshot(options: { excludeAuthProvider?: string } = {}): PiRuntimeAgentSnapshot {
|
||||
const sourceAgentDir = configuredPiAgentDir();
|
||||
const auth = readPiAgentFile(sourceAgentDir, "auth.json", true);
|
||||
const models = readPiAgentFile(sourceAgentDir, "models.json", true);
|
||||
@@ -165,7 +165,18 @@ export function createPiRuntimeAgentSnapshot(): PiRuntimeAgentSnapshot {
|
||||
);
|
||||
}
|
||||
if (auth !== undefined) {
|
||||
writeFileSync(join(snapshotDir, "auth.json"), auth, { flag: "wx", mode: 0o600 });
|
||||
let effectiveAuth = auth;
|
||||
if (options.excludeAuthProvider) {
|
||||
const parsed = parsePiConfigJson(auth);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new PiManagedConfigError();
|
||||
const provider = options.excludeAuthProvider.trim().toLowerCase();
|
||||
effectiveAuth = JSON.stringify(Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key]) => key.trim().toLowerCase() !== provider),
|
||||
));
|
||||
}
|
||||
// secret_env is authoritative for this provider. Keep the operator's auth file intact,
|
||||
// but do not let an old Pi credential override the shared bundle inside this child.
|
||||
writeFileSync(join(snapshotDir, "auth.json"), effectiveAuth, { flag: "wx", mode: 0o600 });
|
||||
}
|
||||
if (models !== undefined) {
|
||||
writeFileSync(join(snapshotDir, "models.json"), models, { flag: "wx", mode: 0o600 });
|
||||
|
||||
@@ -106,23 +106,23 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
||||
});
|
||||
const credentialStatus = deps.credentialStatus ?? ((provider: string | undefined) => {
|
||||
try {
|
||||
const model = deps.modelCatalog.defaultSession
|
||||
? deps.modelCatalog.sessionModels().find((entry) => entry.id === deps.modelCatalog.defaultSession)
|
||||
const model = deps.modelCatalog.defaultInteraction
|
||||
? deps.modelCatalog.sessionModels().find((entry) => entry.id === deps.modelCatalog.defaultInteraction)
|
||||
: undefined;
|
||||
const credentialName = model?.authentication.mode === "secret_env"
|
||||
? model.authentication.apiKeyEnv
|
||||
: undefined;
|
||||
const configuredApiKey = configuredPiProviderApiKey(
|
||||
const configuredApiKey = credentialName ? `$${credentialName}` : configuredPiProviderApiKey(
|
||||
readConfiguredPiAgentFile("models.json", true),
|
||||
provider,
|
||||
) ?? (credentialName ? `$${credentialName}` : undefined);
|
||||
);
|
||||
return piProviderCredentialStatus({
|
||||
provider,
|
||||
authProviders: loadPiAuthProviders(),
|
||||
authProviders: credentialName ? new Set() : loadPiAuthProviders(),
|
||||
resolveCredentialValue: () => credentialName
|
||||
? secretValue(config, credentialName)
|
||||
: config.modelCatalogFile ? undefined : secretValue(config, "THT_MODEL_API_KEY"),
|
||||
credentialFile: config.modelApiKeyFile,
|
||||
credentialFile: credentialName ? undefined : config.modelApiKeyFile,
|
||||
configuredApiKey,
|
||||
});
|
||||
} catch {
|
||||
@@ -152,8 +152,8 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
||||
const installationConfig = (): PiInstallationConfig => {
|
||||
const settings = readSettings();
|
||||
const reasoning = config.defaults.thinking ?? settings.thinking;
|
||||
const selected = deps.modelCatalog.defaultSession
|
||||
? splitCanonicalModelId(deps.modelCatalog.defaultSession)
|
||||
const selected = deps.modelCatalog.defaultInteraction
|
||||
? splitCanonicalModelId(deps.modelCatalog.defaultInteraction)
|
||||
: undefined;
|
||||
return {
|
||||
...(selected ? selected : {}),
|
||||
|
||||
@@ -63,7 +63,7 @@ export class PiProcessManager {
|
||||
) {
|
||||
this.modelCatalog = opts?.modelCatalog ?? loadRuntimeModelCatalog(cfg.modelCatalogFile);
|
||||
this.modelCatalogConfigured = cfg.modelCatalogFile !== undefined
|
||||
|| this.modelCatalog.defaultSession !== null;
|
||||
|| this.modelCatalog.defaultInteraction !== null;
|
||||
this.loadAuthProviders = opts?.authProviders
|
||||
?? ((agentDir) => loadPiAuthProviders({ agentDir }));
|
||||
if (opts?.spawnFn) {
|
||||
@@ -89,25 +89,23 @@ export class PiProcessManager {
|
||||
// This is the final shared boundary for createFor(), spawnFor(), and resume(). Validate
|
||||
// before auth-provider inspection, then make Pi consume the exact copied bytes rather than
|
||||
// reopening mutable mounted auth/models files after this check.
|
||||
const agent = createPiRuntimeAgentSnapshot();
|
||||
const catalogModel = provider && model
|
||||
? this.modelCatalog.sessionModels().find((entry) => entry.provider === provider && entry.model === model)
|
||||
: undefined;
|
||||
const credentialName = catalogModel?.authentication.mode === "secret_env"
|
||||
? catalogModel.authentication.apiKeyEnv : undefined;
|
||||
const agent = createPiRuntimeAgentSnapshot({ excludeAuthProvider: credentialName ? provider : undefined });
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
try {
|
||||
const catalogModel = provider && model
|
||||
? this.modelCatalog.sessionModels()
|
||||
.find((entry) => entry.provider === provider && entry.model === model)
|
||||
: undefined;
|
||||
const credentialName = catalogModel?.authentication.mode === "secret_env"
|
||||
? catalogModel.authentication.apiKeyEnv
|
||||
: undefined;
|
||||
const projectedApiKey = configuredPiProviderApiKey(agent.models, provider)
|
||||
?? (credentialName ? `$${credentialName}` : undefined);
|
||||
const projectedApiKey = credentialName ? `$${credentialName}`
|
||||
: configuredPiProviderApiKey(agent.models, provider);
|
||||
const env = buildPiChildEnv({
|
||||
provider,
|
||||
authProviders: this.loadAuthProviders(agent.agentDir),
|
||||
authProviders: credentialName ? new Set() : this.loadAuthProviders(agent.agentDir),
|
||||
credentialValue: credentialName
|
||||
? secretValue(this.cfg, credentialName)
|
||||
: this.modelCatalogConfigured ? undefined : secretValue(this.cfg, "THT_MODEL_API_KEY"),
|
||||
credentialFile: this.cfg.modelApiKeyFile,
|
||||
credentialFile: credentialName ? undefined : this.cfg.modelApiKeyFile,
|
||||
configuredApiKey: projectedApiKey,
|
||||
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
|
||||
});
|
||||
|
||||
@@ -70,28 +70,29 @@ export function createPiProviderSmoke(
|
||||
try {
|
||||
const canonicalProvider = canonicalPiProvider(provider);
|
||||
if (!canonicalProvider || timeoutMs <= 0) throw providerFailure();
|
||||
const configuredAuthProviders = authProviders();
|
||||
const configuredAuthProviders = new Set(authProviders());
|
||||
const configuredModels = options.readModelsStore
|
||||
? options.readModelsStore()
|
||||
: readConfiguredPiAgentFile("models.json", true);
|
||||
const catalog = options.modelCatalog ?? loadRuntimeModelCatalog(config.modelCatalogFile);
|
||||
const catalogConfigured = config.modelCatalogFile !== undefined
|
||||
|| catalog.defaultSession !== null;
|
||||
|| catalog.defaultInteraction !== null;
|
||||
const catalogModel = catalog.sessionModels()
|
||||
.find((entry) => entry.provider === canonicalProvider && entry.model === model);
|
||||
const upstreamModel = catalogModel?.upstreamModel ?? model;
|
||||
const credentialName = catalogModel?.authentication.mode === "secret_env"
|
||||
? catalogModel.authentication.apiKeyEnv
|
||||
: undefined;
|
||||
const projectedApiKey = configuredPiProviderApiKey(configuredModels, canonicalProvider)
|
||||
?? (credentialName ? `$${credentialName}` : undefined);
|
||||
if (credentialName) configuredAuthProviders.delete(canonicalProvider);
|
||||
const projectedApiKey = credentialName ? `$${credentialName}`
|
||||
: configuredPiProviderApiKey(configuredModels, canonicalProvider);
|
||||
const env = buildPiChildEnv({
|
||||
provider: canonicalProvider,
|
||||
authProviders: configuredAuthProviders,
|
||||
credentialValue: credentialName
|
||||
? secretValue(config, credentialName)
|
||||
: catalogConfigured ? undefined : secretValue(config, "THT_MODEL_API_KEY"),
|
||||
credentialFile: config.modelApiKeyFile,
|
||||
credentialFile: credentialName ? undefined : config.modelApiKeyFile,
|
||||
configuredApiKey: projectedApiKey,
|
||||
});
|
||||
clearPrincipalEnvironment(env);
|
||||
|
||||
@@ -383,6 +383,10 @@ export function sessionRoutes(
|
||||
question: string; name?: string; workspace?: string; workspaceId?: string;
|
||||
provider?: string; model?: string; thinking?: string;
|
||||
};
|
||||
if ((b.provider === undefined) !== (b.model === undefined)
|
||||
|| (b.provider !== undefined && (typeof b.provider !== "string" || typeof b.model !== "string" || !b.provider || !b.model))) {
|
||||
return reply.code(400).send({ error: "provider and model must be supplied together" });
|
||||
}
|
||||
const principal = getPrincipal(req);
|
||||
let s: Settings;
|
||||
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
|
||||
@@ -446,11 +450,9 @@ export function sessionRoutes(
|
||||
}
|
||||
}
|
||||
const requestedCanonical = b.provider && b.model ? `${b.provider}/${b.model}` : undefined;
|
||||
let selectedCanonical = requestedCanonical ?? d.modelCatalog.defaultSession;
|
||||
let modelWarning: string | undefined;
|
||||
if (selectedCanonical && d.modelCatalog.defaultSession && !d.modelCatalog.hasSession(selectedCanonical)) {
|
||||
selectedCanonical = d.modelCatalog.defaultSession;
|
||||
modelWarning = `Configured model ${requestedCanonical ?? "selection"} is unavailable; using ${selectedCanonical}.`;
|
||||
const selectedCanonical = requestedCanonical ?? d.modelCatalog.defaultInteraction;
|
||||
if (selectedCanonical && d.modelCatalog.defaultInteraction && !d.modelCatalog.hasSession(selectedCanonical)) {
|
||||
return reply.code(503).send({ error: MODEL_UNAVAILABLE_MESSAGE, code: "model_unavailable" });
|
||||
}
|
||||
const selected = selectedCanonical ? splitCanonicalModelId(selectedCanonical) : undefined;
|
||||
const provider = selected?.provider ?? b.provider;
|
||||
@@ -554,7 +556,7 @@ export function sessionRoutes(
|
||||
),
|
||||
() => d.mgr.start(id, rt, runtimeOptions),
|
||||
);
|
||||
return { id, ...(modelWarning ? { warning: modelWarning } : {}) };
|
||||
return { id };
|
||||
} finally {
|
||||
if (revisionLease && !manifestPersisted) {
|
||||
await revisionLease.abort().catch((error: unknown) => {
|
||||
@@ -680,10 +682,17 @@ export function sessionRoutes(
|
||||
provider?: string; model?: string; thinking?: string;
|
||||
workspace_id?: string; workspace_revision?: string;
|
||||
};
|
||||
const savedCanonical = saved.provider && saved.model ? `${saved.provider}/${saved.model}` : "";
|
||||
if (d.modelCatalog.defaultSession && (!savedCanonical || !d.modelCatalog.hasSession(savedCanonical))) {
|
||||
const requested = (req.body ?? {}) as { provider?: string; model?: string; thinking?: string };
|
||||
if ((requested.provider === undefined) !== (requested.model === undefined)
|
||||
|| (requested.provider !== undefined && (typeof requested.provider !== "string" || typeof requested.model !== "string" || !requested.provider || !requested.model))) {
|
||||
return reply.code(400).send({ error: "provider and model must be supplied together" });
|
||||
}
|
||||
const selectedCanonical = requested.provider && requested.model
|
||||
? `${requested.provider}/${requested.model}` : d.modelCatalog.defaultInteraction;
|
||||
if (d.modelCatalog.defaultInteraction && (!selectedCanonical || !d.modelCatalog.hasSession(selectedCanonical))) {
|
||||
return reply.code(503).send({ error: MODEL_UNAVAILABLE_MESSAGE, code: "model_unavailable" });
|
||||
}
|
||||
const selected = selectedCanonical ? splitCanonicalModelId(selectedCanonical) : saved;
|
||||
let workspaceConfigPath: string;
|
||||
let workspaceDescriptor: WorkspaceDescriptor | undefined;
|
||||
try {
|
||||
@@ -716,7 +725,7 @@ export function sessionRoutes(
|
||||
if (existing) {
|
||||
const state = existing.bridge.turnState();
|
||||
if (state === "running" || state === "waiting") {
|
||||
return reply.code(200).send({ id, alreadyActive: true });
|
||||
return reply.code(200).send({ id, alreadyActive: true, workspaceId: saved.workspace_id });
|
||||
}
|
||||
}
|
||||
const ensure = await d.readiness.ensure(
|
||||
@@ -727,8 +736,8 @@ export function sessionRoutes(
|
||||
...(ensure.code ? { code: ensure.code } : {}),
|
||||
});
|
||||
const options = {
|
||||
provider: saved?.provider,
|
||||
model: saved?.model,
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
thinking: saved?.thinking ?? settings.thinking,
|
||||
author: principal.displayName ?? principal.subject,
|
||||
principal,
|
||||
@@ -750,7 +759,7 @@ export function sessionRoutes(
|
||||
if (current) {
|
||||
const state = current.bridge.turnState();
|
||||
if (state === "running" || state === "waiting") {
|
||||
return reply.code(200).send({ id, alreadyActive: true });
|
||||
return reply.code(200).send({ id, alreadyActive: true, workspaceId: saved.workspace_id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -791,7 +800,7 @@ export function sessionRoutes(
|
||||
d.mgr.configure(rt, runtimeOptions), null,
|
||||
() => d.mgr.start(id, rt, runtimeOptions),
|
||||
);
|
||||
return reply.code(200).send({ id, alreadyActive: false });
|
||||
return reply.code(200).send({ id, alreadyActive: false, workspaceId: saved.workspace_id });
|
||||
});
|
||||
});
|
||||
app.post("/sessions/:id/close", async (req, reply) => {
|
||||
|
||||
@@ -16,8 +16,8 @@ export function effectiveSettings(
|
||||
modelCatalog?: RuntimeModelCatalog,
|
||||
): Settings {
|
||||
const workspaces = listWorkspaces(cfg.harnessDir);
|
||||
const selected = modelCatalog?.defaultSession
|
||||
? splitCanonicalModelId(modelCatalog.defaultSession)
|
||||
const selected = modelCatalog?.defaultInteraction
|
||||
? splitCanonicalModelId(modelCatalog.defaultInteraction)
|
||||
: undefined;
|
||||
return {
|
||||
workspace: stored.workspace ?? workspaces[0]?.name,
|
||||
|
||||
@@ -56,7 +56,7 @@ test("catalog listing translates upstream Pi IDs back to canonical model keys",
|
||||
session: { reasoning: true, contextWindow: 32768, maxTokens: 8192 },
|
||||
};
|
||||
const modelCatalog: RuntimeModelCatalog = {
|
||||
defaultSession: model.id, defaultMetadataGeneration: null, embedding: null,
|
||||
defaultInteraction: model.id, embedding: null,
|
||||
sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id,
|
||||
};
|
||||
try {
|
||||
@@ -75,6 +75,38 @@ test("catalog listing translates upstream Pi IDs back to canonical model keys",
|
||||
}
|
||||
});
|
||||
|
||||
test("catalog listing supplies the shared DeepSeek key without requiring Pi auth", async () => {
|
||||
const script = scriptWith([{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }]);
|
||||
const secret = join(path.dirname(script), "thothii.secrets");
|
||||
writeFileSync(secret, "DEEPSEEK_API_KEY=shared-key\nOPENAI_API_KEY=unrelated-key\n", { mode: 0o600 });
|
||||
const model: RuntimeModel = {
|
||||
id: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro",
|
||||
label: "DeepSeek V4 Pro", upstreamModel: "deepseek-v4-pro",
|
||||
authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" },
|
||||
sessionAdapter: { mode: "pi_builtin" }, session: { reasoning: false },
|
||||
};
|
||||
const modelCatalog: RuntimeModelCatalog = {
|
||||
defaultInteraction: model.id, embedding: null,
|
||||
sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id,
|
||||
};
|
||||
try {
|
||||
const lister = createPiModelLister(loadConfig({ THT_SECRETS_FILE: secret }), {
|
||||
...noManagedModels, modelCatalog, loadEnabledModels: enabled(model.id),
|
||||
spawnFn: (_command, _args, options) => {
|
||||
expect(options.env.DEEPSEEK_API_KEY).toBe("shared-key");
|
||||
expect(options.env).not.toHaveProperty("OPENAI_API_KEY");
|
||||
expect(options.env).not.toHaveProperty("THT_SECRETS_FILE");
|
||||
return spawn("node", [FAKE, script], { env: options.env }) as any;
|
||||
},
|
||||
});
|
||||
await expect(lister()).resolves.toEqual([{
|
||||
provider: model.provider, id: model.model, name: model.label, reasoning: false,
|
||||
}]);
|
||||
} finally {
|
||||
rmSync(path.dirname(script), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createPiModelLister caches within ttl (spawns once for two calls)", async () => {
|
||||
const script = scriptWith([{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }]);
|
||||
try {
|
||||
|
||||
@@ -20,9 +20,8 @@ function runtimeCatalog(overrides: Record<string, unknown> = {}, secrets = "OPEN
|
||||
const catalogFile = join(root, "catalog.json");
|
||||
const secretsFile = join(root, "thothii.secrets");
|
||||
const catalog = {
|
||||
schemaVersion: 1,
|
||||
defaultSession: "zai/glm-5.3",
|
||||
defaultMetadataGeneration: "zai/glm-5.3",
|
||||
schemaVersion: 2,
|
||||
defaultInteraction: "zai/glm-5.3",
|
||||
embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
models: [
|
||||
{
|
||||
@@ -55,8 +54,8 @@ test("loads session default and safe metadata choices from the normalized runtim
|
||||
const runtime = loadRuntimeModelCatalog(catalogFile);
|
||||
const metadata = loadMetadataGenerationModels({ catalogFile, secretsFile });
|
||||
|
||||
expect(runtime.defaultSession).toBe("zai/glm-5.3");
|
||||
expect(runtime.hasSession("deepseek/deepseek-v4-pro")).toBe(true);
|
||||
expect(runtime.defaultInteraction).toBe("zai/glm-5.3");
|
||||
expect(runtime.hasSession("deepseek/deepseek-v4-pro")).toBe(false);
|
||||
expect(metadata.catalog()).toEqual({
|
||||
models: [{ id: "zai/glm-5.3", label: "GLM 5.3" }],
|
||||
default: "zai/glm-5.3",
|
||||
@@ -69,14 +68,36 @@ test("loads session default and safe metadata choices from the normalized runtim
|
||||
expect(() => metadata.resolve("zai/missing")).toThrow(MetadataGenerationModelUnavailableError);
|
||||
});
|
||||
|
||||
test("DeepSeek uses one identity and bundle credential for native Pi and LiteLLM", () => {
|
||||
const models = ["deepseek-v4-pro", "deepseek-v4-flash"].map((model) => ({
|
||||
id: `deepseek/${model}`, provider: "deepseek", model, label: model, upstreamModel: model,
|
||||
authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" },
|
||||
sessionAdapter: { mode: "pi_builtin" }, metadataAdapter: { litellmProvider: "deepseek" },
|
||||
session: { reasoning: false }, metadataGeneration: { disableThinking: false },
|
||||
}));
|
||||
const files = runtimeCatalog({ defaultInteraction: models[0].id, models }, "DEEPSEEK_API_KEY=shared-key\n");
|
||||
const runtime = loadRuntimeModelCatalog(files.catalogFile);
|
||||
const metadata = loadMetadataGenerationModels(files);
|
||||
expect(runtime.sessionModels().map((model) => model.id)).toEqual(metadata.catalog().models.map((model) => model.id));
|
||||
expect(metadata.catalog().default).toBe(runtime.defaultInteraction);
|
||||
for (const model of models) {
|
||||
expect(metadata.resolve(model.id)).toMatchObject({
|
||||
id: model.id, provider: "deepseek", model: model.model,
|
||||
apiKeyEnv: "DEEPSEEK_API_KEY", apiKey: "shared-key",
|
||||
});
|
||||
}
|
||||
expect(() => metadata.resolve("deepseek-metadata/deepseek-v4-pro"))
|
||||
.toThrow(MetadataGenerationModelUnavailableError);
|
||||
});
|
||||
|
||||
test("returns empty catalogs when no runtime projection is configured", () => {
|
||||
expect(loadRuntimeModelCatalog().defaultSession).toBeNull();
|
||||
expect(loadRuntimeModelCatalog().defaultInteraction).toBeNull();
|
||||
expect(loadMetadataGenerationModels({}).catalog()).toEqual({ models: [], default: null });
|
||||
});
|
||||
|
||||
test("rejects a drifted default and an unprotected projection", () => {
|
||||
const drifted = runtimeCatalog({ defaultSession: "zai/missing" });
|
||||
expect(() => loadRuntimeModelCatalog(drifted.catalogFile)).toThrow("session default is invalid");
|
||||
const drifted = runtimeCatalog({ defaultInteraction: "zai/missing" });
|
||||
expect(() => loadRuntimeModelCatalog(drifted.catalogFile)).toThrow("interaction default is invalid");
|
||||
|
||||
const unprotected = runtimeCatalog();
|
||||
chmodSync(unprotected.catalogFile, 0o666);
|
||||
@@ -85,7 +106,7 @@ test("rejects a drifted default and an unprotected projection", () => {
|
||||
|
||||
test("rejects authentication semantics that cannot come from the installation catalog", () => {
|
||||
const invalid = runtimeCatalog({
|
||||
defaultMetadataGeneration: undefined,
|
||||
defaultInteraction: undefined,
|
||||
models: [{
|
||||
id: "zai/glm-5.3",
|
||||
provider: "zai",
|
||||
@@ -112,3 +133,13 @@ test("splits canonical session identities without provider aliases", () => {
|
||||
expect(splitCanonicalModelId("zai/glm-5.3")).toEqual({ provider: "zai", model: "glm-5.3" });
|
||||
expect(() => splitCanonicalModelId("glm-5.3")).toThrow("model identity is invalid");
|
||||
});
|
||||
|
||||
test("rejects a default supported by only one configured use", () => {
|
||||
const { catalogFile } = runtimeCatalog({ defaultInteraction: "deepseek/deepseek-v4-pro" });
|
||||
expect(() => loadRuntimeModelCatalog(catalogFile)).toThrow("interaction default is invalid");
|
||||
});
|
||||
|
||||
test("requires regenerated runtime schema v2 rather than interpreting two legacy defaults", () => {
|
||||
const { catalogFile } = runtimeCatalog({ schemaVersion: 1, defaultSession: "zai/glm-5.3" });
|
||||
expect(() => loadRuntimeModelCatalog(catalogFile)).toThrow("runtime model catalog is invalid");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test, vi } from "vitest";
|
||||
@@ -7,7 +7,34 @@ import {
|
||||
createPiManagement,
|
||||
type PiExecFile,
|
||||
} from "../src/pi/management.js";
|
||||
import type { RuntimeModelCatalog } from "../src/models/runtime-model-catalog.js";
|
||||
import type { RuntimeModel, RuntimeModelCatalog } from "../src/models/runtime-model-catalog.js";
|
||||
|
||||
test.each([false, true])("catalog credential status ignores legacy Pi auth, missing bundle key: %s", async (missingKey) => {
|
||||
const root = mkdtempSync(join(tmpdir(), "tht-catalog-status-"));
|
||||
writeFileSync(join(root, "auth.json"), JSON.stringify({ deepseek: { type: "api_key", key: "stale-key" } }), { mode: 0o600 });
|
||||
const secret = join(root, "thothii.secrets");
|
||||
writeFileSync(secret, missingKey ? "THT_MODEL_API_KEY=legacy-key\n" : "DEEPSEEK_API_KEY=shared-key\n", { mode: 0o600 });
|
||||
vi.stubEnv("PI_CODING_AGENT_DIR", root);
|
||||
const model: RuntimeModel = {
|
||||
id: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro",
|
||||
label: "DeepSeek", upstreamModel: "deepseek-v4-pro",
|
||||
authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" },
|
||||
sessionAdapter: { mode: "pi_builtin" }, session: { reasoning: false },
|
||||
};
|
||||
try {
|
||||
const service = createPiManagement(loadConfig({ THT_SECRETS_FILE: secret }), {
|
||||
modelCatalog: {
|
||||
defaultInteraction: model.id, embedding: null,
|
||||
sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id,
|
||||
},
|
||||
execute: successfulExec([]), readSettings: () => ({ thinking: "medium" }),
|
||||
});
|
||||
expect((await service.status()).credentials).toBe(missingKey ? "missing" : "present");
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-management-")), "settings.json")) {
|
||||
return loadConfig({
|
||||
@@ -19,8 +46,7 @@ function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-manage
|
||||
}
|
||||
|
||||
const modelCatalog: RuntimeModelCatalog = {
|
||||
defaultSession: "zai/glm-5.2",
|
||||
defaultMetadataGeneration: null,
|
||||
defaultInteraction: "zai/glm-5.2",
|
||||
embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
sessionModels: () => [],
|
||||
metadataModels: () => [],
|
||||
|
||||
@@ -642,27 +642,33 @@ test("session Pi spawn reads the single secret bundle and scrubs its path", asyn
|
||||
}
|
||||
});
|
||||
|
||||
test("session Pi spawn resolves the selected catalog credential from the secret bundle", () => {
|
||||
test.each([false, true])("session Pi uses the catalog bundle despite stale Pi auth: %s", (missingKey) => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "thothii-catalog-credential-"));
|
||||
const agentDir = path.join(root, "agent");
|
||||
mkdirSync(agentDir, { mode: 0o700 });
|
||||
writeFileSync(path.join(agentDir, "auth.json"), "{}\n", { mode: 0o600 });
|
||||
const originalAuth = JSON.stringify({
|
||||
deepseek: { type: "api_key", key: "stale-key" },
|
||||
anthropic: { type: "api_key", key: "unrelated-key" },
|
||||
});
|
||||
writeFileSync(path.join(agentDir, "auth.json"), originalAuth, { mode: 0o600 });
|
||||
writeFileSync(path.join(agentDir, "models.json"), '{"providers":{}}\n', { mode: 0o600 });
|
||||
const secret = path.join(root, "thothii.secrets");
|
||||
writeFileSync(secret, "ZAI_API_KEY=catalog-secret\nTHT_MODEL_API_KEY=legacy-secret\n", { mode: 0o600 });
|
||||
writeFileSync(secret, missingKey ? "THT_MODEL_API_KEY=legacy-secret\n"
|
||||
: "DEEPSEEK_API_KEY=catalog-secret\nTHT_MODEL_API_KEY=legacy-secret\n", { mode: 0o600 });
|
||||
const legacyKey = path.join(root, "legacy-key");
|
||||
writeFileSync(legacyKey, "legacy-file-key", { mode: 0o600 });
|
||||
const model: RuntimeModel = {
|
||||
id: "openai/test-model",
|
||||
provider: "openai",
|
||||
model: "test-model",
|
||||
label: "Test model",
|
||||
upstreamModel: "test-model",
|
||||
authentication: { mode: "secret_env", apiKeyEnv: "ZAI_API_KEY" },
|
||||
id: "deepseek/deepseek-v4-pro",
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
label: "DeepSeek V4 Pro",
|
||||
upstreamModel: "deepseek-v4-pro",
|
||||
authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" },
|
||||
sessionAdapter: { mode: "pi_builtin" },
|
||||
session: { reasoning: false },
|
||||
};
|
||||
const modelCatalog: RuntimeModelCatalog = {
|
||||
defaultSession: model.id,
|
||||
defaultMetadataGeneration: null,
|
||||
defaultInteraction: model.id,
|
||||
embedding: null,
|
||||
sessionModels: () => [model],
|
||||
metadataModels: () => [],
|
||||
@@ -672,17 +678,26 @@ test("session Pi spawn resolves the selected catalog credential from the secret
|
||||
const child = recordingChild();
|
||||
child.stderr.resume = () => {};
|
||||
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
||||
const mgr = new PiProcessManager(loadConfig({ THT_SECRETS_FILE: secret }), {
|
||||
const mgr = new PiProcessManager(loadConfig({ THT_SECRETS_FILE: secret, THT_MODEL_API_KEY_FILE: legacyKey }), {
|
||||
modelCatalog,
|
||||
authProviders: () => new Set(),
|
||||
authProviders: () => new Set(["deepseek"]),
|
||||
spawnFn: (...args: any[]) => { calls.push(args); return child as any; },
|
||||
});
|
||||
try {
|
||||
mgr.createFor("catalog-credential", { provider: "openai", model: "test-model" });
|
||||
expect(calls[0][2].env.ZAI_API_KEY).toBe("catalog-secret");
|
||||
const create = () => mgr.createFor("catalog-credential", { provider: model.provider, model: model.model });
|
||||
if (missingKey) {
|
||||
expect(create).toThrow("model provider credential is unavailable");
|
||||
expect(calls).toHaveLength(0);
|
||||
return;
|
||||
}
|
||||
create();
|
||||
expect(calls[0][2].env.DEEPSEEK_API_KEY).toBe("catalog-secret");
|
||||
expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY");
|
||||
expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY");
|
||||
expect(JSON.parse(readFileSync(path.join(calls[0][2].env.PI_CODING_AGENT_DIR, "auth.json"), "utf8")))
|
||||
.toEqual({ anthropic: { type: "api_key", key: "unrelated-key" } });
|
||||
} finally {
|
||||
expect(readFileSync(path.join(agentDir, "auth.json"), "utf8")).toBe(originalAuth);
|
||||
mgr.teardown("catalog-credential");
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
@@ -750,7 +765,7 @@ test("set_model translates a canonical catalog key to its upstream Pi model ID",
|
||||
session: { reasoning: false, contextWindow: 32768, maxTokens: 8192 },
|
||||
};
|
||||
const modelCatalog: RuntimeModelCatalog = {
|
||||
defaultSession: model.id, defaultMetadataGeneration: null, embedding: null,
|
||||
defaultInteraction: model.id, embedding: null,
|
||||
sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id,
|
||||
};
|
||||
const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), {
|
||||
|
||||
@@ -61,20 +61,22 @@ test("provider smoke resolves the selected catalog credential from the secret bu
|
||||
session: { reasoning: false },
|
||||
};
|
||||
const modelCatalog: RuntimeModelCatalog = {
|
||||
defaultSession: model.id,
|
||||
defaultMetadataGeneration: null,
|
||||
defaultInteraction: model.id,
|
||||
embedding: null,
|
||||
sessionModels: () => [model],
|
||||
metadataModels: () => [],
|
||||
hasSession: (id) => id === model.id,
|
||||
};
|
||||
let spawnEnv: NodeJS.ProcessEnv | undefined;
|
||||
const readAuthStore = vi.fn(() => JSON.stringify({ openai: { type: "api_key", key: "stale-key" } }));
|
||||
const smoke = createPiProviderSmoke(loadConfig({ THT_SECRETS_FILE: secret }), {
|
||||
modelCatalog,
|
||||
authProviders: () => new Set(),
|
||||
authProviders: () => new Set(["openai"]),
|
||||
readAuthStore,
|
||||
readModelsStore: () => undefined,
|
||||
spawnFn: (_command, _args, options) => {
|
||||
spawnEnv = options.env;
|
||||
expect(existsSync(join(options.env.PI_CODING_AGENT_DIR!, "auth.json"))).toBe(false);
|
||||
return successfulProviderChild();
|
||||
},
|
||||
});
|
||||
@@ -85,6 +87,7 @@ test("provider smoke resolves the selected catalog credential from the secret bu
|
||||
expect(spawnEnv?.ZAI_API_KEY).toBe("catalog-secret");
|
||||
expect(spawnEnv).not.toHaveProperty("OPENAI_API_KEY");
|
||||
expect(spawnEnv).not.toHaveProperty("THT_MODEL_API_KEY");
|
||||
expect(readAuthStore).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -306,7 +309,7 @@ test("provider smoke makes one configured request from an isolated no-capability
|
||||
sessionAdapter: { mode: "pi_builtin" }, session: { reasoning: true },
|
||||
};
|
||||
const smokeCatalog: RuntimeModelCatalog = {
|
||||
defaultSession: smokeModel.id, defaultMetadataGeneration: null, embedding: null,
|
||||
defaultInteraction: smokeModel.id, embedding: null,
|
||||
sessionModels: () => [smokeModel], metadataModels: () => [],
|
||||
hasSession: (id) => id === smokeModel.id,
|
||||
};
|
||||
|
||||
@@ -27,10 +27,9 @@ function operationalWorkspace(id = "default") {
|
||||
} as const;
|
||||
}
|
||||
|
||||
function sessionCatalog(defaultSession = "zai/glm-5.2", available = [defaultSession]) {
|
||||
function sessionCatalog(defaultInteraction = "zai/glm-5.2", available = [defaultInteraction]) {
|
||||
return {
|
||||
defaultSession,
|
||||
defaultMetadataGeneration: null,
|
||||
defaultInteraction,
|
||||
embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
sessionModels: () => [],
|
||||
metadataModels: () => [],
|
||||
@@ -909,7 +908,7 @@ test("session lifecycle locates a B session when installation default is A", asy
|
||||
|
||||
active = undefined;
|
||||
expect((await app.inject({ method: "POST", url: "/sessions/session-b/resume" })).json())
|
||||
.toEqual({ id: "session-b", alreadyActive: false });
|
||||
.toEqual({ id: "session-b", alreadyActive: false, workspaceId: "b-workspace" });
|
||||
expect(calls).toContain(`new:${bPath}`);
|
||||
expect(calls).toContain(`list:${bPath}`);
|
||||
expect(calls).toContain(`show:${bPath}`);
|
||||
@@ -1179,6 +1178,29 @@ test("POST /sessions/:id/resume uses the manifest's retained workspace revision"
|
||||
);
|
||||
});
|
||||
|
||||
test.each([undefined, { provider: "local", model: "qwen" }])("resume uses the global model/default, not the historical manifest (%j)", async (payload) => {
|
||||
const configure = vi.fn(async () => {});
|
||||
const manifest = { status: "open", archived: false, provider: "retired", model: "old-model" };
|
||||
const runtime = { bridge: { onClientEvent: () => {}, emitClientEvent: () => {} } };
|
||||
let current: any;
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
mgr: {
|
||||
get: () => current,
|
||||
createFor: () => { current = runtime; return runtime; },
|
||||
configure, start: () => {},
|
||||
} as any,
|
||||
thtRunner: { sessionShow: async () => manifest, reopenSession: async () => {} } as any,
|
||||
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||
runtimeModelCatalog: sessionCatalog("zai/glm-5.2", ["zai/glm-5.2", "local/qwen"]),
|
||||
getSettings: () => ({ workspace: "psd", thinking: "medium" }) as any,
|
||||
});
|
||||
const response = await app.inject({ method: "POST", url: "/sessions/model-resume/resume", ...(payload ? { payload } : {}) });
|
||||
expect(response.statusCode).toBe(200);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(configure).toHaveBeenCalledWith(expect.anything(), expect.objectContaining(payload ?? { provider: "zai", model: "glm-5.2" }));
|
||||
expect(manifest).toMatchObject({ provider: "retired", model: "old-model" });
|
||||
});
|
||||
|
||||
test("POST /sessions/:id/resume returns a sanitized error when its retained revision is unavailable", async () => {
|
||||
const rawFailure = "cannot read /data/workspace-registry/snapshots/secret-revision";
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
@@ -2691,7 +2713,7 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
|
||||
expect(ensureWs).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`);
|
||||
});
|
||||
|
||||
test("POST /sessions falls back from a stale requested model to the catalog default", async () => {
|
||||
test("POST /sessions rejects a stale requested model without silently using the default", async () => {
|
||||
let created = 0;
|
||||
let persisted: any;
|
||||
const runtime = { bridge: { onClientEvent: () => {} } };
|
||||
@@ -2721,13 +2743,10 @@ test("POST /sessions falls back from a stale requested model to the catalog defa
|
||||
payload: { question: "q", provider: "deepseek", model: "deepseek-v4-pro" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({
|
||||
id: "fallback",
|
||||
warning: "Configured model deepseek/deepseek-v4-pro is unavailable; using zai/glm-5.2.",
|
||||
});
|
||||
expect(persisted).toMatchObject({ provider: "zai", model: "glm-5.2" });
|
||||
expect(created).toBe(1);
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(res.json()).toMatchObject({ code: "model_unavailable" });
|
||||
expect(persisted).toBeUndefined();
|
||||
expect(created).toBe(0);
|
||||
});
|
||||
|
||||
test("POST /sessions marks a persisted session failed when runtime construction throws", async () => {
|
||||
|
||||
@@ -160,8 +160,7 @@ test("GET /models returns session choices from the installation model catalog",
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {} as any,
|
||||
runtimeModelCatalog: {
|
||||
defaultSession: "zai/glm-5.2",
|
||||
defaultMetadataGeneration: null,
|
||||
defaultInteraction: "zai/glm-5.2",
|
||||
embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
sessionModels: () => [{
|
||||
id: "zai/glm-5.2", provider: "zai", model: "glm-5.2", label: "GLM 5.2",
|
||||
@@ -187,8 +186,7 @@ test("GET /models returns an empty list when the catalog has no session models",
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {} as any,
|
||||
runtimeModelCatalog: {
|
||||
defaultSession: null,
|
||||
defaultMetadataGeneration: null,
|
||||
defaultInteraction: null,
|
||||
embedding: null,
|
||||
sessionModels: () => [],
|
||||
metadataModels: () => [],
|
||||
|
||||
@@ -10,22 +10,28 @@ workspaceRepository:
|
||||
access: ssh
|
||||
modelCatalog:
|
||||
defaults:
|
||||
session: zai/glm-5.3
|
||||
metadataGeneration: zai/glm-5.3
|
||||
interaction: zai/glm-5.3
|
||||
embedding:
|
||||
id: ollama/qwen3-embedding:0.6b
|
||||
dimensions: 1024
|
||||
providers:
|
||||
deepseek:
|
||||
authentication:
|
||||
mode: pi_auth
|
||||
mode: secret_env
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
session:
|
||||
mode: pi_builtin
|
||||
metadataGeneration:
|
||||
litellmProvider: deepseek
|
||||
models:
|
||||
deepseek-v4-pro:
|
||||
label: DeepSeek V4 Pro
|
||||
session: {}
|
||||
metadataGeneration: {}
|
||||
deepseek-v4-flash:
|
||||
label: DeepSeek V4 Flash
|
||||
session: {}
|
||||
metadataGeneration: {}
|
||||
zai:
|
||||
endpoint:
|
||||
baseUrl: https://api.z.ai/api/coding/paas/v4
|
||||
|
||||
@@ -13,3 +13,8 @@ We considered React-only state, path-based routes and hash routes. React-only st
|
||||
deep-link behavior, path routes require a host catch-all route that does not yet exist in Omics Portal,
|
||||
and hash routes can conflict with host-page fragments. A namespaced query route preserves the current
|
||||
same-document integration boundary while leaving room for a future path adapter.
|
||||
|
||||
The accepted visual direction is A / Workbench for all five surfaces; B and C remain recoverable
|
||||
prototypes. Workspace owns readiness and preprocessing, consuming the Database catalog as a
|
||||
prerequisite. Database owns connection/binding, schema synchronization, descriptions and sensitivity;
|
||||
its page links to the related Workspace's preparation rather than duplicating the preprocessing action.
|
||||
|
||||
@@ -14,8 +14,7 @@ provider/model environment defaults. Those former sources are retired.
|
||||
schemaVersion: 2
|
||||
modelCatalog:
|
||||
defaults:
|
||||
session: zai/glm-5.3
|
||||
metadataGeneration: zai/glm-5.3
|
||||
interaction: zai/glm-5.3
|
||||
|
||||
embedding:
|
||||
id: ollama/qwen3-embedding:0.6b
|
||||
@@ -50,23 +49,63 @@ it contains that use block:
|
||||
- `metadataGeneration` makes it selectable for description generation;
|
||||
- `embedding` is a single installation-level model rather than a selectable list.
|
||||
|
||||
`defaults.session` is required. `defaults.metadataGeneration` is required exactly when at least
|
||||
one metadata-generation model exists. A session manifest pins its canonical identity, so removing a
|
||||
model never silently changes an existing session: resume fails with `model_unavailable`.
|
||||
`defaults.interaction` is the only LLM default, required once per installation, never per workspace.
|
||||
Core and Administration share the user's operational model choice. An explicit choice takes priority
|
||||
over the default and is remembered in this browser for the authenticated user and application mount.
|
||||
Switching workspace does not change the model. Browser-storage restrictions may limit remembering
|
||||
to the current visit; preferences do not synchronize across devices or change installation YAML.
|
||||
An unavailable remembered model is not silently replaced: select another configured model.
|
||||
|
||||
When any metadata-generation models are configured, the default and the operational list must
|
||||
support both `session` and `metadataGeneration`. Entries for just one adapter may remain in the
|
||||
installation inventory, but are not selectable for global interaction. Core-only installations remain
|
||||
supported when no metadata-generation model is configured; Admin AI is then unavailable.
|
||||
The embedding model remains separate and is unaffected by the interaction selector.
|
||||
|
||||
New sessions record the selected model in their manifest. Resume retains the session's workspace and
|
||||
revision, but uses the current global model (the installation default for clients that omit a model).
|
||||
Historical manifest model fields are not rewritten by resume. Archived/finalized sessions remain read-only.
|
||||
|
||||
### Required operator verification for every model
|
||||
|
||||
Catalog validation checks configuration, not model behavior. Before offering a model to users, and
|
||||
after changing its endpoint, adapters, or the Pi/LiteLLM versions, the operator must verify **both**:
|
||||
|
||||
1. **Core / Pi:** select the model, start a test session in a prepared test workspace, exercise an
|
||||
actual tool call and its returned result, a human review gate, and stop/resume. Check streaming,
|
||||
tool arguments, authentication, and reasoning/token-limit compatibility. A plain chat reply or
|
||||
`tht pi test` alone is not sufficient.
|
||||
2. **Administration / LiteLLM:** select the same model and generate descriptions for a small,
|
||||
non-sensitive test table. Check the structured result is accepted and the generation completes.
|
||||
Review the output quality before using it on real metadata. This action writes test metadata
|
||||
and may incur provider charges: use an authorized test database and approved data.
|
||||
|
||||
There is no automatic certification flag or startup model probe. The operator owns this verification;
|
||||
do not infer compatibility from the model label or from success in just one path. Both adapters point
|
||||
to one catalog identity; Pi does not need to route through a new LiteLLM proxy. Models using only
|
||||
`pi_auth` cannot serve the current LiteLLM path and are excluded from shared selection.
|
||||
|
||||
## Session adapters
|
||||
|
||||
Use `pi_builtin` for a model whose technical definition ships with Pi:
|
||||
Use `pi_builtin` for a model whose technical definition ships with Pi. This does not require
|
||||
`pi_auth`: a shared bundle credential lets native Pi and LiteLLM use the same provider identity:
|
||||
|
||||
```yaml
|
||||
deepseek:
|
||||
authentication:
|
||||
mode: pi_auth
|
||||
mode: secret_env
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
session:
|
||||
mode: pi_builtin
|
||||
metadataGeneration:
|
||||
litellmProvider: deepseek
|
||||
models:
|
||||
deepseek-v4-pro:
|
||||
session: {}
|
||||
metadataGeneration: {}
|
||||
deepseek-v4-flash:
|
||||
session: {}
|
||||
metadataGeneration: {}
|
||||
```
|
||||
|
||||
Use `openai_compatible` for an explicit compatible endpoint. Each eligible session model must then
|
||||
@@ -89,6 +128,18 @@ Secret values never belong in installation YAML, generated files, logs, CLI argu
|
||||
requests. The YAML contains only an environment-variable name or an authentication mode. Pi's
|
||||
protected credential file remains selected by the installation authentication configuration.
|
||||
|
||||
For catalog providers using `secret_env`, the bundle is authoritative in both Core and Admin.
|
||||
ThothII removes only the selected provider's old auth entry from the temporary Pi session snapshot;
|
||||
the operator's original Pi auth store and other providers are unchanged. Provider smoke checks use
|
||||
the same precedence, and model enumeration receives the catalog-declared bundle keys. A missing
|
||||
declared key is an error, not permission to fall back to Pi auth or the legacy generic key file.
|
||||
After rotating a bundle key, apply the normal installation lifecycle so processes reload it.
|
||||
|
||||
The PSD descriptor now declares only `deepseek/deepseek-v4-pro` and `deepseek/deepseek-v4-flash`
|
||||
for both uses; it no longer duplicates them under `deepseek-metadata`. Historical records are not
|
||||
rewritten. A saved obsolete identity must be explicitly reselected from the current catalog;
|
||||
it is not silently remapped to another model or account.
|
||||
|
||||
## Generated runtime projections
|
||||
|
||||
Before Compose starts, `tht` validates the installation and atomically writes deterministic files
|
||||
@@ -133,6 +184,16 @@ configuration command. There is no `tht pi configure` and no separate apply comm
|
||||
|
||||
## Migrating a legacy installation
|
||||
|
||||
For schema-v2 descriptors with the former `defaults.session` and `defaults.metadataGeneration`,
|
||||
replace both with `defaults.interaction`. Equal legacy values are accepted and normalized in memory;
|
||||
the loader never rewrites the descriptor. Different values fail with `migration_required`: explicitly
|
||||
choose a model supporting both uses, remove both old fields, and set the single new field. Do not mix
|
||||
new and legacy fields. A Core-only legacy session default can be normalized when Admin AI is absent.
|
||||
|
||||
The generated runtime catalog now uses schema version **2** and only `defaultInteraction`. Regenerate
|
||||
and apply all runtime projections with the matching host/backend release using the normal installation
|
||||
lifecycle; do not deploy only the backend against an old generated catalog or hand-edit generated JSON.
|
||||
|
||||
The migrator reads the former installation `metadataGeneration` block and the two former Pi JSON
|
||||
files, but never modifies them. Supply the facts that cannot be inferred safely and write a separate
|
||||
candidate:
|
||||
@@ -148,13 +209,15 @@ tht --installation /absolute/path/legacy/thothii-installation.yaml installation
|
||||
Review the candidate, move the legacy source files out of the installation only after approval,
|
||||
then select the v2 descriptor. Ambiguous aliases, endpoint conflicts, or missing authentication
|
||||
facts produce field-level errors; the migrator does not guess.
|
||||
The legacy CLI flag `--session-default` now supplies the unified interaction default in the candidate;
|
||||
if it conflicts with the legacy metadata default, align that choice explicitly before retrying.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Meaning | Action |
|
||||
| --- | --- | --- |
|
||||
| `migration_required` | A retired model source or installation schema is still present | Run the installation migrator and review its candidate |
|
||||
| Unknown session or metadata default | The canonical ID is missing the corresponding use block | Correct the provider/model key or add the intended use block |
|
||||
| Invalid interaction default | The canonical ID does not support all configured uses | Correct `defaults.interaction` or the intended adapter blocks |
|
||||
| Generated projection drift | Runtime files differ from the descriptor-derived bytes | Run `tht start` or `tht pi restart --yes --drain` |
|
||||
| `model_unavailable` on resume | The session's pinned model is no longer session-eligible | Restore that catalog entry or keep the session unavailable; do not remap it |
|
||||
| `model_unavailable` on create/resume | The selected global model is no longer eligible | Explicitly choose an eligible model; no fallback is applied |
|
||||
| Provider smoke failure | Credentials, endpoint, or provider availability is invalid | Correct the protected credential or catalog endpoint, restart, then run `tht pi test` |
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Unified Administration Pages
|
||||
|
||||
Status: revised direction A accepted and implemented locally (2026-09-12).
|
||||
The owner selected the latest collapsible context shelf A after restoration of
|
||||
the original Core. Core and session management must remain functionally unchanged;
|
||||
the five Administration pages and global context are the implementation scope.
|
||||
Prototype history remains in `frontend/prototypes/`, including
|
||||
`administration-review/README.md` and `context-shelf/README.md`.
|
||||
Final integration remains on the server.
|
||||
See [implementation and acceptance evidence](../reports/2026-09-12-context-shelf-a-implementation.md).
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Workspace and Pi open over the work area while Database, Memory and Evidence have
|
||||
different page structures. Administrators need five predictable, independently
|
||||
accessible pages that fit beside the Omics Portal sidebar and below its red header.
|
||||
|
||||
## Solution
|
||||
|
||||
Use the chosen A / Workbench direction for all five Administration Pages: a warm
|
||||
page heading, restrained red actions, readable serif titles, sans-serif controls,
|
||||
compact identity/status information, and an index/detail work area appropriate to
|
||||
each domain. Keep B / Inspector and C / Operations Deck as recoverable prototypes.
|
||||
Navigation remains on the right, collapsing within the available application width.
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As an administrator, I want every management entry to open a full page, so that I can use the entire work area.
|
||||
2. As an administrator, I want the five pages to share hierarchy and typography, so that actions and current context are predictable.
|
||||
3. As an administrator, I want to refresh or bookmark a management page, so that I can return directly to it.
|
||||
4. As an administrator, I want Back and Forward to restore navigation, so that browser controls behave normally.
|
||||
5. As an administrator, I want unsaved edits protected during navigation, so that leaving a page does not silently discard work.
|
||||
6. As an administrator, I want existing permissions enforced on direct links, so that a URL cannot bypass access controls.
|
||||
7. As a reviewer, I want my active session retained while visiting Administration, so that navigation does not restart or stop Pi.
|
||||
8. As a workspace operator, I want to select a workspace and inspect its source, runtime requirements and validation, so that I know which environment I am preparing.
|
||||
9. As a workspace operator, I want preprocessing beside that workspace's readiness, so that the operation's target is explicit.
|
||||
10. As a workspace operator, I want catalog revision and preprocessing diagnostics, so that I can understand missing prerequisites and stale derived data.
|
||||
11. As a database operator, I want configuration, synchronization, descriptions and sensitivity in Database management, so that ownership of catalog changes remains clear.
|
||||
12. As a database operator, I want a link to the related workspace's preprocessing status, so that I can complete preparation after catalog changes.
|
||||
13. As a Memory curator, I want existing filtering, CRUD, links and index recovery inside the shared workbench, so that the redesign preserves my workflows.
|
||||
14. As an Evidence curator, I want browsing, source review and file-maintenance instructions inside the shared workbench, so that external Markdown editing remains the authoring workflow.
|
||||
15. As a Pi operator, I want runtime status, catalog defaults, models and diagnostics in a page, so that I can inspect the installation without a management popup.
|
||||
16. As a portal user, I want layout based on the available container width, so that a desktop viewport with a wide portal sidebar still works.
|
||||
17. As a keyboard or mobile user, I want accessible navigation and non-overlapping controls, so that all five pages remain usable at narrow widths and zoom.
|
||||
18. As a product owner, I want B and C preserved, so that an individual page can adopt another design later.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
- Five peer Administration Surfaces, independent of active session existence.
|
||||
- One collapsible top shelf A selects workspace and canonical interaction model for Core and all Administration pages. No duplicate operational selectors inside those pages or the Core composer.
|
||||
- Each explicit choice is remembered independently per browser origin, application mount and authenticated principal. Absent an explicit choice, use the installation default. Removed/unavailable choices require explicit correction, never silent substitution.
|
||||
- Neither Core nor Administration activities are enabled until both choices resolve to available catalog entries. Background refresh errors retain already validated context and mounted drafts.
|
||||
- Workspace/model changes are locked during Core or Administration operations. Navigation itself does not cancel those operations: visited Administration pages and the original Core stay mounted.
|
||||
- Resume returns the session's pinned workspace in its lifecycle response and adopts that workspace without replacing the global model. Older responses can fall back to the session manifest.
|
||||
- Preserve the original eight-phase workflow, left activity log, gate widgets, composer, My sessions/All sessions tabs, groups, archive and session lifecycle. A navigation drawer remains reachable when the log is open or the available width is narrow.
|
||||
- Shared Workbench page/header/layout primitives; data ownership and mutation APIs remain domain-specific.
|
||||
- Namespaced `thoth_route=administration/<surface>` query routing, preserving host path, other query parameters, fragment and history state. Optional `thoth_workspace` carries only a stable workspace identity for cross-links.
|
||||
- Unsaved Administration changes block in-app navigation, browser Back/Forward, and context changes. Save successfully or explicitly cancel inside the editor before leaving; navigation does not offer automatic discard. Reload gets native before-unload protection. Busy state locks context selection, not read-only cross-page navigation.
|
||||
- Workspace owns readiness and full preprocessing. Database owns binding, physical schema synchronization, descriptions and sensitivity. Database status links to workspace preparation; it does not duplicate the run action.
|
||||
- Preprocessing permission remains the existing backend permission. This UI uses the already unified Installation Model Catalog (`defaults.interaction`) and does not introduce another model authority.
|
||||
- A shared responsive frame uses available container width, scoped CSS and bounded scrolling. The portal remains the owner of its header and left sidebar; Thoth does not duplicate them. The host can set `--thoth-app-height` to its available height below the header.
|
||||
- Existing catalog grids remain appropriate for tabular data; form content is inline in the work area. Short confirmations and secondary operation/history panels may remain dialogs/panels.
|
||||
- English interface labels; persisted document content retains the workspace language.
|
||||
- A is the production direction. The three read-only prototypes and their launch script remain available and are not imported by production code.
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- Verify public behavior at the existing AppShell and management-page boundaries with MSW API fixtures; avoid tests of private state or CSS implementation details.
|
||||
- Cover all five direct routes, browser history, host URL preservation, permission denial, guarded navigation and retained session behavior.
|
||||
- Exercise workspace-specific preprocessing targeting and database-to-workspace navigation with authoritative API status fixtures.
|
||||
- Use the existing authenticated Playwright stack to verify full pages, forms, container resize, narrow screens and a simulated portal header/sidebar. Fixtures avoid mutating the installed PSD knowledge.
|
||||
- Run frontend typecheck/build, focused tests while implementing, and the full frontend test suite at integration.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Schema migrations, a new preprocessing service, Evidence web editing, new model configuration authority, actual deployment into Omics Portal, production deployment, and deletion of prototype variants. The only additional backend contract change in this UI increment is the pinned workspace identity in successful Resume responses.
|
||||
|
||||
## Further Notes
|
||||
|
||||
This implements the page direction recorded by ADR 0020, refined by the owner's
|
||||
2026-09-12 acceptance of shelf A and explicit functional-preservation requirements.
|
||||
Testing reuses the existing public UI seams. Ticket breakdown is recorded alongside
|
||||
this spec as four independently reviewable increments. Gitea publication is pending
|
||||
authenticated access; no remote issue identifiers are claimed by these local files.
|
||||
@@ -0,0 +1,13 @@
|
||||
# A1: Navigable Administration Pages
|
||||
|
||||
**What to build:** Workspace and Pi become pages; all five surfaces support deep links, browser history, permission enforcement and guarded navigation while retaining session state.
|
||||
|
||||
**Blocked by:** None (can start immediately).
|
||||
|
||||
**Status:** implemented and locally verified on 2026-09-12; local ticket, Gitea publication pending.
|
||||
|
||||
- [x] Five full-page surfaces reached from the current right navigation.
|
||||
- [x] Refresh, Back/Forward and unrelated host URL/history fields preserved.
|
||||
- [x] Unsaved edits block navigation; operations lock context without blocking page inspection. Unauthorized links denied.
|
||||
- [x] Existing session continuation and management regressions pass.
|
||||
- [x] One global shelf A; independent remembered workspace/model, validated defaults, session-pinned workspace on Resume.
|
||||
@@ -0,0 +1,12 @@
|
||||
# A2: Workspace preparation and Database dependency
|
||||
|
||||
**What to build:** An operator selects a workspace, inspects readiness and catalog revisions, runs preprocessing and follows a reciprocal link to Database configuration.
|
||||
|
||||
**Blocked by:** A1: Navigable Administration Pages.
|
||||
|
||||
**Status:** implemented and regression-tested locally on 2026-09-12; configured server acceptance remains in A4. Local ticket, Gitea publication pending.
|
||||
|
||||
- [x] Preprocessing runs against the globally selected workspace.
|
||||
- [x] Missing, stale, blocked, running and failed status reflect the API.
|
||||
- [x] Database shows the related workspace's readiness and links to its preparation.
|
||||
- [x] Permission and confirmation regressions pass; no preprocessing/data-persistence behavior changed in this increment.
|
||||
@@ -0,0 +1,12 @@
|
||||
# A3: Shared A / Workbench family
|
||||
|
||||
**What to build:** Apply the chosen typography, page hierarchy and list/detail treatment to all five pages, preserving real domain operations and prototype alternatives.
|
||||
|
||||
**Blocked by:** A1: Navigable Administration Pages.
|
||||
|
||||
**Status:** implemented and locally verified on 2026-09-12; local ticket, Gitea publication pending.
|
||||
|
||||
- [x] Common heading, warm surfaces, controls and status hierarchy across five domains.
|
||||
- [x] Memory/Evidence browsing and editing/maintenance behavior preserved.
|
||||
- [x] Database configuration remains in the page work area; Pi status and host instructions are readable.
|
||||
- [x] A/B/C prototype files and launch scripts preserved.
|
||||
@@ -0,0 +1,15 @@
|
||||
# A4: Embedded and responsive acceptance
|
||||
|
||||
**What to build:** Verify the complete family beside a portal sidebar and below its red header, including browser and keyboard navigation at narrow widths.
|
||||
|
||||
**Blocked by:** A2: Workspace preparation and Database dependency; A3: Shared A / Workbench family.
|
||||
|
||||
**Status:** local regression and browser acceptance completed on 2026-09-12; actual server/Omics integration gate remains open. Local ticket, Gitea publication pending.
|
||||
|
||||
- [x] Sampled actual application at 390, 768 and 1280 CSS px; responsive context and navigation visually checked with synthetic data.
|
||||
- [x] Navigation reachable at mobile and desktop widths; original session scope tabs retained.
|
||||
- [x] Typecheck, build, frontend/backend regression suites and targeted browser checks recorded.
|
||||
- [ ] Standards/spec review completed; limits of actual portal integration documented.
|
||||
- [ ] On-server integration under the real portal header/sidebar, including keyboard traversal, zoom and configured runtime operations.
|
||||
|
||||
Evidence and remaining gates: [implementation report](../../reports/2026-09-12-context-shelf-a-implementation.md).
|
||||
@@ -0,0 +1,96 @@
|
||||
# Context shelf A: implementation and local acceptance
|
||||
|
||||
Date: 2026-09-12. Status: implemented and deployed to local Docker; not deployed to the server.
|
||||
|
||||
## Accepted scope
|
||||
|
||||
The owner selected the latest option A and explicitly required functional
|
||||
preservation of Core and session management. Aesthetic refinement is limited to
|
||||
the agreed Administration workbench and global context.
|
||||
|
||||
- Workspace, Evidence, Memory, Database and Pi open as peer pages in the work area.
|
||||
- A single top collapsible shelf selects workspace and interaction model.
|
||||
Explicit choices are independently remembered per origin, mount and principal;
|
||||
installation defaults fill only missing choices. Invalid saved choices fail
|
||||
closed instead of silently choosing another context.
|
||||
- Core's composer, eight phases, review widgets and left activity log remain the
|
||||
original production components. Session scope tabs, grouping, rename, archive,
|
||||
bulk operations, read-only inspection and guarded Resume remain intact.
|
||||
- Visited Admin pages and Core remain mounted during page navigation.
|
||||
Unsaved Admin edits block navigation/context changes until saved or explicitly
|
||||
cancelled in the editor. Failed saves and failed catalog refreshes preserve drafts.
|
||||
- Workspace/model selectors are locked during operations. A navigation drawer
|
||||
preserves access to the session rail when the log is open or space is narrow.
|
||||
- The unified Installation Model Catalog remains the only model authority.
|
||||
Core and metadata generation consume the same canonical selection.
|
||||
Resume now includes the session's pinned `workspaceId` in successful responses,
|
||||
binding the global workspace immediately without replacing the selected model;
|
||||
older backends have a manifest fallback.
|
||||
- Impeccable informed typography, warm surfaces, restrained red, spacing,
|
||||
natural help-text wrapping and container-responsive layout. No Omics header
|
||||
or left sidebar was added to production ThothII.
|
||||
- A, B and C prototype sources and launch scripts were preserved.
|
||||
|
||||
## Verification
|
||||
|
||||
Using Node 24.16.0:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Full frontend Vitest suite (two workers) | 671 tests passed, 82 files |
|
||||
| Full backend Vitest suite (two workers) | Passed; existing 40 opt-in auth runtime projection tests skipped |
|
||||
| Frontend TypeScript build check | Passed |
|
||||
| Backend TypeScript check | Passed |
|
||||
| Frontend production Vite build | Passed; bundle-size advisory remains |
|
||||
| Git whitespace check | Passed |
|
||||
| Strict MkDocs build | Passed |
|
||||
|
||||
Regression fixtures now provide valid context where they exercise normal Core
|
||||
or session workflows. Separate tests explicitly cover invalid saved context,
|
||||
global selector uniqueness, lock/unlock, failed and successful saves, retained
|
||||
drafts across refresh failure, Core/Admin round trips, and pinned-workspace Resume.
|
||||
The existing 40 session-management tests remain green.
|
||||
|
||||
Browser acceptance used the actual production React application against a
|
||||
temporary read-only synthetic API, not the earlier prototype implementation.
|
||||
Checked Core, expanded/collapsed shelf, Memory, session scope tabs and mobile
|
||||
navigation at 390, 768 and 1280 CSS-pixel application widths. The test frame
|
||||
scaled wider layouts to fit the inspection panel; layout width inside the frame
|
||||
remained the stated CSS width. No real data, provider calls or runtime mutations
|
||||
were performed. This is sampled visual acceptance, not certification of every
|
||||
Admin editor on the real portal.
|
||||
|
||||
## Remaining release gates
|
||||
|
||||
### Local Docker update
|
||||
|
||||
At the owner's subsequent request, core and frontend were rebuilt from
|
||||
`/Users/mp/projects/ThothII` and recreated in the existing
|
||||
`thothii-18998cca7b0a` project. Its launcher preserves all previous Compose
|
||||
bindings and adds `/private/tmp/thothii-context-a.compose.yaml` last to override
|
||||
the old worktree build context. Only core/frontend were recreated; PostgreSQL,
|
||||
Qdrant, embedding and persistent volumes were retained.
|
||||
|
||||
Verified HTTP 200 for the frontend and both direct/proxied core health endpoints.
|
||||
The served production bundle contains the global shelf and unsaved-change guard,
|
||||
without the synthetic preview API. All five persistent services report healthy.
|
||||
The mounted catalog is schema v2, default `zai/glm-5.3`, with shared
|
||||
DeepSeek Pro/Flash, local Qwen and GLM model identities.
|
||||
Previous image tags are retained as `before-context-a-20260912` for each image.
|
||||
This startup check does not exercise authenticated Core/provider or Admin workflows.
|
||||
|
||||
### Server release
|
||||
|
||||
1. Integrate on the server beneath Omics Portal's actual red header and beside
|
||||
its existing sidebar. Supply available height through `--thoth-app-height`
|
||||
when the host is shorter than the viewport. Check real host styles, widths,
|
||||
zoom, keyboard traversal and Back/Forward.
|
||||
2. Use approved test data to verify configured Core/Pi and Admin/LiteLLM models
|
||||
through their real workflows. Unit fixtures do not certify provider compatibility.
|
||||
3. Verify workspace readiness, synchronization and resume on the configured
|
||||
stack before release. Follow the existing guarded server migration runbook.
|
||||
4. Gitea publication and final independent standards/spec review remain separate
|
||||
gates; no remote issues or deployment are claimed by this implementation.
|
||||
|
||||
The working tree also contains earlier model-catalog and Administration work.
|
||||
Unrelated changes were preserved; this report does not claim a clean or committed tree.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Unified interaction LLM — local implementation
|
||||
|
||||
Date: 2026-09-12. This change is separate from selection of the final administration/context-shelf
|
||||
prototype. No deployment, Gitea issue publication, or Omics Portal modification was performed.
|
||||
|
||||
## Implemented
|
||||
|
||||
- One authored `modelCatalog.defaults.interaction` per installation, independent of workspace.
|
||||
The local PSD descriptor and tracked example now use `zai/glm-5.3` in this field.
|
||||
- One generated `defaultInteraction` in runtime catalog schema v2; host projections, backend
|
||||
settings, Pi diagnostics, Core admission and metadata-generation consumers use that value.
|
||||
- Separate Pi/LiteLLM adapters remain. When Admin AI is configured, both public lists and Pi's
|
||||
enabled list use their intersection. Single-use entries remain inventory, not global choices.
|
||||
Core-only installations remain possible when no Admin model is configured. Embedding is unchanged.
|
||||
- Equal legacy defaults normalize on read without rewriting the source; divergent defaults or
|
||||
mixed old/new fields fail with migration guidance. Generated schema-v1 catalogs must be regenerated.
|
||||
- The existing Core and Database selectors now share application preferences, using complete
|
||||
canonical IDs, including when two providers use the same short model name.
|
||||
- Explicit LLM choices are remembered in browser storage per origin/application mount and
|
||||
authenticated issuer/subject. Automatic defaults are not stored as explicit choices. Workspace
|
||||
and thinking preferences retain their existing in-memory lifetime.
|
||||
- Core/description-generation activity disables the existing model controls using existing state
|
||||
and the shared run cache. This is not a new cross-workspace/background job subsystem.
|
||||
- New sessions reject unavailable selections without fallback. Resume receives the global model
|
||||
(or uses the installation default when omitted), keeps the historical workspace/revision and does
|
||||
not overwrite the original manifest model fields. Archived/finalized sessions remain read-only.
|
||||
- Pi management instructions and the configuration guide require an operator to exercise each
|
||||
model in both Core and Administration. There is no automatic certification flag or live probe.
|
||||
|
||||
## PSD DeepSeek consolidation
|
||||
|
||||
Following explicit operator approval, the local PSD descriptor and its tracked example use one
|
||||
`deepseek` provider with native Pi and LiteLLM adapters. Both `deepseek/deepseek-v4-pro` and
|
||||
`deepseek/deepseek-v4-flash` are eligible for shared selection. The duplicate `deepseek-metadata`
|
||||
provider declaration is removed; historical records remain unchanged. The installation default
|
||||
stays `zai/glm-5.3`.
|
||||
|
||||
A value-free comparison confirmed that the existing Pi DeepSeek key and the existing
|
||||
`DEEPSEEK_API_KEY` bundle entry are identical; both source files have mode 0600. No secret file
|
||||
was changed. The catalog now declares `secret_env` / `DEEPSEEK_API_KEY` as the authoritative
|
||||
source for both paths. Pi session snapshots exclude the selected provider's old auth entry,
|
||||
without changing the original store or unrelated entries. Missing bundle keys fail closed instead
|
||||
of falling back to old Pi auth or the generic key file. Availability enumeration, credential status,
|
||||
and isolated provider smoke checks follow the catalog declaration as well.
|
||||
|
||||
## Verification
|
||||
|
||||
- All host CLI Go packages pass `go test ./...`.
|
||||
- Backend under Node 24.16.0 after DeepSeek consolidation: 108 test files pass, 1 skipped;
|
||||
1,377 tests pass, 40 skipped.
|
||||
Backend TypeScript check passes.
|
||||
- New regression coverage checks shared DeepSeek configuration/projection, canonical Core/Admin
|
||||
identities, bundle-backed model enumeration, stale Pi auth precedence, missing-key refusal,
|
||||
preservation of the source auth store, and sanitized credential status. All credentials in these
|
||||
tests are synthetic; no model inference is performed.
|
||||
- Full frontend run: 658 tests pass; three remaining failures concern existing administration-layout
|
||||
work: Workspace preprocessing region, Catalog status placement, and Sync history entry point.
|
||||
These controls were already being changed in the dirty worktree before this model change.
|
||||
- The focused Core/Admin model synchronization test also verifies locking during Core and
|
||||
description-generation activity, unlocking afterward, and provider-qualified identity. It passes.
|
||||
- Frontend typecheck still reports three `exact` option errors in the pre-existing, untracked
|
||||
`AppShell.administration.test.tsx` (lines 48, 66, 71). No new type errors are reported.
|
||||
- Earlier parallel runs encountered timing-sensitive auth-helper failures; the complete backend
|
||||
rerun with Node 24 and two workers passes. The Homebrew `node@24` path on this machine actually
|
||||
reported Node 25; the verified Node 24 executable is under the user's nvm installation.
|
||||
- No live provider calls or full deployed Pi/LiteLLM/Omics acceptance tests were performed.
|
||||
- `git diff --check` passes. Strict documentation build remains blocked by an existing link in
|
||||
the untracked `plans/2026-09-10-administration-pages-spec.md` pointing outside the documentation
|
||||
tree to the administration-review prototype README. The updated configuration guide adds no warning.
|
||||
|
||||
The full frontend gate is not green; this is not a deployment-ready acceptance claim. Pre-existing
|
||||
production UI edits and every prototype are preserved. The final collapsible global context layout,
|
||||
general Core/Admin admission policy and server assembly remain in the wider UI workstream.
|
||||
|
||||
## Applying later
|
||||
|
||||
Use the matching host/backend release, review the installation descriptor, then apply the normal
|
||||
installation lifecycle to regenerate **all** runtime projections together. Do not deploy only the new
|
||||
backend against an old generated catalog. Follow the dual-path operator checklist in
|
||||
[Installation Model Catalog](../general/pi-configuration.md).
|
||||
@@ -232,11 +232,16 @@ test("getMe fetches the typed authenticated principal", async () => {
|
||||
});
|
||||
|
||||
test("resumeSession returns the typed runtime disposition", async () => {
|
||||
server.use(http.post("/api/sessions/s1/resume", () =>
|
||||
HttpResponse.json({ id: "s1", alreadyActive: false })));
|
||||
workspacePreferences.save({ workspaceId: "different-workspace", provider: "local", model: "qwen", thinking: "medium" });
|
||||
let body: unknown;
|
||||
server.use(http.post("/api/sessions/s1/resume", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "s1", alreadyActive: false });
|
||||
}));
|
||||
|
||||
const result: { id: string; alreadyActive: boolean } = await resumeSession("s1");
|
||||
expect(result).toEqual({ id: "s1", alreadyActive: false });
|
||||
expect(body).toEqual({ provider: "local", model: "qwen" });
|
||||
});
|
||||
|
||||
test("renameSession POSTs {name}", async () => {
|
||||
|
||||
@@ -26,11 +26,12 @@ async function selectedPreferences(precondition?: AuthOperationPrecondition): Pr
|
||||
// application memory from the installation defaults once, then keep choices ephemeral.
|
||||
const legacy = await getSettings();
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
const latest = workspacePreferences.load();
|
||||
return workspacePreferences.save({
|
||||
workspaceId: saved.workspaceId ?? legacy.workspace,
|
||||
provider: saved.provider ?? legacy.provider,
|
||||
model: saved.model ?? legacy.model,
|
||||
thinking: saved.thinking ?? legacy.thinking,
|
||||
workspaceId: latest.workspaceId ?? legacy.workspace,
|
||||
provider: latest.provider ?? legacy.provider,
|
||||
model: latest.model ?? legacy.model,
|
||||
thinking: latest.thinking ?? legacy.thinking,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,8 +140,20 @@ export const postSteer = (id: string, text: string) =>
|
||||
export const closeSession = (id: string) =>
|
||||
apiFetch<void>(`/sessions/${id}/close`, { method: "POST" });
|
||||
|
||||
export const resumeSession = (id: string) =>
|
||||
apiFetch<ResumeSessionResult>(`/sessions/${id}/resume`, { method: "POST" });
|
||||
export async function resumeSession(id: string) {
|
||||
const operation = captureAuthOperation();
|
||||
const precondition = operation ? {
|
||||
operation,
|
||||
isCurrent: () => isAuthOperationCurrent(operation, { sessionId: null, disposalEpoch: operation.disposalEpoch }),
|
||||
} : undefined;
|
||||
const selected = await selectedPreferences(precondition);
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
return apiFetch<ResumeSessionResult>(`/sessions/${id}/resume`, {
|
||||
method: "POST",
|
||||
// The workspace is owned by the persisted session, never by this request.
|
||||
body: JSON.stringify({ provider: selected.provider, model: selected.model }),
|
||||
});
|
||||
}
|
||||
|
||||
export const renameSession = (id: string, name: string) =>
|
||||
apiFetch<void>(`/sessions/${id}/rename`, { method: "POST", body: JSON.stringify({ name }) });
|
||||
|
||||
@@ -160,6 +160,8 @@ export interface Principal {
|
||||
export interface ResumeSessionResult {
|
||||
id: string;
|
||||
alreadyActive: boolean;
|
||||
/** The session's pinned workspace; never supplied by the client. */
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface SessionDocument {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { listDescriptionGenerationRuns } from "../api/catalog-databases";
|
||||
import { hasPermission, useAuthUser } from "../auth/authState";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
|
||||
/** Reuse the existing run cache across Core/Admin navigation; no background job manager. */
|
||||
export function useInteractionModelBusy(): boolean {
|
||||
const coreActive = useSessionStore((state) => state.agentActive);
|
||||
const user = useAuthUser();
|
||||
const { data: runs } = useQuery({
|
||||
queryKey: ["description-generation-runs", 50],
|
||||
queryFn: () => listDescriptionGenerationRuns(50),
|
||||
enabled: hasPermission(user, "database.manage"),
|
||||
retry: false,
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
return coreActive || Boolean(runs?.some((run) => run.status === "queued" || run.status === "running"));
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { vi } from "vitest";
|
||||
import { server } from "../test/msw";
|
||||
import { setAuthState } from "../auth/authState";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import { AppShell } from "./AppShell";
|
||||
import { workspacePreferences } from "../workspaces/preferences";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
|
||||
const permissions = ["session.use", "database.manage", "workspace.manage", "workspace.secrets.manage", "memory.manage", "evidence.manage", "pi.manage"];
|
||||
function renderShell(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
|
||||
return render(<QueryClientProvider client={client}>
|
||||
<AppShell canLogout={false} />
|
||||
</QueryClientProvider>);
|
||||
}
|
||||
beforeEach(() => {
|
||||
setAuthState({ issuer: "test", subject: "admin", roles: ["admin"], permissions, isAdmin: true, csrfToken: null, session: null });
|
||||
localStorage.clear(); useSessionStore.getState().resetSession();
|
||||
workspacePreferences.reset();
|
||||
window.history.replaceState(null, "", "/");
|
||||
FakeEventSource.instances = [];
|
||||
(globalThis as any).EventSource = FakeEventSource;
|
||||
server.use(
|
||||
http.get("/api/sessions", () => HttpResponse.json([])),
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "alpha", provider: "test", model: "test", thinking: "low" })),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("alpha"), workspaceSummaryFixture("beta")])),
|
||||
http.get("/api/workspace-registry/status", () => HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })),
|
||||
http.get("/api/workspaces/:id/runtime-configuration", ({ params }) => HttpResponse.json({ workspaceId: params.id, revision: workspaceRevisionFixture(String(params.id)), configurationState: "ready", requirements: [] })),
|
||||
http.get("/api/workspaces/:id", ({ params }) => HttpResponse.json({ workspace: canonicalWorkspaceFixture(String(params.id)), revision: workspaceRevisionFixture(String(params.id)) })),
|
||||
http.get("/api/pi-management/status", () => HttpResponse.json({ ready: true, credentials: "present", config: { provider: "test", model: "test", reasoning: "low" } })),
|
||||
http.get("/api/workspaces/:id/memory", () => HttpResponse.json({ items: [], total: 0, page: 1, page_size: 25 })),
|
||||
http.get("/api/workspaces/:id/memory/pending", () => HttpResponse.json([])),
|
||||
);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
test.each(["Workspace", "Evidence", "Memory", "Database", "Pi"])("reloads the %s route as a full page", async name => {
|
||||
window.history.replaceState({ portal: "kept" }, "", `/portal?lang=it&thoth_route=administration/${name.toLowerCase()}#host`);
|
||||
renderShell();
|
||||
expect(await screen.findByRole("main", { name: `${name} management` })).toBeVisible();
|
||||
expect(screen.queryByRole("dialog", { name: `${name} management` })).not.toBeInTheDocument();
|
||||
expect(window.location.hash).toBe("#host");
|
||||
expect(window.history.state.portal).toBe("kept");
|
||||
});
|
||||
|
||||
test("preserves host state and query parameters through Back and Forward", async () => {
|
||||
window.history.replaceState({ portal: { view: "thoth" } }, "", "/portal?lang=it#host");
|
||||
renderShell();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pi management" }));
|
||||
act(() => window.history.back());
|
||||
expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible();
|
||||
act(() => window.history.forward());
|
||||
expect(await screen.findByRole("main", { name: "Pi management" })).toBeVisible();
|
||||
expect(new URLSearchParams(window.location.search).get("lang")).toBe("it");
|
||||
expect(window.location.pathname).toBe("/portal");
|
||||
expect(window.location.hash).toBe("#host");
|
||||
expect(window.history.state.portal).toEqual({ view: "thoth" });
|
||||
});
|
||||
|
||||
test.each(["workspace", "database", "memory", "evidence", "pi"])("denies a direct %s link for a non-administrator", async name => {
|
||||
setAuthState({ issuer: "test", subject: "reader", roles: ["user"], permissions: ["session.use"], isAdmin: false, csrfToken: null, session: null });
|
||||
window.history.replaceState(null, "", `/?thoth_route=administration/${name}`);
|
||||
renderShell();
|
||||
expect(await screen.findByRole("main", { name: "Administration unavailable" })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Back and Core navigation preserve a Memory draft until the editor cancels it", async () => {
|
||||
renderShell();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
|
||||
const page = await screen.findByRole("main", { name: "Memory management" });
|
||||
await userEvent.click(await within(page).findByRole("button", { name: "New card" }));
|
||||
await userEvent.type(within(page).getByLabelText("Title"), "Keep this draft");
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
act(() => window.history.back());
|
||||
await waitFor(() => expect(new URLSearchParams(window.location.search).get("thoth_route")).toBe("administration/memory"));
|
||||
expect(within(page).getByLabelText("Title")).toHaveValue("Keep this draft");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
|
||||
expect(page).toBeVisible();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
confirm.mockReturnValue(true);
|
||||
await userEvent.click(within(page).getByRole("button", { name: "Cancel" }));
|
||||
act(() => window.history.back());
|
||||
expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible();
|
||||
act(() => window.history.forward());
|
||||
expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("runs preprocessing for the globally selected workspace", async () => {
|
||||
const targets: string[] = [];
|
||||
server.use(http.post("/api/workspaces/:id/preprocessing", ({ params }) => {
|
||||
targets.push(String(params.id));
|
||||
return HttpResponse.json({ schemaVersion: 1, workspaceId: params.id, state: "ready", actionable: true, clearable: true, detail: "Prepared" });
|
||||
}));
|
||||
window.history.replaceState(null, "", "/?thoth_route=administration/workspace&thoth_workspace=beta");
|
||||
workspacePreferences.selectWorkspace("beta");
|
||||
renderShell();
|
||||
const preparation = await screen.findByRole("region", { name: "Workspace readiness" });
|
||||
await userEvent.click(await within(preparation).findByRole("button", { name: "Run again" }));
|
||||
await waitFor(() => expect(targets).toEqual(["beta"]));
|
||||
expect(within(preparation).getByRole("link", { name: "Open Database configuration and schema" })).toHaveAttribute("href", "/?thoth_route=administration%2Fdatabase&thoth_workspace=beta");
|
||||
});
|
||||
|
||||
test("option A has one collapsible global context and preserves the Core draft across Admin", async () => {
|
||||
renderShell();
|
||||
const composer = await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "Keep my original Core question");
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
expect(screen.getAllByRole("combobox", { name: "Workspace" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("combobox", { name: "Model" })).toHaveLength(1);
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
|
||||
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Done" }));
|
||||
expect(screen.queryByRole("combobox", { name: "Model" })).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
|
||||
expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible();
|
||||
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
|
||||
expect(screen.getByRole("textbox", { name: /new question/i })).toBe(composer);
|
||||
expect(composer).toHaveValue("Keep my original Core question");
|
||||
});
|
||||
|
||||
test("invalid remembered choices do not silently fall back and gate both Core and Admin", async () => {
|
||||
workspacePreferences.selectWorkspace("removed");
|
||||
workspacePreferences.selectModel("removed/model");
|
||||
window.history.replaceState(null, "", "/?thoth_route=administration/memory");
|
||||
renderShell();
|
||||
await screen.findByText(/Unavailable saved choices are not replaced/);
|
||||
expect(screen.getByRole("button", { name: "New session" })).toBeDisabled();
|
||||
expect(screen.queryByRole("main", { name: "Memory management" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "alpha");
|
||||
expect(screen.queryByRole("main", { name: "Memory management" })).not.toBeInTheDocument();
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Model" }), "test/test");
|
||||
expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("workspace and model stay locked during Core and unlock after it finishes", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
act(() => useSessionStore.getState().setAgentActive(true));
|
||||
expect(screen.getByRole("combobox", { name: "Workspace" })).toBeDisabled();
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled();
|
||||
act(() => useSessionStore.getState().setAgentActive(false));
|
||||
expect(screen.getByRole("combobox", { name: "Workspace" })).toBeEnabled();
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("dirty Memory blocks workspace and model changes, including after a failed save", async () => {
|
||||
let saveAttempts = 0;
|
||||
server.use(
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [
|
||||
{ provider: "test", id: "test", name: "Test LLM", reasoning: true },
|
||||
{ provider: "test", id: "other", name: "Other LLM", reasoning: true },
|
||||
] })),
|
||||
http.post("/api/workspaces/:id/memory", () => { saveAttempts++; return HttpResponse.json({ code: "memory_unavailable", error: "Save failed" }, { status: 500 }); }),
|
||||
);
|
||||
window.history.replaceState(null, "", "/?thoth_route=administration/memory");
|
||||
renderShell();
|
||||
const page = await screen.findByRole("main", { name: "Memory management" });
|
||||
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
|
||||
await userEvent.type(within(page).getByLabelText("Title"), "Do not lose this");
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Model" }), "test/other");
|
||||
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "alpha", model: "test" });
|
||||
await userEvent.type(within(page).getByLabelText("Content"), "Retain this content.");
|
||||
await userEvent.type(within(page).getByLabelText("Scope"), "This workspace.");
|
||||
await userEvent.click(within(page).getByRole("button", { name: "Save card" }));
|
||||
await waitFor(() => expect(saveAttempts).toBe(1));
|
||||
expect(await within(page).findByRole("alert")).toBeVisible();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
|
||||
expect(page).toBeVisible();
|
||||
expect(within(page).getByLabelText("Title")).toHaveValue("Do not lose this");
|
||||
});
|
||||
|
||||
test("a failed catalog refresh retains a mounted Admin draft", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
window.history.replaceState(null, "", "/?thoth_route=administration/memory");
|
||||
renderShell(client);
|
||||
const page = await screen.findByRole("main", { name: "Memory management" });
|
||||
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
|
||||
await userEvent.type(within(page).getByLabelText("Title"), "Still here");
|
||||
server.use(http.get("/api/models", () => new HttpResponse(null, { status: 503 })));
|
||||
await act(async () => { await client.refetchQueries({ queryKey: ["models"], exact: true }); });
|
||||
expect(page).toBeVisible();
|
||||
expect(within(page).getByLabelText("Title")).toHaveValue("Still here");
|
||||
});
|
||||
|
||||
test("Resume binds the pinned workspace immediately and leaves the global model unchanged", async () => {
|
||||
server.use(
|
||||
http.get("/api/sessions", () => HttpResponse.json([{
|
||||
id: "in-beta", status: "open", question: "Beta session", active: true,
|
||||
created_at: "2026-09-12T00:00:00Z", archived: false,
|
||||
}])),
|
||||
http.post("/api/sessions/in-beta/resume", () => HttpResponse.json({ id: "in-beta", alreadyActive: true, workspaceId: "beta" })),
|
||||
http.get("/api/sessions/in-beta", () => HttpResponse.json({ id: "in-beta", phase: 3, workspace_id: "beta", provider: "old", model: "old" })),
|
||||
);
|
||||
renderShell();
|
||||
await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.click(await screen.findByTestId("session-item-in-beta"));
|
||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" });
|
||||
});
|
||||
|
||||
test("a successful Memory save releases the navigation guard and the context lock", async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
server.use(http.post("/api/workspaces/:id/memory", async ({ request }) => {
|
||||
const card = await request.json() as object;
|
||||
await gate;
|
||||
return HttpResponse.json({ id: "saved", saved: true, indexed: true, action: "upsert",
|
||||
card: { ...card, id: "saved", workspace_id: "alpha", revision: "1", origin: "manual", indexed: true } });
|
||||
}));
|
||||
window.history.replaceState(null, "", "/?thoth_route=administration/memory");
|
||||
renderShell();
|
||||
const page = await screen.findByRole("main", { name: "Memory management" });
|
||||
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
|
||||
await userEvent.type(within(page).getByLabelText("Title"), "Saved card");
|
||||
await userEvent.type(within(page).getByLabelText("Scope"), "This workspace");
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
await userEvent.click(within(page).getByRole("button", { name: "Save card" }));
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
|
||||
expect(page).toBeVisible();
|
||||
await act(async () => release());
|
||||
await within(page).findByText("Saved and indexed.");
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
|
||||
expect(await screen.findByRole("textbox", { name: /new question/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Admin generation locks the context and Core submission across page navigation", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderShell(client);
|
||||
const composer = await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "Wait for Admin to finish");
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
await act(async () => {
|
||||
client.setQueryData(["description-generation-runs", 50], [{ id: "r1", status: "running" }]);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled());
|
||||
expect(screen.getByRole("combobox", { name: "Workspace" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /send/i })).toBeDisabled();
|
||||
await act(async () => { client.setQueryData(["description-generation-runs", 50], []); });
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled());
|
||||
expect(screen.getByRole("button", { name: /send/i })).toBeEnabled();
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AppShell } from "./AppShell";
|
||||
import { clearAuthState, getAuthGeneration, getAuthState, setAuthState, useAuthGeneration, useAuthUser } from "../auth/authState";
|
||||
import { server } from "../test/msw";
|
||||
import { workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { workspacePreferences } from "../workspaces/preferences";
|
||||
|
||||
@@ -37,11 +38,11 @@ beforeEach(() => {
|
||||
server.use(
|
||||
http.get("/api/sessions", () => HttpResponse.json([])),
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("default")])),
|
||||
http.get("/api/workspace-registry/status", () => HttpResponse.json({
|
||||
branch: "main", ahead: 0, behind: 0, degraded: false,
|
||||
})),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })),
|
||||
http.get("/api/health/dwh", () => HttpResponse.json({ ok: true })),
|
||||
);
|
||||
});
|
||||
@@ -54,7 +55,9 @@ describe("authenticated shell permissions", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
|
||||
const memory = screen.getByRole("main", { name: "Memory management" });
|
||||
expect(memory).toBeVisible();
|
||||
expect(within(memory).getByRole("combobox", { name: "Workspace" })).toBeVisible();
|
||||
expect(within(memory).queryByRole("combobox", { name: "Workspace" })).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
expect(screen.getByRole("combobox", { name: "Workspace" })).toHaveValue("default");
|
||||
});
|
||||
test("does not use the legacy installation default as a preprocessing workspace", async () => {
|
||||
const requestedWorkspaceIds: string[] = [];
|
||||
@@ -98,6 +101,7 @@ describe("authenticated shell permissions", () => {
|
||||
|
||||
const administration = await screen.findByRole("button", { name: "Administration" });
|
||||
await user.click(administration);
|
||||
await user.selectOptions(await screen.findByRole("combobox", { name: "Workspace" }), "psd-clinical");
|
||||
await waitFor(() => expect(requestedWorkspaceIds).toContain("psd-clinical"));
|
||||
expect(requestedWorkspaceIds).not.toContain("local");
|
||||
});
|
||||
@@ -139,14 +143,14 @@ describe("authenticated shell permissions", () => {
|
||||
const separator = within(panel).getByRole("separator");
|
||||
const workspace = within(panel).getByRole("button", { name: "Workspace management" });
|
||||
const pi = within(panel).getByRole("button", { name: "Pi management" });
|
||||
const preprocessing = within(panel).getByRole("region", { name: "Workspace preprocessing" });
|
||||
expect(panel.children[0]).toBe(database);
|
||||
expect(panel.children[1]).toBe(memory);
|
||||
expect(panel.children[2]).toBe(evidence);
|
||||
expect(panel.children[3]).toBe(separator);
|
||||
expect(panel.children[4]).toBe(workspace);
|
||||
expect(panel.children[5]).toBe(pi);
|
||||
expect(panel.children[6]).toBe(preprocessing);
|
||||
expect(within(panel).queryByRole("region", { name: "Workspace preprocessing" })).not.toBeInTheDocument();
|
||||
expect(within(panel).getByText(/Workspace readiness/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument();
|
||||
|
||||
await user.keyboard(" ");
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { server } from "../test/msw";
|
||||
import { workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { clearAuthState, setAuthState } from "../auth/authState";
|
||||
@@ -51,8 +52,8 @@ beforeEach(() => {
|
||||
model: "test",
|
||||
thinking: "low",
|
||||
})),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("default")])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })),
|
||||
http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })),
|
||||
http.get("/api/catalog/metrics", () => HttpResponse.json({
|
||||
scope: "global",
|
||||
@@ -214,10 +215,10 @@ test("hides the complete administrative navigation from a regular user", () => {
|
||||
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not leave database management without confirming a dirty form", async () => {
|
||||
test("requires saving or cancelling a dirty database form before leaving", async () => {
|
||||
server.use(http.get("/api/catalog/databases", () => HttpResponse.json([{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
workspaceId: "psd-clinical",
|
||||
workspaceId: "default",
|
||||
workspaceName: "Policlinico San Donato",
|
||||
workspaceAvailable: true,
|
||||
workspaceRevision: { commit: "a".repeat(40), blob: "b".repeat(40) },
|
||||
@@ -250,19 +251,19 @@ test("does not leave database management without confirming a dirty form", async
|
||||
await expandAdministration();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Database management" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
|
||||
const schema = screen.getByLabelText("Schema");
|
||||
const schema = await screen.findByLabelText("Schema");
|
||||
await userEvent.clear(schema);
|
||||
await userEvent.type(schema, "reporting");
|
||||
|
||||
const newSession = screen.getByRole("button", { name: "New session" });
|
||||
await userEvent.click(newSession);
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith("Discard unsaved database changes and leave database management?");
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("main", { name: "Database management" })).toBeVisible();
|
||||
expect(schema).toHaveValue("reporting");
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Back to list" }));
|
||||
await userEvent.click(newSession);
|
||||
await waitFor(() => expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument());
|
||||
confirm.mockRestore();
|
||||
|
||||
@@ -43,7 +43,7 @@ beforeEach(() => {
|
||||
snapshotPath: "/data/workspaces/default.yaml",
|
||||
},
|
||||
}])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ test("New session starts prewarm without delaying composer focus", async () => {
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /^new session$/i }));
|
||||
|
||||
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||
const composer = await screen.findByRole("textbox", { name: /new question/i });
|
||||
await waitFor(() => expect(prewarmStarted).toBe(true));
|
||||
await waitFor(() => expect(composer).toHaveFocus());
|
||||
expect(composer).toHaveAttribute("data-awaiting-input", "true");
|
||||
@@ -84,7 +84,7 @@ test("a known preprocessing requirement disables New session", async () => {
|
||||
const newSession = screen.getByRole("button", { name: "New session" });
|
||||
await waitFor(() => expect(newSession).toBeDisabled());
|
||||
expect(newSession).toHaveAttribute("data-navigation-state", "unavailable");
|
||||
expect(newSession).toHaveAttribute("title", "Catalog revision 18 is not indexed.");
|
||||
await waitFor(() => expect(newSession).toHaveAttribute("title", "Catalog revision 18 is not indexed."));
|
||||
});
|
||||
|
||||
test("records the prompt without central duplication, then opens the live log", async () => {
|
||||
@@ -98,7 +98,7 @@ test("records the prompt without central duplication, then opens the live log",
|
||||
);
|
||||
renderShell();
|
||||
|
||||
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||
const composer = await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "How many patients?");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
@@ -126,7 +126,7 @@ test("a failed create restores the landing view and preserves the question for r
|
||||
);
|
||||
renderShell();
|
||||
|
||||
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||
const composer = await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "Keep this question");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
@@ -150,7 +150,7 @@ test("a DWH-unreachable precheck shows a specific alert and preserves the questi
|
||||
);
|
||||
renderShell();
|
||||
|
||||
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||
const composer = await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "quanti pazienti?");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
@@ -176,13 +176,15 @@ test("model selector shows the three Pi-enabled models and stores the selected p
|
||||
);
|
||||
renderShell();
|
||||
|
||||
const select = await screen.findByRole("combobox", { name: "Model" });
|
||||
await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
const select = screen.getByRole("combobox", { name: "Model" });
|
||||
await waitFor(() => {
|
||||
expect(within(select).getAllByRole("option").map((option) => option.textContent)).toEqual([
|
||||
"GLM-5.2", "DeepSeek V4 Flash", "Qwen3.6 35B A3B Local",
|
||||
"Choose a model", "GLM-5.2", "DeepSeek V4 Flash", "Qwen3.6 35B A3B Local",
|
||||
]);
|
||||
});
|
||||
await userEvent.selectOptions(select, "qwen3.6-35b-a3b");
|
||||
await userEvent.selectOptions(select, "local-qwen/qwen3.6-35b-a3b");
|
||||
await waitFor(() => expect(workspacePreferences.load()).toEqual({
|
||||
workspaceId: "default", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low",
|
||||
}));
|
||||
@@ -205,7 +207,7 @@ test("opens Workspace management from the right sidebar without interrupting the
|
||||
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
|
||||
const dialog = screen.getByRole("dialog", { name: "Workspace management" });
|
||||
const dialog = screen.getByRole("main", { name: "Workspace management" });
|
||||
const workArea = screen.getByTestId("conversation-column");
|
||||
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
||||
const workspaceManagement = within(sessionNavigation).getByRole("button", { name: "Workspace management" });
|
||||
@@ -214,17 +216,17 @@ test("opens Workspace management from the right sidebar without interrupting the
|
||||
expect(sessionNavigation).not.toContainElement(dialog);
|
||||
expect(screen.getByTestId("app-shell")).toHaveAttribute("data-activity-layout", "closed");
|
||||
expect(workspaceManagement).toHaveAttribute("aria-current", "page");
|
||||
expect(workspaceManagement).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.queryByRole("dialog", { name: "Workspace management" })).not.toBeInTheDocument();
|
||||
expect(workspaceManagement).toHaveAttribute("data-navigation-state", "current");
|
||||
expect(workspaceManagement).toHaveClass("bg-[oklch(var(--nav-active))]");
|
||||
expect(newSession).toHaveAttribute("data-navigation-state", "available");
|
||||
expect(newSession).not.toHaveAttribute("aria-current");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Close workspace management" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Workspace management" })).not.toBeInTheDocument());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
|
||||
await waitFor(() => expect(screen.queryByRole("main", { name: "Workspace management" })).not.toBeInTheDocument());
|
||||
expect(newSession).toHaveAttribute("aria-current", "page");
|
||||
expect(newSession).toHaveAttribute("data-navigation-state", "current");
|
||||
expect(workspaceManagement).toHaveAttribute("aria-expanded", "false");
|
||||
expect(new URLSearchParams(window.location.search).has("thoth_route")).toBe(false);
|
||||
expect(workspaceManagement).toHaveAttribute("data-navigation-state", "available");
|
||||
});
|
||||
|
||||
@@ -245,10 +247,10 @@ test("does not block the shell when the DWH is unavailable at startup", async ()
|
||||
});
|
||||
|
||||
|
||||
test("marks the composer as awaiting input for a pending freetext gate", () => {
|
||||
test("marks the composer as awaiting input for a pending freetext gate", async () => {
|
||||
useSessionStore.setState({
|
||||
pendingWidget: { id: "free-1", widget: "freetext", title: "Clarify" },
|
||||
});
|
||||
renderShell();
|
||||
expect(screen.getByRole("textbox", { name: /new question/i })).toHaveAttribute("data-awaiting-input", "true");
|
||||
expect(await screen.findByRole("textbox", { name: /new question/i })).toHaveAttribute("data-awaiting-input", "true");
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { server } from "../test/msw";
|
||||
import { workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
import { AppShell } from "./AppShell";
|
||||
import type { AuthenticatedUser } from "../api/types";
|
||||
@@ -74,8 +75,8 @@ beforeEach(() => {
|
||||
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false, permissions: ["session.use", "pi.manage"] }),
|
||||
),
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("psd")])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "zai", id: "glm-5.2", name: "Test LLM", reasoning: true }] })),
|
||||
http.get("/api/sessions", () => HttpResponse.json(LIST)),
|
||||
http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||
http.get("/api/sessions/:id/documents", () => HttpResponse.json([
|
||||
@@ -123,7 +124,7 @@ test("Pi management preserves the open session summary and the model activity ti
|
||||
await user.click(screen.getByRole("button", { name: "Administration" }));
|
||||
await user.click(screen.getByRole("button", { name: "Pi management" }));
|
||||
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
|
||||
const dialog = screen.getByRole("dialog", { name: "Pi management" });
|
||||
const dialog = screen.getByRole("main", { name: "Pi management" });
|
||||
const workArea = screen.getByTestId("conversation-column");
|
||||
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
||||
const piManagement = within(sessionNavigation).getByRole("button", { name: "Pi management" });
|
||||
@@ -131,27 +132,27 @@ test("Pi management preserves the open session summary and the model activity ti
|
||||
expect(workArea).toContainElement(dialog);
|
||||
expect(sessionNavigation).not.toContainElement(dialog);
|
||||
expect(piManagement).toHaveAttribute("aria-current", "page");
|
||||
expect(piManagement).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.queryByRole("dialog", { name: "Pi management" })).not.toBeInTheDocument();
|
||||
expect(piManagement).toHaveAttribute("data-navigation-state", "current");
|
||||
expect(newSession).toHaveAttribute("data-navigation-state", "available");
|
||||
expect(newSession).not.toHaveAttribute("aria-current");
|
||||
const preservedSummary = screen.getByRole("complementary", { name: "Session summary" });
|
||||
expect(preservedSummary).not.toHaveAttribute("aria-hidden", "true");
|
||||
const preservedSummary = document.querySelector('[aria-label="Session summary"]');
|
||||
expect(preservedSummary).toHaveAttribute("aria-hidden", "true");
|
||||
expect(preservedSummary).toHaveTextContent("Attiva uno");
|
||||
await user.click(screen.getByRole("button", { name: "Close Pi management" }));
|
||||
await user.click(screen.getByRole("button", { name: "Return to session" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
|
||||
});
|
||||
expect(newSession).toHaveAttribute("aria-current", "page");
|
||||
expect(newSession).toHaveAttribute("data-navigation-state", "current");
|
||||
expect(piManagement).toHaveAttribute("aria-expanded", "false");
|
||||
expect(new URLSearchParams(window.location.search).has("thoth_route")).toBe(false);
|
||||
expect(piManagement).toHaveAttribute("data-navigation-state", "available");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Resume" }));
|
||||
await screen.findByRole("button", { name: "Show model activity" });
|
||||
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Preserved activity" }));
|
||||
await user.click(screen.getByRole("button", { name: "Pi management" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Close Pi management" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Return to session" }));
|
||||
await waitFor(() => expect(screen.queryByRole("heading", { name: "Pi management" })).not.toBeInTheDocument());
|
||||
await user.click(screen.getByRole("button", { name: "Show model activity" }));
|
||||
expect(await screen.findByRole("heading", { name: "Model activity" })).toBeVisible();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { AppShell } from "./AppShell";
|
||||
import { server } from "../test/msw";
|
||||
import { workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
import { clearAuthState, setAuthState } from "../auth/authState";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
@@ -32,8 +33,8 @@ beforeEach(() => {
|
||||
server.use(
|
||||
http.get("/api/me", () => HttpResponse.json({ issuer: "local", subject: "user-a", isAdmin: false })),
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("default")])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })),
|
||||
http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })),
|
||||
);
|
||||
});
|
||||
@@ -91,7 +92,7 @@ test("a held new-session completion cannot replace the newer active s2 target",
|
||||
http.get("/api/sessions/:id", ({ params }) => HttpResponse.json({ id: params.id, status: "open", phase: 1 })),
|
||||
);
|
||||
renderShell();
|
||||
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||
const composer = await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "Held new question");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
await createStarted.promise;
|
||||
|
||||
+121
-81
@@ -20,7 +20,6 @@ import { DatabaseManagementPage } from "./DatabaseManagementPage";
|
||||
import { MemoryManagementPage } from "./MemoryManagementPage";
|
||||
import { EvidenceManagementPage } from "./EvidenceManagementPage";
|
||||
import { ArchiveNavigation } from "./ArchiveNavigation";
|
||||
import { WorkspacePreprocessingControl } from "./WorkspacePreprocessingControl";
|
||||
import { Pencil, ArrowLeft, ArrowRight, ChevronDown, Trash2 } from "lucide-react";
|
||||
import { Accordion } from "@base-ui/react/accordion";
|
||||
import { Button, buttonVariants } from "../components/ui/button";
|
||||
@@ -37,20 +36,23 @@ import {
|
||||
} from "../components/ui/dialog";
|
||||
import type { SessionScope, SessionSummary } from "../api/types";
|
||||
import { useAuthGeneration, useAuthUser } from "../auth/authState";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useIsMutating, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent } from "react";
|
||||
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation";
|
||||
import { getSettings } from "../api/settings";
|
||||
import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences";
|
||||
import { workspacePreferences } from "../workspaces/preferences";
|
||||
import { getWorkspacePreprocessingStatus } from "../api/workspace-preprocessing";
|
||||
import { AdministrationNavigationProvider, useAdministrationNavigation } from "./administration/AdministrationNavigation";
|
||||
import { AdministrationHeader } from "./administration/AdministrationPage";
|
||||
import { WorkingContextProvider, useWorkingContext } from "../workspaces/WorkingContext";
|
||||
import { WorkingContextShelf } from "./WorkingContextShelf";
|
||||
import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
|
||||
|
||||
interface AppShellProps {
|
||||
canLogout: boolean;
|
||||
}
|
||||
|
||||
type ActiveSurface = "core" | "database-management" | "memory-management" | "evidence-management";
|
||||
type ActiveManagementPanel = "workspace" | "pi" | null;
|
||||
const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"];
|
||||
|
||||
function sessionScopeTabClass(selected: boolean): string {
|
||||
@@ -89,6 +91,10 @@ export function resolveDatabaseManagementPresentation({
|
||||
}
|
||||
|
||||
export function AppShell({ canLogout }: AppShellProps) {
|
||||
const workingContext = useWorkingContext();
|
||||
const interactionBusy = useInteractionModelBusy();
|
||||
const mutationBusy = useIsMutating() > 0;
|
||||
const [adminBusy, setAdminBusy] = useState({ database: false, workspace: false, evidence: false, pi: false });
|
||||
const authenticatedUser = useAuthUser();
|
||||
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
|
||||
const {
|
||||
@@ -166,18 +172,9 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettings,
|
||||
});
|
||||
const [workspacePreference, setWorkspacePreference] = useState<WorkspacePreference>(
|
||||
() => workspacePreferences.load(),
|
||||
);
|
||||
useEffect(() => {
|
||||
const unsubscribe = workspacePreferences.subscribe(setWorkspacePreference);
|
||||
// Child effects may seed the singleton before this parent effect subscribes.
|
||||
setWorkspacePreference(workspacePreferences.load());
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
// Only an ID selected from the active Workspace Registry may address workspace-scoped APIs.
|
||||
// Installation settings are a legacy hint and can name a removed/non-catalog workspace.
|
||||
const selectedWorkspaceId = workspacePreference.workspaceId;
|
||||
const selectedWorkspaceId = workingContext.workspace?.id;
|
||||
const preprocessingQuery = useQuery({
|
||||
queryKey: ["workspace-preprocessing", selectedWorkspaceId],
|
||||
queryFn: () => getWorkspacePreprocessingStatus(selectedWorkspaceId!),
|
||||
@@ -204,7 +201,6 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [showActivity, setShowActivity] = useState(false);
|
||||
const [activeSurface, setActiveSurface] = useState<ActiveSurface>("core");
|
||||
const databaseManagementPresentation = resolveDatabaseManagementPresentation({
|
||||
isDevelopment: import.meta.env.DEV,
|
||||
mode: import.meta.env.MODE,
|
||||
@@ -214,8 +210,25 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
const databaseNavigationRef = useRef({ dirty: false, busy: false });
|
||||
const updateDatabaseNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => {
|
||||
databaseNavigationRef.current = state;
|
||||
setAdminBusy(previous => previous.database === state.busy ? previous : { ...previous, database: state.busy });
|
||||
}, []);
|
||||
const [activeManagementPanel, setActiveManagementPanel] = useState<ActiveManagementPanel>(null);
|
||||
const workspaceNavigationRef = useRef({ dirty: false, busy: false });
|
||||
const updateWorkspaceNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => {
|
||||
workspaceNavigationRef.current = state;
|
||||
setAdminBusy(previous => previous.workspace === state.busy ? previous : { ...previous, workspace: state.busy });
|
||||
}, []);
|
||||
const { route, navigate } = useAdministrationNavigation(canLeaveDatabaseManagement, () => {
|
||||
if (activeSurface === "memory-management") return memoryDirty || memoryBusy;
|
||||
const state = activeSurface === "database-management" ? databaseNavigationRef.current
|
||||
: activeSurface === "workspace-management" ? workspaceNavigationRef.current : null;
|
||||
return Boolean(state && (state.dirty || state.busy));
|
||||
});
|
||||
const activeSurface = route.surface;
|
||||
const [visited, setVisited] = useState<Set<string>>(() => new Set([activeSurface]));
|
||||
useEffect(() => { setVisited(previous => previous.has(activeSurface) ? previous : new Set([...previous, activeSurface])); }, [activeSurface]);
|
||||
const administrationBusy = mutationBusy || memoryBusy || Object.values(adminBusy).some(Boolean);
|
||||
const contextLocked = interactionBusy || creatingSession || administrationBusy
|
||||
|| preprocessingQuery.data?.state === "running";
|
||||
const [adminNavigationValue, setAdminNavigationValue] = useState<string[]>([]);
|
||||
const [activeOpen, setActiveOpen] = useState(true);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
@@ -306,30 +319,22 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
}
|
||||
|
||||
function canLeaveDatabaseManagement(): boolean {
|
||||
if (activeSurface === "memory-management" && memoryBusy) {
|
||||
toast.info("Wait for the Memory operation to finish before leaving this page");
|
||||
const dirty = activeSurface === "workspace-management" ? workspaceNavigationRef.current.dirty
|
||||
: activeSurface === "memory-management" ? memoryDirty
|
||||
: activeSurface === "database-management" ? databaseNavigationRef.current.dirty : false;
|
||||
if (dirty) {
|
||||
toast.warning("Save your administration changes, or cancel the edit, before leaving this page.");
|
||||
return false;
|
||||
}
|
||||
if (activeSurface === "memory-management" && memoryDirty) {
|
||||
return window.confirm("Discard unsaved Memory changes and leave Memory management?");
|
||||
}
|
||||
if (activeSurface !== "database-management") return true;
|
||||
if (databaseNavigationRef.current.busy) {
|
||||
toast.info("Wait for the database operation to finish before leaving this page");
|
||||
return false;
|
||||
}
|
||||
if (databaseNavigationRef.current.dirty) {
|
||||
return window.confirm("Discard unsaved database changes and leave database management?");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function openPanel(id: string) {
|
||||
if (!workingContext.ready) return;
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
const s = sessions.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel(null);
|
||||
navigate({ surface: "core" }, true);
|
||||
// A session with a live Pi runtime opens straight into its live view: doResume
|
||||
// reconnects to the already-active runtime and replays its pending gate, so an
|
||||
// in-progress session never shows an empty screen that reads as "stopped". Cold or
|
||||
@@ -349,9 +354,9 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
});
|
||||
}
|
||||
async function doResume(id: string, databaseExitApproved = false) {
|
||||
if (!workingContext.ready) return;
|
||||
if (!databaseExitApproved && !canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel(null);
|
||||
navigate({ surface: "core" }, true);
|
||||
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
|
||||
if (!guard) return;
|
||||
const token = ++resumeInvocationRef.current;
|
||||
@@ -388,6 +393,9 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
|
||||
const latest = latestResumeIntentRef.current;
|
||||
if (latest?.token !== operation.latestToken || latest.id !== id) return;
|
||||
// Bind context atomically with Resume, before displaying any session or Admin actions.
|
||||
// Older servers omit this field; their manifest remains the compatibility fallback.
|
||||
if (result.workspaceId) workspacePreferences.selectWorkspace(result.workspaceId);
|
||||
const reconnectSameSession = activeSessionIdRef.current === id;
|
||||
setPanelSession(null);
|
||||
setAwaitingQuestion(false);
|
||||
@@ -417,7 +425,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
// Paint the persisted re-entry phase while the replacement stream starts replaying.
|
||||
// The manifest's `phase` is the 1-based current phase (1..8).
|
||||
try {
|
||||
const m = (await getSession(id)) as { phase?: number };
|
||||
const m = (await getSession(id)) as { phase?: number; workspace_id?: string };
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return;
|
||||
const latestAfterManifest = latestResumeIntentRef.current;
|
||||
if (
|
||||
@@ -425,6 +433,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
|| latestAfterManifest.id !== id
|
||||
|| activeSessionIdRef.current !== id
|
||||
) return;
|
||||
if (!result.workspaceId && m.workspace_id) workspacePreferences.selectWorkspace(m.workspace_id);
|
||||
if (typeof m.phase === "number" && m.phase >= 1 && m.phase <= 8) {
|
||||
setPhase(`F${m.phase}`);
|
||||
}
|
||||
@@ -557,7 +566,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
session={s}
|
||||
groups={groups}
|
||||
onResume={() => doResume(s.id)}
|
||||
onView={() => setPanelSession(s)}
|
||||
onView={() => { if (canLeaveDatabaseManagement()) { navigate({ surface: "core" }, true); setPanelSession(s); } }}
|
||||
onRename={() => setRenameTarget(s)}
|
||||
onMove={(g) => move(s, g)}
|
||||
onNewGroup={() => newGroup(s)}
|
||||
@@ -633,13 +642,13 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
}, [lastSystemEvent]);
|
||||
|
||||
function startNewSession() {
|
||||
if (!workingContext.ready || administrationBusy) return;
|
||||
if (preprocessingBlocksNewSession) {
|
||||
toast.warning(preprocessingQuery.data?.detail ?? "Workspace preprocessing is required.");
|
||||
return;
|
||||
}
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel(null);
|
||||
navigate({ surface: "core" }, true);
|
||||
invalidateResumeIntent();
|
||||
newSessionOperationRef.current = null;
|
||||
resetSession();
|
||||
@@ -707,13 +716,35 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
await logoutUser();
|
||||
}
|
||||
|
||||
const currentNavigation = activeSurface === "database-management"
|
||||
? "database"
|
||||
: activeSurface === "memory-management" ? "memory" : activeSurface === "evidence-management" ? "evidence" : activeManagementPanel ?? "core";
|
||||
const currentNavigation = activeSurface.replace("-management", "");
|
||||
const administrationPermitted = activeSurface === "core" || (isAdmin && (
|
||||
activeSurface === "workspace-management" ||
|
||||
(activeSurface === "database-management" && canManageDatabase) ||
|
||||
(activeSurface === "memory-management" && canManageMemory) ||
|
||||
(activeSurface === "evidence-management" && canManageEvidence) ||
|
||||
(activeSurface === "pi-management" && canManagePi)
|
||||
));
|
||||
const adminNavigationOpen = adminNavigationValue.includes("administration");
|
||||
const managementNavigationCurrent = currentNavigation !== "core";
|
||||
|
||||
function changeContextWorkspace(id: string) {
|
||||
if (contextLocked || !canLeaveDatabaseManagement() || id === workingContext.workspaceId) return;
|
||||
invalidateResumeIntent();
|
||||
setPanelSession(null);
|
||||
selectActiveSession(null);
|
||||
resetSession();
|
||||
setAwaitingQuestion(false);
|
||||
workspacePreferences.selectWorkspace(id);
|
||||
}
|
||||
function changeContextModel(id: string) {
|
||||
if (!contextLocked && canLeaveDatabaseManagement()) workspacePreferences.selectModel(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkingContextProvider value={{ workspaceId: workingContext.workspaceId, ready: workingContext.ready, locked: contextLocked, administrationBusy: administrationBusy || (interactionBusy && !agentActive) }}>
|
||||
<AdministrationNavigationProvider navigate={navigate}>
|
||||
<div className="thot-context-layout">
|
||||
<WorkingContextShelf context={workingContext} locked={contextLocked} onWorkspace={changeContextWorkspace} onModel={changeContextModel} />
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={panelWidthsStyle}
|
||||
@@ -722,8 +753,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
data-activity-resizing={activityResizing}
|
||||
data-session-resizing={sessionResizing}
|
||||
className={[
|
||||
"flex h-screen bg-background text-foreground",
|
||||
["memory-management", "evidence-management"].includes(activeSurface) ? "max-md:pt-12" : "",
|
||||
"thot-app-shell flex bg-background text-foreground",
|
||||
activityResizing || sessionResizing ? "select-none cursor-col-resize" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
@@ -748,7 +778,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => setActiveManagementPanel("workspace")} hidden={activeSurface !== "core"} />}
|
||||
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => navigate({ surface: "workspace-management" })} hidden={activeSurface !== "core"} />}
|
||||
{activeSurface === "core" && showActivity && desktopSplit && (
|
||||
<div className="relative w-0 shrink-0">
|
||||
<div
|
||||
@@ -764,21 +794,29 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
|
||||
{/* Conversation column */}
|
||||
<WorkAreaPanelHost data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
|
||||
{activeSurface === "memory-management" && (
|
||||
<MemoryManagementPage canManage={canManageMemory} onDirtyChange={setMemoryDirty} onBusyChange={setMemoryBusy} />
|
||||
{!workingContext.ready && <main className="thot-administration-page" aria-label="Working context required"><AdministrationHeader title="Choose your working context" description="Select an available workspace and AI model above to enable Core and Administration." /></main>}
|
||||
{!administrationPermitted && <main className="thot-administration-page" aria-label="Administration unavailable">
|
||||
<AdministrationHeader title="Administration unavailable" description="Your account does not have permission to open this page." />
|
||||
</main>}
|
||||
{workingContext.ready && isAdmin && canManageMemory && (activeSurface === "memory-management" || visited.has("memory-management")) && (
|
||||
<div style={{ display: activeSurface === "memory-management" ? "contents" : "none" }}><MemoryManagementPage key={selectedWorkspaceId} initialWorkspaceId={selectedWorkspaceId} canManage={canManageMemory} onDirtyChange={setMemoryDirty} onBusyChange={setMemoryBusy} /></div>
|
||||
)}
|
||||
{activeSurface === "evidence-management" && <EvidenceManagementPage canManage={canManageEvidence} />}
|
||||
{activeSurface === "database-management" && (
|
||||
{workingContext.ready && isAdmin && canManageEvidence && (activeSurface === "evidence-management" || visited.has("evidence-management")) && <div style={{ display: activeSurface === "evidence-management" ? "contents" : "none" }}><EvidenceManagementPage key={selectedWorkspaceId} initialWorkspaceId={selectedWorkspaceId} canManage={canManageEvidence} /></div>}
|
||||
{workingContext.ready && isAdmin && canManageDatabase && (activeSurface === "database-management" || visited.has("database-management")) && (
|
||||
<div style={{ display: activeSurface === "database-management" ? "contents" : "none" }}>
|
||||
<DatabaseManagementPage
|
||||
key={selectedWorkspaceId}
|
||||
initialWorkspaceId={selectedWorkspaceId}
|
||||
canManage={canManageDatabase}
|
||||
canManageSecrets={canManageWorkspaceSecrets}
|
||||
onNavigationStateChange={updateDatabaseNavigationState}
|
||||
presentation={databaseManagementPresentation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
aria-hidden={activeSurface !== "core"}
|
||||
className={activeSurface === "core" ? "contents" : "hidden"}
|
||||
aria-hidden={activeSurface !== "core" || !workingContext.ready}
|
||||
className={activeSurface === "core" && workingContext.ready ? "contents" : "hidden"}
|
||||
>
|
||||
{activeSessionId && (
|
||||
<div className="relative shrink-0 border-b border-border/70 bg-background/80 px-6 py-2.5 backdrop-blur">
|
||||
@@ -857,23 +895,29 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceManager
|
||||
open={activeManagementPanel === "workspace"}
|
||||
onClose={() => setActiveManagementPanel(null)}
|
||||
<div style={{ display: activeSurface === "workspace-management" && workingContext.ready ? "contents" : "none" }}><WorkspaceManager
|
||||
key={selectedWorkspaceId}
|
||||
page
|
||||
initialWorkspaceId={selectedWorkspaceId}
|
||||
open={workingContext.ready && isAdmin && (activeSurface === "workspace-management" || visited.has("workspace-management"))}
|
||||
onClose={() => navigate({ surface: "core" })}
|
||||
onNavigationStateChange={updateWorkspaceNavigationState}
|
||||
canManageWorkspace={canManageWorkspace}
|
||||
canManageSecrets={canManageWorkspaceSecrets}
|
||||
/>
|
||||
{canManagePi && (
|
||||
<PiManagement
|
||||
open={activeManagementPanel === "pi"}
|
||||
onClose={() => setActiveManagementPanel(null)}
|
||||
/>
|
||||
canPreprocess={canManageDatabase}
|
||||
/></div>
|
||||
{isAdmin && canManagePi && (
|
||||
<div style={{ display: activeSurface === "pi-management" && workingContext.ready ? "contents" : "none" }}><PiManagement
|
||||
page
|
||||
open={workingContext.ready && (activeSurface === "pi-management" || visited.has("pi-management"))}
|
||||
onClose={() => navigate({ surface: "core" })}
|
||||
/></div>
|
||||
)}
|
||||
</WorkAreaPanelHost>
|
||||
|
||||
{/* Right session rail */}
|
||||
{(activeSurface !== "core" || !showActivity) && (
|
||||
<ArchiveNavigation surface={activeSurface}>
|
||||
{(
|
||||
<ArchiveNavigation surface={activeSurface} forceDrawer={activeSurface === "core" && showActivity}>
|
||||
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
|
||||
<div className="thot-session-navigation__header relative text-center">
|
||||
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
||||
@@ -899,15 +943,18 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</div>
|
||||
|
||||
<div className="thot-session-navigation__primary-controls">
|
||||
{activeSurface !== "core" && <Button variant="outline" size="sm" className="w-full" onClick={() => navigate({ surface: "core" })}>
|
||||
Return to session
|
||||
</Button>}
|
||||
<Button
|
||||
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
|
||||
size="sm"
|
||||
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||
aria-current={currentNavigation === "core" ? "page" : undefined}
|
||||
data-navigation-state={preprocessingBlocksNewSession
|
||||
data-navigation-state={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy
|
||||
? "unavailable"
|
||||
: currentNavigation === "core" ? "current" : "available"}
|
||||
disabled={preprocessingBlocksNewSession}
|
||||
disabled={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy}
|
||||
title={preprocessingBlocksNewSession ? preprocessingQuery.data?.detail : undefined}
|
||||
onClick={startNewSession}
|
||||
>
|
||||
@@ -949,8 +996,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
title={canManageDatabase ? undefined : "Database management permission is required"}
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveManagementPanel(null);
|
||||
setActiveSurface("database-management");
|
||||
navigate({ surface: "database-management" }, true);
|
||||
}}
|
||||
>
|
||||
Database management
|
||||
@@ -965,15 +1011,14 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
data-navigation-state={currentNavigation === "memory" ? "current" : "available"}
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveManagementPanel(null);
|
||||
setActiveSurface("memory-management");
|
||||
navigate({ surface: "memory-management" }, true);
|
||||
}}
|
||||
>Memory management</Button>
|
||||
<Button variant={currentNavigation === "evidence" ? "navigationActive" : "outline"}
|
||||
size="sm" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||
aria-current={currentNavigation === "evidence" ? "page" : undefined}
|
||||
disabled={!canManageEvidence} title={canManageEvidence ? undefined : "Evidence management permission is required"}
|
||||
onClick={() => { if (!canLeaveDatabaseManagement()) return; setActiveManagementPanel(null); setActiveSurface("evidence-management"); }}
|
||||
onClick={() => navigate({ surface: "evidence-management" })}
|
||||
>Evidence management</Button>
|
||||
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
|
||||
<Button
|
||||
@@ -981,12 +1026,10 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
size="sm"
|
||||
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||
aria-current={currentNavigation === "workspace" ? "page" : undefined}
|
||||
aria-expanded={activeManagementPanel === "workspace"}
|
||||
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel("workspace");
|
||||
navigate({ surface: "workspace-management" }, true);
|
||||
}}
|
||||
>
|
||||
Workspace management
|
||||
@@ -996,25 +1039,19 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
size="sm"
|
||||
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||
aria-current={currentNavigation === "pi" ? "page" : undefined}
|
||||
aria-expanded={activeManagementPanel === "pi"}
|
||||
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
|
||||
disabled={!canManagePi}
|
||||
title={canManagePi ? undefined : "Pi management permission is required"}
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel("pi");
|
||||
navigate({ surface: "pi-management" }, true);
|
||||
}}
|
||||
>
|
||||
Pi management
|
||||
</Button>
|
||||
<WorkspacePreprocessingControl
|
||||
workspaceId={selectedWorkspaceId}
|
||||
status={preprocessingQuery.data}
|
||||
loading={preprocessingQuery.isLoading}
|
||||
unavailable={preprocessingQuery.isError}
|
||||
canManage={canManageDatabase}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" className="w-full min-w-0 whitespace-normal" onClick={() => navigate({ surface: "workspace-management", workspace: selectedWorkspaceId })}>
|
||||
Workspace readiness: {preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking"}
|
||||
</Button>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
@@ -1241,6 +1278,9 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdministrationNavigationProvider>
|
||||
</WorkingContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "../components/ui/dialog";
|
||||
|
||||
const query = "(max-width: 767px)";
|
||||
function subscribe(listener: () => void) {
|
||||
const media = window.matchMedia(query);
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
}
|
||||
const isNarrow = () => window.matchMedia(query).matches;
|
||||
|
||||
/** Keep the archive content readable on phones, using the shared accessible dialog. */
|
||||
export function ArchiveNavigation({ surface, children }: { surface: string; children: ReactNode }) {
|
||||
const narrow = useSyncExternalStore(subscribe, isNarrow, () => false);
|
||||
/** Measure the embedded application, which can be narrower than the viewport. */
|
||||
export function ArchiveNavigation({ surface, forceDrawer = false, children }: { surface: string; forceDrawer?: boolean; children: ReactNode }) {
|
||||
const marker = useRef<HTMLSpanElement>(null);
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
const shell = marker.current?.closest<HTMLElement>(".thot-app-shell");
|
||||
if (!shell) return;
|
||||
const update = () => {
|
||||
const width = shell.getBoundingClientRect().width || window.innerWidth;
|
||||
const collapsed = forceDrawer || width < (surface === "core" ? 768 : 1000);
|
||||
setNarrow(collapsed);
|
||||
shell.dataset.administrationNarrow = String(collapsed);
|
||||
};
|
||||
update();
|
||||
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(update) : null;
|
||||
observer?.observe(shell);
|
||||
window.addEventListener("resize", update);
|
||||
return () => { observer?.disconnect(); window.removeEventListener("resize", update); delete shell.dataset.administrationNarrow; };
|
||||
}, [surface, forceDrawer]);
|
||||
useEffect(() => { setOpen(false); }, [surface, narrow]);
|
||||
if (!narrow || !["memory-management", "evidence-management"].includes(surface)) return children;
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<div className="fixed inset-x-0 top-0 z-30 flex h-12 items-center justify-between border-b bg-background px-4">
|
||||
const anchor = <span ref={marker} hidden />;
|
||||
if (!narrow) return <>{anchor}{children}</>;
|
||||
return <>{anchor}<Dialog open={open} onOpenChange={setOpen}>
|
||||
<div className="thot-administration-mobile-nav">
|
||||
<span className="font-heading font-semibold">ThothII</span>
|
||||
<DialogTrigger render={<Button variant="outline" size="sm" />}>Navigation</DialogTrigger>
|
||||
</div>
|
||||
@@ -26,5 +35,5 @@ export function ArchiveNavigation({ surface, children }: { surface: string; chil
|
||||
<DialogDescription className="sr-only">Choose an administration page or a session.</DialogDescription>
|
||||
<div className="min-h-0 overflow-y-auto [&>aside]:w-full">{children}</div>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
</Dialog></>;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import type {
|
||||
} from "../api/catalog-databases";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { DatabaseManagementPage } from "./DatabaseManagementPage";
|
||||
import { workspacePreferences } from "../workspaces/preferences";
|
||||
import { ComposerFooter } from "./SteerInput";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
|
||||
beforeEach(() => { localStorage.clear(); });
|
||||
|
||||
const noSecrets = {
|
||||
password: false,
|
||||
@@ -142,7 +147,7 @@ function makeDescriptionGenerationRun(
|
||||
return {
|
||||
id: "77777777-7777-4777-8777-777777777777",
|
||||
databaseId: "11111111-1111-4111-8111-111111111111",
|
||||
modelId: "local-qwen",
|
||||
modelId: "local/qwen",
|
||||
scope: "selected_columns",
|
||||
language: "it",
|
||||
status: "queued",
|
||||
@@ -221,8 +226,8 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
|
||||
updatedAt: "2026-08-31T09:00:00Z",
|
||||
})),
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
);
|
||||
renderPage({ rows: [makeDatabase()], presentation: "fleet" });
|
||||
@@ -231,8 +236,8 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
|
||||
expect(pageHeading).toBeVisible();
|
||||
const applicationBar = pageHeading.closest("header");
|
||||
expect(applicationBar).not.toBeNull();
|
||||
expect(within(applicationBar!).getByRole("region", { name: "Catalog status" })).toBeVisible();
|
||||
expect(within(applicationBar!).getByRole("group", { name: "Database management actions" })).toBeVisible();
|
||||
expect(screen.getByRole("region", { name: "Catalog status" })).toBeVisible();
|
||||
expect(screen.getByRole("group", { name: "Database management actions" })).toBeVisible();
|
||||
expect(screen.queryByText("Thoth catalog · Fleet ledger")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Inspect physical metadata, curate descriptions and control catalog operations.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Back to workspace" })).not.toBeInTheDocument();
|
||||
@@ -241,8 +246,8 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
|
||||
const summary = screen.getByRole("region", { name: "Fleet summary" });
|
||||
expect(await within(summary).findByText("2,275")).toBeVisible();
|
||||
expect(within(summary).getByText("75%")).toBeVisible();
|
||||
const modelSelector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
expect(modelSelector).toHaveValue("local-qwen");
|
||||
const modelSelector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
||||
expect(modelSelector).toHaveValue("local/qwen");
|
||||
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
const tablesAction = within(databaseRow).getByRole("button", { name: "View tables for Policlinico San Donato" });
|
||||
@@ -253,10 +258,10 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
|
||||
|
||||
const manager = screen.getByRole("main", { name: "Database management" });
|
||||
await user.click(within(databaseRow).getByRole("button", { name: "Edit Policlinico San Donato" }));
|
||||
const editDrawer = await screen.findByRole("dialog", { name: "Edit database" });
|
||||
const editDrawer = await screen.findByRole("region", { name: "Edit database form" });
|
||||
expect(manager).toContainElement(editDrawer);
|
||||
expect(editDrawer).toHaveAttribute("aria-modal", "false");
|
||||
await user.click(within(editDrawer).getByRole("button", { name: "Close database panel" }));
|
||||
expect(screen.queryByRole("dialog", { name: "Edit database" })).not.toBeInTheDocument();
|
||||
await user.click(within(editDrawer).getByRole("button", { name: "Back to list" }));
|
||||
|
||||
const restoredDatabaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(restoredDatabaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
@@ -264,8 +269,8 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
|
||||
expect(within(actionPicker).getByRole("option", { name: /^Synchronize all(?:,|$)/ })).toBeVisible();
|
||||
expect(within(actionPicker).queryByRole("option", { name: "Synchronize all columns" })).not.toBeInTheDocument();
|
||||
await user.selectOptions(actionPicker, "generate-missing");
|
||||
expect(screen.getAllByRole("combobox", { name: "Metadata-generation LLM model" })).toHaveLength(1);
|
||||
expect(modelSelector).toHaveValue("local-qwen");
|
||||
expect(screen.getAllByRole("combobox", { name: "Global LLM model" })).toHaveLength(1);
|
||||
expect(modelSelector).toHaveValue("local/qwen");
|
||||
|
||||
});
|
||||
|
||||
@@ -471,7 +476,7 @@ test("offers catalog configuration directly on an unconfigured Fleet workspace",
|
||||
name: "Configure catalog for Research laboratory",
|
||||
}));
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Configure catalog" });
|
||||
const drawer = await screen.findByRole("region", { name: "Configure catalog form" });
|
||||
expect(within(drawer).getByLabelText("Workspace")).toHaveAttribute("readonly");
|
||||
expect(within(drawer).getByLabelText("Workspace")).toHaveValue("Research laboratory");
|
||||
});
|
||||
@@ -555,7 +560,7 @@ test("reopens database synchronization history when no run is active", async ()
|
||||
await user.click(within(databaseRow).getByRole("button", {
|
||||
name: "View Policlinico San Donato",
|
||||
}));
|
||||
await user.click(screen.getByRole("button", { name: "Sync history" }));
|
||||
await user.click(screen.getByRole("button", { name: "View schema synchronization logs" }));
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Schema synchronization" });
|
||||
expect(drawer).toBeVisible();
|
||||
@@ -567,22 +572,53 @@ test("reopens database synchronization history when no run is active", async ()
|
||||
test("selects the configured metadata-description model by default", async () => {
|
||||
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [
|
||||
{ id: "openai-mini", label: "OpenAI Mini" },
|
||||
{ id: "local-qwen", label: "Local Qwen" },
|
||||
{ id: "openai/mini", label: "OpenAI Mini" },
|
||||
{ id: "local/qwen", label: "Local Qwen" },
|
||||
],
|
||||
default: "local-qwen",
|
||||
default: "local/qwen",
|
||||
})));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveValue("local-qwen"));
|
||||
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveValue("local/qwen"));
|
||||
});
|
||||
|
||||
test("Core and Admin share one canonical selection, including models with the same short ID", async () => {
|
||||
const models = [{ id: "local/qwen", label: "Local Qwen" }, { id: "remote/qwen", label: "Remote Qwen" }];
|
||||
server.use(
|
||||
http.get("/api/catalog/databases", () => HttpResponse.json([makeDatabase()])),
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models, default: "local/qwen" })),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: models.map((m) => ({
|
||||
provider: m.id.split("/")[0], id: "qwen", name: m.label, reasoning: false,
|
||||
})) })),
|
||||
http.get("/api/settings", () => HttpResponse.json({ provider: "local", model: "qwen" })),
|
||||
);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><ComposerFooter /><DatabaseManagementPage canManage canManageSecrets /></QueryClientProvider>);
|
||||
const admin = await screen.findByRole("combobox", { name: "Global LLM model" });
|
||||
const core = await screen.findByRole("combobox", { name: "Model" });
|
||||
await waitFor(() => expect(admin).toHaveValue("local/qwen"));
|
||||
act(() => useSessionStore.getState().setAgentActive(true));
|
||||
expect(core).toBeDisabled();
|
||||
expect(admin).toBeDisabled();
|
||||
act(() => useSessionStore.getState().setAgentActive(false));
|
||||
act(() => client.setQueryData(["description-generation-runs", 50], [makeDescriptionGenerationRun()]));
|
||||
await waitFor(() => expect(core).toBeDisabled());
|
||||
await waitFor(() => expect(admin).toBeDisabled());
|
||||
act(() => client.setQueryData(["description-generation-runs", 50], []));
|
||||
await waitFor(() => expect(core).toBeEnabled());
|
||||
await waitFor(() => expect(admin).toBeEnabled());
|
||||
await userEvent.selectOptions(admin, "remote/qwen");
|
||||
await waitFor(() => expect(core).toHaveValue("remote/qwen"));
|
||||
await userEvent.selectOptions(core, "local/qwen");
|
||||
await waitFor(() => expect(admin).toHaveValue("local/qwen"));
|
||||
});
|
||||
|
||||
test("keeps both run-history buttons visible beside the metadata-description selector", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
||||
const toolbar = screen.getByRole("group", { name: "Database management controls" });
|
||||
const metadataControls = screen.getByRole("group", { name: "Metadata description controls" });
|
||||
const actions = screen.getByRole("group", { name: "Database management actions" });
|
||||
@@ -622,22 +658,23 @@ test("keeps both run-history buttons visible beside the metadata-description sel
|
||||
expect(within(suggestionDrawer).getByText("No sensitivity analysis runs yet.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes the metadata-description model in page-local state", async () => {
|
||||
test("changes the global model shared with Core", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [
|
||||
{ id: "openai-mini", label: "OpenAI Mini" },
|
||||
{ id: "local-qwen", label: "Local Qwen" },
|
||||
{ id: "openai/mini", label: "OpenAI Mini" },
|
||||
{ id: "local/qwen", label: "Local Qwen" },
|
||||
],
|
||||
default: "local-qwen",
|
||||
default: "local/qwen",
|
||||
})));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveValue("local-qwen"));
|
||||
await user.selectOptions(selector, "openai-mini");
|
||||
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveValue("local/qwen"));
|
||||
await user.selectOptions(selector, "openai/mini");
|
||||
|
||||
expect(selector).toHaveValue("openai-mini");
|
||||
expect(selector).toHaveValue("openai/mini");
|
||||
expect(workspacePreferences.load()).toMatchObject({ provider: "openai", model: "mini" });
|
||||
});
|
||||
|
||||
test("explains application setup when no metadata-description model is configured", async () => {
|
||||
@@ -647,7 +684,7 @@ test("explains application setup when no metadata-description model is configure
|
||||
})));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
||||
expect(selector).toBeDisabled();
|
||||
expect(await screen.findByText(
|
||||
"No metadata-generation LLM model is configured for this installation.",
|
||||
@@ -661,11 +698,11 @@ test("uses a safe disabled setup state when metadata-description models are unav
|
||||
}, { status: 503 })));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveDisplayValue("Model choices unavailable"));
|
||||
expect(selector).toBeDisabled();
|
||||
expect(screen.getByText(
|
||||
"Metadata-generation LLM model choices are unavailable.",
|
||||
"Global LLM model choices are unavailable.",
|
||||
)).toBeVisible();
|
||||
expect(screen.queryByText("internal model registry details")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -674,12 +711,12 @@ test.each([null, "missing-model"])(
|
||||
"disables metadata-description selection when the public response has no usable default (%s)",
|
||||
async (configuredDefault) => {
|
||||
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "openai-mini", label: "OpenAI Mini" }],
|
||||
models: [{ id: "openai/mini", label: "OpenAI Mini" }],
|
||||
default: configuredDefault,
|
||||
})));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveDisplayValue("No usable model configured"));
|
||||
expect(selector).toBeDisabled();
|
||||
expect(screen.getByText(
|
||||
@@ -691,7 +728,7 @@ test.each([null, "missing-model"])(
|
||||
test("renders only the public metadata-generation model contract", async () => {
|
||||
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{
|
||||
id: "approved-model",
|
||||
id: "approved/model",
|
||||
label: "Approved model",
|
||||
provider: "private-provider",
|
||||
model: "private/upstream-model",
|
||||
@@ -699,12 +736,12 @@ test("renders only the public metadata-generation model contract", async () => {
|
||||
apiKeyEnv: "PRIVATE_MODEL_API_KEY",
|
||||
credential: "raw-private-credential",
|
||||
}],
|
||||
default: "approved-model",
|
||||
default: "approved/model",
|
||||
})));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveValue("approved-model"));
|
||||
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveValue("approved/model"));
|
||||
expect(within(selector).getAllByRole("option").map((option) => option.textContent))
|
||||
.toEqual(["Approved model"]);
|
||||
expect(document.body).not.toHaveTextContent(/private-provider|private\/upstream-model/);
|
||||
@@ -766,8 +803,8 @@ test("discloses source sampling before starting Generate missing descriptions",
|
||||
let startBody: unknown;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
|
||||
startBody = await request.json();
|
||||
@@ -793,7 +830,7 @@ test("discloses source sampling before starting Generate missing descriptions",
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
modelId: "local/qwen",
|
||||
scope: "missing",
|
||||
}));
|
||||
expect(screen.queryByText(/replace existing generated descriptions/i)).not.toBeInTheDocument();
|
||||
@@ -805,8 +842,8 @@ test("keeps Fleet Generate missing descriptions behind the source-data disclosur
|
||||
let generationStarts = 0;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => {
|
||||
generationStarts += 1;
|
||||
@@ -822,8 +859,8 @@ test("keeps Fleet Generate missing descriptions behind the source-data disclosur
|
||||
"generate-missing",
|
||||
);
|
||||
await waitFor(() => expect(screen.getByRole("combobox", {
|
||||
name: "Metadata-generation LLM model",
|
||||
})).toHaveValue("local-qwen"));
|
||||
name: "Global LLM model",
|
||||
})).toHaveValue("local/qwen"));
|
||||
await user.click(screen.getByRole("button", { name: "Run action" }));
|
||||
|
||||
expect(screen.getByRole("note", {
|
||||
@@ -896,8 +933,8 @@ test("confirms generating all descriptions, supports cancel, and sends the datab
|
||||
let startBody: unknown;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
|
||||
startBody = await request.json();
|
||||
@@ -929,7 +966,7 @@ test("confirms generating all descriptions, supports cancel, and sends the datab
|
||||
await user.click(screen.getByRole("button", { name: "Generate all descriptions" }));
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
modelId: "local/qwen",
|
||||
scope: "all",
|
||||
}));
|
||||
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
||||
@@ -939,8 +976,8 @@ test("keeps the database selected and safely explains when no descriptions are e
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => HttpResponse.json({
|
||||
code: "description_generation_no_eligible_targets",
|
||||
@@ -985,8 +1022,8 @@ test.each([
|
||||
});
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => (
|
||||
HttpResponse.json(queuedRun, { status: 202 })
|
||||
@@ -1036,8 +1073,8 @@ test.each([
|
||||
reason: "database.manage is absent",
|
||||
canManage: false,
|
||||
rows: [makeDatabase()],
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
configuredDefault: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
configuredDefault: "local/qwen",
|
||||
selectedRows: 1,
|
||||
},
|
||||
{
|
||||
@@ -1052,8 +1089,8 @@ test.each([
|
||||
reason: "the Workspace Database is not configured",
|
||||
canManage: true,
|
||||
rows: [unconfigured],
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
configuredDefault: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
configuredDefault: "local/qwen",
|
||||
selectedRows: 1,
|
||||
},
|
||||
{
|
||||
@@ -1067,8 +1104,8 @@ test.each([
|
||||
workspaceName: "Radiology",
|
||||
}),
|
||||
],
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
configuredDefault: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
configuredDefault: "local/qwen",
|
||||
selectedRows: 2,
|
||||
},
|
||||
])("disables database-wide generation when $reason", async ({
|
||||
@@ -1102,8 +1139,8 @@ test("disables database-wide generation while a description generation is active
|
||||
const queuedRun = makeDescriptionGenerationRun({ scope: "missing" });
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => (
|
||||
HttpResponse.json(queuedRun, { status: 202 })
|
||||
@@ -1871,8 +1908,8 @@ test("starts description generation for multiple selected tables", async () => {
|
||||
let startBody: unknown;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
|
||||
patientsTable,
|
||||
@@ -1903,7 +1940,7 @@ test("starts description generation for multiple selected tables", async () => {
|
||||
await user.click(generate);
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
modelId: "local/qwen",
|
||||
scope: "selected_tables",
|
||||
targetIds: [patientsTable.id, visitsTable.id],
|
||||
}));
|
||||
@@ -1919,7 +1956,7 @@ test("generates descriptions for every column in selected tables", async () => {
|
||||
const queuedRun = makeDescriptionGenerationRun({ scope: "selected_columns", total: 1 });
|
||||
let startBody: unknown;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models: [{ id: "local-qwen", label: "Local Qwen" }], default: "local-qwen" })),
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models: [{ id: "local/qwen", label: "Local Qwen" }], default: "local/qwen" })),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.get(`/api/catalog/databases/:databaseId/tables/${patientsTable.id}/columns`, () => HttpResponse.json([patientIdColumn])),
|
||||
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
|
||||
@@ -1938,7 +1975,7 @@ test("generates descriptions for every column in selected tables", async () => {
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }));
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
modelId: "local/qwen",
|
||||
scope: "selected_columns",
|
||||
targetIds: [patientIdColumn.id],
|
||||
}));
|
||||
@@ -2059,8 +2096,8 @@ test("keeps selected tables when description generation cannot start", async ()
|
||||
};
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
|
||||
patientsTable,
|
||||
@@ -2113,8 +2150,8 @@ test("refreshes Catalog Tables and Catalog Columns after table generation comple
|
||||
let tableReads = 0;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => {
|
||||
tableReads += 1;
|
||||
@@ -2320,10 +2357,10 @@ test("starts one selected column with the configured default model", async () =>
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [
|
||||
{ id: "openai-mini", label: "OpenAI Mini" },
|
||||
{ id: "local-qwen", label: "Local Qwen" },
|
||||
{ id: "openai/mini", label: "OpenAI Mini" },
|
||||
{ id: "local/qwen", label: "Local Qwen" },
|
||||
],
|
||||
default: "local-qwen",
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
|
||||
@@ -2353,7 +2390,7 @@ test("starts one selected column with the configured default model", async () =>
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
modelId: "local/qwen",
|
||||
scope: "selected_columns",
|
||||
targetIds: [patientIdColumn.id],
|
||||
}));
|
||||
@@ -2373,8 +2410,8 @@ test("shows database sensitivity analysis only for a selection and rejects multi
|
||||
});
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", () => {
|
||||
suggestionCalls += 1;
|
||||
@@ -2495,8 +2532,8 @@ test("requests database-level sensitivity analysis for the only selected databas
|
||||
const run = makeSensitivityAnalysisRun({ scope: "all", total: 1 });
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/sensitive-data-suggestion-runs", () => {
|
||||
historyCalls += 1;
|
||||
@@ -2539,8 +2576,8 @@ test("refetches sensitivity analysis history after a failed request", async () =
|
||||
let historyCalls = 0;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/sensitive-data-suggestion-runs", () => {
|
||||
historyCalls += 1;
|
||||
@@ -2579,8 +2616,8 @@ test("requests sensitivity analysis only for selected tables", async () => {
|
||||
let suggestionBody: unknown;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
|
||||
patientsTable,
|
||||
@@ -2650,8 +2687,8 @@ test("allows a human downgrade and saves only explicit sensitivity changes", asy
|
||||
const patches: Array<{ columnId: string; body: unknown }> = [];
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.get(
|
||||
@@ -2777,8 +2814,8 @@ test("polls a description run and renders its events in sequence order", async (
|
||||
const eventAfters: number[] = [];
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
|
||||
@@ -2814,7 +2851,7 @@ test("polls a description run and renders its events in sequence order", async (
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
expect(within(drawer).getByText(/local-qwen/)).toBeVisible();
|
||||
expect(within(drawer).getByText(/local[/]qwen/)).toBeVisible();
|
||||
expect(within(drawer).getByText(/selected columns/)).toBeVisible();
|
||||
expect(await within(drawer).findByRole("heading", { name: "Completed" }, { timeout: 4_000 })).toBeVisible();
|
||||
expect(within(drawer).getByText("1", { selector: "[data-count='total']" })).toBeVisible();
|
||||
@@ -2852,8 +2889,8 @@ test("refreshes Catalog Tables and Catalog Columns after column generation compl
|
||||
let columns = [patientIdColumn];
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => {
|
||||
tableReads += 1;
|
||||
@@ -2907,8 +2944,8 @@ test("keeps the selected column and shows a safe message when generation cannot
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
|
||||
@@ -2938,13 +2975,13 @@ test("shows a basic failed run without exposing private model or provider fields
|
||||
const user = userEvent.setup();
|
||||
let columnReads = 0;
|
||||
const queuedRun = {
|
||||
...makeDescriptionGenerationRun({ modelId: "approved-model" }),
|
||||
...makeDescriptionGenerationRun({ modelId: "approved/model" }),
|
||||
provider: "private-provider",
|
||||
apiKey: "sk-private-key",
|
||||
};
|
||||
const failedRun = {
|
||||
...makeDescriptionGenerationRun({
|
||||
modelId: "approved-model",
|
||||
modelId: "approved/model",
|
||||
status: "failed",
|
||||
processed: 1,
|
||||
failed: 1,
|
||||
@@ -2963,13 +3000,13 @@ test("shows a basic failed run without exposing private model or provider fields
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{
|
||||
id: "approved-model",
|
||||
id: "approved/model",
|
||||
label: "Approved model",
|
||||
provider: "private-provider",
|
||||
upstreamModel: "private/upstream-model",
|
||||
apiKeyEnv: "PRIVATE_MODEL_API_KEY",
|
||||
}],
|
||||
default: "approved-model",
|
||||
default: "approved/model",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => {
|
||||
@@ -3022,8 +3059,8 @@ test("offers generation and consolidation for multiple selected columns", async
|
||||
let startBody: unknown;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => (
|
||||
@@ -3063,7 +3100,7 @@ test("offers generation and consolidation for multiple selected columns", async
|
||||
await user.click(generate);
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
modelId: "local/qwen",
|
||||
scope: "selected_columns",
|
||||
targetIds: [patientIdColumn.id, patientNameColumn.id],
|
||||
}));
|
||||
@@ -3072,7 +3109,7 @@ test("offers generation and consolidation for multiple selected columns", async
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: "database.manage is absent", canManage: false, models: [{ id: "local-qwen", label: "Local Qwen" }], configuredDefault: "local-qwen" },
|
||||
{ name: "database.manage is absent", canManage: false, models: [{ id: "local/qwen", label: "Local Qwen" }], configuredDefault: "local/qwen" },
|
||||
{ name: "no usable model is configured", canManage: true, models: [], configuredDefault: null },
|
||||
])("disables selected table and column generation when $name", async ({ canManage, models, configuredDefault }) => {
|
||||
const user = userEvent.setup();
|
||||
@@ -3113,8 +3150,8 @@ test("disables another generation start while the selected-column run is active"
|
||||
const queuedRun = makeDescriptionGenerationRun();
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
||||
default: "local/qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, CircleAlert, History, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { workspacePreferences } from "../workspaces/preferences";
|
||||
import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
|
||||
import { WorkspacePreparation } from "./administration/WorkspacePreparation";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -55,13 +58,13 @@ import { DatabaseTables } from "./database-management/DatabaseTables";
|
||||
import { DatabaseRelationships } from "./database-management/DatabaseRelationships";
|
||||
import { CatalogSyncDrawer } from "./database-management/CatalogSyncDrawer";
|
||||
import { MetadataGenerationModelSelector } from "./database-management/MetadataGenerationModelSelector";
|
||||
import { useWorkingContextScope } from "../workspaces/WorkingContext";
|
||||
import { DescriptionGenerationDrawer } from "./database-management/DescriptionGenerationDrawer";
|
||||
import { SensitivityAnalysisHistoryDrawer } from "./database-management/SensitivityAnalysisHistoryDrawer";
|
||||
import { SensitiveDataReviewDrawer } from "./database-management/SensitiveDataReviewDrawer";
|
||||
import {
|
||||
FleetLedgerBreadcrumb,
|
||||
FleetLedgerContent,
|
||||
FleetLedgerDrawer,
|
||||
FleetLedgerHeader,
|
||||
FleetLedgerKpiStrip,
|
||||
FleetLedgerRealStatus,
|
||||
@@ -106,6 +109,7 @@ function readPersistedDatabaseScreen(): DatabaseScreen {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialWorkspaceId?: string;
|
||||
canManage: boolean;
|
||||
canManageSecrets: boolean;
|
||||
onNavigationStateChange?: (state: DatabaseNavigationState) => void;
|
||||
@@ -157,11 +161,13 @@ function isDescriptionGenerationActive(run?: DescriptionGenerationRun | null): b
|
||||
}
|
||||
|
||||
export function DatabaseManagementPage({
|
||||
initialWorkspaceId,
|
||||
canManage,
|
||||
canManageSecrets,
|
||||
onNavigationStateChange,
|
||||
presentation = "legacy",
|
||||
}: Props) {
|
||||
const workingContext = useWorkingContextScope();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
data,
|
||||
@@ -174,13 +180,14 @@ export function DatabaseManagementPage({
|
||||
queryFn: listCatalogDatabases,
|
||||
retry: false,
|
||||
});
|
||||
const rows = data ?? [];
|
||||
const rows = (data ?? []).filter(row => !workingContext || row.workspaceId === workingContext.workspaceId);
|
||||
const metadataModelsQuery = useQuery({
|
||||
queryKey: ["metadata-generation-models"],
|
||||
queryFn: listMetadataGenerationModels,
|
||||
retry: false,
|
||||
});
|
||||
const metadataModels = metadataModelsQuery.data?.models ?? [];
|
||||
const interactionBusy = useInteractionModelBusy();
|
||||
const configuredMetadataDefault = metadataModelsQuery.data?.default ?? "";
|
||||
const defaultMetadataModel = configuredMetadataDefault
|
||||
&& metadataModels.some((model) => model.id === configuredMetadataDefault)
|
||||
@@ -210,7 +217,11 @@ export function DatabaseManagementPage({
|
||||
);
|
||||
|
||||
const [screen, setScreen] = useState<DatabaseScreen>(readPersistedDatabaseScreen);
|
||||
const [selectedMetadataModel, setSelectedMetadataModel] = useState("");
|
||||
const [interactionPreferences, setInteractionPreferences] = useState(() => workspacePreferences.load());
|
||||
useEffect(() => workspacePreferences.subscribe(setInteractionPreferences), []);
|
||||
const selectedMetadataModel = interactionPreferences.provider && interactionPreferences.model
|
||||
? `${interactionPreferences.provider}/${interactionPreferences.model}` : defaultMetadataModel;
|
||||
const setSelectedMetadataModel = (id: string) => { workspacePreferences.selectModel(id); };
|
||||
const [draft, setDraft] = useState<DatabaseFormDraft | null>(null);
|
||||
const [baseline, setBaseline] = useState("");
|
||||
const [formSource, setFormSource] = useState<FormSource | null>(null);
|
||||
@@ -256,7 +267,11 @@ export function DatabaseManagementPage({
|
||||
const formHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedMetadataModel(defaultMetadataModel);
|
||||
if (!defaultMetadataModel) return;
|
||||
const slash = defaultMetadataModel.indexOf("/");
|
||||
workspacePreferences.migrate({
|
||||
provider: defaultMetadataModel.slice(0, slash), model: defaultMetadataModel.slice(slash + 1),
|
||||
});
|
||||
}, [defaultMetadataModel]);
|
||||
|
||||
const activeRow = screen.kind === "list"
|
||||
@@ -286,7 +301,11 @@ export function DatabaseManagementPage({
|
||||
const busy = busyAction !== null;
|
||||
const catalogChildVisible = screen.kind === "tables" || screen.kind === "relationships";
|
||||
const navigationDirty = catalogChildVisible ? tablesNavigationState.dirty : dirty;
|
||||
const navigationBusy = catalogChildVisible ? tablesNavigationState.busy : busy;
|
||||
const navigationBusy = busy || tablesNavigationState.busy
|
||||
|| Boolean(observedActiveSensitivityAnalysisRun)
|
||||
|| Boolean(observedActiveDescriptionGenerationRun)
|
||||
|| rows.some(row => row.activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(row.activeSyncRun.state))
|
||||
|| Boolean(activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeSyncRun.state));
|
||||
|
||||
useEffect(() => {
|
||||
onNavigationStateChange?.({ dirty: navigationDirty, busy: navigationBusy });
|
||||
@@ -392,6 +411,15 @@ export function DatabaseManagementPage({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const initialWorkspaceOpened = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialWorkspaceId || initialWorkspaceOpened.current || !data) return;
|
||||
initialWorkspaceOpened.current = true;
|
||||
const row = data.find(item => item.workspaceId === initialWorkspaceId);
|
||||
if (row) openForm(row.configured ? "edit" : "configure", row, document.body);
|
||||
else toast.info("This workspace's database configuration is unavailable");
|
||||
}, [initialWorkspaceId, data, openForm]);
|
||||
|
||||
const viewRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
|
||||
openForm("view", row, origin);
|
||||
}, [openForm]);
|
||||
@@ -1037,7 +1065,7 @@ export function DatabaseManagementPage({
|
||||
);
|
||||
const fleetMetadataModelControl = (
|
||||
<MetadataGenerationModelSelector
|
||||
canManage={canManage}
|
||||
canManage={canManage && !interactionBusy && !descriptionGenerationActive && !busyAction}
|
||||
data={metadataModelsQuery.data}
|
||||
isError={metadataModelsQuery.isError}
|
||||
isLoading={metadataModelsQuery.isLoading}
|
||||
@@ -1153,21 +1181,19 @@ export function DatabaseManagementPage({
|
||||
if (presentation === "fleet") {
|
||||
const fleetFormMode = formVisible ? screen.kind as DatabaseFormMode : null;
|
||||
const fleetDrawer = formVisible && fleetFormMode ? (
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
eyebrow="Catalog configuration"
|
||||
title={databaseFormTitle(
|
||||
fleetFormMode,
|
||||
"drawer",
|
||||
)}
|
||||
description={`${activeRow.workspaceName} · ${activeRow.workspaceId}`}
|
||||
onClose={backToList}
|
||||
closeLabel="Close database panel"
|
||||
busy={busy}
|
||||
width="wide"
|
||||
>
|
||||
<div className="thot-administration-split thot-database-workbench">
|
||||
<nav aria-label="Database workspaces" className="thot-administration-index p-4">
|
||||
<p className="thot-administration-eyebrow mb-3">Workspace catalog</p>
|
||||
{rows.map(row => <Button key={row.workspaceId} variant={row.workspaceId === activeRow.workspaceId ? "navigationActive" : "ghost"}
|
||||
aria-current={row.workspaceId === activeRow.workspaceId ? "page" : undefined} disabled={busy}
|
||||
className="w-full justify-start whitespace-normal mb-2" onClick={event => {
|
||||
if (dirty && !window.confirm("Discard unsaved database changes?")) return;
|
||||
openForm(row.configured ? "edit" : "configure", row, event.currentTarget);
|
||||
}}>{databaseDisplayName(row)}</Button>)}
|
||||
</nav>
|
||||
<div className="min-w-0">
|
||||
<DatabaseForm
|
||||
presentation="drawer"
|
||||
presentation="page"
|
||||
mode={fleetFormMode}
|
||||
row={activeRow}
|
||||
draft={draft}
|
||||
@@ -1198,7 +1224,9 @@ export function DatabaseManagementPage({
|
||||
onOpenSensitiveHistory={openSensitivityReviewItemHistory}
|
||||
activeSyncRun={currentActiveRun}
|
||||
/>
|
||||
</FleetLedgerDrawer>
|
||||
<WorkspacePreparation workspaceId={activeRow.workspaceId} canManage={canManage} databaseSummary />
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const fleetContent = screen.kind === "tables" && tablesVisible ? (
|
||||
@@ -1278,7 +1306,7 @@ export function DatabaseManagementPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<main aria-label="Database management" className="flex min-h-0 flex-1 overflow-hidden bg-background">
|
||||
<main aria-label="Database management" className="thot-administration-page thot-administration-database">
|
||||
<FleetLedgerShell
|
||||
header={(
|
||||
<FleetLedgerHeader
|
||||
@@ -1309,8 +1337,8 @@ export function DatabaseManagementPage({
|
||||
className="whitespace-nowrap"
|
||||
aria-label="View schema synchronization logs"
|
||||
title="View schema synchronization progress and history"
|
||||
disabled={!canManage || (!selectedDatabaseId && !activeSyncRun?.databaseId)}
|
||||
onClick={() => openSync(selectedDatabaseId ? rows.find((row) => row.id === selectedDatabaseId) : undefined)}
|
||||
disabled={!canManage || (!activeRow && !selectedDatabaseId && !activeSyncRun?.databaseId)}
|
||||
onClick={() => openSync(activeRow ?? (selectedDatabaseId ? rows.find((row) => row.id === selectedDatabaseId) : undefined))}
|
||||
>
|
||||
<RefreshCw aria-hidden="true" />
|
||||
{currentActiveRun ? "Schema sync active" : "Schema sync logs"}
|
||||
@@ -1373,10 +1401,13 @@ export function DatabaseManagementPage({
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
children={<FleetLedgerContent busy={isLoading || isFetching}>{fleetContent}</FleetLedgerContent>}
|
||||
children={<>
|
||||
{fleetDrawer ?? <FleetLedgerContent busy={isLoading || isFetching}>{fleetContent}</FleetLedgerContent>}
|
||||
{!fleetDrawer && (activeRow ?? rows.find(row => row.id === selectedDatabaseId)) &&
|
||||
<WorkspacePreparation workspaceId={(activeRow ?? rows.find(row => row.id === selectedDatabaseId))!.workspaceId} canManage={canManage} databaseSummary />}
|
||||
</>}
|
||||
drawer={(
|
||||
<>
|
||||
{fleetDrawer}
|
||||
{catalogDrawers}
|
||||
</>
|
||||
)}
|
||||
@@ -1407,7 +1438,7 @@ export function DatabaseManagementPage({
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<MetadataGenerationModelSelector
|
||||
canManage={canManage}
|
||||
canManage={canManage && !interactionBusy && !descriptionGenerationActive && !busyAction}
|
||||
data={metadataModelsQuery.data}
|
||||
isError={metadataModelsQuery.isError}
|
||||
isLoading={metadataModelsQuery.isLoading}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useAuthGeneration } from "../auth/authState";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { MarkdownView } from "../viewers/MarkdownView";
|
||||
import { EvidenceSources } from "./EvidenceSourceReview";
|
||||
import { AdministrationHeader } from "./administration/AdministrationPage";
|
||||
import { useWorkingContextScope } from "../workspaces/WorkingContext";
|
||||
|
||||
const control = "rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
const kinds = ["domain", "glossary", "enum", "example", "mapping", "normalization", "formula", "reference"];
|
||||
@@ -32,22 +34,21 @@ function CopyText({ text, label, showText = true }: { text: string; label: strin
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function EvidenceManagementPage({ canManage }: { canManage: boolean }) {
|
||||
export function EvidenceManagementPage({ canManage, initialWorkspaceId }: { canManage: boolean; initialWorkspaceId?: string }) {
|
||||
const context = useWorkingContextScope();
|
||||
const generation = useAuthGeneration();
|
||||
const [workspace, setWorkspace] = useState("");
|
||||
const [workspace, setWorkspace] = useState(initialWorkspaceId ?? "");
|
||||
const workspaces = useQuery({ queryKey: ["evidence-workspaces", generation],
|
||||
queryFn: () => apiFetch<Array<{ id: string; displayName?: string }>>("/workspaces"), enabled: canManage });
|
||||
if (!canManage) return <main className="p-6">Evidence administration is not permitted.</main>;
|
||||
return <main aria-label="Evidence management" className="flex min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4 border-b px-6 py-5">
|
||||
<div><h1 className="font-serif text-2xl font-semibold">Evidence management</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Review workspace knowledge and maintain its Markdown files.</p></div>
|
||||
return <main aria-label="Evidence management" className="thot-administration-page">
|
||||
<AdministrationHeader title="Evidence management" description="Review workspace knowledge and maintain its Markdown files." actions={!context &&
|
||||
<label className="grid min-w-52 gap-1 text-sm">Workspace
|
||||
<select className={control} value={workspace} onChange={event => setWorkspace(event.target.value)}>
|
||||
<option value="">Choose a workspace</option>
|
||||
{workspaces.data?.map(w => <option key={w.id} value={w.id}>{w.displayName ?? w.id}</option>)}
|
||||
</select></label>
|
||||
</header>
|
||||
} />
|
||||
{workspaces.isError && <p role="alert" className="p-6">{apiErrorMessage(workspaces.error)}</p>}
|
||||
{workspace ? <WorkspaceEvidence key={`${generation}:${workspace}`} workspace={workspace} generation={generation} />
|
||||
: <p className="p-8 text-muted-foreground">Choose a workspace to browse its Evidence and locate the files to edit.</p>}
|
||||
@@ -79,8 +80,9 @@ function WorkspaceEvidence({ workspace, generation }: { workspace: string; gener
|
||||
{list.isError && <p role="alert">{apiErrorMessage(list.error)}</p>}
|
||||
{list.isPending && <p role="status">Loading Evidence files…</p>}
|
||||
{list.data?.errors.map(error => <div key={error.file} role="alert" className="rounded-md border border-destructive/40 p-3 text-sm"><strong>{error.file}</strong><p>{error.message}</p><p>Correct this file before consolidation.</p></div>)}
|
||||
<div className="grid min-h-0 gap-6 xl:grid-cols-[minmax(20rem,2fr)_minmax(25rem,3fr)]">
|
||||
<section aria-label="Evidence list" className="min-w-0">
|
||||
<div className="thot-administration-split" style={{ margin: 0 }}>
|
||||
<section aria-label="Evidence list" className="thot-administration-index p-4">
|
||||
<h2 className="thot-administration-eyebrow mb-3">Evidence library</h2>
|
||||
<p className="mb-2 text-sm text-muted-foreground">{list.data?.total ?? 0} Evidence units</p>
|
||||
<div className="overflow-auto"><table className="w-full text-left text-sm"><thead><tr className="border-b text-muted-foreground"><th className="py-2 pr-3 font-medium">Evidence</th><th className="pr-3 font-medium">Kind</th><th className="font-medium">Status</th></tr></thead>
|
||||
<tbody>{list.data?.items.map(item => <tr key={item.id} className={`border-b ${selected === item.id ? "bg-muted" : ""}`}><td className="py-3 pr-3"><button className="text-left font-medium underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" onClick={() => setSelected(item.id)}>{item.title}</button><div className="mt-1 break-all text-xs text-muted-foreground">{item.id}</div></td><td className="pr-3 align-top pt-3">{item.kind}</td><td className="align-top pt-3">{states[item.status]}</td></tr>)}</tbody>
|
||||
@@ -88,7 +90,7 @@ function WorkspaceEvidence({ workspace, generation }: { workspace: string; gener
|
||||
{list.data?.total === 0 && <p className="py-6 text-sm text-muted-foreground">No Evidence matches these filters. Clear the filters or use the file instructions to create a unit.</p>}
|
||||
<nav aria-label="Evidence pages" className="mt-4 flex items-center gap-3"><Button variant="outline" size="sm" disabled={page === 1 || list.isFetching} onClick={() => setPage(p => p-1)}>Previous</Button><span className="text-sm">Page {page}</span><Button variant="outline" size="sm" disabled={!list.data || page*25 >= list.data.total || list.isFetching} onClick={() => setPage(p => p+1)}>Next</Button></nav>
|
||||
</section>
|
||||
<section aria-label="Evidence detail" className="min-w-0">
|
||||
<section aria-label="Evidence detail" className="thot-administration-detail">
|
||||
{detail.isError ? <p role="alert">{apiErrorMessage(detail.error)}</p> : selected && detail.isPending ? <p role="status">Loading Evidence…</p> : detail.data?.item ? <UnitDetail item={detail.data.item} location={detail.data.location} /> : <p className="py-10 text-sm text-muted-foreground">Select an Evidence unit to read its complete content and provenance.</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
type MemoryCard, type MemoryFamily, type MemoryInput, type MemoryOutcome,
|
||||
} from "../api/memory";
|
||||
import { useAuthGeneration } from "../auth/authState";
|
||||
import { AdministrationHeader } from "./administration/AdministrationPage";
|
||||
import { useWorkingContextScope } from "../workspaces/WorkingContext";
|
||||
|
||||
const families: Record<MemoryFamily, string> = {
|
||||
domain_clarification: "Domain clarification", sql_rule: "SQL rule",
|
||||
@@ -19,10 +21,12 @@ function Input(props: InputHTMLAttributes<HTMLInputElement>) { return <input {..
|
||||
const emptyCard = (): MemoryInput => ({ family: "domain_clarification", subject: "", detail: "",
|
||||
scope: "", rationale: "", question: "", sql: "", concepts: [], dependencies: [], links: [] });
|
||||
|
||||
export function MemoryManagementPage({ canManage, onDirtyChange, onBusyChange }: {
|
||||
export function MemoryManagementPage({ canManage, onDirtyChange, onBusyChange, initialWorkspaceId }: {
|
||||
canManage: boolean; onDirtyChange: (dirty: boolean) => void; onBusyChange?: (busy: boolean) => void;
|
||||
initialWorkspaceId?: string;
|
||||
}) {
|
||||
const [workspace, setWorkspace] = useState("");
|
||||
const context = useWorkingContextScope();
|
||||
const [workspace, setWorkspace] = useState(initialWorkspaceId ?? "");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const generation = useAuthGeneration();
|
||||
@@ -31,10 +35,8 @@ export function MemoryManagementPage({ canManage, onDirtyChange, onBusyChange }:
|
||||
useEffect(() => { onDirtyChange(dirty); return () => onDirtyChange(false); }, [dirty, onDirtyChange]);
|
||||
useEffect(() => { onBusyChange?.(busy); return () => onBusyChange?.(false); }, [busy, onBusyChange]);
|
||||
if (!canManage) return <main aria-label="Memory management" className="p-6">Memory administration is not permitted.</main>;
|
||||
return <main aria-label="Memory management" className="flex min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||
<header className="flex shrink-0 flex-wrap items-end justify-between gap-4 border-b px-6 py-5">
|
||||
<div><h1 className="font-serif text-2xl font-semibold">Memory management</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Curate reusable knowledge for each workspace.</p></div>
|
||||
return <main aria-label="Memory management" className="thot-administration-page">
|
||||
<AdministrationHeader title="Memory management" description="Curate reusable knowledge for each workspace." actions={!context &&
|
||||
<label className="grid min-w-52 gap-1 text-sm font-medium">Workspace
|
||||
<select aria-label="Workspace" disabled={busy} className={control} value={workspace} onChange={event => {
|
||||
if (dirty && !window.confirm("Discard unsaved Memory changes?")) return;
|
||||
@@ -42,7 +44,7 @@ export function MemoryManagementPage({ canManage, onDirtyChange, onBusyChange }:
|
||||
}}><option value="">Choose a workspace</option>
|
||||
{workspaces.data?.map(w => <option value={w.id} key={w.id}>{w.displayName ?? w.id}</option>)}
|
||||
</select></label>
|
||||
</header>
|
||||
} />
|
||||
{workspaces.isError && <p role="alert" className="p-6">{apiErrorMessage(workspaces.error)}</p>}
|
||||
{workspace ? <MemoryWorkspace key={`${generation}:${workspace}`} workspace={workspace} onDirtyChange={setDirty} onBusyChange={setBusy} />
|
||||
: <p className="p-8 text-muted-foreground">Choose a workspace to browse its knowledge or create a Memory card.</p>}
|
||||
@@ -113,7 +115,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
|
||||
}
|
||||
|
||||
return <>
|
||||
<div className="flex shrink-0 flex-wrap items-end gap-3 px-6 py-4">
|
||||
<div className="thot-administration-toolbar">
|
||||
<label className="grid min-w-52 flex-1 gap-1 text-sm">Search cards
|
||||
<Input value={filters.q} onChange={e => filter("q", e.target.value)} placeholder="Text or card ID" /></label>
|
||||
<label className="grid gap-1 text-sm">Family<select aria-label="Filter by family" className={control} value={filters.family} onChange={e => filter("family", e.target.value)}>
|
||||
@@ -137,8 +139,9 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
|
||||
</details>
|
||||
{notice && <p role="status" className="border-b bg-muted/50 px-6 py-3 text-sm">{notice}</p>}
|
||||
{error && <p role="alert" className="px-6 py-3 text-sm text-destructive">{error}</p>}
|
||||
<div className="grid min-h-72 shrink-0 grid-cols-1 lg:grid-cols-[minmax(260px,0.8fr)_minmax(360px,1.2fr)]">
|
||||
<section aria-label="Memory cards" className="min-w-0 border-b lg:border-r lg:border-b-0">
|
||||
<div className="thot-administration-split">
|
||||
<section aria-label="Memory cards" className="thot-administration-index">
|
||||
<h2 className="thot-administration-section-label">Knowledge library</h2>
|
||||
{list.isPending && <p role="status" className="p-6 text-sm">Loading Memory cards…</p>}
|
||||
{list.isError && <div role="alert" className="p-6"><p>{apiErrorMessage(list.error)}</p><Button variant="outline" onClick={() => void list.refetch()}>Retry loading</Button></div>}
|
||||
{list.data && <>
|
||||
@@ -158,7 +161,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
|
||||
</nav>
|
||||
</>}
|
||||
</section>
|
||||
<section aria-label="Memory detail" className="min-w-0 px-6 py-5">
|
||||
<section aria-label="Memory detail" className="thot-administration-detail">
|
||||
{!draft && !selected && <p className="py-8 text-sm text-muted-foreground">Select a card to inspect its content, scope and provenance.</p>}
|
||||
{selected && !draft && <div className="space-y-5">
|
||||
<div><p className="text-xs text-muted-foreground">{families[selected.family]}</p><h2 className="mt-1 break-words font-serif text-xl font-semibold">{selected.subject}</h2></div>
|
||||
|
||||
@@ -149,7 +149,7 @@ test("shows the single-source host workflow in accessible platform tabs", async
|
||||
]);
|
||||
for (const expected of [
|
||||
"deploy/<installation-id>/thothii-installation.yaml",
|
||||
"modelCatalog.defaults.session",
|
||||
"modelCatalog.defaults.interaction",
|
||||
"provider/model",
|
||||
"generated projections",
|
||||
"tht pi restart --yes --drain",
|
||||
@@ -200,7 +200,7 @@ test("shows an explicit incomplete state when the catalog default is unavailable
|
||||
|
||||
const incomplete = await screen.findByRole("alert", { name: "Pi configuration incomplete" });
|
||||
expect(incomplete).toHaveTextContent("The catalog default is unavailable");
|
||||
expect(incomplete).toHaveTextContent("modelCatalog.defaults.session");
|
||||
expect(incomplete).toHaveTextContent("modelCatalog.defaults.interaction");
|
||||
expect(screen.getByRole("button", { name: "Test catalog default" })).toBeDisabled();
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ function ReadinessRail({ ready, configured, credentials, smokeState }: {
|
||||
<section
|
||||
data-testid="pi-readiness-rail"
|
||||
aria-label="Pi readiness sequence"
|
||||
className="grid gap-3 border-l-2 border-border pl-4 sm:grid-cols-4"
|
||||
className="grid gap-3 border-y border-border py-4 sm:grid-cols-2"
|
||||
>
|
||||
<RailItem label="Runtime" value={ready ? "Runtime ready" : "Runtime needs attention"} state={ready ? "ready" : "attention"} />
|
||||
<RailItem label="Defaults" value={configured ? "Defaults ready" : "Defaults incomplete"} state={configured ? "ready" : "attention"} />
|
||||
@@ -159,11 +159,11 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
|
||||
</li>
|
||||
<li>
|
||||
<h4 className="font-semibold text-foreground">Choose eligibility and defaults</h4>
|
||||
<p className="mt-1 text-muted-foreground">Use <code>modelCatalog.defaults.session</code> as the single default for new sessions. Add a <code>session</code> or <code>metadataGeneration</code> block to make a model eligible for that use. When metadata generation is present, set <code>defaults.metadataGeneration</code>. Runtime files such as Pi <code>models.json</code> and <code>settings.json</code> are generated projections and must not be edited.</p>
|
||||
<p className="mt-1 text-muted-foreground">Use <code>modelCatalog.defaults.interaction</code> as the single installation-wide LLM default for Core and Administration. When Admin AI is configured, a selectable model needs both <code>session</code> and <code>metadataGeneration</code> blocks. The user's last model choice takes precedence and does not depend on workspace. Runtime files such as Pi <code>models.json</code> and <code>settings.json</code> are generated projections and must not be edited.</p>
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-muted-foreground">
|
||||
<dt className="font-mono text-foreground">defaults.session </dt><dd>contains exactly one canonical provider/model identity.</dd>
|
||||
<dt className="font-mono text-foreground">defaults.metadataGeneration </dt><dd>selects the metadata model when that use exists.</dd>
|
||||
<dt className="font-mono text-foreground">defaults.interaction </dt><dd>contains exactly one canonical provider/model identity for every workspace.</dd>
|
||||
</dl>
|
||||
<p className="mt-2 text-muted-foreground">Before making each model available, verify an actual Core tool-call/review cycle through Pi and a small test description-generation run through LiteLLM. Configuration validation and Pi smoke tests alone do not certify both paths. Use approved test data; generation may incur provider charges.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h4 className="font-semibold text-foreground">Complete the provider setup</h4>
|
||||
@@ -275,7 +275,8 @@ function PiPlatformInstructions() {
|
||||
);
|
||||
}
|
||||
|
||||
export function PiManagement({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
export function PiManagement({ open, onClose, page = false }: { open: boolean; onClose: () => void; page?: boolean }) {
|
||||
const [section, setSection] = useState<"runtime" | "instructions">("runtime");
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<Feedback>();
|
||||
const [smokeState, setSmokeState] = useState<SmokeState>();
|
||||
@@ -330,6 +331,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
return (
|
||||
<WorkAreaPanel
|
||||
open={open}
|
||||
mode={page ? "page" : "panel"}
|
||||
ariaLabel="Pi management"
|
||||
width="expanded"
|
||||
eyebrow="Installation controls"
|
||||
@@ -337,9 +339,17 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
description="Review the bundled runtime, inspect the catalog default, and test its provider configuration."
|
||||
onClose={onClose}
|
||||
closeLabel="Close Pi management"
|
||||
bodyClassName={page ? "thot-work-area-page__body--pi" : undefined}
|
||||
>
|
||||
<PiPlatformInstructions />
|
||||
<div className="mt-5">
|
||||
<div className={page ? "thot-pi-workbench" : undefined}>
|
||||
{page && <nav aria-label="Pi sections" className="thot-pi-index">
|
||||
<p className="thot-administration-eyebrow mb-3">Installation</p>
|
||||
<Button variant={section === "runtime" ? "navigationActive" : "ghost"} className="w-full justify-start mb-2" aria-current={section === "runtime" ? "page" : undefined} onClick={() => setSection("runtime")}>Runtime and models</Button>
|
||||
<Button variant={section === "instructions" ? "navigationActive" : "ghost"} className="w-full justify-start whitespace-normal" aria-current={section === "instructions" ? "page" : undefined} onClick={() => setSection("instructions")}>Host maintenance</Button>
|
||||
</nav>}
|
||||
<div className={page ? "thot-pi-detail" : undefined}>
|
||||
{(!page || section === "instructions") && <PiPlatformInstructions />}
|
||||
<div className={page ? undefined : "mt-5"} hidden={page && section !== "runtime"}>
|
||||
{forbidden ? (
|
||||
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
|
||||
<p className="font-semibold">Pi management is not permitted</p>
|
||||
@@ -377,7 +387,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
|
||||
<section aria-label="Installation catalog default" className="border-t border-border/70 pt-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div><h3 className="font-heading text-sm font-semibold">Installation catalog default</h3><p className="mt-1 text-sm text-muted-foreground">This value is read from <code>modelCatalog.defaults.session</code>. Change the installation YAML and reload Pi; this page never creates a second model default.</p></div>
|
||||
<div><h3 className="font-heading text-sm font-semibold">Installation catalog default</h3><p className="mt-1 text-sm text-muted-foreground">This value is read from <code>modelCatalog.defaults.interaction</code>. Change the installation YAML and reload Pi; this page never creates a second model default.</p></div>
|
||||
</div>
|
||||
{catalogReady ? (
|
||||
<>
|
||||
@@ -394,7 +404,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
<div className="mt-4">
|
||||
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||
<p className="font-semibold">The catalog default is unavailable.</p>
|
||||
<p className="mt-1">Fix <code>modelCatalog.defaults.session</code> in the installation YAML, reload Pi, then retry this panel.</p>
|
||||
<p className="mt-1">Fix <code>modelCatalog.defaults.interaction</code> in the installation YAML, reload Pi, then retry this panel.</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled><FlaskConical />Test catalog default</Button>
|
||||
@@ -453,6 +463,8 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WorkAreaPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ test("switching workspaces preserves a globally available catalog model", async
|
||||
const workspaceSelector = await screen.findByRole("combobox", { name: "Workspace" });
|
||||
await waitFor(() => expect(workspaceSelector).toHaveTextContent("psd-clinical"));
|
||||
await userEvent.selectOptions(workspaceSelector, "psd-clinical");
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveValue("deepseek-v4-pro"));
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveValue("deepseek/deepseek-v4-pro"));
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent("DeepSeek V4 Pro");
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q");
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
WorkspaceSelectionError, workspacePolicyGate, workspacePreferences, type WorkspacePreference,
|
||||
} from "../workspaces/drafts";
|
||||
import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation";
|
||||
import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
|
||||
import { useWorkingContextScope } from "../workspaces/WorkingContext";
|
||||
|
||||
const THINKING_LEVELS = ["low", "medium", "high"] as const;
|
||||
|
||||
@@ -44,6 +46,7 @@ export function SteerInput({
|
||||
awaitingInput = false,
|
||||
working = false,
|
||||
}: Props) {
|
||||
const context = useWorkingContextScope();
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
|
||||
@@ -76,7 +79,7 @@ export function SteerInput({
|
||||
|
||||
async function submit() {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || busy) return;
|
||||
if (!trimmed || busy || (context && (!context.ready || context.administrationBusy))) return;
|
||||
const operation = captureAuthOperation({ sessionId, disposalEpoch: operationEpochRef.current });
|
||||
if (!operation) return;
|
||||
setBusy(true);
|
||||
@@ -164,7 +167,7 @@ export function SteerInput({
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!text.trim() || busy}
|
||||
disabled={!text.trim() || busy || Boolean(context && (!context.ready || context.administrationBusy))}
|
||||
aria-label="Send"
|
||||
title="Send"
|
||||
className="grid size-7 shrink-0 place-items-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||
@@ -180,6 +183,8 @@ export function SteerInput({
|
||||
* Right: live context usage plus ephemeral workspace/model/thinking selectors.
|
||||
*/
|
||||
export function ComposerFooter() {
|
||||
const globalContext = useWorkingContextScope();
|
||||
const interactionBusy = useInteractionModelBusy();
|
||||
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings });
|
||||
const { data: workspaces = [], isLoading: workspacesLoading, isError: workspaceSummariesError } = useQuery({
|
||||
queryKey: ["workspaces"], queryFn: listWorkspaces,
|
||||
@@ -188,6 +193,7 @@ export function ComposerFooter() {
|
||||
const models = modelsData?.models ?? [];
|
||||
const tokenUsage = useSessionStore((state) => state.tokenUsage);
|
||||
const [preferences, setPreferences] = useState<WorkspacePreference>(() => workspacePreferences.load());
|
||||
useEffect(() => workspacePreferences.subscribe(setPreferences), []);
|
||||
const selectableWorkspaces = workspaces.filter((workspace) => workspace.revision);
|
||||
const registryDefaultWorkspaceId = settings?.workspace
|
||||
&& selectableWorkspaces.some((workspace) => workspace.id === settings.workspace)
|
||||
@@ -197,24 +203,27 @@ export function ComposerFooter() {
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings || workspacesLoading || workspaceSummariesError) return;
|
||||
if (globalContext || !settings || workspacesLoading || workspaceSummariesError) return;
|
||||
setPreferences(workspacePreferences.migrate({
|
||||
workspaceId: registryDefaultWorkspaceId,
|
||||
provider: settings.provider,
|
||||
model: settings.model,
|
||||
thinking: settings.thinking,
|
||||
}));
|
||||
}, [registryDefaultWorkspaceId, settings, workspaceSummariesError, workspacesLoading]);
|
||||
}, [globalContext, registryDefaultWorkspaceId, settings, workspaceSummariesError, workspacesLoading]);
|
||||
|
||||
const workspace = preferences.workspaceId ?? registryDefaultWorkspaceId ?? "";
|
||||
const selectedWorkspace = workspaces.find((candidate) => candidate.id === workspace);
|
||||
const selectedWorkspaceHasRevision = Boolean(selectedWorkspace?.revision);
|
||||
const model = preferences.model ?? settings?.model ?? "";
|
||||
const model = preferences.provider && preferences.model
|
||||
? `${preferences.provider}/${preferences.model}`
|
||||
: settings?.provider && settings.model ? `${settings.provider}/${settings.model}` : "";
|
||||
const thinking = preferences.thinking ?? settings?.thinking ?? "medium";
|
||||
|
||||
const policyModels = models;
|
||||
|
||||
useEffect(() => {
|
||||
if (globalContext) return;
|
||||
if (!workspace) {
|
||||
workspacePolicyGate.clear();
|
||||
} else if (workspacesLoading) {
|
||||
@@ -229,7 +238,7 @@ export function ComposerFooter() {
|
||||
} else {
|
||||
workspacePolicyGate.allowLegacy(workspace);
|
||||
}
|
||||
}, [selectedWorkspace, workspace, workspaceSummariesError, workspacesLoading]);
|
||||
}, [globalContext, selectedWorkspace, workspace, workspaceSummariesError, workspacesLoading]);
|
||||
|
||||
function update(patch: WorkspacePreference) {
|
||||
if (patch.workspaceId && patch.workspaceId !== workspace) {
|
||||
@@ -243,11 +252,10 @@ export function ComposerFooter() {
|
||||
}
|
||||
|
||||
function onModelChange(id: string) {
|
||||
const m = policyModels.find((x) => x.id === id);
|
||||
update({ model: id, provider: m?.provider });
|
||||
setPreferences(workspacePreferences.selectModel(id));
|
||||
}
|
||||
|
||||
const knownModel = policyModels.some((m) => m.id === model);
|
||||
const knownModel = policyModels.some((m) => `${m.provider}/${m.id}` === model);
|
||||
const showModelFallback = !knownModel;
|
||||
const contextPct = tokenUsage && tokenUsage.contextWindow > 0
|
||||
? tokenUsage.totalTokens / tokenUsage.contextWindow
|
||||
@@ -256,7 +264,7 @@ export function ComposerFooter() {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/60 px-3 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspaceId: v })}>
|
||||
{!globalContext && <FooterSelect aria-label="Workspace" value={workspace} disabled={interactionBusy} onChange={(v) => update({ workspaceId: v })}>
|
||||
{workspaces.length === 0 ? (
|
||||
<option value="">— workspace —</option>
|
||||
) : (
|
||||
@@ -266,7 +274,7 @@ export function ComposerFooter() {
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</FooterSelect>
|
||||
</FooterSelect>}
|
||||
<span
|
||||
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
|
||||
aria-label="Input, cached, and output tokens"
|
||||
@@ -276,21 +284,21 @@ export function ComposerFooter() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<FooterSelect aria-label="Model" value={model} onChange={onModelChange}>
|
||||
{!globalContext && <FooterSelect aria-label="Model" value={model} disabled={interactionBusy} onChange={onModelChange}>
|
||||
{policyModels.length === 0 ? (
|
||||
<option value={model}>{model || "— model —"}</option>
|
||||
) : (
|
||||
<>
|
||||
{showModelFallback && <option value={model}>{model || "— model —"}</option>}
|
||||
{policyModels.map((m) => (
|
||||
<option key={`${m.provider}/${m.id}`} value={m.id}>
|
||||
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</FooterSelect>
|
||||
<FooterSelect aria-label="Thinking level" value={thinking} onChange={(v) => update({ thinking: v })} className="capitalize">
|
||||
</FooterSelect>}
|
||||
<FooterSelect aria-label="Thinking level" value={thinking} disabled={interactionBusy} onChange={(v) => update({ thinking: v })} className="capitalize">
|
||||
{THINKING_LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
@@ -315,7 +323,7 @@ function FooterSelect({
|
||||
onChange: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
} & Pick<React.SelectHTMLAttributes<HTMLSelectElement>, "aria-label">) {
|
||||
} & Pick<React.SelectHTMLAttributes<HTMLSelectElement>, "aria-label" | "disabled">) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
|
||||
@@ -7,6 +7,39 @@
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.thot-work-area-page {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: oklch(var(--foreground));
|
||||
background: oklch(var(--background));
|
||||
}
|
||||
|
||||
.thot-work-area-page__header {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1.35rem clamp(1rem, 3vw, 2rem) 1.15rem;
|
||||
border-bottom: 1px solid oklch(var(--primary-foreground) / 0.18);
|
||||
color: oklch(var(--primary-foreground));
|
||||
background-color: oklch(var(--primary));
|
||||
background-image: none;
|
||||
box-shadow: inset 0 -1px 0 oklch(var(--primary-foreground) / 0.18);
|
||||
}
|
||||
|
||||
.thot-work-area-page__body {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.thot-work-area-panel {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { X } from "lucide-react";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
import "./WorkAreaPanel.css";
|
||||
import { AdministrationHeader } from "./administration/AdministrationPage";
|
||||
|
||||
function classes(base: string, className?: string) {
|
||||
return className ? `${base} ${className}` : base;
|
||||
@@ -116,6 +117,7 @@ export function useModelessPanel(open: boolean, onClose: () => void, busy = fals
|
||||
|
||||
export interface WorkAreaPanelProps {
|
||||
open: boolean;
|
||||
mode?: "panel" | "page";
|
||||
ariaLabel?: string;
|
||||
eyebrow?: ReactNode;
|
||||
title: ReactNode;
|
||||
@@ -140,6 +142,7 @@ export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
|
||||
function WorkAreaPanel(
|
||||
{
|
||||
open,
|
||||
mode = "panel",
|
||||
ariaLabel,
|
||||
eyebrow,
|
||||
title,
|
||||
@@ -160,7 +163,7 @@ export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
|
||||
const panelHost = useContext(WorkAreaPanelHostContext);
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const panelRef = useModelessPanel(open, onClose, busy);
|
||||
const panelRef = useModelessPanel(mode === "panel" && open, onClose, busy);
|
||||
const assignRef = (node: HTMLElement | null) => {
|
||||
panelRef.current = node;
|
||||
if (typeof forwardedRef === "function") forwardedRef(node);
|
||||
@@ -169,7 +172,31 @@ export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const panel = (
|
||||
const panel = mode === "page" ? (
|
||||
<main
|
||||
ref={assignRef}
|
||||
className={classes("thot-work-area-page", className)}
|
||||
data-work-area-page=""
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={busy}
|
||||
>
|
||||
<AdministrationHeader title={title} description={description} />
|
||||
<div
|
||||
className={classes("thot-work-area-page__body", bodyClassName)}
|
||||
data-work-area-panel-region="body"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<footer
|
||||
className={classes("thot-work-area-panel__footer", footerClassName)}
|
||||
data-work-area-panel-region="footer"
|
||||
>
|
||||
{footer}
|
||||
</footer>
|
||||
) : null}
|
||||
</main>
|
||||
) : (
|
||||
<aside
|
||||
ref={assignRef}
|
||||
role="dialog"
|
||||
@@ -234,6 +261,6 @@ export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
|
||||
</aside>
|
||||
);
|
||||
|
||||
return panelHost ? createPortal(panel, panelHost) : panel;
|
||||
return mode === "panel" && panelHost ? createPortal(panel, panelHost) : panel;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
.thot-context-layout { display: flex; flex-direction: column; min-width: 0; height: var(--thoth-app-height, 100dvh); overflow: hidden; }
|
||||
.thot-context-layout > .thot-app-shell { flex: 1; height: auto; min-height: 0; }
|
||||
.thot-context-shelf { flex: none; min-width: 0; max-height: 60%; overflow: auto; border-bottom: 1px solid oklch(var(--border)); background: oklch(var(--muted)/.5); container: working-context / inline-size; }
|
||||
.thot-context-trigger { display: flex; align-items: center; gap: 16px; width: 100%; min-height: 72px; padding: 14px 24px; text-align: left; }
|
||||
.thot-context-summary { min-width: 0; flex: 1; }
|
||||
.thot-context-summary small { display: block; font: 600 .625rem/1.6 var(--font-mono); color: oklch(var(--muted-foreground)); letter-spacing: .065em; }
|
||||
.thot-context-summary strong { display: block; font: 600 .875rem/1.6 var(--font-sans); overflow-wrap: anywhere; }
|
||||
.thot-context-summary strong span { padding-inline: 12px; color: oklch(var(--muted-foreground)); }
|
||||
.thot-context-action { display: flex; align-items: center; gap: 8px; color: oklch(var(--primary)); font-size: .75rem; font-weight: 600; }
|
||||
.thot-context-trigger[aria-expanded=true] .thot-context-action > svg:last-child { transform: rotate(180deg); }
|
||||
.thot-context-body { padding: 0 24px 20px; }
|
||||
.thot-context-body[hidden] { display: none; }
|
||||
.thot-context-explanation { max-width: none; margin: 0 0 20px; font-size: .8125rem; line-height: 1.6; color: oklch(var(--muted-foreground)); }
|
||||
.thot-context-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 24px; }
|
||||
.thot-context-fields label { display: grid; min-width: 0; gap: 7px; font-size: .8125rem; }
|
||||
.thot-context-fields label > span { display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
||||
.thot-context-fields select { min-width: 0; width: 100%; padding: 9px 12px; border: 1px solid oklch(var(--border)); border-radius: 6px; background: oklch(var(--card)); }
|
||||
.thot-context-fields small, .thot-context-body > p[role] { color: oklch(var(--muted-foreground)); font-size: .75rem; line-height: 1.6; }
|
||||
.thot-context-body > p[role] { margin-top: 12px; }
|
||||
.thot-context-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.thot-context-shelf :is(button,select):focus-visible { outline: 2px solid oklch(var(--ring)); outline-offset: 3px; }
|
||||
.thot-context-shelf select:disabled { opacity: .65; }
|
||||
@container working-context (max-width: 38rem) {
|
||||
.thot-context-fields { grid-template-columns: minmax(0,1fr); gap: 14px; }
|
||||
.thot-context-trigger { padding: 12px 16px; gap: 10px; }
|
||||
.thot-context-body { padding: 0 16px 16px; }
|
||||
.thot-context-action > span { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, Cpu, Layers, LockKeyhole, SlidersHorizontal } from "lucide-react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import type { useWorkingContext } from "../workspaces/WorkingContext";
|
||||
import "./WorkingContextShelf.css";
|
||||
|
||||
export function WorkingContextShelf({ context, locked, onWorkspace, onModel }: {
|
||||
context: ReturnType<typeof useWorkingContext>; locked: boolean;
|
||||
onWorkspace: (id: string) => void; onModel: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const open = expanded || !context.ready;
|
||||
return <section className="thot-context-shelf" aria-label="Global working context">
|
||||
<button className="thot-context-trigger" aria-expanded={open} aria-controls="thoth-working-context"
|
||||
onClick={() => setExpanded(!open)}>
|
||||
<SlidersHorizontal size={18} aria-hidden />
|
||||
<span className="thot-context-summary"><small>WORKING CONTEXT</small><strong>{context.workspace?.displayName ?? "Choose workspace"}<span aria-hidden> / </span>{context.model?.name ?? "Choose model"}</strong></span>
|
||||
<span className="thot-context-action">{locked && <LockKeyhole size={15} aria-hidden />}<span>{locked ? "Locked" : open ? "Close" : "Change context"}</span><ChevronDown size={16} aria-hidden /></span>
|
||||
</button>
|
||||
<div id="thoth-working-context" hidden={!open} className="thot-context-body">
|
||||
<p className="thot-context-explanation">Your last choices take precedence over installation defaults. Workspace and model are remembered independently.</p>
|
||||
<div className="thot-context-fields">
|
||||
<label><span><Layers size={15} aria-hidden />Workspace</span>
|
||||
<select aria-label="Workspace" value={context.workspace ? context.workspaceId : ""} disabled={locked || context.loading} onChange={event => onWorkspace(event.target.value)}>
|
||||
<option value="" disabled>Choose a workspace</option>{context.workspaces.map(workspace => <option key={workspace.id} value={workspace.id}>{workspace.displayName}</option>)}
|
||||
</select><small>{context.origins.workspace}</small>
|
||||
</label>
|
||||
<label><span><Cpu size={15} aria-hidden />AI interaction model</span>
|
||||
<select aria-label="Model" value={context.model ? context.modelId : ""} disabled={locked || context.loading} onChange={event => onModel(event.target.value)}>
|
||||
<option value="" disabled>Choose a model</option>{context.models.map(model => <option key={`${model.provider}/${model.id}`} value={`${model.provider}/${model.id}`}>{model.name}</option>)}
|
||||
</select><small>{context.origins.model}</small>
|
||||
</label>
|
||||
</div>
|
||||
{locked && <p role="status">Workspace and model are locked until the current operation finishes.</p>}
|
||||
{context.ready && context.error && <p role="status">Context refresh failed. Your current choices and unsaved work have been retained.</p>}
|
||||
{!context.ready && <p role="status">{context.loading ? "Loading working context…" : context.error ? "Working context is unavailable. Retry or ask the installation operator to check configuration." : "Select an available workspace and model to enable Core and Administration. Unavailable saved choices are not replaced automatically."}</p>}
|
||||
<div className="thot-context-footer">{context.error && <Button variant="outline" onClick={context.retry}>Retry context</Button>}{context.ready && <Button variant="outline" onClick={() => setExpanded(false)}>Done</Button>}</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "../auth/authOperation";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { WorkAreaPanel } from "./WorkAreaPanel";
|
||||
import { WorkspacePreparation } from "./administration/WorkspacePreparation";
|
||||
|
||||
import "./WorkspaceManager.css";
|
||||
|
||||
@@ -73,19 +74,30 @@ type WorkspaceOperationGuard = Readonly<{
|
||||
context: WorkspaceOperationContext;
|
||||
}>;
|
||||
|
||||
import { useWorkingContextScope } from "../workspaces/WorkingContext";
|
||||
|
||||
export function WorkspaceManager({
|
||||
open,
|
||||
onClose,
|
||||
page = false,
|
||||
initialWorkspaceId,
|
||||
onNavigationStateChange,
|
||||
canPreprocess = false,
|
||||
canManageWorkspace = false,
|
||||
canManageSecrets = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
page?: boolean;
|
||||
initialWorkspaceId?: string;
|
||||
onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void;
|
||||
canPreprocess?: boolean;
|
||||
canManageWorkspace?: boolean;
|
||||
canManageSecrets?: boolean;
|
||||
}) {
|
||||
const workingContext = useWorkingContextScope();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(initialWorkspaceId);
|
||||
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
|
||||
const [notice, setNotice] = useState<string>();
|
||||
const [diagnostics, setDiagnostics] = useState<string[]>([]);
|
||||
@@ -202,7 +214,7 @@ export function WorkspaceManager({
|
||||
queryFn: () => guardedQuery(listWorkspaces),
|
||||
enabled: open,
|
||||
});
|
||||
const workspaces = workspacesQuery.data ?? [];
|
||||
const workspaces = (workspacesQuery.data ?? []).filter(workspace => !workingContext || workspace.id === workingContext.workspaceId);
|
||||
const selectedSummary = useMemo(
|
||||
() => workspaces.find(({ id }) => id === selectedId),
|
||||
[selectedId, workspaces],
|
||||
@@ -224,11 +236,13 @@ export function WorkspaceManager({
|
||||
};
|
||||
|
||||
const selectWorkspace = (id: string) => {
|
||||
if (page && (busyAction || (hasEnteredSecrets && !window.confirm("Discard unsaved workspace secrets?")))) return;
|
||||
invalidateWorkspaceContext();
|
||||
setSelectedId(id);
|
||||
};
|
||||
|
||||
const showLevelOne = () => {
|
||||
if (page && (busyAction || (hasEnteredSecrets && !window.confirm("Discard unsaved workspace secrets?")))) return;
|
||||
invalidateWorkspaceContext();
|
||||
setSelectedId(undefined);
|
||||
};
|
||||
@@ -386,10 +400,15 @@ export function WorkspaceManager({
|
||||
: undefined;
|
||||
const runtime = runtimeQuery.data;
|
||||
const hasEnteredSecrets = Object.values(secretValues).some((value) => value.length > 0);
|
||||
useEffect(() => {
|
||||
onNavigationStateChange?.({ dirty: open && hasEnteredSecrets, busy: open && Boolean(busyAction) });
|
||||
return () => onNavigationStateChange?.({ dirty: false, busy: false });
|
||||
}, [open, hasEnteredSecrets, busyAction, onNavigationStateChange]);
|
||||
|
||||
return (
|
||||
<WorkAreaPanel
|
||||
open={open}
|
||||
mode={page ? "page" : "panel"}
|
||||
ariaLabel="Workspace management"
|
||||
width="expanded"
|
||||
eyebrow="Workspace configuration"
|
||||
@@ -408,7 +427,7 @@ export function WorkspaceManager({
|
||||
className="mb-3 w-full justify-start"
|
||||
onClick={showLevelOne}
|
||||
>
|
||||
<ArrowLeft />Back to Level 1
|
||||
<ArrowLeft />{page ? "Repository overview" : "Back to Level 1"}
|
||||
</Button>
|
||||
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Available workspaces</p>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
@@ -455,7 +474,7 @@ export function WorkspaceManager({
|
||||
{!selectedSummary ? (
|
||||
<section data-testid="workspace-overview" className="mx-auto max-w-3xl space-y-5">
|
||||
<div>
|
||||
<p className="thot-label">Level 1 · Repository</p>
|
||||
<p className="thot-label">Repository</p>
|
||||
<h2 className="font-heading text-xl font-semibold">What workspace files tell ThothII</h2>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm leading-6 text-muted-foreground">
|
||||
@@ -506,11 +525,13 @@ export function WorkspaceManager({
|
||||
) : (
|
||||
<section className="mx-auto max-w-4xl space-y-5">
|
||||
<div className="border-b border-border/70 pb-4">
|
||||
<p className="thot-label">Level 2 · Selected workspace</p>
|
||||
<p className="thot-label">Selected workspace</p>
|
||||
<h2 className="font-heading text-xl font-semibold">{selectedSummary.displayName}</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">{selectedSummary.id}</p>
|
||||
</div>
|
||||
|
||||
{page && selectedId && <WorkspacePreparation workspaceId={selectedId} canManage={canPreprocess} />}
|
||||
|
||||
{(detailQuery.isLoading || runtimeQuery.isLoading) && <p className="text-sm text-muted-foreground">Loading workspace configuration…</p>}
|
||||
{(detailQuery.isError || runtimeQuery.isError) && (
|
||||
<QueryError name="Workspace configuration failed" message="Could not load this workspace configuration." retryLabel="Retry workspace configuration" onRetry={() => { void Promise.all([detailQuery.refetch(), runtimeQuery.refetch()]); }} />
|
||||
|
||||
@@ -166,12 +166,14 @@ export function WorkspacePreprocessingControl({
|
||||
loading,
|
||||
unavailable,
|
||||
canManage,
|
||||
presentation = "rail",
|
||||
}: {
|
||||
workspaceId?: string;
|
||||
status?: WorkspacePreprocessingStatus;
|
||||
loading: boolean;
|
||||
unavailable: boolean;
|
||||
canManage: boolean;
|
||||
presentation?: "rail" | "page";
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [confirmingClear, setConfirmingClear] = useState(false);
|
||||
@@ -225,7 +227,8 @@ export function WorkspacePreprocessingControl({
|
||||
workspaceId
|
||||
&& status?.actionable
|
||||
&& canManage
|
||||
&& !mutation.isPending,
|
||||
&& !mutation.isPending
|
||||
&& !clearMutation.isPending,
|
||||
);
|
||||
const clearable = Boolean(
|
||||
workspaceId
|
||||
@@ -240,7 +243,8 @@ export function WorkspacePreprocessingControl({
|
||||
<section
|
||||
aria-label="Workspace preprocessing"
|
||||
aria-busy={state === "running" || loading || clearMutation.isPending}
|
||||
className="mt-0.5 min-w-0 max-w-full border-t border-border/90 px-0.5 pt-2"
|
||||
className="thot-preprocessing-control mt-0.5 min-w-0 max-w-full border-t border-border/90 px-0.5 pt-2"
|
||||
data-presentation={presentation}
|
||||
>
|
||||
<p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary">
|
||||
Preprocessing
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, type AnchorHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
export type AdministrationSurface = "database-management" | "memory-management" | "evidence-management" | "workspace-management" | "pi-management";
|
||||
export type ActiveSurface = "core" | AdministrationSurface;
|
||||
export interface AdministrationRoute { surface: ActiveSurface; workspace?: string }
|
||||
const surfaces: Record<AdministrationSurface, string> = {
|
||||
"database-management": "database", "memory-management": "memory", "evidence-management": "evidence",
|
||||
"workspace-management": "workspace", "pi-management": "pi",
|
||||
};
|
||||
|
||||
function readRoute(): AdministrationRoute {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const entry = Object.entries(surfaces).find(([, name]) => params.get("thoth_route") === `administration/${name}`);
|
||||
return { surface: entry ? entry[0] as AdministrationSurface : "core", workspace: entry ? params.get("thoth_workspace") || undefined : undefined };
|
||||
}
|
||||
|
||||
function hrefFor(route: AdministrationRoute): string {
|
||||
const url = new URL(window.location.href);
|
||||
if (route.surface === "core") url.searchParams.delete("thoth_route");
|
||||
else url.searchParams.set("thoth_route", `administration/${surfaces[route.surface]}`);
|
||||
if (route.surface !== "core" && route.workspace) url.searchParams.set("thoth_workspace", route.workspace);
|
||||
else url.searchParams.delete("thoth_workspace");
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
interface HistoryPosition { index: number; key: string }
|
||||
function position(state: unknown): HistoryPosition | undefined {
|
||||
const value = (state as { thothNavigation?: HistoryPosition } | null)?.thothNavigation;
|
||||
return value && typeof value.index === "number" && typeof value.key === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/** Own only Thoth's query and history namespace, leaving the host's URL/state intact. */
|
||||
export function useAdministrationNavigation(canLeave: () => boolean, hasUnsavedWork: () => boolean) {
|
||||
const [route, setRoute] = useState<AdministrationRoute>(readRoute);
|
||||
const policy = useRef({ canLeave, hasUnsavedWork });
|
||||
policy.current = { canLeave, hasUnsavedWork };
|
||||
const current = useRef({ route, href: window.location.href, state: window.history.state });
|
||||
const restoring = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const existing = position(window.history.state);
|
||||
const state = { ...window.history.state, thothNavigation: existing ?? { index: 0, key: crypto.randomUUID() } };
|
||||
window.history.replaceState(state, "", window.location.href);
|
||||
current.current = { route: readRoute(), href: window.location.href, state };
|
||||
const onPopState = () => {
|
||||
if (restoring.current) { restoring.current = false; return; }
|
||||
const next = readRoute();
|
||||
const previous = current.current;
|
||||
const changingPage = next.surface !== previous.route.surface || next.workspace !== previous.route.workspace;
|
||||
if (changingPage && !policy.current.canLeave()) {
|
||||
const from = position(previous.state);
|
||||
const to = position(window.history.state);
|
||||
if (from && to && from.key === to.key && from.index !== to.index) {
|
||||
restoring.current = true;
|
||||
window.history.go(from.index - to.index);
|
||||
} else {
|
||||
// Host-created entries lack our position. Restore the visible URL without
|
||||
// inventing a direction or navigating outside the application's document.
|
||||
window.history.replaceState(previous.state, "", previous.href);
|
||||
}
|
||||
return;
|
||||
}
|
||||
current.current = { route: next, href: window.location.href, state: window.history.state };
|
||||
setRoute(next);
|
||||
};
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!policy.current.hasUnsavedWork()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("popstate", onPopState);
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function navigate(next: AdministrationRoute, approved = false) {
|
||||
if (restoring.current) return;
|
||||
const previous = current.current;
|
||||
if (next.surface === previous.route.surface && next.workspace === previous.route.workspace) return;
|
||||
if (!approved && !policy.current.canLeave()) return;
|
||||
const oldPosition = position(window.history.state) ?? { index: 0, key: crypto.randomUUID() };
|
||||
const state = { ...window.history.state, thothNavigation: { ...oldPosition, index: oldPosition.index + 1 } };
|
||||
window.history.pushState(state, "", hrefFor(next));
|
||||
current.current = { route: next, href: window.location.href, state };
|
||||
setRoute(next);
|
||||
}
|
||||
return { route, navigate };
|
||||
}
|
||||
|
||||
const NavigationContext = createContext<((route: AdministrationRoute) => void) | undefined>(undefined);
|
||||
export function AdministrationNavigationProvider({ navigate, children }: { navigate: (route: AdministrationRoute) => void; children: ReactNode }) {
|
||||
return <NavigationContext.Provider value={navigate}>{children}</NavigationContext.Provider>;
|
||||
}
|
||||
|
||||
export function AdministrationLink({ surface, workspace, children, ...props }: {
|
||||
surface: AdministrationSurface; workspace?: string;
|
||||
} & AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
const navigate = useContext(NavigationContext);
|
||||
const route = { surface, workspace };
|
||||
return <a {...props} href={hrefFor(route)} onClick={event => {
|
||||
props.onClick?.(event);
|
||||
if (!navigate || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault(); navigate(route);
|
||||
}}>{children}</a>;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
.thot-app-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: var(--thoth-app-height, 100dvh);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thot-administration-page,
|
||||
.thot-work-area-page {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
background: oklch(var(--background));
|
||||
color: oklch(var(--foreground));
|
||||
container: administration / inline-size;
|
||||
}
|
||||
|
||||
.thot-administration-header {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
padding: 1.65rem 1.75rem 1.35rem;
|
||||
border-bottom: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--muted) / 0.35);
|
||||
}
|
||||
.thot-administration-header__copy { min-width: 0; flex: 1 1 20rem; }
|
||||
.thot-administration-header h1 {
|
||||
margin: 0.35rem 0 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.875rem;
|
||||
line-height: 1.15;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.thot-administration-eyebrow {
|
||||
margin: 0;
|
||||
font: 600 0.6875rem/1.4 var(--font-mono);
|
||||
color: oklch(var(--muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.065em;
|
||||
}
|
||||
.thot-administration-description {
|
||||
max-width: 68ch;
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: oklch(var(--muted-foreground));
|
||||
}
|
||||
.thot-administration-header__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; min-width: 0; max-width: 100%; }
|
||||
.thot-administration-header__actions > label { min-width: 0; width: 15rem; max-width: 100%; }
|
||||
.thot-administration-page :where(input, select, textarea, fieldset) { min-width: 0; max-width: 100%; }
|
||||
.thot-administration-page :where(pre, code, dd) { overflow-wrap: anywhere; }
|
||||
.thot-administration-page :where(p, li) { overflow-wrap: anywhere; }
|
||||
.thot-administration-toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; padding: 1.25rem 1.75rem; }
|
||||
.thot-administration-toolbar > label { min-width: 0; flex: 1 1 10rem; }
|
||||
.thot-administration-split {
|
||||
display: grid;
|
||||
flex: none;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.4fr);
|
||||
min-height: 18rem;
|
||||
margin: 1.25rem 1.75rem;
|
||||
border: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--card));
|
||||
}
|
||||
.thot-administration-index { min-width: 0; background: oklch(var(--muted) / 0.45); border-inline-end: 1px solid oklch(var(--border)); }
|
||||
.thot-administration-detail { min-width: 0; padding: 1.35rem; }
|
||||
.thot-administration-index :where(button[aria-current]) { background: oklch(var(--nav-active)); color: oklch(var(--nav-active-foreground)); }
|
||||
.thot-administration-index button { overflow-wrap: anywhere; }
|
||||
.thot-administration-section-label { padding: 1rem 1.25rem 0; font: 600 0.6875rem/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: oklch(var(--muted-foreground)); }
|
||||
.thot-administration-link { color: oklch(var(--primary)); text-decoration: underline; text-underline-offset: 0.2em; font-size: 0.875rem; }
|
||||
.thot-administration-link:focus-visible { outline: 2px solid oklch(var(--ring)); outline-offset: 3px; }
|
||||
.thot-administration-dependency { padding: 1rem 1.25rem; border-block: 1px solid oklch(var(--border)); background: oklch(var(--muted) / 0.35); font-size: 0.875rem; }
|
||||
.thot-administration-dependency p { max-width: 72ch; margin-bottom: 0.5rem; }
|
||||
.thot-administration-mobile-nav { position: absolute; inset: 0 0 auto; z-index: 30; display: flex; height: 3rem; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0 1rem; border-bottom: 1px solid oklch(var(--border)); background: oklch(var(--background)); }
|
||||
.thot-app-shell[data-administration-narrow="true"] { padding-top: 3rem; }
|
||||
.thot-work-area-page .thot-workspace-manager__layout { flex: 1; min-height: 22rem; }
|
||||
.thot-work-area-page .thot-workspace-manager__content { padding: 1.4rem; }
|
||||
.thot-work-area-page__body--pi { padding: 1.5rem 1.75rem; }
|
||||
.thot-pi-workbench { display: grid; grid-template-columns: minmax(12rem, 0.55fr) minmax(0, 1.5fr); border: 1px solid oklch(var(--border)); background: oklch(var(--card)); }
|
||||
.thot-pi-index { background: oklch(var(--muted) / 0.45); border-inline-end: 1px solid oklch(var(--border)); padding: 1.25rem; }
|
||||
.thot-pi-index a { display: block; padding: 0.65rem 0; font-size: 0.875rem; }
|
||||
.thot-pi-detail { min-width: 0; padding: 1.35rem; }
|
||||
.thot-workspace-preparation { border-block-start: 1px solid oklch(var(--border)); padding-top: 1.25rem; }
|
||||
.thot-workspace-preparation .thot-preprocessing-control { max-width: 40rem; }
|
||||
.thot-preprocessing-control[data-presentation="page"] { padding: 1rem; margin-top: 0.75rem; }
|
||||
.thot-preprocessing-control[data-presentation="page"] :where(strong, span, small, button) { font-size: 0.8125rem; line-height: 1.5; }
|
||||
.thot-preprocessing-control[data-presentation="page"] button { height: auto; min-height: 2.25rem; white-space: normal; }
|
||||
.thot-administration-database .thot-fleet-ledger__workspace { overflow-y: auto; }
|
||||
.thot-administration-database .thot-fleet-ledger__content { flex: 1 0 26rem; min-height: 26rem; }
|
||||
.thot-administration-database .thot-fleet-ledger__kpi-value { font-size: 1.125rem; }
|
||||
.thot-administration-database .thot-administration-dependency { flex: none; }
|
||||
.thot-database-workbench { grid-template-columns: minmax(10rem, 0.55fr) minmax(0, 1.5fr); }
|
||||
.thot-database-workbench form > .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@container administration (max-width: 48rem) {
|
||||
.thot-administration-split, .thot-pi-workbench { grid-template-columns: minmax(0, 1fr); }
|
||||
.thot-database-workbench form > .grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.thot-administration-index, .thot-pi-index { border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
|
||||
.thot-administration-index { max-height: 22rem; overflow-y: auto; }
|
||||
.thot-administration-header { padding: 1.25rem; }
|
||||
.thot-administration-split { margin: 1rem; }
|
||||
.thot-work-area-page .thot-workspace-manager__layout { display: flex; flex-direction: column; overflow: visible; }
|
||||
.thot-work-area-page .thot-workspace-manager__navigation { max-height: 18rem; flex: none; border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
|
||||
.thot-work-area-page .thot-workspace-manager__content { overflow: visible; }
|
||||
}
|
||||
@container administration (max-width: 30rem) {
|
||||
.thot-administration-header h1 { font-size: 1.5rem; }
|
||||
.thot-administration-header, .thot-administration-toolbar, .thot-work-area-page__body--pi { padding: 1rem; }
|
||||
.thot-administration-detail, .thot-pi-detail { padding: 1rem; }
|
||||
.thot-administration-split { margin: 0.75rem; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "./AdministrationPage.css";
|
||||
|
||||
export function AdministrationHeader({ title, description, actions, eyebrow = "Administration / Workbench" }: {
|
||||
title: ReactNode; description?: ReactNode; actions?: ReactNode; eyebrow?: ReactNode;
|
||||
}) {
|
||||
return <header className="thot-administration-header">
|
||||
<div className="thot-administration-header__copy">
|
||||
<p className="thot-administration-eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
{description && <p className="thot-administration-description">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="thot-administration-header__actions">{actions}</div>}
|
||||
</header>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getWorkspacePreprocessingStatus } from "../../api/workspace-preprocessing";
|
||||
import { WorkspacePreprocessingControl } from "../WorkspacePreprocessingControl";
|
||||
import { AdministrationLink } from "./AdministrationNavigation";
|
||||
|
||||
export function WorkspacePreparation({ workspaceId, canManage, databaseSummary = false }: {
|
||||
workspaceId: string; canManage: boolean; databaseSummary?: boolean;
|
||||
}) {
|
||||
const query = useQuery({ queryKey: ["workspace-preprocessing", workspaceId],
|
||||
queryFn: () => getWorkspacePreprocessingStatus(workspaceId), enabled: canManage, retry: false, refetchInterval: 5000 });
|
||||
if (!canManage) return <p className="text-sm text-muted-foreground">Database management permission is required to inspect or run preprocessing.</p>;
|
||||
if (databaseSummary) return <section aria-label="Workspace preparation dependency" className="thot-administration-dependency">
|
||||
<p><strong>Workspace preparation</strong> · {workspaceId}</p>
|
||||
<p role="status">{query.isLoading ? "Checking workspace readiness…" : query.isError ? "Workspace readiness is unavailable." : query.data?.detail}</p>
|
||||
{query.isError && <button className="thot-administration-link" onClick={() => void query.refetch()}>Retry readiness</button>}
|
||||
<AdministrationLink surface="workspace-management" workspace={workspaceId} className="thot-administration-link">Open workspace preprocessing</AdministrationLink>
|
||||
</section>;
|
||||
return <section aria-label="Workspace readiness" className="thot-workspace-preparation">
|
||||
<p className="thot-administration-eyebrow">Preparation</p>
|
||||
<h3 className="font-heading text-lg font-semibold mt-1">Ready for analysis</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Preprocessing prepares this workspace's schema, value lookup and Evidence indexes from the Database catalog. Memory remains separate.</p>
|
||||
<AdministrationLink surface="database-management" workspace={workspaceId} className="thot-administration-link">Open Database configuration and schema</AdministrationLink>
|
||||
{query.data && <dl className="mt-3 flex flex-wrap gap-5 text-sm">
|
||||
<div><dt className="text-muted-foreground">Catalog revision</dt><dd className="font-mono">{query.data.metadataRevision ?? "Unavailable"}</dd></div>
|
||||
<div><dt className="text-muted-foreground">Prepared revision</dt><dd className="font-mono">{query.data.preprocessedMetadataRevision ?? "Not prepared"}</dd></div>
|
||||
</dl>}
|
||||
<WorkspacePreprocessingControl key={workspaceId} workspaceId={workspaceId} status={query.data} loading={query.isLoading} unavailable={query.isError} canManage={canManage} presentation="page" />
|
||||
{query.isError && <button className="thot-administration-link" onClick={() => void query.refetch()}>Retry readiness</button>}
|
||||
</section>;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { forwardRef, useId, useState, type ReactNode } from "react"
|
||||
import { ArrowLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { AdministrationHeader } from "../administration/AdministrationPage"
|
||||
import {
|
||||
WorkAreaPanel,
|
||||
WorkAreaPanelHostProvider,
|
||||
@@ -117,17 +118,13 @@ export function FleetLedgerHeader({
|
||||
className,
|
||||
}: FleetLedgerHeaderProps) {
|
||||
return (
|
||||
<header className={classes("thot-fleet-ledger__header", className)}>
|
||||
<div className="thot-fleet-ledger__heading-copy">
|
||||
<h1 className="thot-fleet-ledger__title">{title}</h1>
|
||||
</div>
|
||||
{status ? <div className="thot-fleet-ledger__header-status">{status}</div> : null}
|
||||
{actions ? (
|
||||
<div className="thot-fleet-ledger__header-actions" role="group" aria-label={actionsLabel}>
|
||||
<div className={className}>
|
||||
<AdministrationHeader title={title} description="Configure database connections, synchronize schema and curate the catalog." />
|
||||
<div className="thot-fleet-ledger__header-status">{status}</div>
|
||||
{actions && <div className="thot-administration-toolbar" role="group" aria-label={actionsLabel}>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId } from "react";
|
||||
import { useWorkingContextScope } from "../../workspaces/WorkingContext";
|
||||
import type { MetadataGenerationModels } from "../../api/catalog-databases";
|
||||
|
||||
export const NO_METADATA_GENERATION_LLM_MODEL_MESSAGE =
|
||||
@@ -24,16 +25,19 @@ export function MetadataGenerationModelSelector({
|
||||
variant = "default",
|
||||
}: Props) {
|
||||
const helpId = useId();
|
||||
const workingContext = useWorkingContextScope();
|
||||
if (workingContext) return null;
|
||||
const models = data?.models ?? [];
|
||||
const configuredDefault = data?.default ?? "";
|
||||
const hasUsableDefault = configuredDefault !== ""
|
||||
&& models.some((model) => model.id === configuredDefault);
|
||||
const noUsableModel = !isLoading && !isError && Boolean(data) && !hasUsableDefault;
|
||||
const staleSelection = Boolean(selectedModel) && !models.some((model) => model.id === selectedModel);
|
||||
const unavailableMessage = !isLoading && isError
|
||||
? "Metadata-generation LLM model choices are unavailable."
|
||||
? "Global LLM model choices are unavailable."
|
||||
: noUsableModel
|
||||
? NO_METADATA_GENERATION_LLM_MODEL_MESSAGE
|
||||
: null;
|
||||
: staleSelection ? "The global LLM model is unavailable. Choose a configured model; no fallback is applied." : null;
|
||||
const compact = variant === "compact";
|
||||
|
||||
return (
|
||||
@@ -45,15 +49,16 @@ export function MetadataGenerationModelSelector({
|
||||
? "flex min-w-0 items-center gap-2 text-xs font-medium text-foreground"
|
||||
: "grid gap-1.5 text-xs font-medium text-foreground"}
|
||||
>
|
||||
<span className={compact ? "whitespace-nowrap" : undefined}>Metadata-generation LLM model</span>
|
||||
<span className={compact ? "whitespace-nowrap" : undefined}>Global LLM model</span>
|
||||
<select
|
||||
className={`${compact ? "h-8 min-w-52" : "h-9 w-full"} rounded-md border border-input bg-card px-3 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15 disabled:bg-muted disabled:text-muted-foreground`}
|
||||
aria-label="Metadata-generation LLM model"
|
||||
aria-label="Global LLM model"
|
||||
aria-describedby={unavailableMessage ? helpId : undefined}
|
||||
value={selectedModel}
|
||||
disabled={!canManage || isLoading || isError || !hasUsableDefault}
|
||||
onChange={(event) => onSelectedModelChange(event.target.value)}
|
||||
>
|
||||
{staleSelection && <option value={selectedModel}>{selectedModel} (unavailable)</option>}
|
||||
{isLoading ? <option value="">Loading models…</option> : null}
|
||||
{!isLoading && isError ? <option value="">Model choices unavailable</option> : null}
|
||||
{noUsableModel ? (
|
||||
@@ -64,6 +69,7 @@ export function MetadataGenerationModelSelector({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">Shared with Core and all workspaces.</span>
|
||||
{unavailableMessage ? (
|
||||
<p id={helpId} className="max-w-72 text-xs font-normal leading-4 text-muted-foreground">
|
||||
{unavailableMessage}
|
||||
|
||||
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { act } from "react";
|
||||
import { server } from "../test/msw";
|
||||
import { workspaceSummaryFixture } from "../test/workspace-fixtures";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
import { App } from "../App";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
@@ -23,8 +24,8 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async
|
||||
http.get("/api/settings", () => HttpResponse.json({
|
||||
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
})),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("default")])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }] })),
|
||||
http.post("/api/sessions/s1/response", async ({ request }) => {
|
||||
responded = await request.json();
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
|
||||
@@ -38,6 +38,9 @@ if (!window.matchMedia) {
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
beforeEach(() => workspacePreferences.reset());
|
||||
beforeEach(() => {
|
||||
workspacePreferences.reset();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { listWorkspaces } from "../api/workspaces";
|
||||
import { listModels } from "../api/models";
|
||||
import { getSettings } from "../api/settings";
|
||||
import { workspacePreferences } from "./preferences";
|
||||
import { workspacePolicyGate } from "./drafts";
|
||||
|
||||
/** One validated operational context. Never substitute another model for an unavailable choice. */
|
||||
export function useWorkingContext() {
|
||||
const [preferences, setPreferences] = useState(() => workspacePreferences.load());
|
||||
useEffect(() => {
|
||||
const unsubscribe = workspacePreferences.subscribe(setPreferences);
|
||||
setPreferences(workspacePreferences.load());
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: getSettings });
|
||||
const workspacesQuery = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces });
|
||||
const modelsQuery = useQuery({ queryKey: ["models"], queryFn: listModels });
|
||||
const workspaces = (workspacesQuery.data ?? []).filter(workspace => workspace.revision);
|
||||
const models = modelsQuery.data?.models ?? [];
|
||||
const defaultWorkspace = settings.data?.workspace;
|
||||
useEffect(() => {
|
||||
if (!settings.data || !workspacesQuery.isSuccess) return;
|
||||
workspacePreferences.migrate({ workspaceId: defaultWorkspace, provider: settings.data.provider,
|
||||
model: settings.data.model, thinking: settings.data.thinking });
|
||||
}, [settings.data, workspacesQuery.isSuccess, defaultWorkspace]);
|
||||
const workspaceId = preferences.workspaceId ?? defaultWorkspace ?? "";
|
||||
const modelId = preferences.provider && preferences.model ? `${preferences.provider}/${preferences.model}`
|
||||
: settings.data?.provider && settings.data.model ? `${settings.data.provider}/${settings.data.model}` : "";
|
||||
const workspace = workspaces.find(item => item.id === workspaceId);
|
||||
const model = models.find(item => `${item.provider}/${item.id}` === modelId);
|
||||
const loading = settings.isPending || workspacesQuery.isPending || modelsQuery.isPending;
|
||||
const error = settings.isError || workspacesQuery.isError || modelsQuery.isError;
|
||||
// A failed background refresh must not unmount an editor and destroy its unsaved draft.
|
||||
// Initial failures still fail closed: no validated catalog/settings means no ready context.
|
||||
const ready = !loading && !!settings.data && !!workspace && !!model;
|
||||
useEffect(() => {
|
||||
if (!workspaceId) workspacePolicyGate.clear();
|
||||
else if (workspacesQuery.isPending) workspacePolicyGate.beginSummary(workspaceId);
|
||||
else if (!workspace) workspacePolicyGate.rejectSummary(workspaceId);
|
||||
else { workspacePolicyGate.select(workspaceId); workspacePolicyGate.resolve(workspaceId); }
|
||||
}, [workspaceId, workspace, workspacesQuery.isPending, workspacesQuery.isError]);
|
||||
return { preferences, workspaces, models, workspaceId, modelId, workspace, model, loading, error, ready,
|
||||
origins: workspacePreferences.origins(),
|
||||
retry: () => { void settings.refetch(); void workspacesQuery.refetch(); void modelsQuery.refetch(); } };
|
||||
}
|
||||
|
||||
export interface WorkingContextScope { workspaceId: string; ready: boolean; locked: boolean; administrationBusy?: boolean }
|
||||
export const WorkingContextContext = createContext<WorkingContextScope | null>(null);
|
||||
export const WorkingContextProvider = WorkingContextContext.Provider;
|
||||
// The optional scope keeps independently rendered management pages usable in their existing tests/tools.
|
||||
export function useWorkingContextScope() { return useContext(WorkingContextContext); }
|
||||
@@ -1,11 +1,40 @@
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { workspacePreferences } from "./preferences";
|
||||
import { clearAuthState, setAuthState } from "../auth/authState";
|
||||
|
||||
const userA = {
|
||||
issuer: "local", subject: "preference-user-a", roles: ["user"] as const,
|
||||
permissions: ["session.use"], isAdmin: false, csrfToken: "a".repeat(43), session: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
clearAuthState();
|
||||
workspacePreferences.reset();
|
||||
});
|
||||
|
||||
test("remembers only explicit global model choices per user, independently of workspace", () => {
|
||||
setAuthState(userA);
|
||||
workspacePreferences.migrate({ workspaceId: "one", provider: "zai", model: "glm-5.3" });
|
||||
expect(localStorage.length).toBe(0);
|
||||
workspacePreferences.selectModel("local/qwen");
|
||||
workspacePreferences.save({ ...workspacePreferences.load(), workspaceId: "two" });
|
||||
workspacePreferences.reset();
|
||||
expect(workspacePreferences.load()).toEqual({ provider: "local", model: "qwen" });
|
||||
setAuthState({ ...userA, subject: "preference-user-b" });
|
||||
expect(workspacePreferences.load()).toEqual({});
|
||||
workspacePreferences.migrate({ provider: "zai", model: "glm-5.3" });
|
||||
setAuthState(userA);
|
||||
expect(workspacePreferences.load()).toEqual({ provider: "local", model: "qwen" });
|
||||
});
|
||||
|
||||
test("Admin-first initialization can later receive workspace defaults without replacing its model", () => {
|
||||
workspacePreferences.migrate({ provider: "local", model: "qwen" });
|
||||
workspacePreferences.selectModel("zai/glm-5.3");
|
||||
expect(workspacePreferences.migrate({ workspaceId: "psd", provider: "local", model: "qwen", thinking: "medium" }))
|
||||
.toEqual({ workspaceId: "psd", provider: "zai", model: "glm-5.3", thinking: "medium" });
|
||||
});
|
||||
|
||||
test("keeps selections only in current application memory", () => {
|
||||
localStorage.setItem("unrelated", "keep-me");
|
||||
workspacePreferences.save({
|
||||
@@ -50,3 +79,34 @@ test("notifies shell consumers when the selected workspace changes", () => {
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener).toHaveBeenLastCalledWith({ workspaceId: "catalog-two" });
|
||||
});
|
||||
|
||||
test("remembers workspace and model independently, ahead of defaults, per principal", () => {
|
||||
setAuthState(userA);
|
||||
workspacePreferences.migrate({ workspaceId: "default", provider: "zai", model: "glm-5.3" });
|
||||
expect(workspacePreferences.origins()).toEqual({ workspace: "Installation default", model: "Installation default" });
|
||||
workspacePreferences.selectWorkspace("research");
|
||||
workspacePreferences.selectModel("deepseek/deepseek-v4-pro");
|
||||
workspacePreferences.selectWorkspace("clinical");
|
||||
workspacePreferences.reset();
|
||||
expect(workspacePreferences.migrate({ workspaceId: "default", provider: "zai", model: "glm-5.3" }))
|
||||
.toMatchObject({ workspaceId: "clinical", provider: "deepseek", model: "deepseek-v4-pro" });
|
||||
expect(workspacePreferences.origins()).toEqual({ workspace: "Last choice", model: "Last choice" });
|
||||
setAuthState({ ...userA, subject: "another-user" });
|
||||
expect(workspacePreferences.load()).toEqual({});
|
||||
});
|
||||
|
||||
test("isolates remembered context between application mounts", () => {
|
||||
setAuthState(userA);
|
||||
const original = location.href;
|
||||
try {
|
||||
history.replaceState(null, "", "/portal-one");
|
||||
workspacePreferences.selectWorkspace("research");
|
||||
workspacePreferences.selectModel("local/qwen");
|
||||
history.replaceState(null, "", "/portal-two");
|
||||
workspacePreferences.reset();
|
||||
expect(workspacePreferences.load()).toEqual({});
|
||||
history.replaceState(null, "", "/portal-one");
|
||||
workspacePreferences.reset();
|
||||
expect(workspacePreferences.load()).toEqual({ workspaceId: "research", provider: "local", model: "qwen" });
|
||||
} finally { history.replaceState(null, "", original); }
|
||||
});
|
||||
|
||||
@@ -8,19 +8,52 @@ export interface WorkspacePreference {
|
||||
let current: WorkspacePreference | undefined;
|
||||
const listeners = new Set<(value: WorkspacePreference) => void>();
|
||||
|
||||
function modelStorageKey(): string | undefined {
|
||||
const user = getAuthState();
|
||||
// localStorage is already origin-scoped. Include application mount and principal, not
|
||||
// workspace: selecting another workspace must never select another LLM.
|
||||
return user ? `thothii:interaction-model:v1:${location.pathname}:${JSON.stringify([user.issuer, user.subject])}` : undefined;
|
||||
}
|
||||
|
||||
function rememberedModel(): Partial<WorkspacePreference> {
|
||||
try {
|
||||
const key = modelStorageKey();
|
||||
const id: unknown = key ? JSON.parse(localStorage.getItem(key) ?? "null") : null;
|
||||
if (typeof id !== "string" || !/^[a-z][a-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(id)) return {};
|
||||
const slash = id.indexOf("/");
|
||||
return { provider: id.slice(0, slash), model: id.slice(slash + 1) };
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function workspaceStorageKey(): string | undefined {
|
||||
return modelStorageKey()?.replace("interaction-model:v1", "workspace:v1");
|
||||
}
|
||||
function rememberedWorkspace(): Partial<WorkspacePreference> {
|
||||
try {
|
||||
const key = workspaceStorageKey();
|
||||
const id: unknown = key ? JSON.parse(localStorage.getItem(key) ?? "null") : null;
|
||||
return typeof id === "string" && id.length > 0 && id.length <= 256 ? { workspaceId: id } : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
subscribeAuthState(() => {
|
||||
current = undefined;
|
||||
notify();
|
||||
});
|
||||
|
||||
function snapshot(value: WorkspacePreference | undefined): WorkspacePreference {
|
||||
return value === undefined ? {} : { ...value };
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
const value = snapshot(current);
|
||||
const value = { ...rememberedWorkspace(), ...rememberedModel(), ...snapshot(current) };
|
||||
for (const listener of listeners) listener(value);
|
||||
}
|
||||
|
||||
/** Ephemeral workspace choices for the current application process. */
|
||||
/** Shared context: explicit workspace/model choices are independently remembered per user/mount. */
|
||||
export const workspacePreferences = {
|
||||
load(): WorkspacePreference {
|
||||
return snapshot(current);
|
||||
return { ...rememberedWorkspace(), ...rememberedModel(), ...snapshot(current) };
|
||||
},
|
||||
|
||||
save(value: WorkspacePreference): WorkspacePreference {
|
||||
@@ -29,10 +62,39 @@ export const workspacePreferences = {
|
||||
return snapshot(current);
|
||||
},
|
||||
|
||||
/** Seed current memory from backend installation settings only before the first local choice. */
|
||||
/** The only explicit operational LLM choice, shared by Core and Administration. */
|
||||
selectModel(id: string): WorkspacePreference {
|
||||
const slash = id.indexOf("/");
|
||||
if (slash <= 0 || slash === id.length - 1) throw new Error("Invalid model identity");
|
||||
const value = { ...this.load(), provider: id.slice(0, slash), model: id.slice(slash + 1) };
|
||||
try {
|
||||
const key = modelStorageKey();
|
||||
if (key) localStorage.setItem(key, JSON.stringify(id));
|
||||
} catch { /* Keep this visit usable when browser storage is disabled. */ }
|
||||
return this.save(value);
|
||||
},
|
||||
|
||||
selectWorkspace(workspaceId: string): WorkspacePreference {
|
||||
try {
|
||||
const key = workspaceStorageKey();
|
||||
if (key) localStorage.setItem(key, JSON.stringify(workspaceId));
|
||||
} catch { /* Preserve this visit's selection even without browser storage. */ }
|
||||
return this.save({ ...this.load(), workspaceId });
|
||||
},
|
||||
|
||||
origins() {
|
||||
return {
|
||||
workspace: rememberedWorkspace().workspaceId ? "Last choice" : "Installation default",
|
||||
model: rememberedModel().model ? "Last choice" : "Installation default",
|
||||
};
|
||||
},
|
||||
|
||||
/** Fill missing context fields without overwriting explicit choices, including Admin-first entry. */
|
||||
migrate(settings: WorkspacePreference): WorkspacePreference {
|
||||
if (current === undefined) {
|
||||
current = { ...settings };
|
||||
const selected = this.load();
|
||||
const next = { ...settings, ...selected };
|
||||
if (JSON.stringify(current) !== JSON.stringify(next)) {
|
||||
current = next;
|
||||
notify();
|
||||
}
|
||||
return snapshot(current);
|
||||
@@ -49,3 +111,4 @@ export const workspacePreferences = {
|
||||
notify();
|
||||
},
|
||||
};
|
||||
import { getAuthState, subscribeAuthState } from "../auth/authState";
|
||||
|
||||
@@ -125,6 +125,9 @@ func Load(path string) (Installation, error) {
|
||||
if err := ensureOnlyOneDocument(decoder); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
if err := raw.ModelCatalog.NormalizeDefaults(); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
|
||||
if raw.SchemaVersion != 2 {
|
||||
return Installation{}, errors.New("migration_required: installation schemaVersion must be 2")
|
||||
|
||||
@@ -19,8 +19,8 @@ func TestLoadAcceptsInstallationModelCatalog(t *testing.T) {
|
||||
if installation.SchemaVersion != 2 {
|
||||
t.Fatalf("SchemaVersion = %d, want 2", installation.SchemaVersion)
|
||||
}
|
||||
if installation.ModelCatalog.Defaults.Session != "zai/glm-5.3" {
|
||||
t.Fatalf("session default = %q", installation.ModelCatalog.Defaults.Session)
|
||||
if installation.ModelCatalog.Defaults.Interaction != "zai/glm-5.3" {
|
||||
t.Fatalf("session default = %q", installation.ModelCatalog.Defaults.Interaction)
|
||||
}
|
||||
models := installation.ModelCatalog.RuntimeModels()
|
||||
if len(models) != 2 || models[0].ID != "local/qwen" || models[1].ID != "zai/glm-5.3" {
|
||||
@@ -28,6 +28,43 @@ func TestLoadAcceptsInstallationModelCatalog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsSharedDeepSeekBuiltinWithBundleAuthentication(t *testing.T) {
|
||||
path, _, envFile, _ := writeInstallation(t, "local")
|
||||
catalog := strings.Replace(validModelCatalogYAML(), "interaction: zai/glm-5.3", "interaction: deepseek/deepseek-v4-pro", 1)
|
||||
catalog += ` deepseek:
|
||||
authentication:
|
||||
mode: secret_env
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
session:
|
||||
mode: pi_builtin
|
||||
metadataGeneration:
|
||||
litellmProvider: deepseek
|
||||
models:
|
||||
deepseek-v4-pro:
|
||||
session: {}
|
||||
metadataGeneration: {}
|
||||
deepseek-v4-flash:
|
||||
session: {}
|
||||
metadataGeneration: {}
|
||||
`
|
||||
rewriteInstallationCatalog(t, path, catalog)
|
||||
secretPath := filepath.Join(filepath.Dir(path), "secrets.env")
|
||||
if err := os.WriteFile(secretPath, []byte("DEEPSEEK_API_KEY=shared-test-key\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(envFile, []byte("THT_AUTH_CONFIG_ROOT="+filepath.Join(filepath.Dir(path), "auth")+"\nTHT_INSTALLATION_CONFIG_SOURCE="+path+"\nTHT_SECRETS_FILE="+secretPath+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
i, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := i.ModelCatalog.Providers["deepseek"]
|
||||
if p.Authentication.APIKeyEnv != "DEEPSEEK_API_KEY" || p.Session.Mode != "pi_builtin" || p.MetadataGeneration.LiteLLMProvider != "deepseek" || len(p.Models) != 2 {
|
||||
t.Fatalf("unexpected shared DeepSeek provider: %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsLegacyMetadataGenerationWithMigrationRequired(t *testing.T) {
|
||||
path, _, _, _ := writeInstallation(t, "local")
|
||||
contents, err := os.ReadFile(path)
|
||||
@@ -70,8 +107,8 @@ func TestLoadRejectsInvalidModelCatalogDefaultsAndAuthentication(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "unknown session default",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), "session: zai/glm-5.3", "session: zai/missing", 1),
|
||||
wantError: "defaults.session",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), "interaction: zai/glm-5.3", "interaction: zai/missing", 1),
|
||||
wantError: "defaults.interaction",
|
||||
},
|
||||
{
|
||||
name: "embedding must use the installation Ollama service",
|
||||
@@ -86,9 +123,9 @@ func TestLoadRejectsInvalidModelCatalogDefaultsAndAuthentication(t *testing.T) {
|
||||
wantError: "same upstreamModel",
|
||||
},
|
||||
{
|
||||
name: "metadata default required",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), " metadataGeneration: local/qwen\n", "", 1),
|
||||
wantError: "defaults.metadataGeneration",
|
||||
name: "interaction default required",
|
||||
catalog: strings.Replace(validModelCatalogYAML(), " interaction: zai/glm-5.3\n", "", 1),
|
||||
wantError: "defaults.interaction",
|
||||
},
|
||||
{
|
||||
name: "pi auth cannot serve metadata",
|
||||
@@ -145,8 +182,7 @@ func rewriteInstallationCatalog(t *testing.T, path, catalog string) {
|
||||
func validModelCatalogYAML() string {
|
||||
return `modelCatalog:
|
||||
defaults:
|
||||
session: zai/glm-5.3
|
||||
metadataGeneration: local/qwen
|
||||
interaction: zai/glm-5.3
|
||||
embedding:
|
||||
id: ollama/qwen3-embedding:0.6b
|
||||
dimensions: 1024
|
||||
@@ -158,8 +194,11 @@ func validModelCatalogYAML() string {
|
||||
mode: none
|
||||
session:
|
||||
mode: openai_compatible
|
||||
metadataGeneration:
|
||||
litellmProvider: openai
|
||||
models:
|
||||
glm-5.3:
|
||||
metadataGeneration: {}
|
||||
session:
|
||||
reasoning: true
|
||||
contextWindow: 200000
|
||||
@@ -182,3 +221,48 @@ func validModelCatalogYAML() string {
|
||||
disableThinking: true
|
||||
`
|
||||
}
|
||||
|
||||
func TestInteractionDefaultLegacyMigration(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, defaults string
|
||||
wantError bool
|
||||
}{
|
||||
{"equal legacy defaults", " session: zai/glm-5.3\n metadataGeneration: zai/glm-5.3\n", false},
|
||||
{"divergent legacy defaults", " session: zai/glm-5.3\n metadataGeneration: local/qwen\n", true},
|
||||
{"mixed new and legacy fields", " interaction: zai/glm-5.3\n session: zai/glm-5.3\n", true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path, _, _, _ := writeInstallation(t, "local")
|
||||
rewriteInstallationCatalog(t, path, strings.Replace(validModelCatalogYAML(), " interaction: zai/glm-5.3\n", test.defaults, 1))
|
||||
before, _ := os.ReadFile(path)
|
||||
installation, err := Load(path)
|
||||
if test.wantError {
|
||||
if err == nil || !strings.Contains(err.Error(), "migration_required") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installation.ModelCatalog.Defaults.Interaction != "zai/glm-5.3" || installation.ModelCatalog.Defaults.Session != "" || installation.ModelCatalog.Defaults.MetadataGeneration != "" {
|
||||
t.Fatalf("defaults were not normalized: %#v", installation.ModelCatalog.Defaults)
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("Load rewrote the operator configuration")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractionDefaultMustSupportBothConfiguredUses(t *testing.T) {
|
||||
path, _, _, _ := writeInstallation(t, "local")
|
||||
// Keep Admin configured for local/qwen but remove that use from the selected default.
|
||||
catalog := strings.Replace(validModelCatalogYAML(), " metadataGeneration:\n litellmProvider: openai\n", "", 1)
|
||||
catalog = strings.Replace(catalog, " metadataGeneration: {}\n", "", 1)
|
||||
rewriteInstallationCatalog(t, path, catalog)
|
||||
if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "defaults.interaction") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,26 @@ type ModelCatalog struct {
|
||||
}
|
||||
|
||||
type ModelCatalogDefaults struct {
|
||||
Session string `yaml:"session" json:"session"`
|
||||
MetadataGeneration string `yaml:"metadataGeneration,omitempty" json:"metadataGeneration,omitempty"`
|
||||
Interaction string `yaml:"interaction" json:"interaction"`
|
||||
// Legacy input only. NormalizeDefaults clears these before projections or serialization.
|
||||
Session string `yaml:"session,omitempty" json:"-"`
|
||||
MetadataGeneration string `yaml:"metadataGeneration,omitempty" json:"-"`
|
||||
}
|
||||
|
||||
// NormalizeDefaults accepts unambiguous legacy configuration without rewriting the source file.
|
||||
func (c *ModelCatalog) NormalizeDefaults() error {
|
||||
d := &c.Defaults
|
||||
if d.Interaction != "" && (d.Session != "" || d.MetadataGeneration != "") {
|
||||
return errors.New("migration_required: use only modelCatalog.defaults.interaction, not legacy defaults")
|
||||
}
|
||||
if d.Interaction == "" {
|
||||
if d.Session != "" && d.MetadataGeneration != "" && d.Session != d.MetadataGeneration {
|
||||
return errors.New("migration_required: session and metadataGeneration defaults differ; explicitly choose modelCatalog.defaults.interaction and remove both legacy defaults")
|
||||
}
|
||||
d.Interaction = d.Session
|
||||
}
|
||||
d.Session, d.MetadataGeneration = "", ""
|
||||
return nil
|
||||
}
|
||||
|
||||
type ModelCatalogEmbedding struct {
|
||||
@@ -138,8 +156,8 @@ func (c ModelCatalog) RuntimeModels() []RuntimeModel {
|
||||
|
||||
// Validate rejects ambiguous, duplicated, or runtime-incompatible catalog declarations.
|
||||
func (c ModelCatalog) Validate(environment map[string]string) error {
|
||||
if c.Defaults.Session == "" {
|
||||
return errors.New("modelCatalog.defaults.session is required")
|
||||
if c.Defaults.Interaction == "" {
|
||||
return errors.New("modelCatalog.defaults.interaction is required")
|
||||
}
|
||||
if !catalogModelIDPattern.MatchString(c.Embedding.ID) || !strings.HasPrefix(c.Embedding.ID, "ollama/") || c.Embedding.Dimensions <= 0 {
|
||||
return errors.New("modelCatalog.embedding requires an ollama canonical id and positive dimensions")
|
||||
@@ -206,17 +224,9 @@ func (c ModelCatalog) Validate(environment map[string]string) error {
|
||||
}
|
||||
|
||||
models := c.RuntimeModels()
|
||||
if !runtimeModelEligible(models, c.Defaults.Session, "session") {
|
||||
return fmt.Errorf("modelCatalog.defaults.session %q is not a session model", c.Defaults.Session)
|
||||
}
|
||||
if hasMetadata && c.Defaults.MetadataGeneration == "" {
|
||||
return errors.New("modelCatalog.defaults.metadataGeneration is required when metadata models are configured")
|
||||
}
|
||||
if !hasMetadata && c.Defaults.MetadataGeneration != "" {
|
||||
return errors.New("modelCatalog.defaults.metadataGeneration must be empty when metadata generation is unavailable")
|
||||
}
|
||||
if hasMetadata && !runtimeModelEligible(models, c.Defaults.MetadataGeneration, "metadata") {
|
||||
return fmt.Errorf("modelCatalog.defaults.metadataGeneration %q is not a metadata-generation model", c.Defaults.MetadataGeneration)
|
||||
if !runtimeModelEligible(models, c.Defaults.Interaction, "session") ||
|
||||
(hasMetadata && !runtimeModelEligible(models, c.Defaults.Interaction, "metadata")) {
|
||||
return fmt.Errorf("modelCatalog.defaults.interaction %q must support Core and every configured Admin LLM use", c.Defaults.Interaction)
|
||||
}
|
||||
return validateCatalogSecrets(environment, secretsNeeded)
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func Run(request Request) error {
|
||||
|
||||
func reconcileCatalog(legacy legacyDescriptor, piModels piCatalog, settings piSettings, request Request) (config.ModelCatalog, error) {
|
||||
catalog := config.ModelCatalog{
|
||||
Defaults: config.ModelCatalogDefaults{Session: request.SessionDefault},
|
||||
Defaults: config.ModelCatalogDefaults{Interaction: request.SessionDefault},
|
||||
Embedding: config.ModelCatalogEmbedding{ID: request.EmbeddingID, Dimensions: request.EmbeddingDimensions},
|
||||
Providers: make(map[string]config.ModelProvider),
|
||||
}
|
||||
@@ -251,7 +251,9 @@ func reconcileCatalog(legacy legacyDescriptor, piModels piCatalog, settings piSe
|
||||
if !ok {
|
||||
return catalog, errors.New("metadataGeneration.default does not identify one legacy model")
|
||||
}
|
||||
catalog.Defaults.MetadataGeneration = mapped
|
||||
if mapped != request.SessionDefault {
|
||||
return catalog, errors.New("migration_required: legacy metadata default differs from the requested interaction model; align the legacy default explicitly before migration")
|
||||
}
|
||||
} else if legacy.MetadataGeneration.Default != "" {
|
||||
return catalog, errors.New("metadataGeneration.default is set without models")
|
||||
}
|
||||
|
||||
@@ -35,10 +35,10 @@ func TestRunEmitsValidatedCandidateWithoutChangingLegacyInputs(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("candidate config.Load() error = %v", err)
|
||||
}
|
||||
if candidate.SchemaVersion != 2 || candidate.ModelCatalog.Defaults.Session != "zai/glm-5.3" {
|
||||
if candidate.SchemaVersion != 2 || candidate.ModelCatalog.Defaults.Interaction != "zai/glm-5.3" {
|
||||
t.Fatalf("candidate = %#v", candidate.ModelCatalog)
|
||||
}
|
||||
if got := candidate.ModelCatalog.Defaults.MetadataGeneration; got != "zai/glm-5.3" {
|
||||
if got := candidate.ModelCatalog.Defaults.Interaction; got != "zai/glm-5.3" {
|
||||
t.Fatalf("metadata default = %q", got)
|
||||
}
|
||||
if candidate.ModelCatalog.Providers["deepseek"].Session.Mode != "pi_builtin" {
|
||||
|
||||
@@ -24,11 +24,10 @@ const (
|
||||
var renameProjectionDirectory = os.Rename
|
||||
|
||||
type runtimeCatalog struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
DefaultSession string `json:"defaultSession"`
|
||||
DefaultMetadataGeneration string `json:"defaultMetadataGeneration,omitempty"`
|
||||
Embedding config.ModelCatalogEmbedding `json:"embedding"`
|
||||
Models []runtimeCatalogModel `json:"models"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
DefaultInteraction string `json:"defaultInteraction"`
|
||||
Embedding config.ModelCatalogEmbedding `json:"embedding"`
|
||||
Models []runtimeCatalogModel `json:"models"`
|
||||
}
|
||||
|
||||
type runtimeCatalogModel struct {
|
||||
@@ -74,19 +73,33 @@ type piSettings struct {
|
||||
|
||||
// Render builds every projection in memory so no partial candidate can be published.
|
||||
func Render(installation config.Installation) (map[string][]byte, error) {
|
||||
if err := installation.ModelCatalog.NormalizeDefaults(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
models := installation.ModelCatalog.RuntimeModels()
|
||||
hasMetadata := false
|
||||
for _, model := range models {
|
||||
hasMetadata = hasMetadata || model.MetadataGeneration != nil
|
||||
}
|
||||
runtimeModels := make([]runtimeCatalogModel, 0, len(models))
|
||||
customProviders := make(map[string]piProvider)
|
||||
enabled := make([]string, 0)
|
||||
for _, model := range models {
|
||||
sessionAdapter, metadataAdapter := model.SessionAdapter, model.MetadataAdapter
|
||||
if model.Session == nil {
|
||||
sessionAdapter = nil
|
||||
}
|
||||
if model.MetadataGeneration == nil {
|
||||
metadataAdapter = nil
|
||||
}
|
||||
runtimeModels = append(runtimeModels, runtimeCatalogModel{
|
||||
ID: model.ID, Provider: model.Provider, Model: model.Model, Label: model.Label,
|
||||
UpstreamModel: model.UpstreamModel, Endpoint: model.Endpoint,
|
||||
Authentication: model.Authentication, SessionAdapter: model.SessionAdapter,
|
||||
MetadataAdapter: model.MetadataAdapter, Session: model.Session,
|
||||
Authentication: model.Authentication, SessionAdapter: sessionAdapter,
|
||||
MetadataAdapter: metadataAdapter, Session: model.Session,
|
||||
MetadataGeneration: model.MetadataGeneration,
|
||||
})
|
||||
if model.Session == nil {
|
||||
if model.Session == nil || (hasMetadata && model.MetadataGeneration == nil) {
|
||||
continue
|
||||
}
|
||||
if model.SessionAdapter == nil || model.SessionAdapter.Mode != "openai_compatible" {
|
||||
@@ -117,9 +130,8 @@ func Render(installation config.Installation) (map[string][]byte, error) {
|
||||
sort.Strings(enabled)
|
||||
|
||||
catalogBytes, err := marshalJSON(runtimeCatalog{
|
||||
SchemaVersion: 1, DefaultSession: installation.ModelCatalog.Defaults.Session,
|
||||
DefaultMetadataGeneration: installation.ModelCatalog.Defaults.MetadataGeneration,
|
||||
Embedding: installation.ModelCatalog.Embedding, Models: runtimeModels,
|
||||
SchemaVersion: 2, DefaultInteraction: installation.ModelCatalog.Defaults.Interaction,
|
||||
Embedding: installation.ModelCatalog.Embedding, Models: runtimeModels,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render runtime model catalog: %w", err)
|
||||
@@ -294,7 +306,7 @@ func renderCompose(installation config.Installation, fingerprint string) []byte
|
||||
embedding-model-init:
|
||||
environment:
|
||||
OLLAMA_MODEL: %s
|
||||
`, strconv.Quote(fingerprint), strconv.Quote(installation.ModelCatalog.Defaults.Session),
|
||||
`, strconv.Quote(fingerprint), strconv.Quote(installation.ModelCatalog.Defaults.Interaction),
|
||||
strconv.Quote(embedding.ID), strconv.Quote(embeddingModelName(embedding.ID)), strconv.Quote(strconv.Itoa(embedding.Dimensions)),
|
||||
strconv.Quote(installation.GeneratedModelCatalogPath()), strconv.Quote(installation.GeneratedPiModelsPath()),
|
||||
strconv.Quote(installation.GeneratedPiSettingsPath()), strconv.Quote(embedding.ID),
|
||||
|
||||
@@ -2,6 +2,7 @@ package modelprojection
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -32,7 +33,7 @@ func TestRenderProducesDeterministicCatalogPiAndComposeProjections(t *testing.T)
|
||||
}
|
||||
|
||||
catalog := string(first[CatalogFile])
|
||||
for _, expected := range []string{`"id": "local/qwen"`, `"defaultSession": "zai/glm-5.3"`, `"embedding"`} {
|
||||
for _, expected := range []string{`"id": "local/qwen"`, `"defaultInteraction": "zai/glm-5.3"`, `"embedding"`} {
|
||||
if !strings.Contains(catalog, expected) {
|
||||
t.Fatalf("catalog projection missing %s:\n%s", expected, catalog)
|
||||
}
|
||||
@@ -42,8 +43,8 @@ func TestRenderProducesDeterministicCatalogPiAndComposeProjections(t *testing.T)
|
||||
t.Fatalf("Pi models projection must contain only custom providers:\n%s", piModels)
|
||||
}
|
||||
settings := string(first[PiSettingsFile])
|
||||
if !strings.Contains(settings, `"deepseek/deepseek-v4-pro"`) || !strings.Contains(settings, `"local/qwen2.5:7b"`) {
|
||||
t.Fatalf("Pi settings projection missing enabled session models:\n%s", settings)
|
||||
if strings.Contains(settings, `"deepseek/deepseek-v4-pro"`) || !strings.Contains(settings, `"local/qwen2.5:7b"`) {
|
||||
t.Fatalf("Pi settings must enable only shared Core/Admin models:\n%s", settings)
|
||||
}
|
||||
if !strings.Contains(piModels, `"id": "qwen2.5:7b"`) || !strings.Contains(catalog, `"id": "local/qwen"`) {
|
||||
t.Fatalf("Pi projection must map canonical catalog keys to upstream model IDs")
|
||||
@@ -56,6 +57,69 @@ func TestRenderProducesDeterministicCatalogPiAndComposeProjections(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepSeekBuiltinAndMetadataShareCatalogIdentityAndSecretReference(t *testing.T) {
|
||||
i := projectionFixture(t)
|
||||
p := i.ModelCatalog.Providers["deepseek"]
|
||||
p.Authentication = config.ModelAuthentication{Mode: "secret_env", APIKeyEnv: "DEEPSEEK_API_KEY"}
|
||||
p.MetadataGeneration = &config.ModelMetadataAdapter{LiteLLMProvider: "deepseek"}
|
||||
for _, id := range []string{"deepseek-v4-pro", "deepseek-v4-flash"} {
|
||||
p.Models[id] = config.CatalogModel{Session: &config.SessionModel{}, MetadataGeneration: &config.MetadataGenerationModel{}}
|
||||
}
|
||||
i.ModelCatalog.Providers["deepseek"] = p
|
||||
i.ModelCatalog.Defaults.Interaction = "deepseek/deepseek-v4-pro"
|
||||
files, err := Render(i)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var catalog runtimeCatalog
|
||||
if err := json.Unmarshal(files[CatalogFile], &catalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
for _, model := range catalog.Models {
|
||||
if model.Provider != "deepseek" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if model.Authentication.APIKeyEnv != "DEEPSEEK_API_KEY" || model.SessionAdapter == nil || model.MetadataAdapter == nil {
|
||||
t.Fatalf("DeepSeek must share auth and both adapters: %#v", model)
|
||||
}
|
||||
if !bytes.Contains(files[PiSettingsFile], []byte(model.ID)) {
|
||||
t.Fatalf("shared model missing from Pi selection: %s", model.ID)
|
||||
}
|
||||
}
|
||||
if count != 2 || bytes.Contains(files[PiModelsFile], []byte("deepseek")) {
|
||||
t.Fatal("both DeepSeek models must use native Pi definitions, not duplicated custom definitions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectionKeepsSingleUseInventoryOutOfSharedPiSelection(t *testing.T) {
|
||||
i := projectionFixture(t)
|
||||
p := i.ModelCatalog.Providers["zai"]
|
||||
p.Models["core-only"] = config.CatalogModel{Session: &config.SessionModel{ContextWindow: 32768, MaxTokens: 8192}}
|
||||
p.Models["admin-only"] = config.CatalogModel{MetadataGeneration: &config.MetadataGenerationModel{}}
|
||||
i.ModelCatalog.Providers["zai"] = p
|
||||
files, err := Render(i)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var catalog runtimeCatalog
|
||||
if err := json.Unmarshal(files[CatalogFile], &catalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, model := range catalog.Models {
|
||||
if model.ID == "zai/core-only" && model.MetadataAdapter != nil {
|
||||
t.Fatal("projected an unused metadata adapter")
|
||||
}
|
||||
if model.ID == "zai/admin-only" && model.SessionAdapter != nil {
|
||||
t.Fatal("projected an unused session adapter")
|
||||
}
|
||||
}
|
||||
if bytes.Contains(files[PiSettingsFile], []byte("core-only")) || bytes.Contains(files[PiSettingsFile], []byte("admin-only")) {
|
||||
t.Fatal("single-use inventory leaked into global Pi selection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDoesNotReplaceUnchangedProjection(t *testing.T) {
|
||||
installation := projectionFixture(t)
|
||||
if err := Generate(installation); err != nil {
|
||||
@@ -167,11 +231,11 @@ func projectionFixture(t *testing.T) config.Installation {
|
||||
return config.Installation{
|
||||
Path: filepath.Join(root, "thothii-installation.yaml"),
|
||||
ModelCatalog: config.ModelCatalog{
|
||||
Defaults: config.ModelCatalogDefaults{Session: "zai/glm-5.3", MetadataGeneration: "local/qwen"},
|
||||
Defaults: config.ModelCatalogDefaults{Interaction: "zai/glm-5.3"},
|
||||
Embedding: config.ModelCatalogEmbedding{ID: "ollama/qwen3-embedding:0.6b", Dimensions: 1024},
|
||||
Providers: map[string]config.ModelProvider{
|
||||
"deepseek": {Authentication: config.ModelAuthentication{Mode: "pi_auth"}, Session: &config.ModelSessionAdapter{Mode: "pi_builtin"}, Models: map[string]config.CatalogModel{"deepseek-v4-pro": {Session: &config.SessionModel{}}}},
|
||||
"zai": {Endpoint: &config.ModelEndpoint{BaseURL: "https://api.z.ai/v1"}, Authentication: config.ModelAuthentication{Mode: "none"}, Session: &config.ModelSessionAdapter{Mode: "openai_compatible"}, Models: map[string]config.CatalogModel{"glm-5.3": {Label: "GLM-5.3", Session: &config.SessionModel{Reasoning: true, ContextWindow: 200000, MaxTokens: 131072}}}},
|
||||
"zai": {Endpoint: &config.ModelEndpoint{BaseURL: "https://api.z.ai/v1"}, Authentication: config.ModelAuthentication{Mode: "none"}, Session: &config.ModelSessionAdapter{Mode: "openai_compatible"}, MetadataGeneration: &config.ModelMetadataAdapter{LiteLLMProvider: "openai"}, Models: map[string]config.CatalogModel{"glm-5.3": {Label: "GLM-5.3", Session: &config.SessionModel{Reasoning: true, ContextWindow: 200000, MaxTokens: 131072}, MetadataGeneration: &config.MetadataGenerationModel{}}}},
|
||||
"local": {Endpoint: &config.ModelEndpoint{BaseURL: "http://ollama:11434/v1"}, Authentication: config.ModelAuthentication{Mode: "none"}, Session: &config.ModelSessionAdapter{Mode: "openai_compatible"}, MetadataGeneration: &config.ModelMetadataAdapter{LiteLLMProvider: "openai"}, Models: map[string]config.CatalogModel{"qwen": {UpstreamModel: "qwen2.5:7b", Session: &config.SessionModel{ContextWindow: 32768, MaxTokens: 8192}, MetadataGeneration: &config.MetadataGenerationModel{DisableThinking: true}}}},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -377,7 +377,7 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error)
|
||||
|
||||
func defaultModelCatalog() config.ModelCatalog {
|
||||
return config.ModelCatalog{
|
||||
Defaults: config.ModelCatalogDefaults{Session: "deepseek/deepseek-v4-pro"},
|
||||
Defaults: config.ModelCatalogDefaults{Interaction: "deepseek/deepseek-v4-pro"},
|
||||
Embedding: config.ModelCatalogEmbedding{ID: "ollama/qwen3-embedding:0.6b", Dimensions: 1024},
|
||||
Providers: map[string]config.ModelProvider{
|
||||
"deepseek": {
|
||||
|
||||
Reference in New Issue
Block a user