diff --git a/docs/superpowers/plans/2026-08-12-config-import-export.md b/docs/superpowers/plans/2026-08-12-config-import-export.md new file mode 100644 index 0000000..d64ea20 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-config-import-export.md @@ -0,0 +1,2036 @@ +# Model & Image Config Import/Export Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an admin export/import the model-registry and image-generation configs as JSON files (with plaintext secrets), with replace-or-merge import semantics. + +**Architecture:** Backend adds two admin-only export GET routes behind a new `config:export` delegation scope; import reuses the existing validated PUT routes with all merge logic computed client-side in a pure, unit-tested module. The WebUI BFF proxies the export routes; a shared import dialog serves both editors. + +**Tech Stack:** Python 3 / Starlette / pydantic / pytest (backend); Next.js 16 / React 19 / next-intl 4 / vitest 3 node-env (WebUI). + +**Spec:** `EvoScientist-WebUI/docs/superpowers/specs/2026-08-12-config-import-export-design.md` + +## Global Constraints + +- **Two repos.** Backend: `/Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist`. WebUI: `/Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist-WebUI`. Paths below are repo-relative; each task names its repo. +- **WIP GUARD (critical).** Both repos carry the user's uncommitted work in files this plan touches (`EvoScientist/model_registry/http_api.py`, `src/i18n/messages/{en,zh}/index.ts`, `src/i18n/messages/messages.test.ts`, `src/app/components/RegistryEditor.tsx`, `src/app/components/ImageModelsEditor.tsx`). Before EVERY commit run `git status --short`; stage ONLY the exact files the task created/modified by name — NEVER `git add -A` / `git add .`. If a task must edit a file that already has uncommitted changes, HALT and report BLOCKED (do not stash, do not commit the user's WIP). +- Envelope `kind` strings are exact: `evoscientist.model-registry` / `evoscientist.image-generation`; `format_version` is integer `1`. +- Client rejects import text over `1_000_000` bytes. +- The plaintext-secrets surface is exactly the two new export GET routes behind scope `config:export` (admin-only in the WebUI delegation). No other route changes its never-secrets behavior. +- i18n catalogs zh/en are updated in lockstep (same keys, same ICU vars) and the new domain module is registered in both `index.ts` files AND in the module list of `src/i18n/messages/messages.test.ts` (that test hardcodes the list — forgetting it fails the suite). +- Backend `expected_revision` is deprecated (last-write-wins); import bodies still send the current revision because the BFF PUT route requires it, but no 409/conflict handling is built. +- WebUI tests: vitest node env, include pattern `src/**/*.test.ts` only (no Testing Library, no jsdom, no `.tsx` tests). All testable logic lives in pure `.ts` modules. +- Test commands — backend: `cd /Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist && python -m pytest tests/test_config_export.py -v`. WebUI: `cd /Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist-WebUI && npx vitest run ` and `npx tsc --noEmit`. + +--- + +### Task 1: Backend export endpoints (EvoScientist repo) + +**Files:** +- Modify: `EvoScientist/model_registry/http_api.py` (imports at lines 24-33, models near line 113-130, `routes()` at lines 519-560, handlers after `get_image_generation`/`put_image_generation` handlers ~lines 682-710, sync workers after `_image_generation_response` ~line 939) +- Test: `tests/test_config_export.py` (create) + +**Interfaces:** +- Consumes: existing `ModelRuntimeStore.load_registry()`, `store.list_credential_revisions() -> dict[str, int]` (store.py:523), `store.resolve_credential(credential_id, revision) -> str` (store.py:458), `load_image_generation_settings()` (already imported into http_api at line 53), `_authenticate(request, required_scope=..., require_thread_id=False)` handler pattern, `_error_response(exc, request_id)`. +- Produces: `GET /api/model-registry/export` and `GET /api/image-generation/export`, both requiring scope `config:export`, returning the envelope JSON below. + +**WIP guard:** before starting, run `git status --short EvoScientist/model_registry/http_api.py`. If it shows ` M`, STOP and report BLOCKED — the file has uncommitted user changes. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_config_export.py`: + +```python +"""Contract tests for the admin config-export endpoints. + +These two GET routes deliberately break the "APIs never return secrets" +invariant behind the admin-only ``config:export`` scope (config +import/export design doc, 2026-08-12). +""" + +from __future__ import annotations + +import time +import uuid + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from EvoScientist.image_gen import config as image_config +from EvoScientist.image_gen.config import ImageGenerationSettings, ImageModelEntry +from EvoScientist.model_registry import http_api +from EvoScientist.model_registry.auth import BffAuthenticator +from EvoScientist.model_registry.endpoint_policy import EndpointPolicy +from EvoScientist.model_registry.http_api import ApiServices, model_registry_routes +from EvoScientist.model_registry.platform import DelegationPublicKey +from EvoScientist.model_registry.resolver import ModelRegistryResolver +from EvoScientist.model_registry.snapshots import SnapshotService +from EvoScientist.model_registry.store import ModelRuntimeStore +from tests.registry_fixtures import ZHIPU_SECRET, activate_store + +SERVICE_TOKEN = "bff-service-token" + +_PRIVATE_KEY = ec.generate_private_key(ec.SECP256R1()) +_PRIVATE_PEM = _PRIVATE_KEY.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), +) +_PUBLIC_PEM = _PRIVATE_KEY.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, +) + + +@pytest.fixture +def config_path(tmp_path): + path = tmp_path / "config.yaml" + path.write_text("other_section: {keep: true}\n", encoding="utf-8") + return path + + +@pytest.fixture +def services(tmp_path, config_path, monkeypatch): + store = ModelRuntimeStore(config_dir=tmp_path / "runtime") + monkeypatch.setattr( + http_api, + "load_image_generation_settings", + lambda: image_config.load_image_generation_settings(config_path=config_path), + ) + resolver = ModelRegistryResolver(store) + return ApiServices( + store=store, + resolver=resolver, + snapshot_service=SnapshotService(store, resolver), + endpoint_policy=EndpointPolicy([]), + authenticator=BffAuthenticator( + service_token=SERVICE_TOKEN, + service_token_hash=None, + delegation_keys=( + DelegationPublicKey(deployment_id="webui-1", public_key=_PUBLIC_PEM), + ), + jti_store=store, + ), + ) + + +@pytest.fixture +def client(services): + app = Starlette(routes=model_registry_routes(lambda: services)) + return TestClient(app) + + +def _headers(scopes): + now = int(time.time()) + claims = { + "iss": "WebUI", + "aud": "EvoScientist", + "sub": "admin-1", + "scopes": list(scopes), + "deployment_id": "webui-1", + "iat": now, + "exp": now + 30, + "jti": uuid.uuid4().hex, + } + return { + "Authorization": f"Bearer {SERVICE_TOKEN}", + "X-Evo-Actor": jwt.encode(claims, _PRIVATE_PEM, algorithm="ES256"), + } + + +def _export_headers(): + return _headers(["config:export"]) + + +def test_registry_export_returns_registry_with_plaintext_credentials(client, services): + activate_store(services.store) + response = client.get("/api/model-registry/export", headers=_export_headers()) + assert response.status_code == 200 + body = response.json() + assert body["kind"] == "evoscientist.model-registry" + assert body["format_version"] == 1 + assert isinstance(body["exported_at"], str) and body["exported_at"] + provider_ids = {p["id"] for p in body["payload"]["registry"]["providers"]} + assert provider_ids == {"zhipu-glm", "local-ollama"} + assert body["payload"]["credentials"] == [ + {"credential_id": "zhipu-primary", "secret_value": ZHIPU_SECRET} + ] + + +def test_registry_export_empty_store_has_empty_credentials(client): + response = client.get("/api/model-registry/export", headers=_export_headers()) + assert response.status_code == 200 + assert response.json()["payload"]["credentials"] == [] + + +def test_registry_export_requires_export_scope(client, services): + activate_store(services.store) + response = client.get( + "/api/model-registry/export", + headers=_headers(["model_config:read", "model_config:write"]), + ) + assert response.status_code == 403 + assert response.json()["error"]["code"] == "FORBIDDEN" + + +def test_registry_export_requires_delegation(client): + response = client.get( + "/api/model-registry/export", + headers={"Authorization": f"Bearer {SERVICE_TOKEN}"}, + ) + assert response.status_code == 401 + + +def test_image_export_returns_plaintext_api_keys(client, config_path): + settings = ImageGenerationSettings( + default_model="gpt-image-2", + timeout_seconds=60.0, + models=[ + ImageModelEntry( + id="gpt-image-2", + name="GPT Image", + provider="openai", + api_key="sk-image-secret", + base_url="https://api.example.com/v1", + ) + ], + ) + image_config.save_image_generation_settings(settings, config_path=config_path) + response = client.get("/api/image-generation/export", headers=_export_headers()) + assert response.status_code == 200 + body = response.json() + assert body["kind"] == "evoscientist.image-generation" + assert body["format_version"] == 1 + payload = body["payload"] + assert payload["default_model"] == "gpt-image-2" + assert payload["timeout_seconds"] == 60.0 + assert payload["models"][0]["api_key"] == "sk-image-secret" + assert "api_key_configured" not in payload["models"][0] + + +def test_image_export_requires_export_scope(client): + response = client.get( + "/api/image-generation/export", + headers=_headers(["model_config:read", "model_config:write"]), + ) + assert response.status_code == 403 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist && python -m pytest tests/test_config_export.py -v` +Expected: FAIL — 404 on both `/export` paths (routes do not exist). + +- [ ] **Step 3: Implement the export routes** + +In `EvoScientist/model_registry/http_api.py`: + +(a) Add the datetime import (after line 30 `import uuid`): + +```python +from datetime import datetime, timezone +``` + +(b) Add response models (after the `PutModelRegistryRequest` class, ~line 130): + +```python +class RegistryExportCredential(BaseModel): + """One plaintext credential in the registry export envelope.""" + + credential_id: CredentialId + secret_value: str + + +class RegistryExportPayload(BaseModel): + registry: RegistryV4 + credentials: list[RegistryExportCredential] + + +class ModelRegistryExportResponse(BaseModel): + """Admin-only whole-registry export; contains plaintext secrets.""" + + kind: Literal["evoscientist.model-registry"] = "evoscientist.model-registry" + format_version: Literal[1] = 1 + exported_at: str + payload: RegistryExportPayload + + +class ImageGenerationExportResponse(BaseModel): + """Admin-only image-settings export; contains plaintext api keys.""" + + kind: Literal["evoscientist.image-generation"] = "evoscientist.image-generation" + format_version: Literal[1] = 1 + exported_at: str + payload: ImageGenerationSettings +``` + +(c) Register the routes in `routes()` — after the `Route("/api/model-registry/test", ...)` entry add: + +```python + Route( + "/api/model-registry/export", + self.export_model_registry, + methods=["GET"], + ), +``` + +and after the `Route("/api/image-generation/test", ...)` entry add: + +```python + Route( + "/api/image-generation/export", + self.export_image_generation, + methods=["GET"], + ), +``` + +(d) Add the handlers (after the `test_image_generation` handler, before the snapshot handlers): + +```python + async def export_model_registry(self, request: Request) -> Response: + request_id = uuid.uuid4().hex + try: + services, _actor = await self._authenticate( + request, required_scope="config:export", require_thread_id=False + ) + response = await asyncio.to_thread(self._registry_export, services) + except ModelRegistryError as exc: + return _error_response(exc, request_id) + return JSONResponse(response.model_dump(mode="json")) + + async def export_image_generation(self, request: Request) -> Response: + request_id = uuid.uuid4().hex + try: + await self._authenticate( + request, required_scope="config:export", require_thread_id=False + ) + response = await asyncio.to_thread(self._image_generation_export) + except ModelRegistryError as exc: + return _error_response(exc, request_id) + return JSONResponse(response.model_dump(mode="json")) +``` + +(e) Add the sync workers (after `_image_generation_response`, before `_save_image_generation`): + +```python + @staticmethod + def _registry_export(services: ApiServices) -> ModelRegistryExportResponse: + store = services.store + registry = store.load_registry() + referenced = sorted( + { + provider.auth.credential_id + for provider in registry.providers + if provider.auth.credential_id is not None + } + ) + revisions = store.list_credential_revisions() + credentials = [ + RegistryExportCredential( + credential_id=credential_id, + secret_value=store.resolve_credential( + credential_id, revisions[credential_id] + ), + ) + for credential_id in referenced + if credential_id in revisions + ] + return ModelRegistryExportResponse( + exported_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + payload=RegistryExportPayload(registry=registry, credentials=credentials), + ) + + @staticmethod + def _image_generation_export() -> ImageGenerationExportResponse: + return ImageGenerationExportResponse( + exported_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + payload=load_image_generation_settings(), + ) +``` + +Note: `_image_generation_export` calls the module-level `load_image_generation_settings` name so the existing test monkeypatch pattern (`monkeypatch.setattr(http_api, "load_image_generation_settings", ...)`) keeps working. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist && python -m pytest tests/test_config_export.py -v` +Expected: 6 passed. + +Also run the neighboring suites to catch regressions: +`python -m pytest tests/test_model_registry_http.py tests/test_image_generation_http.py -q` +Expected: all pass (note: the user's WIP may leave some image tests failing — if a failure exists, re-run that single test on a clean checkout via `git stash` is FORBIDDEN; instead report the failing test name and whether it touches code this task changed; only failures caused by THIS task's diff block completion). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist +git add EvoScientist/model_registry/http_api.py tests/test_config_export.py +``` +If `git status --short` shows other modifications to `http_api.py` beyond yours (user WIP), STOP — report BLOCKED instead of committing. +```bash +git commit -m "feat(model-registry): add admin config export endpoints with plaintext secrets" +``` + +--- + +### Task 2: WebUI delegation scope + BFF export proxy routes (EvoScientist-WebUI repo) + +**Files:** +- Modify: `src/lib/server/delegation.ts` (scopes at lines 23-37) +- Create: `src/app/api/model-registry/export/route.ts` +- Create: `src/app/api/model-registry/export/route.test.ts` +- Create: `src/app/api/image-generation/export/route.ts` +- Create: `src/app/api/image-generation/export/route.test.ts` + +**Interfaces:** +- Consumes: `requireActor`/`requireAdmin`/`Actor` from `@/lib/server/actor`, `configApiFetch(actor, path)` from `@/lib/server/evoscientistConfigClient`, `isCrossOrigin` from `@/lib/server/workspace`, `NO_STORE`/`routeErrorResponse` from `@/lib/server/routeErrors` — the exact pattern of `src/app/api/model-registry/route.ts`. +- Produces: `GET /api/model-registry/export` and `GET /api/image-generation/export` BFF routes (admin-gated JSON passthrough); `SCOPE_CONFIG_EXPORT` exported from `delegation.ts`, included in admin JWT scopes only. + +- [ ] **Step 1: Write the failing tests** + +Create `src/app/api/model-registry/export/route.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireActor: vi.fn(), + requireAdmin: vi.fn(), + configApiFetch: vi.fn(), + isCrossOrigin: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/actor", () => ({ + requireActor: mocks.requireActor, + requireAdmin: mocks.requireAdmin, + ActorError: class ActorError extends Error { + constructor( + message: string, + readonly status: 401 | 403 = 401 + ) { + super(message); + } + }, +})); +vi.mock("@/lib/server/evoscientistConfigClient", () => ({ + configApiFetch: mocks.configApiFetch, +})); +vi.mock("@/lib/server/workspace", () => ({ + isCrossOrigin: mocks.isCrossOrigin, +})); + +const routes = await import("./route"); + +function request(): NextRequest { + return new NextRequest("http://localhost/api/model-registry/export", { + method: "GET", + }); +} + +describe("model-registry export route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.isCrossOrigin.mockReturnValue(false); + mocks.requireActor.mockReturnValue({ sub: "alice", role: "admin" }); + mocks.requireAdmin.mockReturnValue(undefined); + mocks.configApiFetch.mockResolvedValue({ + body: { kind: "evoscientist.model-registry", format_version: 1 }, + }); + }); + + it("GET proxies the backend export for an admin", async () => { + const response = await routes.GET(request()); + expect(response.status).toBe(200); + expect(mocks.configApiFetch).toHaveBeenCalledWith( + { sub: "alice", role: "admin" }, + "/api/model-registry/export" + ); + expect(await response.json()).toEqual({ + kind: "evoscientist.model-registry", + format_version: 1, + }); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("GET returns 403 for a non-admin", async () => { + const { ActorError } = await import("@/lib/server/actor"); + mocks.requireAdmin.mockImplementation(() => { + throw new ActorError("Admin role required.", 403); + }); + const response = await routes.GET(request()); + expect(response.status).toBe(403); + expect(mocks.configApiFetch).not.toHaveBeenCalled(); + }); + + it("GET returns 401 when unauthenticated", async () => { + const { ActorError } = await import("@/lib/server/actor"); + mocks.requireActor.mockImplementation(() => { + throw new ActorError("Authentication required.", 401); + }); + const response = await routes.GET(request()); + expect(response.status).toBe(401); + }); + + it("GET returns 403 for cross-origin requests", async () => { + mocks.isCrossOrigin.mockReturnValue(true); + const response = await routes.GET(request()); + expect(response.status).toBe(403); + expect(mocks.configApiFetch).not.toHaveBeenCalled(); + }); +}); +``` + +Create `src/app/api/image-generation/export/route.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireActor: vi.fn(), + requireAdmin: vi.fn(), + configApiFetch: vi.fn(), + isCrossOrigin: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/server/actor", () => ({ + requireActor: mocks.requireActor, + requireAdmin: mocks.requireAdmin, + ActorError: class ActorError extends Error { + constructor( + message: string, + readonly status: 401 | 403 = 401 + ) { + super(message); + } + }, +})); +vi.mock("@/lib/server/evoscientistConfigClient", () => ({ + configApiFetch: mocks.configApiFetch, +})); +vi.mock("@/lib/server/workspace", () => ({ + isCrossOrigin: mocks.isCrossOrigin, +})); + +const routes = await import("./route"); + +function request(): NextRequest { + return new NextRequest("http://localhost/api/image-generation/export", { + method: "GET", + }); +} + +describe("image-generation export route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.isCrossOrigin.mockReturnValue(false); + mocks.requireActor.mockReturnValue({ sub: "alice", role: "admin" }); + mocks.requireAdmin.mockReturnValue(undefined); + mocks.configApiFetch.mockResolvedValue({ + body: { kind: "evoscientist.image-generation", format_version: 1 }, + }); + }); + + it("GET proxies the backend export for an admin", async () => { + const response = await routes.GET(request()); + expect(response.status).toBe(200); + expect(mocks.configApiFetch).toHaveBeenCalledWith( + { sub: "alice", role: "admin" }, + "/api/image-generation/export" + ); + expect(await response.json()).toEqual({ + kind: "evoscientist.image-generation", + format_version: 1, + }); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("GET returns 403 for a non-admin", async () => { + const { ActorError } = await import("@/lib/server/actor"); + mocks.requireAdmin.mockImplementation(() => { + throw new ActorError("Admin role required.", 403); + }); + const response = await routes.GET(request()); + expect(response.status).toBe(403); + expect(mocks.configApiFetch).not.toHaveBeenCalled(); + }); + + it("GET returns 401 when unauthenticated", async () => { + const { ActorError } = await import("@/lib/server/actor"); + mocks.requireActor.mockImplementation(() => { + throw new ActorError("Authentication required.", 401); + }); + const response = await routes.GET(request()); + expect(response.status).toBe(401); + }); + + it("GET returns 403 for cross-origin requests", async () => { + mocks.isCrossOrigin.mockReturnValue(true); + const response = await routes.GET(request()); + expect(response.status).toBe(403); + expect(mocks.configApiFetch).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist-WebUI && npx vitest run src/app/api/model-registry/export/route.test.ts src/app/api/image-generation/export/route.test.ts` +Expected: FAIL — `./route` module not found. + +- [ ] **Step 3: Add the scope and the two routes** + +In `src/lib/server/delegation.ts`, add after line 28 (`SCOPE_SYSTEM_READ`): + +```ts +export const SCOPE_CONFIG_EXPORT = "config:export"; +``` + +and add `SCOPE_CONFIG_EXPORT` as the last entry of `ADMIN_SCOPES` (NOT `USER_SCOPES`). + +Create `src/app/api/model-registry/export/route.ts`: + +```ts +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, requireAdmin, type Actor } from "@/lib/server/actor"; +import { configApiFetch } from "@/lib/server/evoscientistConfigClient"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + requireAdmin(actor); + const { body } = await configApiFetch( + actor, + "/api/model-registry/export" + ); + return NextResponse.json(body, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} +``` + +Create `src/app/api/image-generation/export/route.ts` — identical except the backend path: + +```ts +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, requireAdmin, type Actor } from "@/lib/server/actor"; +import { configApiFetch } from "@/lib/server/evoscientistConfigClient"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + let actor: Actor | undefined; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + actor = requireActor(request); + requireAdmin(actor); + const { body } = await configApiFetch( + actor, + "/api/image-generation/export" + ); + return NextResponse.json(body, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error, actor); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/app/api/model-registry/export/route.test.ts src/app/api/image-generation/export/route.test.ts` +Expected: 8 passed. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist-WebUI +git status --short src/lib/server/delegation.ts # must be clean; if dirty, STOP and report BLOCKED +git add src/lib/server/delegation.ts src/app/api/model-registry/export/route.ts src/app/api/model-registry/export/route.test.ts src/app/api/image-generation/export/route.ts src/app/api/image-generation/export/route.test.ts +git commit -m "feat(bff): proxy admin config export routes with config:export scope" +``` + +--- + +### Task 3: WebUI i18n `configTransfer` catalogs (EvoScientist-WebUI repo) + +**Files:** +- Create: `src/i18n/messages/en/configTransfer.ts` +- Create: `src/i18n/messages/zh/configTransfer.ts` +- Modify: `src/i18n/messages/en/index.ts`, `src/i18n/messages/zh/index.ts` +- Modify: `src/i18n/messages/messages.test.ts` + +**Interfaces:** +- Produces: the `configTransfer` translation namespace consumed by Tasks 5-6 via `useTranslations("configTransfer")`. Key set (both locales, identical): + - `export`, `exporting`, `import`, `exportSecretsWarning`, `exportFailed {status}`, `importDisabledWhileDirty` + - `dialog.titleRegistry`, `dialog.titleImage`, `dialog.description`, `dialog.fileLabel`, `dialog.pasteLabel`, `dialog.pastePlaceholder` + - `dialog.errors.too_large`, `dialog.errors.invalid_json`, `dialog.errors.wrong_kind {expected}`, `dialog.errors.unsupported_version`, `dialog.errors.invalid_shape` + - `dialog.previewRegistry {providers, models}`, `dialog.previewImage {models}`, `dialog.missingSecretsTitle`, `dialog.secretsNotice` + - `dialog.modeLabel`, `dialog.modeReplace`, `dialog.modeReplaceHint`, `dialog.modeMerge`, `dialog.modeMergeHint` + - `dialog.cancel`, `dialog.submit`, `dialog.submitting`, `dialog.success`, `dialog.failed {message}`, `dialog.loadCurrentFailed {status}` + +**WIP guard:** `git status --short src/i18n/messages/` must be clean before committing. If dirty (user WIP), STOP and report BLOCKED. + +- [ ] **Step 1: Create the English catalog** `src/i18n/messages/en/configTransfer.ts`: + +```ts +const configTransfer = { + configTransfer: { + export: "Export", + exporting: "Exporting…", + import: "Import", + exportSecretsWarning: + "The exported file contains plaintext API keys — store it securely.", + exportFailed: "Export failed ({status})", + importDisabledWhileDirty: "Save or discard your changes before importing.", + dialog: { + titleRegistry: "Import model registry", + titleImage: "Import image models", + description: + "Import a previously exported JSON file. It may contain plaintext API keys.", + fileLabel: "Choose a .json export file", + pasteLabel: "Or paste the export JSON", + pastePlaceholder: '{"kind": "evoscientist.model-registry", ...}', + errors: { + too_large: "The file is larger than 1 MB.", + invalid_json: "The content is not valid JSON.", + wrong_kind: "This file is not a {expected} export.", + unsupported_version: + "Unsupported format version; only version 1 files are accepted.", + invalid_shape: "The file payload does not match the expected structure.", + }, + previewRegistry: "{providers} providers, {models} models", + previewImage: "{models} image models", + missingSecretsTitle: "Entries with no secret in the file:", + secretsNotice: + "Plaintext secrets from the file will be written to the server.", + modeLabel: "Import mode", + modeReplace: "Replace", + modeReplaceHint: "Overwrite the current configuration entirely.", + modeMerge: "Merge", + modeMergeHint: "Update matching entries, add new ones, keep the rest.", + cancel: "Cancel", + submit: "Import", + submitting: "Importing…", + success: "Configuration imported.", + failed: "Import failed: {message}", + loadCurrentFailed: "Failed to load the current configuration ({status})", + }, + }, +} as const; + +export default configTransfer; +``` + +- [ ] **Step 2: Create the Chinese catalog** `src/i18n/messages/zh/configTransfer.ts` (identical keys and ICU vars): + +```ts +const configTransfer = { + configTransfer: { + export: "导出", + exporting: "导出中…", + import: "导入", + exportSecretsWarning: "导出的文件包含明文 API 密钥,请妥善保管。", + exportFailed: "导出失败({status})", + importDisabledWhileDirty: "请先保存或放弃当前修改,再执行导入。", + dialog: { + titleRegistry: "导入模型注册表", + titleImage: "导入图像模型", + description: "导入先前导出的 JSON 文件,文件可能包含明文 API 密钥。", + fileLabel: "选择 .json 导出文件", + pasteLabel: "或粘贴导出的 JSON", + pastePlaceholder: '{"kind": "evoscientist.model-registry", ...}', + errors: { + too_large: "文件超过 1 MB。", + invalid_json: "内容不是有效的 JSON。", + wrong_kind: "该文件不是 {expected} 导出文件。", + unsupported_version: "不支持的格式版本,仅接受版本 1 的文件。", + invalid_shape: "文件内容与期望的结构不匹配。", + }, + previewRegistry: "{providers} 个提供商,{models} 个模型", + previewImage: "{models} 个图像模型", + missingSecretsTitle: "文件中缺少密钥的条目:", + secretsNotice: "文件中的明文密钥将被写入服务器。", + modeLabel: "导入模式", + modeReplace: "替换", + modeReplaceHint: "完全覆盖当前配置。", + modeMerge: "合并", + modeMergeHint: "更新同 ID 条目,新增缺失条目,保留其余条目。", + cancel: "取消", + submit: "导入", + submitting: "导入中…", + success: "配置已导入。", + failed: "导入失败:{message}", + loadCurrentFailed: "加载当前配置失败({status})", + }, + }, +} as const; + +export default configTransfer; +``` + +- [ ] **Step 3: Register the module** + +In both `src/i18n/messages/en/index.ts` and `src/i18n/messages/zh/index.ts`: add `import configTransfer from "./configTransfer";` after the `systemConfig` import, and `...configTransfer,` after `...systemConfig,` in the default export. + +In `src/i18n/messages/messages.test.ts`: add `import enConfigTransfer from "./en/configTransfer";` after the `enSystemConfig` import, and `enConfigTransfer,` after `enSystemConfig,` in the `modules` array of the "domain modules do not shadow each other" test. + +- [ ] **Step 4: Run the catalog test** + +Run: `npx vitest run src/i18n/messages/messages.test.ts` +Expected: all pass (key-set parity, ICU var parity, no shadowing). + +- [ ] **Step 5: Commit** + +```bash +git add src/i18n/messages/en/configTransfer.ts src/i18n/messages/zh/configTransfer.ts src/i18n/messages/en/index.ts src/i18n/messages/zh/index.ts src/i18n/messages/messages.test.ts +git commit -m "feat(i18n): add configTransfer catalog for config import/export" +``` + +--- + +### Task 4: WebUI `configTransfer.ts` pure logic (EvoScientist-WebUI repo) + +**Files:** +- Create: `src/lib/configTransfer.ts` +- Test: `src/lib/configTransfer.test.ts` + +**Interfaces:** +- Consumes: types from `@/lib/modelRegistry` (`RegistryV4`, `ProviderConfig`, `ModelRef`, `CredentialWrite`, `PutModelRegistryRequest`) and `@/lib/imageGeneration` (`ImageGenerationSettings`, `ImageModel`, `PutImageGenerationRequest`). +- Produces (Task 5-6 rely on these exact names): + - `REGISTRY_EXPORT_KIND`, `IMAGE_EXPORT_KIND`, `EXPORT_FORMAT_VERSION`, `MAX_IMPORT_BYTES` constants + - `RegistryExportEnvelope`, `ImageExportEnvelope`, `ParseErrorCode`, `ParseResult` types + - `parseRegistryExport(text: string): ParseResult` + - `parseImageExport(text: string): ParseResult` + - `mergeRegistry(current: RegistryV4, imported: RegistryV4): RegistryV4` + - `mergeImageSettings(current: ImageGenerationSettings, imported: ImageExportEnvelope["payload"]): PutImageGenerationRequest` + - `buildRegistryImportBody(envelope, current: { revision: number; registry: RegistryV4 }, mode: "replace" | "merge"): PutModelRegistryRequest` + - `buildImageImportBody(envelope: ImageExportEnvelope, current: ImageGenerationSettings, mode: "replace" | "merge"): PutImageGenerationRequest` + - `registryMissingSecrets(envelope: RegistryExportEnvelope): string[]` (provider ids) + - `imageMissingSecrets(envelope: ImageExportEnvelope, current: ImageGenerationSettings): string[]` (model ids) + - `exportFilename(kind: "model-registry" | "image-generation", date?: Date): string` + - `downloadTextFile(filename: string, text: string): void` (DOM only, not unit-tested) + +- [ ] **Step 1: Write the failing test** `src/lib/configTransfer.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + buildImageImportBody, + buildRegistryImportBody, + exportFilename, + IMAGE_EXPORT_KIND, + imageMissingSecrets, + mergeImageSettings, + mergeRegistry, + parseImageExport, + parseRegistryExport, + REGISTRY_EXPORT_KIND, + registryMissingSecrets, + type ImageExportEnvelope, + type RegistryExportEnvelope, +} from "./configTransfer"; +import type { ProviderConfig, RegistryV4 } from "./modelRegistry"; +import type { ImageGenerationSettings, ImageModel } from "./imageGeneration"; + +function provider(id: string, modelKeys: string[] = ["m1"]): ProviderConfig { + return { + id, + name: id, + adapter: "openai-compatible", + base_url: "https://example.com/v1", + auth: { mode: "api_key", credential_id: `${id}-cred` }, + enabled: true, + runtime: { + timeout_seconds: 120, + max_retries: 2, + default_temperature: null, + default_top_p: null, + default_reasoning_effort: "auto", + }, + models: modelKeys.map((key) => ({ + key, + name: key, + upstream_model_id: key, + enabled: true, + runtime: { + limit_mode: "combined", + context_window_tokens: 128000, + max_input_tokens: null, + max_output_tokens: 4096, + min_effective_input_tokens: 8192, + fixed_system_reserve_tokens: 4096, + fixed_tools_reserve_tokens: 8192, + fixed_attachments_reserve_tokens: 4096, + limits_status: "confirmed", + limits_source: "provider", + temperature: null, + top_p: null, + reasoning_effort: "auto", + declared_capabilities: { + tools: true, + vision: false, + structured_output: true, + }, + }, + })), + }; +} + +function registry(providers: ProviderConfig[], primaryId: string | null = null): RegistryV4 { + const primary = + primaryId === null + ? null + : { provider_id: primaryId, model_key: providers.find((p) => p.id === primaryId)?.models[0]?.key ?? "m1" }; + return { + version: 4, + revision: 1, + state: "active", + defaults: { primary }, + providers, + }; +} + +function registryEnvelope(providers: ProviderConfig[]): RegistryExportEnvelope { + return { + kind: REGISTRY_EXPORT_KIND, + format_version: 1, + exported_at: "2026-08-12T09:00:00Z", + payload: { + registry: registry(providers), + credentials: providers.map((p) => ({ + credential_id: `${p.id}-cred`, + secret_value: `sk-${p.id}`, + })), + }, + }; +} + +function imageModel(id: string, apiKey = `sk-${id}`): ImageModel { + return { + id, + name: id, + provider: "openai", + api_key: apiKey, + base_url: "https://example.com/v1", + enabled: true, + default_size: "1024x1024", + default_quality: "auto", + params: {}, + }; +} + +function imageEnvelope(models: ImageModel[]): ImageExportEnvelope { + return { + kind: IMAGE_EXPORT_KIND, + format_version: 1, + exported_at: "2026-08-12T09:00:00Z", + payload: { default_model: models[0]?.id ?? "", timeout_seconds: 60, models }, + }; +} + +function imageSettings(models: Array<{ id: string; configured: boolean }>): ImageGenerationSettings { + return { + default_model: models[0]?.id ?? "", + timeout_seconds: 120, + models: models.map(({ id, configured }) => ({ + ...imageModel(id, ""), + api_key_configured: configured, + api_key_hint: configured ? "...cret" : null, + })), + }; +} + +describe("parseRegistryExport", () => { + it("parses a valid envelope", () => { + const envelope = registryEnvelope([provider("a")]); + const result = parseRegistryExport(JSON.stringify(envelope)); + expect(result).toEqual({ ok: true, value: envelope }); + }); + it("rejects text over 1 MB", () => { + expect(parseRegistryExport("x".repeat(1_000_001))).toEqual({ + ok: false, + code: "too_large", + }); + }); + it("rejects invalid JSON", () => { + expect(parseRegistryExport("{nope")).toEqual({ ok: false, code: "invalid_json" }); + }); + it("rejects the wrong kind", () => { + const envelope = { ...registryEnvelope([provider("a")]), kind: IMAGE_EXPORT_KIND }; + expect(parseRegistryExport(JSON.stringify(envelope))).toEqual({ + ok: false, + code: "wrong_kind", + }); + }); + it("rejects an unsupported format_version", () => { + const envelope = { ...registryEnvelope([provider("a")]), format_version: 2 }; + expect(parseRegistryExport(JSON.stringify(envelope))).toEqual({ + ok: false, + code: "unsupported_version", + }); + }); + it("rejects a malformed payload", () => { + const envelope = { kind: REGISTRY_EXPORT_KIND, format_version: 1, payload: { registry: { version: 3 }, credentials: [] } }; + expect(parseRegistryExport(JSON.stringify(envelope))).toEqual({ + ok: false, + code: "invalid_shape", + }); + }); + it("rejects credentials with non-string secrets", () => { + const envelope = registryEnvelope([provider("a")]); + (envelope.payload.credentials[0] as unknown as { secret_value: number }).secret_value = 42; + expect(parseRegistryExport(JSON.stringify(envelope))).toEqual({ + ok: false, + code: "invalid_shape", + }); + }); +}); + +describe("parseImageExport", () => { + it("parses a valid envelope", () => { + const envelope = imageEnvelope([imageModel("gpt-image-2")]); + expect(parseImageExport(JSON.stringify(envelope))).toEqual({ + ok: true, + value: envelope, + }); + }); + it("rejects a registry file", () => { + expect(parseImageExport(JSON.stringify(registryEnvelope([provider("a")])))).toEqual({ + ok: false, + code: "wrong_kind", + }); + }); + it("rejects a model with an unknown provider", () => { + const envelope = imageEnvelope([imageModel("x")]); + (envelope.payload.models[0] as unknown as { provider: string }).provider = "midjourney"; + expect(parseImageExport(JSON.stringify(envelope))).toEqual({ + ok: false, + code: "invalid_shape", + }); + }); +}); + +describe("mergeRegistry", () => { + it("replaces same-id providers, appends new, keeps unmentioned", () => { + const current = registry([provider("keep"), provider("old")]); + const imported = registry([provider("old", ["m2"]), provider("new")]); + const merged = mergeRegistry(current, imported); + expect(merged.providers.map((p) => p.id)).toEqual(["keep", "old", "new"]); + expect(merged.providers[1].models.map((m) => m.key)).toEqual(["m2"]); + }); + it("keeps the current default primary when it survives the merge", () => { + const current = registry([provider("keep"), provider("old")], "keep"); + const merged = mergeRegistry(current, registry([provider("old")])); + expect(merged.defaults.primary?.provider_id).toBe("keep"); + }); + it("falls back to the imported primary when the current one is gone", () => { + const current = registry([provider("gone")], "gone"); + const imported = registry([provider("new")], "new"); + const merged = mergeRegistry(current, imported); + expect(merged.defaults.primary?.provider_id).toBe("new"); + }); +}); + +describe("buildRegistryImportBody", () => { + it("replace mode sends the imported registry plus credential writes", () => { + const envelope = registryEnvelope([provider("a")]); + const body = buildRegistryImportBody(envelope, { revision: 7, registry: registry([provider("z")]) }, "replace"); + expect(body.expected_revision).toBe(7); + expect(body.registry.providers.map((p) => p.id)).toEqual(["a"]); + expect(body.credential_writes).toEqual([ + { credential_id: "a-cred", operation: "replace", secret_value: "sk-a" }, + ]); + }); + it("merge mode merges against the current registry", () => { + const envelope = registryEnvelope([provider("a")]); + const body = buildRegistryImportBody(envelope, { revision: 3, registry: registry([provider("z")]) }, "merge"); + expect(body.registry.providers.map((p) => p.id)).toEqual(["z", "a"]); + }); +}); + +describe("registryMissingSecrets", () => { + it("lists providers whose credential is absent from the file", () => { + const envelope = registryEnvelope([provider("a"), provider("b")]); + envelope.payload.credentials = envelope.payload.credentials.filter( + (c) => c.credential_id !== "b-cred" + ); + expect(registryMissingSecrets(envelope)).toEqual(["b"]); + }); +}); + +describe("mergeImageSettings", () => { + it("replaces same-id models, appends new, keeps unmentioned with blank key", () => { + const current = imageSettings([ + { id: "keep", configured: true }, + { id: "old", configured: true }, + ]); + const merged = mergeImageSettings(current, imageEnvelope([imageModel("old"), imageModel("new")]).payload); + expect(merged.models.map((m) => m.id)).toEqual(["keep", "old", "new"]); + expect(merged.models[0].api_key).toBe(""); // blank → backend keeps stored key + expect(merged.models[1].api_key).toBe("sk-old"); + }); + it("takes scalars from the import", () => { + const current = imageSettings([{ id: "a", configured: false }]); + const merged = mergeImageSettings(current, { default_model: "a", timeout_seconds: 30, models: [imageModel("a")] }); + expect(merged.timeout_seconds).toBe(30); + }); +}); + +describe("buildImageImportBody", () => { + it("replace mode sends imported models as-is", () => { + const envelope = imageEnvelope([imageModel("a", "")]); + const body = buildImageImportBody(envelope, imageSettings([{ id: "z", configured: true }]), "replace"); + expect(body.models.map((m) => m.id)).toEqual(["a"]); + expect(body.models[0].api_key).toBe(""); + }); +}); + +describe("imageMissingSecrets", () => { + it("lists models with no key in the file and none stored", () => { + const envelope = imageEnvelope([imageModel("a", ""), imageModel("b", ""), imageModel("c")]); + const current = imageSettings([{ id: "b", configured: true }]); + expect(imageMissingSecrets(envelope, current)).toEqual(["a"]); + }); +}); + +describe("exportFilename", () => { + it("formats the date-stamped filename", () => { + expect(exportFilename("model-registry", new Date("2026-08-12T09:00:00Z"))).toBe( + "evoscientist-model-registry-2026-08-12.json" + ); + expect(exportFilename("image-generation", new Date("2026-08-12T09:00:00Z"))).toBe( + "evoscientist-image-generation-2026-08-12.json" + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/configTransfer.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** `src/lib/configTransfer.ts`: + +```ts +import type { + CredentialWrite, + ModelRef, + PutModelRegistryRequest, + RegistryV4, +} from "@/lib/modelRegistry"; +import type { + ImageGenerationSettings, + ImageModel, + PutImageGenerationRequest, +} from "@/lib/imageGeneration"; + +export const REGISTRY_EXPORT_KIND = "evoscientist.model-registry"; +export const IMAGE_EXPORT_KIND = "evoscientist.image-generation"; +export const EXPORT_FORMAT_VERSION = 1; +export const MAX_IMPORT_BYTES = 1_000_000; + +export interface RegistryExportCredential { + credential_id: string; + secret_value: string; +} + +export interface RegistryExportEnvelope { + kind: typeof REGISTRY_EXPORT_KIND; + format_version: typeof EXPORT_FORMAT_VERSION; + exported_at: string; + payload: { + registry: RegistryV4; + credentials: RegistryExportCredential[]; + }; +} + +export type ImageExportPayload = PutImageGenerationRequest; + +export interface ImageExportEnvelope { + kind: typeof IMAGE_EXPORT_KIND; + format_version: typeof EXPORT_FORMAT_VERSION; + exported_at: string; + payload: ImageExportPayload; +} + +export type ParseErrorCode = + | "too_large" + | "invalid_json" + | "wrong_kind" + | "unsupported_version" + | "invalid_shape"; + +export type ParseResult = + | { ok: true; value: T } + | { ok: false; code: ParseErrorCode }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function parseEnvelope( + text: string, + expectedKind: string +): ParseResult> { + if (new TextEncoder().encode(text).length > MAX_IMPORT_BYTES) { + return { ok: false, code: "too_large" }; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { ok: false, code: "invalid_json" }; + } + if (!isRecord(parsed)) return { ok: false, code: "invalid_shape" }; + if (parsed.kind !== expectedKind) return { ok: false, code: "wrong_kind" }; + if (parsed.format_version !== EXPORT_FORMAT_VERSION) { + return { ok: false, code: "unsupported_version" }; + } + return { ok: true, value: parsed }; +} + +export function parseRegistryExport( + text: string +): ParseResult { + const parsed = parseEnvelope(text, REGISTRY_EXPORT_KIND); + if (!parsed.ok) return parsed; + const payload = parsed.value.payload; + if (!isRecord(payload)) return { ok: false, code: "invalid_shape" }; + const registry = payload.registry; + if ( + !isRecord(registry) || + registry.version !== 4 || + !Array.isArray(registry.providers) + ) { + return { ok: false, code: "invalid_shape" }; + } + for (const provider of registry.providers) { + if ( + !isRecord(provider) || + typeof provider.id !== "string" || + !Array.isArray(provider.models) + ) { + return { ok: false, code: "invalid_shape" }; + } + for (const model of provider.models) { + if (!isRecord(model) || typeof model.key !== "string") { + return { ok: false, code: "invalid_shape" }; + } + } + } + if (!Array.isArray(payload.credentials)) { + return { ok: false, code: "invalid_shape" }; + } + for (const credential of payload.credentials) { + if ( + !isRecord(credential) || + typeof credential.credential_id !== "string" || + typeof credential.secret_value !== "string" + ) { + return { ok: false, code: "invalid_shape" }; + } + } + return { ok: true, value: parsed.value as unknown as RegistryExportEnvelope }; +} + +export function parseImageExport(text: string): ParseResult { + const parsed = parseEnvelope(text, IMAGE_EXPORT_KIND); + if (!parsed.ok) return parsed; + const payload = parsed.value.payload; + if ( + !isRecord(payload) || + typeof payload.default_model !== "string" || + typeof payload.timeout_seconds !== "number" || + !Array.isArray(payload.models) + ) { + return { ok: false, code: "invalid_shape" }; + } + for (const model of payload.models) { + if ( + !isRecord(model) || + typeof model.id !== "string" || + (model.provider !== "openai" && model.provider !== "gemini") || + typeof model.api_key !== "string" || + typeof model.base_url !== "string" || + !isRecord(model.params) + ) { + return { ok: false, code: "invalid_shape" }; + } + } + return { ok: true, value: parsed.value as unknown as ImageExportEnvelope }; +} + +function modelRefExists(registry: RegistryV4, ref: ModelRef | null): boolean { + if (!ref) return false; + const provider = registry.providers.find((p) => p.id === ref.provider_id); + return provider?.models.some((m) => m.key === ref.model_key) ?? false; +} + +/** Merge by provider id: imported replaces same-id wholesale, new ids are + * appended, unmentioned providers are kept. */ +export function mergeRegistry( + current: RegistryV4, + imported: RegistryV4 +): RegistryV4 { + const importedById = new Map( + imported.providers.map((provider) => [provider.id, provider]) + ); + const currentIds = new Set(current.providers.map((provider) => provider.id)); + const providers = current.providers.map( + (provider) => importedById.get(provider.id) ?? provider + ); + for (const provider of imported.providers) { + if (!currentIds.has(provider.id)) providers.push(provider); + } + const merged = structuredClone(current); + merged.providers = structuredClone(providers); + merged.defaults = { + primary: + [current.defaults.primary, imported.defaults.primary].find((ref) => + modelRefExists(merged, ref) + ) ?? null, + }; + return merged; +} + +function registryCredentialWrites( + envelope: RegistryExportEnvelope +): CredentialWrite[] { + return envelope.payload.credentials.map((credential) => ({ + credential_id: credential.credential_id, + operation: "replace", + secret_value: credential.secret_value, + })); +} + +export function buildRegistryImportBody( + envelope: RegistryExportEnvelope, + current: { revision: number; registry: RegistryV4 }, + mode: "replace" | "merge" +): PutModelRegistryRequest { + return { + expected_revision: current.revision, + registry: + mode === "replace" + ? structuredClone(envelope.payload.registry) + : mergeRegistry(current.registry, envelope.payload.registry), + credential_writes: registryCredentialWrites(envelope), + }; +} + +/** Merge by model id: same rule as the registry. Kept entries send a blank + * api_key, which the backend interprets as "keep the stored key". */ +export function mergeImageSettings( + current: ImageGenerationSettings, + imported: ImageExportPayload +): PutImageGenerationRequest { + const importedById = new Map(imported.models.map((model) => [model.id, model])); + const currentIds = new Set(current.models.map((model) => model.id)); + const models: ImageModel[] = current.models.map((model) => { + const replacement = importedById.get(model.id); + if (replacement) return structuredClone(replacement); + return { + id: model.id, + name: model.name, + provider: model.provider, + api_key: "", + base_url: model.base_url, + enabled: model.enabled, + default_size: model.default_size, + default_quality: model.default_quality, + params: structuredClone(model.params), + }; + }); + for (const model of imported.models) { + if (!currentIds.has(model.id)) models.push(structuredClone(model)); + } + return { + default_model: imported.default_model, + timeout_seconds: imported.timeout_seconds, + models, + }; +} + +export function buildImageImportBody( + envelope: ImageExportEnvelope, + current: ImageGenerationSettings, + mode: "replace" | "merge" +): PutImageGenerationRequest { + if (mode === "replace") { + return structuredClone(envelope.payload); + } + return mergeImageSettings(current, envelope.payload); +} + +/** Ids of providers that need a credential but have none in the file. */ +export function registryMissingSecrets( + envelope: RegistryExportEnvelope +): string[] { + const withSecret = new Set( + envelope.payload.credentials.map((credential) => credential.credential_id) + ); + const missing: string[] = []; + for (const provider of envelope.payload.registry.providers) { + const credentialId = provider.auth?.credential_id; + if (provider.auth?.mode !== "none" && credentialId && !withSecret.has(credentialId)) { + missing.push(provider.id); + } + } + return missing; +} + +/** Ids of imported image models with no key in the file and no stored key. */ +export function imageMissingSecrets( + envelope: ImageExportEnvelope, + current: ImageGenerationSettings +): string[] { + const configured = new Set( + current.models + .filter((model) => model.api_key_configured) + .map((model) => model.id) + ); + return envelope.payload.models + .filter((model) => !model.api_key && !configured.has(model.id)) + .map((model) => model.id); +} + +export function exportFilename( + kind: "model-registry" | "image-generation", + date: Date = new Date() +): string { + return `evoscientist-${kind}-${date.toISOString().slice(0, 10)}.json`; +} + +export function downloadTextFile(filename: string, text: string): void { + const url = URL.createObjectURL(new Blob([text], { type: "application/json" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/configTransfer.test.ts` +Expected: all pass. Also run `npx tsc --noEmit`. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/configTransfer.ts src/lib/configTransfer.test.ts +git commit -m "feat(lib): add configTransfer parse/merge logic for config import/export" +``` + +--- + +### Task 5: WebUI `ConfigImportDialog` shared component (EvoScientist-WebUI repo) + +**Files:** +- Create: `src/app/components/ConfigImportDialog.tsx` + +**Interfaces:** +- Consumes: Task 4's `parseRegistryExport`/`parseImageExport`/`buildRegistryImportBody`/`buildImageImportBody`/`registryMissingSecrets`/`imageMissingSecrets`/`MAX_IMPORT_BYTES`/envelope types; `getImageGeneration`/`putImageGeneration` from `@/lib/imageGeneration`; `GetModelRegistryResponse` from `@/lib/modelRegistry`; Task 3's `configTransfer` i18n namespace; existing `@/components/ui/dialog` + `button`. +- Produces: `ConfigImportDialog` with props `{ kind: "model-registry" | "image-generation"; open: boolean; onOpenChange: (open: boolean) => void; onImported: () => Promise | void }` — Task 6 mounts it in both editors. + +No unit tests (vitest has no DOM environment; all logic is covered by Task 4's tests). Verification is typecheck + manual. + +- [ ] **Step 1: Create the component** `src/app/components/ConfigImportDialog.tsx`: + +```tsx +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + buildImageImportBody, + buildRegistryImportBody, + IMAGE_EXPORT_KIND, + imageMissingSecrets, + MAX_IMPORT_BYTES, + parseImageExport, + parseRegistryExport, + REGISTRY_EXPORT_KIND, + registryMissingSecrets, + type ImageExportEnvelope, + type ParseErrorCode, + type RegistryExportEnvelope, +} from "@/lib/configTransfer"; +import type { GetModelRegistryResponse } from "@/lib/modelRegistry"; +import { + getImageGeneration, + putImageGeneration, + type ImageGenerationSettings, +} from "@/lib/imageGeneration"; + +const inputClass = + "w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"; + +type Kind = "model-registry" | "image-generation"; +type Mode = "replace" | "merge"; + +interface ConfigImportDialogProps { + kind: Kind; + open: boolean; + onOpenChange: (open: boolean) => void; + onImported: () => Promise | void; +} + +interface ParsedState { + registry: RegistryExportEnvelope | null; + image: ImageExportEnvelope | null; + missing: string[]; +} + +const EMPTY_IMAGE_SETTINGS: ImageGenerationSettings = { + default_model: "", + timeout_seconds: 0, + models: [], +}; + +export function ConfigImportDialog({ + kind, + open, + onOpenChange, + onImported, +}: ConfigImportDialogProps) { + const t = useTranslations("configTransfer"); + const [text, setText] = useState(""); + const [parseError, setParseError] = useState(null); + const [parsed, setParsed] = useState(null); + const [mode, setMode] = useState("replace"); + const [submitError, setSubmitError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) { + setText(""); + setParseError(null); + setParsed(null); + setMode("replace"); + setSubmitError(null); + setSubmitting(false); + } + }, [open]); + + const analyze = useCallback( + async (raw: string) => { + setText(raw); + setSubmitError(null); + if (!raw.trim()) { + setParseError(null); + setParsed(null); + return; + } + if (kind === "model-registry") { + const result = parseRegistryExport(raw); + if (!result.ok) { + setParseError(result.code); + setParsed(null); + return; + } + setParseError(null); + setParsed({ + registry: result.value, + image: null, + missing: registryMissingSecrets(result.value), + }); + } else { + const result = parseImageExport(raw); + if (!result.ok) { + setParseError(result.code); + setParsed(null); + return; + } + // Current stored keys decide which empty file keys are real gaps. + let current = EMPTY_IMAGE_SETTINGS; + try { + current = await getImageGeneration(); + } catch { + // Conservative: every empty key is flagged. + } + setParseError(null); + setParsed({ + registry: null, + image: result.value, + missing: imageMissingSecrets(result.value, current), + }); + } + }, + [kind] + ); + + const onFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + if (file.size > MAX_IMPORT_BYTES) { + setText(""); + setParsed(null); + setParseError("too_large"); + return; + } + await analyze(await file.text()); + }; + + const submit = async () => { + if (!parsed) return; + setSubmitting(true); + setSubmitError(null); + try { + if (kind === "model-registry" && parsed.registry) { + const currentResponse = await fetch("/api/model-registry", { + cache: "no-store", + }); + if (!currentResponse.ok) { + throw new Error( + t("dialog.loadCurrentFailed", { status: currentResponse.status }) + ); + } + const current = (await currentResponse.json()) as GetModelRegistryResponse; + const body = buildRegistryImportBody(parsed.registry, current, mode); + const putResponse = await fetch("/api/model-registry", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!putResponse.ok) { + const errorBody = await putResponse.json().catch(() => null); + const message = + typeof errorBody?.message === "string" + ? errorBody.message + : `(${putResponse.status})`; + throw new Error(t("dialog.failed", { message })); + } + } else if (kind === "image-generation" && parsed.image) { + const current = await getImageGeneration(); + await putImageGeneration(buildImageImportBody(parsed.image, current, mode)); + } + toast.success(t("dialog.success")); + onOpenChange(false); + await onImported(); + } catch (reason) { + setSubmitError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setSubmitting(false); + } + }; + + const parseErrorMessages: Record = { + too_large: t("dialog.errors.too_large"), + invalid_json: t("dialog.errors.invalid_json"), + wrong_kind: t("dialog.errors.wrong_kind", { + expected: kind === "model-registry" ? REGISTRY_EXPORT_KIND : IMAGE_EXPORT_KIND, + }), + unsupported_version: t("dialog.errors.unsupported_version"), + invalid_shape: t("dialog.errors.invalid_shape"), + }; + + const preview = + parsed?.registry != null + ? t("dialog.previewRegistry", { + providers: parsed.registry.payload.registry.providers.length, + models: parsed.registry.payload.registry.providers.reduce( + (count, provider) => count + provider.models.length, + 0 + ), + }) + : parsed?.image != null + ? t("dialog.previewImage", { models: parsed.image.payload.models.length }) + : null; + + return ( + + + + + {kind === "model-registry" + ? t("dialog.titleRegistry") + : t("dialog.titleImage")} + + {t("dialog.description")} + +
+ +