diff --git a/AGENTS.md b/AGENTS.md index 8984399c..be29bc0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,8 +98,10 @@ frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness → - **`tht`'s `-c`/`--config` is a PER-COMMAND option** — it must follow the subcommand, never precede it (`ThtRunner.buildArgv` enforces this; prepending caused live 500s). - **`--json` output must be pristine** (only valid JSON on stdout) — used as a machine contract. -- **UI strings are English; document *content* stays the workspace language** (Italian for - `psd`) because it's the real data. Only chrome/labels are English. +- **Localization:** deterministic UI uses the EN/IT catalogs with English fallback; + model interaction uses the session manifest's immutable `interaction_language`. + Workspace content, SQL, and identifiers remain unchanged. For shell modes, portal + integration, or translations, read `docs/operations/shell-and-localization.md`. - **Workspace schema v4** defines workspace identity and optional Evidence only. PostgreSQL Metadata Catalog owns database identity, binding, schema, descriptions, sensitivity, and relationships; embedding/model facts come from the installation catalog. The legacy `harness/workspaces/*.yaml` runtime snapshots still use diff --git a/CONTEXT.md b/CONTEXT.md index 826e80cf..bf82e195 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -595,6 +595,35 @@ essere ripristinabile con refresh e cronologia e non contiene valori transitori un portale host. Conserva la propria gerarchia funzionale, ma deve rispettare la geometria, l'autenticazione e le regole responsive del portale host. +**Full Thoth Shell** — L'esperienza Thoth autonoma che possiede il proprio header e il proprio +layout di pagina. Non replica la navigazione amministrativa del portale host e non dipende dal suo +template visuale. + +**Shell mode** — La scelta di installazione fra `embedded` e `full`. Determina chi possiede il +chrome globale, i comandi di identità e le integrazioni visuali, ma non cambia il workflow o la +persistenza delle sessioni. + +**Fullscreen state** — Lo stato temporaneo in cui il documento applicativo occupa il fullscreen +del browser. È distinto da `Shell mode`: una Full Thoth Shell può essere aperta senza fullscreen; +il passaggio è attivato da un comando esplicito e può essere annullato con la stessa azione o con +il comando nativo del browser. + +**Portal Shell Adapter** — Il confine sostituibile che traduce lo stato e i comandi del chrome di +un portale host nel modello semantico usato da Thoth. L'adapter non possiede autorizzazione, +sessioni di workflow o contenuti del modello. + +**Host Shell State** — Il minimo stato visuale fornito dal portale host: locale UI, tema e stato +fullscreen. In una Embedded Thoth Shell è la fonte autorevole per queste preferenze; +non include identità, token o stato di autenticazione, che restano responsabilità dell'accesso. + +**UI locale** — La lingua delle label, dei messaggi, dei tooltip, degli stati e delle istruzioni +non generate dal modello nell'interfaccia Thoth. È distinta dalla lingua dei contenuti di un +workspace. + +**Interaction language** — La lingua in cui il modello presenta domande, spiegazioni e proposte +al revisore durante una sessione. Viene fissata alla creazione della sessione e rimane invariata +durante una ripresa, anche se la UI locale corrente cambia. + **Administrative Page Family** — L'insieme delle cinque Administration Page che condividono shell, navigazione, tipografia e regole responsive, pur mantenendo contenuti e operazioni specifici: Workspace, Evidence, Memory, Database e Pi. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index be947785..6c6d3a95 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # ThothII — Project State -Last updated: 2026-09-12. +Last updated: 2026-09-13. 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/`, @@ -12,6 +12,18 @@ Authentik, internal catalog/embedding services, and the PSD workspace repository `docs/operations/server-upgrade-gitea-workspace-v2.md`. Treat its operator gates and rollback requirements as mandatory; do not replace the running server stack in place. +## Full/embedded shell and bilingual interface + +Full/embedded shell, EN/IT UI, immutable session interaction language, dark theme, +fullscreen and full-mode logout are implemented. The local Mac descriptor explicitly +sets `shell.mode: full` and `shell.defaultLocale: en`; the native `tht` and local +core/frontend images were updated on 2026-09-13. Omics remains embedded and server +verified, through the replaceable presentation-only PortalAdapter. Its changes are +in local Omics commit `95154e1`; no production deployment or push was performed. +See `docs/operations/shell-and-localization.md` for integration and installation +instructions and `docs/reports/2026-09-13-full-shell-implementation.md` for tests, +independent reviews, local browser checks and rollback details. + ## Current product shape Gitea #28–#31 follow-up is implemented in this checkout: Workspace's four tabs, diff --git a/backend/src/pi/pi-process-manager.ts b/backend/src/pi/pi-process-manager.ts index 5712bcd6..ed370e4d 100644 --- a/backend/src/pi/pi-process-manager.ts +++ b/backend/src/pi/pi-process-manager.ts @@ -25,6 +25,7 @@ export interface SessionRuntime { } export interface RuntimeOptions { + interactionLanguage?: string | null; provider?: string; model?: string; thinking?: string; @@ -48,6 +49,7 @@ export class PiProcessManager { private spawnFn: ( sessionId: string, author: string, provider: string | undefined, model: string | undefined, principal?: PrincipalContext, runtimeConfigPath?: string, + interactionLanguage?: string | null, ) => ChildProcessWithoutNullStreams; private loadAuthProviders: (agentDir: string) => ReadonlySet; private modelCatalog: RuntimeModelCatalog; @@ -67,11 +69,11 @@ export class PiProcessManager { this.loadAuthProviders = opts?.authProviders ?? ((agentDir) => loadPiAuthProviders({ agentDir })); if (opts?.spawnFn) { - this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath) => - this.spawnPi(opts.spawnFn!, sessionId, author, provider, model, principal, runtimeConfigPath); + this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath, language) => + this.spawnPi(opts.spawnFn!, sessionId, author, provider, model, principal, runtimeConfigPath, language); } else { - this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath) => - this.spawnPi(nodeSpawn, sessionId, author, provider, model, principal, runtimeConfigPath); + this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath, language) => + this.spawnPi(nodeSpawn, sessionId, author, provider, model, principal, runtimeConfigPath, language); } } @@ -85,6 +87,7 @@ export class PiProcessManager { private spawnPi( spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined, model: string | undefined, principal?: PrincipalContext, runtimeConfigPath?: string, + interactionLanguage?: string | null, ): ChildProcessWithoutNullStreams { // 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 @@ -110,6 +113,9 @@ export class PiProcessManager { additions: { THT_SESSION: sessionId, THT_AUTHOR: author }, }); env.PI_CODING_AGENT_DIR = agent.agentDir; + // A launch hint only: the gate reads the authoritative manifest before each turn. + delete env.THT_INTERACTION_LANGUAGE; + if (interactionLanguage) env.THT_INTERACTION_LANGUAGE = interactionLanguage; env.PI_CODING_AGENT_SESSION_DIR = agent.sessionDir; clearPrincipalEnvironment(env); if (principal) Object.assign(env, principalEnvironment(principal)); @@ -187,7 +193,9 @@ export class PiProcessManager { const model = o.model ?? this.cfg.defaults.model; let child: ChildProcessWithoutNullStreams; try { - child = this.spawnFn(sessionId, author, provider, model, o.principal, o.runtimeConfig?.path); + child = this.spawnFn( + sessionId, author, provider, model, o.principal, o.runtimeConfig?.path, o.interactionLanguage, + ); } catch (error) { o.runtimeConfig?.release(); throw error; @@ -296,8 +304,13 @@ export class PiProcessManager { } async resume(sessionId: string, tht: ThtRunner): Promise { - const manifest = await tht.sessionShow(sessionId) as { provider?: string; model?: string; thinking?: string } | null; + const manifest = await tht.sessionShow(sessionId) as { + provider?: string; model?: string; thinking?: string; interaction_language?: string | null; + } | null; + const language = manifest?.interaction_language + ?? (await tht.ensureInteractionLanguage(sessionId)).interaction_language; return this.spawnFor(sessionId, { + interactionLanguage: language, provider: manifest?.provider, model: manifest?.model, thinking: manifest?.thinking, diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 918c9c05..a9f3a224 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -13,6 +13,7 @@ import type { MaintenanceBarrier } from "../runtime/maintenance-gate.js"; import { hasPermission, isPrincipalContext, requirePermission } from "../auth/authorization.js"; import { splitCanonicalModelId, type RuntimeModelCatalog } from "../models/runtime-model-catalog.js"; import type { CatalogRepository } from "../catalog/types.js"; +import { interactionLanguage } from "../tht/interaction-language.js"; const BOOTSTRAP_FAILURE_MESSAGE = "Session startup failed. Check configuration and connectivity, then Resume the session."; @@ -381,8 +382,13 @@ export function sessionRoutes( app.post("/sessions", async (req, reply) => { const b = req.body as { question: string; name?: string; workspace?: string; workspaceId?: string; - provider?: string; model?: string; thinking?: string; + provider?: string; model?: string; thinking?: string; interactionLanguage?: unknown; }; + const language = interactionLanguage(b?.interactionLanguage); + if (!language) return reply.code(400).send({ + code: "invalid_interaction_language", + error: "interactionLanguage must be a well-formed BCP-47 language tag", + }); 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" }); @@ -507,6 +513,7 @@ export function sessionRoutes( ({ id } = await runner.sessionNew({ question: b.question, name: b.name, workspaceConfigPath, workspaceId, workspaceRevision, provider, model, thinking, + interactionLanguage: language, })); manifestPersisted = true; if (revisionLease) { @@ -520,6 +527,7 @@ export function sessionRoutes( } catch { return storageFailure(reply); } const options = { provider, model, thinking, + interactionLanguage: language, author: principal.displayName ?? principal.subject, principal, question: b.question, @@ -664,6 +672,13 @@ export function sessionRoutes( app.post("/sessions/:id/resume", async (req, reply) => { const id = (req.params as any).id; const principal = getPrincipal(req); + if (Object.hasOwn(req.body ?? {}, "interactionLanguage") + || Object.hasOwn(req.body ?? {}, "interaction_language")) { + return reply.code(400).send({ + code: "interaction_language_pinned", + error: "Resume uses the session's persisted interaction language; overrides are not accepted", + }); + } return withSessionLifecycle(id, async () => { let settings: Settings; let located: LocatedSession | undefined; @@ -681,6 +696,7 @@ export function sessionRoutes( const saved = manifest as { provider?: string; model?: string; thinking?: string; workspace_id?: string; workspace_revision?: string; + interaction_language?: string | null; }; const requested = (req.body ?? {}) as { provider?: string; model?: string; thinking?: string }; if ((requested.provider === undefined) !== (requested.model === undefined) @@ -719,6 +735,12 @@ export function sessionRoutes( }); } try { settings = await d.getSettings(principal); } catch { return storageFailure(reply); } + let language = saved.interaction_language; + if (language == null) { + try { + language = (await runner.ensureInteractionLanguage(id, workspaceConfigPath)).interaction_language; + } catch { return reply.code(503).send({ error: RESUME_FAILURE_MESSAGE }); } + } // This check belongs inside the per-session lock: a preceding cold Resume may have // installed a running runtime while this request was waiting. const existing = d.mgr.get(id); @@ -737,6 +759,7 @@ export function sessionRoutes( }); const options = { provider: selected.provider, + interactionLanguage: language, model: selected.model, thinking: saved?.thinking ?? settings.thinking, author: principal.displayName ?? principal.subject, diff --git a/backend/src/tht/interaction-language.ts b/backend/src/tht/interaction-language.ts new file mode 100644 index 00000000..a91e6d3b --- /dev/null +++ b/backend/src/tht/interaction-language.ts @@ -0,0 +1,10 @@ +/** Validate/canonicalize the tag; available translation catalogs belong to the UI. */ +export function interactionLanguage(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + try { + const [canonical] = Intl.getCanonicalLocales(value); + return canonical; + } catch { + return undefined; + } +} diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index a93624a9..8f5f7851 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -59,6 +59,7 @@ export interface SessionRow { author: string | null; workspace_id?: string | null; workspace_revision?: string | null; + interaction_language?: string | null; archived?: boolean; } @@ -482,6 +483,7 @@ export class ThtRunner { async sessionNew(o: { question: string; + interactionLanguage?: string; provider?: string; model?: string; thinking?: string; @@ -497,6 +499,7 @@ export class ThtRunner { ["--provider", o.provider], ["--model", o.model], ["--thinking", o.thinking], + ["--interaction-language", o.interactionLanguage], ["--name", o.name], ["--workspace-id", o.workspaceId], ["--workspace-revision", o.workspaceRevision], @@ -533,6 +536,12 @@ export class ThtRunner { return this.json(["session", "show", id, "--json"], workspace); } + ensureInteractionLanguage(id: string, workspace?: string) { + return this.json<{ interaction_language: string }>( + ["session", "ensure-interaction-language", id, "--json"], workspace, + ); + } + sqlPreview(id: string, p: { limit?: number; offset?: number }, workspace?: string) { // No positional FILE: the harness resolves sql_final.sql from the session // via _session_sql_file(cfg, session_id), which respects the workspace path. diff --git a/backend/test/e2e-f1.test.ts b/backend/test/e2e-f1.test.ts index cf3df606..8298a24d 100644 --- a/backend/test/e2e-f1.test.ts +++ b/backend/test/e2e-f1.test.ts @@ -36,7 +36,7 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → il modell ollamaEnsure: async () => ({ ok: true }), searchPack: async () => {}, sessionNew: async () => ({ id: "s1" }), - sessionShow: async (_id: string) => ({ id: "s1", provider: undefined, model: undefined, thinking: undefined }), + sessionShow: async (_id: string) => ({ id: "s1", interaction_language: "en", provider: undefined, model: undefined, thinking: undefined }), sessionList: async () => [], } as any, spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any, @@ -48,7 +48,7 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → il modell const created = await fetch(`${base}/sessions`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ workspace: "w", question: "q" }), + body: JSON.stringify({ workspace: "w", question: "q", interactionLanguage: "en" }), }); expect(created.status).toBe(200); diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts index 7ceef352..e8e9ae9f 100644 --- a/backend/test/pi-process-manager.test.ts +++ b/backend/test/pi-process-manager.test.ts @@ -190,6 +190,29 @@ test("Pi receives the leased workspace runtime config and releases it on direct expect(release).toHaveBeenCalledOnce(); }); +test("Pi language launch hint comes from the session options and never ambient environment", () => { + const previous = process.env.THT_INTERACTION_LANGUAGE; + process.env.THT_INTERACTION_LANGUAGE = "it"; + const environments: NodeJS.ProcessEnv[] = []; + const mgr = new PiProcessManager(loadConfig({}), { + spawnFn: (_command, _args, options) => { + environments.push(options.env); + return recordingChild() as any; + }, + }); + try { + mgr.createFor("explicit-language", { interactionLanguage: "en" }); + mgr.teardown("explicit-language"); + mgr.createFor("manifest-resolved-in-gate"); + mgr.teardown("manifest-resolved-in-gate"); + expect(environments[0].THT_INTERACTION_LANGUAGE).toBe("en"); + expect(environments[1]).not.toHaveProperty("THT_INTERACTION_LANGUAGE"); + } finally { + if (previous === undefined) delete process.env.THT_INTERACTION_LANGUAGE; + else process.env.THT_INTERACTION_LANGUAGE = previous; + } +}); + test("a close-only child event releases its temporary Pi agent snapshot", () => { const child = recordingChild(); let snapshotDir: string | undefined; diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 973211ec..a24f882e 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -53,7 +53,11 @@ const defaultWorkspaceRegistry = { function buildApp(config: Parameters[0], deps: Record = {}) { const thtRunner = deps.thtRunner - ? { qdrantEnsure: async () => ({ ok: true }), ...(deps.thtRunner as object) } + ? { + qdrantEnsure: async () => ({ ok: true }), + ensureInteractionLanguage: async () => ({ interaction_language: "en" }), + ...(deps.thtRunner as object), + } : undefined; return buildRealApp(config, { workspaceRuntimeSupport: () => true, @@ -88,6 +92,129 @@ const aliceHeaders = { "x-thoth-is-admin": "0", }; +test.each([undefined, null, "", "en--US", "en_US", "en\nIGNORE", "en