From 875d17b62f9af2a43a76bc4ae8a490798ff9d5b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:12:16 +0200 Subject: [PATCH 01/10] Run cockpit prod with srvx --- apps/cockpit/Dockerfile | 2 +- apps/cockpit/package.json | 1 + pnpm-lock.yaml | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/cockpit/Dockerfile b/apps/cockpit/Dockerfile index 3dabe27..0947173 100644 --- a/apps/cockpit/Dockerfile +++ b/apps/cockpit/Dockerfile @@ -35,4 +35,4 @@ RUN pnpm --dir apps/cockpit run build WORKDIR /workspace/apps/cockpit EXPOSE 3000 -CMD ["pnpm", "exec", "vite", "preview", "--host", "0.0.0.0", "--port", "3000", "--strictPort"] +CMD ["pnpm", "exec", "srvx", "serve", "--dir", ".", "--entry", "dist/server/server.js", "--static", "dist/client", "--prod"] diff --git a/apps/cockpit/package.json b/apps/cockpit/package.json index bc639d4..56dfc03 100644 --- a/apps/cockpit/package.json +++ b/apps/cockpit/package.json @@ -32,6 +32,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-icons": "5.5.0", + "srvx": "0.11.8", "tailwindcss": "4.3.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a10db5..5f85e89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: react-icons: specifier: 5.5.0 version: 5.5.0(react@19.2.4) + srvx: + specifier: 0.11.8 + version: 0.11.8 tailwindcss: specifier: 4.3.0 version: 4.3.0 From 27fcf3001bcb7cd34cecafe8015618e914411338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:18:55 +0200 Subject: [PATCH 02/10] Install only prod deps in cockpit image --- apps/cockpit/Dockerfile | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/cockpit/Dockerfile b/apps/cockpit/Dockerfile index 0947173..6592d05 100644 --- a/apps/cockpit/Dockerfile +++ b/apps/cockpit/Dockerfile @@ -28,11 +28,47 @@ WORKDIR /workspace/apps/cockpit EXPOSE 3000 CMD ["pnpm", "exec", "vite", "dev", "--host", "0.0.0.0", "--port", "3000", "--strictPort"] -FROM base AS prod +FROM base AS build COPY . . RUN pnpm --dir apps/cockpit run build +FROM node:24-alpine AS prod-deps +ENV PNPM_HOME='/pnpm' +ENV PATH="$PNPM_HOME:$PATH" + +RUN corepack enable + +WORKDIR /workspace + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/cockpit/package.json apps/cockpit/package.json +COPY libs/ts-log/package.json libs/ts-log/package.json + +RUN pnpm install --prod --frozen-lockfile --ignore-scripts + +FROM node:24-alpine AS prod +ENV PNPM_HOME='/pnpm' +ENV PATH="$PNPM_HOME:$PATH" + +ARG BACKEND_BASE_URL=http://localhost:5010 +ARG ASSISTANT_SERVICE_BASE_URL=http://localhost:5020 +ARG VITE_BACKEND_API_BASE_URL=http://localhost:5010 +ARG VITE_ASSISTANT_API_BASE_URL=http://localhost:5020 +ENV BACKEND_BASE_URL=$BACKEND_BASE_URL +ENV VITE_BACKEND_API_BASE_URL=$VITE_BACKEND_API_BASE_URL +ENV ASSISTANT_SERVICE_BASE_URL=$ASSISTANT_SERVICE_BASE_URL +ENV VITE_ASSISTANT_API_BASE_URL=$VITE_ASSISTANT_API_BASE_URL + +RUN corepack enable + +WORKDIR /workspace + +COPY --from=prod-deps /workspace/node_modules ./node_modules +COPY --from=prod-deps /workspace/apps/cockpit/node_modules ./apps/cockpit/node_modules +COPY . . +COPY --from=build /workspace/apps/cockpit/dist apps/cockpit/dist + WORKDIR /workspace/apps/cockpit EXPOSE 3000 CMD ["pnpm", "exec", "srvx", "serve", "--dir", ".", "--entry", "dist/server/server.js", "--static", "dist/client", "--prod"] From a4669ff2f0c458a2f4403cbe9c363d2e4972c3ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:23:08 +0200 Subject: [PATCH 03/10] Copy only cockpit runtime artifacts --- apps/cockpit/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cockpit/Dockerfile b/apps/cockpit/Dockerfile index 6592d05..c9a067b 100644 --- a/apps/cockpit/Dockerfile +++ b/apps/cockpit/Dockerfile @@ -66,7 +66,9 @@ WORKDIR /workspace COPY --from=prod-deps /workspace/node_modules ./node_modules COPY --from=prod-deps /workspace/apps/cockpit/node_modules ./apps/cockpit/node_modules -COPY . . +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/cockpit/package.json apps/cockpit/package.json +COPY libs/ts-log/package.json libs/ts-log/package.json COPY --from=build /workspace/apps/cockpit/dist apps/cockpit/dist WORKDIR /workspace/apps/cockpit From bb552bb6cc138d8724530f77e244083d4775c108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:02:30 +0200 Subject: [PATCH 04/10] Deploy cockpit runtime with pnpm deploy --- apps/cockpit/Dockerfile | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/apps/cockpit/Dockerfile b/apps/cockpit/Dockerfile index c9a067b..90dc8de 100644 --- a/apps/cockpit/Dockerfile +++ b/apps/cockpit/Dockerfile @@ -31,26 +31,10 @@ CMD ["pnpm", "exec", "vite", "dev", "--host", "0.0.0.0", "--port", "3000", "--st FROM base AS build COPY . . -RUN pnpm --dir apps/cockpit run build - -FROM node:24-alpine AS prod-deps -ENV PNPM_HOME='/pnpm' -ENV PATH="$PNPM_HOME:$PATH" - -RUN corepack enable - -WORKDIR /workspace - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY apps/cockpit/package.json apps/cockpit/package.json -COPY libs/ts-log/package.json libs/ts-log/package.json - -RUN pnpm install --prod --frozen-lockfile --ignore-scripts +RUN pnpm --dir apps/cockpit run build \ + && pnpm --filter @central/cockpit deploy --prod --legacy --offline /workspace/cockpit-deploy FROM node:24-alpine AS prod -ENV PNPM_HOME='/pnpm' -ENV PATH="$PNPM_HOME:$PATH" - ARG BACKEND_BASE_URL=http://localhost:5010 ARG ASSISTANT_SERVICE_BASE_URL=http://localhost:5020 ARG VITE_BACKEND_API_BASE_URL=http://localhost:5010 @@ -60,17 +44,11 @@ ENV VITE_BACKEND_API_BASE_URL=$VITE_BACKEND_API_BASE_URL ENV ASSISTANT_SERVICE_BASE_URL=$ASSISTANT_SERVICE_BASE_URL ENV VITE_ASSISTANT_API_BASE_URL=$VITE_ASSISTANT_API_BASE_URL -RUN corepack enable - -WORKDIR /workspace +WORKDIR /workspace/apps/cockpit -COPY --from=prod-deps /workspace/node_modules ./node_modules -COPY --from=prod-deps /workspace/apps/cockpit/node_modules ./apps/cockpit/node_modules -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY apps/cockpit/package.json apps/cockpit/package.json -COPY libs/ts-log/package.json libs/ts-log/package.json -COPY --from=build /workspace/apps/cockpit/dist apps/cockpit/dist +COPY --from=build /workspace/cockpit-deploy/node_modules ./node_modules +COPY --from=build /workspace/cockpit-deploy/package.json ./package.json +COPY --from=build /workspace/apps/cockpit/dist ./dist -WORKDIR /workspace/apps/cockpit EXPOSE 3000 -CMD ["pnpm", "exec", "srvx", "serve", "--dir", ".", "--entry", "dist/server/server.js", "--static", "dist/client", "--prod"] +CMD ["node", "node_modules/srvx/bin/srvx.mjs", "serve", "--dir", ".", "--entry", "dist/server/server.js", "--static", "dist/client", "--prod"] From 00a1507c6e1c36f576cb70b9c93a6426dc288b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:11:58 +0200 Subject: [PATCH 05/10] Keep cockpit devtools out of prod --- apps/cockpit/package.json | 4 ++-- apps/cockpit/src/routes/__root.tsx | 8 +++++--- apps/cockpit/vite.config.ts | 2 +- pnpm-lock.yaml | 12 ++++++------ 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/cockpit/package.json b/apps/cockpit/package.json index 56dfc03..7b7c030 100644 --- a/apps/cockpit/package.json +++ b/apps/cockpit/package.json @@ -23,9 +23,7 @@ "@meteocons/svg": "^0.1.0", "@ricky0123/vad-react": "0.0.33", "@tailwindcss/vite": "4.2.1", - "@tanstack/react-devtools": "0.9.6", "@tanstack/react-router": "1.163.2", - "@tanstack/react-router-devtools": "1.163.2", "@tanstack/react-router-ssr-query": "1.163.2", "@tanstack/react-start": "1.163.2", "@tanstack/router-plugin": "1.163.2", @@ -37,6 +35,8 @@ }, "devDependencies": { "@tanstack/devtools-vite": "0.5.1", + "@tanstack/react-devtools": "0.9.6", + "@tanstack/react-router-devtools": "1.163.2", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", diff --git a/apps/cockpit/src/routes/__root.tsx b/apps/cockpit/src/routes/__root.tsx index b4e49f0..4b4b266 100644 --- a/apps/cockpit/src/routes/__root.tsx +++ b/apps/cockpit/src/routes/__root.tsx @@ -1,13 +1,15 @@ -import type { PropsWithChildren } from 'react'; +import { lazy, type PropsWithChildren } from 'react'; import { createRootRoute, HeadContent, Scripts } from '@tanstack/react-router'; import appCss from '../styles.css?url'; import { PageLayout } from '@/components/PageLayout/PageLayout.tsx'; -import { Devtools } from '@/components/Devtools/Devtools.tsx'; import { Section } from '@/components/Section/Section.tsx'; import { ContentLayout } from '@/components/ContentLayout/ContentLayout.tsx'; import { Navigation } from '@/components/Navigation/Navigation.tsx'; import { MdOutlineHome as HomeIcon } from 'react-icons/md'; +const Devtools = import.meta.env.DEV + ? lazy(() => import('@/components/Devtools/Devtools.tsx').then((module) => ({ default: module.Devtools }))) + : null; const title = 'Central Dashboard'; export const Route = createRootRoute({ @@ -33,7 +35,7 @@ function RootDocument({ children }: PropsWithChildren) { {children} - + {Devtools ? : null} diff --git a/apps/cockpit/vite.config.ts b/apps/cockpit/vite.config.ts index e4de23e..a4979e1 100644 --- a/apps/cockpit/vite.config.ts +++ b/apps/cockpit/vite.config.ts @@ -7,7 +7,7 @@ import tailwindcss from '@tailwindcss/vite'; const config = defineConfig(({ mode }) => ({ plugins: [ - devtools(), + ...(mode === 'development' ? [devtools()] : []), tsconfigPaths({ projects: ['./tsconfig.json'] }), tailwindcss(), ...(mode === 'test' ? [] : [tanstackStart()]), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f85e89..4d6d885 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,15 +47,9 @@ importers: '@tailwindcss/vite': specifier: 4.2.1 version: 4.2.1(vite@7.3.1(@types/node@24.1.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) - '@tanstack/react-devtools': - specifier: 0.9.6 - version: 0.9.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11) '@tanstack/react-router': specifier: 1.163.2 version: 1.163.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/react-router-devtools': - specifier: 1.163.2 - version: 1.163.2(@tanstack/react-router@1.163.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.2)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-router-ssr-query': specifier: 1.163.2 version: 1.163.2(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(@tanstack/react-router@1.163.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -84,6 +78,12 @@ importers: '@tanstack/devtools-vite': specifier: 0.5.1 version: 0.5.1(vite@7.3.1(@types/node@24.1.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/react-devtools': + specifier: 0.9.6 + version: 0.9.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11) + '@tanstack/react-router-devtools': + specifier: 1.163.2 + version: 1.163.2(@tanstack/react-router@1.163.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.2)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 From a361726bede9e94011dc7df4328a154da3cdbabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:31:50 +0200 Subject: [PATCH 06/10] Set artifact retention to 1 day --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8be86aa..2a8ad05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -357,3 +357,4 @@ jobs: with: name: central-deploy-${{ github.ref_name }} path: dist/central-deploy-${{ github.ref_name }}.tar.gz + retention-days: 1 From eb0a981d72068f3a701165b93751b365df88967e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:42:32 +0200 Subject: [PATCH 07/10] Disable VAD --- apps/cockpit/Dockerfile | 1 - apps/cockpit/README.md | 13 +- apps/cockpit/package.json | 7 - apps/cockpit/scripts/sync-vad-assets.mjs | 179 ------------------ .../src/domain/assistant/Jarvis/Jarvis.tsx | 63 ++---- .../domain/voice/components/VoiceWidget.tsx | 66 +------ .../domain/voice/model/vadAssetPaths.test.ts | 38 ---- .../src/domain/voice/model/vadAssetPaths.ts | 32 ---- package.json | 3 +- pnpm-lock.yaml | 132 ------------- 10 files changed, 22 insertions(+), 512 deletions(-) delete mode 100644 apps/cockpit/scripts/sync-vad-assets.mjs delete mode 100644 apps/cockpit/src/domain/voice/model/vadAssetPaths.test.ts delete mode 100644 apps/cockpit/src/domain/voice/model/vadAssetPaths.ts diff --git a/apps/cockpit/Dockerfile b/apps/cockpit/Dockerfile index 90dc8de..e9fee0d 100644 --- a/apps/cockpit/Dockerfile +++ b/apps/cockpit/Dockerfile @@ -17,7 +17,6 @@ WORKDIR /workspace COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/cockpit/package.json apps/cockpit/package.json -COPY apps/cockpit/scripts/ apps/cockpit/scripts/ COPY libs/ts-log/package.json libs/ts-log/package.json RUN pnpm install --frozen-lockfile diff --git a/apps/cockpit/README.md b/apps/cockpit/README.md index 622702b..bad27fa 100644 --- a/apps/cockpit/README.md +++ b/apps/cockpit/README.md @@ -67,8 +67,7 @@ The `/finance/cash` route manages manual income and expense transactions: The voice widget keeps `service-assistant` as the backend boundary, but its primary turn path is now streamed: - Browser speech segments are cut locally with browser VAD. -- Browser VAD model/worklet assets are self-hosted from cockpit under `public/vendor/` instead of loading from a CDN. -- The ONNX runtime module and WASM binary are self-hosted as Vite-managed app assets, so dev/build keep working without CDN requests. +- Browser voice activity detection is temporarily disabled. - The browser streams turns directly to `service-assistant` via `POST /api/v1/assistant/turn/stream`. - `service-assistant` then performs `STT -> streamed LLM -> chunked TTS`. - Cockpit starts audio playback as soon as the first synthesized chunk arrives. @@ -85,15 +84,7 @@ Voice widget diagnostics are written as structured `@central/ts-log` records wit For local debugging in Node-backed assistant turns, cockpit dumps artifacts into `apps/cockpit/tmp/` as input audio, per-chunk output audio files, and a JSON metadata file. -Self-hosted voice assets are synchronized from the installed `@ricky0123/vad-web` and `onnxruntime-web` packages by: - -```bash -pnpm --dir apps/cockpit run sync:voice-vad-assets -``` - -The workspace `postinstall` runs that sync automatically after `pnpm install` or dependency updates from the repository root. - -`build`, `start:dev`, `start:preview`, `test`, and `typecheck` also run the sync automatically before execution, so the matching runtime assets are always refreshed without manual copying. +VAD asset synchronization is disabled while browser VAD is offline. ## Container diff --git a/apps/cockpit/package.json b/apps/cockpit/package.json index 7b7c030..4f4fd4c 100644 --- a/apps/cockpit/package.json +++ b/apps/cockpit/package.json @@ -6,22 +6,15 @@ "#/*": "./src/*" }, "scripts": { - "sync:voice-vad-assets": "node scripts/sync-vad-assets.mjs", - "prebuild": "pnpm run sync:voice-vad-assets", "build": "vite build", - "prestart:dev": "pnpm run sync:voice-vad-assets", "start:dev": "vite dev --port 5000", - "prestart:preview": "pnpm run sync:voice-vad-assets", "start:preview": "vite preview", - "pretest": "pnpm run sync:voice-vad-assets", "test": "vitest run", - "pretypecheck": "pnpm run sync:voice-vad-assets", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { "@central/ts-log": "workspace:*", "@meteocons/svg": "^0.1.0", - "@ricky0123/vad-react": "0.0.33", "@tailwindcss/vite": "4.2.1", "@tanstack/react-router": "1.163.2", "@tanstack/react-router-ssr-query": "1.163.2", diff --git a/apps/cockpit/scripts/sync-vad-assets.mjs b/apps/cockpit/scripts/sync-vad-assets.mjs deleted file mode 100644 index c966aa5..0000000 --- a/apps/cockpit/scripts/sync-vad-assets.mjs +++ /dev/null @@ -1,179 +0,0 @@ -import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { createRequire } from 'node:module' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const cockpitRoot = path.resolve(__dirname, '..') -const publicVendorRoot = path.join(cockpitRoot, 'public', 'vendor') -const generatedVoiceAssetRoot = path.join(cockpitRoot, 'src', 'widgets', 'voice', 'generated') -const manifestPath = path.join(publicVendorRoot, 'voice-vad-assets.manifest.json') -const legacyOnnxPublicAssetRoot = path.join(publicVendorRoot, 'onnxruntime-web') - -const require = createRequire(import.meta.url) -const vadReactPackageJsonPath = require.resolve('@ricky0123/vad-react/package.json') -const vadReactRequire = createRequire(vadReactPackageJsonPath) - -function packageRootFromPackageJson(packageJsonPath) { - return path.dirname(packageJsonPath) -} - -async function readPackageVersion(packageJsonPath) { - const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) - if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) { - throw new Error(`Package at ${packageJsonPath} does not declare a valid version.`) - } - - return packageJson.version -} - -async function resolvePackageJsonPath(packageName, resolver) { - const entryPath = resolver.resolve(packageName) - let currentDirectory = path.dirname(entryPath) - - while (true) { - const packageJsonPath = path.join(currentDirectory, 'package.json') - - try { - const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) - if (packageJson.name === packageName) { - return packageJsonPath - } - } catch { - // keep walking up to the package root - } - - const parentDirectory = path.dirname(currentDirectory) - if (parentDirectory === currentDirectory) { - break - } - - currentDirectory = parentDirectory - } - - throw new Error(`Failed to locate package.json for ${packageName}.`) -} - -async function ensureFilesExist(directory, requiredFiles) { - for (const requiredFile of requiredFiles) { - const requiredPath = path.join(directory, requiredFile) - try { - await stat(requiredPath) - } catch (error) { - throw new Error( - `Expected runtime asset "${requiredFile}" in ${directory}, but it was not found.`, - { cause: error }, - ) - } - } -} - -async function copyFiles(sourceDirectory, destinationDirectory, files) { - await rm(destinationDirectory, { force: true, recursive: true }) - await mkdir(destinationDirectory, { recursive: true }) - - for (const file of files) { - const sourcePath = path.join(sourceDirectory, file) - const destinationPath = path.join(destinationDirectory, file) - - await mkdir(path.dirname(destinationPath), { recursive: true }) - await cp(sourcePath, destinationPath) - } -} - -async function countFiles(directory) { - const entries = await stat(directory) - if (!entries.isDirectory()) { - return 0 - } - - let fileCount = 0 - const stack = [directory] - - while (stack.length > 0) { - const currentDirectory = stack.pop() - const children = await readdir(currentDirectory, { withFileTypes: true }) - - for (const child of children) { - const childPath = path.join(currentDirectory, child.name) - if (child.isDirectory()) { - stack.push(childPath) - continue - } - - if (child.isFile()) { - fileCount += 1 - } - } - } - - return fileCount -} - -async function syncPackageAssets({ destinationName, packageName, requiredFiles, resolver = require }) { - const packageJsonPath = await resolvePackageJsonPath(packageName, resolver) - const packageRoot = packageRootFromPackageJson(packageJsonPath) - const version = await readPackageVersion(packageJsonPath) - const sourceDirectory = path.join(packageRoot, 'dist') - const destinationDirectory = path.join(cockpitRoot, destinationName) - - await ensureFilesExist(sourceDirectory, requiredFiles) - await copyFiles(sourceDirectory, destinationDirectory, requiredFiles) - await ensureFilesExist(destinationDirectory, requiredFiles) - - return { - destinationDirectory, - destinationName, - fileCount: await countFiles(destinationDirectory), - packageName, - sourceDirectory, - version, - } -} - -async function run() { - await rm(legacyOnnxPublicAssetRoot, { force: true, recursive: true }) - - const copiedPackages = await Promise.all([ - syncPackageAssets({ - destinationName: path.join('public', 'vendor', 'vad'), - packageName: '@ricky0123/vad-web', - requiredFiles: ['silero_vad_legacy.onnx', 'silero_vad_v5.onnx', 'vad.worklet.bundle.min.js'], - resolver: vadReactRequire, - }), - syncPackageAssets({ - destinationName: path.join('src', 'widgets', 'voice', 'generated', 'onnxruntime-web'), - packageName: 'onnxruntime-web', - requiredFiles: ['ort-wasm-simd-threaded.jsep.mjs', 'ort-wasm-simd-threaded.jsep.wasm'], - resolver: vadReactRequire, - }), - ]) - - const manifest = { - copiedAt: new Date().toISOString(), - packages: copiedPackages.map((copiedPackage) => ({ - destinationDirectory: path.relative(cockpitRoot, copiedPackage.destinationDirectory), - destinationName: copiedPackage.destinationName, - fileCount: copiedPackage.fileCount, - packageName: copiedPackage.packageName, - sourceDirectory: path.relative(cockpitRoot, copiedPackage.sourceDirectory), - version: copiedPackage.version, - })), - } - - await mkdir(publicVendorRoot, { recursive: true }) - await mkdir(generatedVoiceAssetRoot, { recursive: true }) - await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') - - for (const copiedPackage of copiedPackages) { - console.log( - `Synced ${copiedPackage.packageName}@${copiedPackage.version} -> ${path.relative(cockpitRoot, copiedPackage.destinationDirectory)} (${copiedPackage.fileCount} files)`, - ) - } -} - -run().catch((error) => { - console.error(error instanceof Error ? error.message : error) - process.exitCode = 1 -}) diff --git a/apps/cockpit/src/domain/assistant/Jarvis/Jarvis.tsx b/apps/cockpit/src/domain/assistant/Jarvis/Jarvis.tsx index f6c102f..a98013d 100644 --- a/apps/cockpit/src/domain/assistant/Jarvis/Jarvis.tsx +++ b/apps/cockpit/src/domain/assistant/Jarvis/Jarvis.tsx @@ -1,6 +1,5 @@ import 'src/domain/assistant/Jarvis/jarvis.css'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useMicVAD } from '@ricky0123/vad-react'; import { MdGraphicEq as OutputIcon, MdMic as MicIcon, @@ -9,8 +8,6 @@ import { } from 'react-icons/md'; import { cx } from '@/utils/styles.ts'; import { formatJarvisPercent, type JarvisMode, resolveJarvisSystemState } from '@/domain/assistant/Jarvis/model.ts'; -import { configureVoiceOrt, VAD_BASE_ASSET_PATH } from '@/domain/voice/model/vadAssetPaths.ts'; -import { getFloat32SignalLevel } from '@/domain/voice/model/audioLevel.ts'; import { useVoiceConversation } from '@/domain/voice/model/useVoiceConversation.ts'; import { JarvisOrb } from '@/domain/assistant/Jarvis/JarvisOrb.tsx'; @@ -38,11 +35,6 @@ const INITIAL_MICROPHONE_STATE: MicrophoneState = { userSpeaking: false, }; -type ActiveMicrophoneProps = { - onSpeechSegment: (audio: Float32Array) => Promise; - onStateChange: (state: MicrophoneState) => void; -}; - function clamp01(value: number): number { if (Number.isNaN(value) || value <= 0) { return 0; @@ -130,39 +122,6 @@ function useJarvisMotion(mode: JarvisMode, microphoneLevel: number, playbackLeve return frame; } -function ActiveMicrophone({ onSpeechSegment, onStateChange }: ActiveMicrophoneProps) { - const [micLevel, setMicLevel] = useState(0); - const vad = useMicVAD({ - startOnLoad: true, - baseAssetPath: VAD_BASE_ASSET_PATH, - ortConfig: configureVoiceOrt, - onFrameProcessed: (_probabilities, frame) => { - setMicLevel(getFloat32SignalLevel(frame)); - }, - onSpeechEnd: (audio) => { - setMicLevel(0); - void onSpeechSegment(audio); - }, - onVADMisfire: () => setMicLevel(0), - }); - - useEffect(() => { - onStateChange({ - error: vad.errored ? String(vad.errored) : null, - isListening: vad.listening, - isLoading: vad.loading, - micLevel: vad.userSpeaking ? micLevel : Math.min(micLevel, 0.16), - userSpeaking: vad.userSpeaking, - }); - }, [micLevel, onStateChange, vad.errored, vad.listening, vad.loading, vad.userSpeaking]); - - useEffect(() => { - return () => onStateChange(INITIAL_MICROPHONE_STATE); - }, [onStateChange]); - - return null; -} - function TelemetryList({ items, title }: { items: ReadonlyArray<{ label: string; value: string }>; title: string }) { return (
@@ -181,12 +140,13 @@ function TelemetryList({ items, title }: { items: ReadonlyArray<{ label: string; export function Jarvis() { const [isEnabled, setIsEnabled] = useState(false); - const [microphoneState, setMicrophoneState] = useState(INITIAL_MICROPHONE_STATE); + const microphoneState = INITIAL_MICROPHONE_STATE; const conversation = useVoiceConversation({ language: 'de', }); - const shouldListen = isEnabled && conversation.status !== 'processing' && conversation.status !== 'playing'; + const isVadEnabled = false; + const shouldListen = false; const systemState = useMemo( () => @@ -217,8 +177,10 @@ export function Jarvis() { return; } - setIsEnabled(true); - }, [conversation, isEnabled]); + if (isVadEnabled) { + setIsEnabled(true); + } + }, [conversation, isEnabled, isVadEnabled]); const leftTelemetry = useMemo( () => [ @@ -228,7 +190,7 @@ export function Jarvis() { }, { label: 'ARRAY', - value: microphoneState.isListening ? 'ARMED' : isEnabled ? 'HOLD' : 'OFFLINE', + value: microphoneState.isListening ? 'ARMED' : isVadEnabled && isEnabled ? 'HOLD' : 'OFFLINE', }, { label: 'CAPTURE', @@ -275,7 +237,7 @@ export function Jarvis() { conversation.transcript ?? (isEnabled ? 'Awaiting a captured speech segment from the live microphone.' - : 'Activate the system to arm local speech detection.'); + : 'Voice Activity Detection is temporarily disabled.'); const responseCopy = conversation.responseText ?? (conversation.status === 'playing' @@ -310,9 +272,10 @@ export function Jarvis() { type="button" className={cx('jarvis-shell__toggle', isEnabled ? 'jarvis-shell__toggle--active' : undefined)} onClick={toggleEnabled} + disabled={!isVadEnabled} > {isEnabled ? : } - {isEnabled ? 'Stand down' : 'Activate system'} + {isEnabled ? 'Stand down' : 'VAD disabled'}
@@ -342,10 +305,6 @@ export function Jarvis() { {errorMessage ?
{errorMessage}
: null} - - {shouldListen ? ( - - ) : null} ); } diff --git a/apps/cockpit/src/domain/voice/components/VoiceWidget.tsx b/apps/cockpit/src/domain/voice/components/VoiceWidget.tsx index 958b707..ffe7acc 100644 --- a/apps/cockpit/src/domain/voice/components/VoiceWidget.tsx +++ b/apps/cockpit/src/domain/voice/components/VoiceWidget.tsx @@ -1,43 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useMicVAD } from '@ricky0123/vad-react'; +import { useMemo } from 'react'; import { MdGraphicEq as VoiceIcon, MdMic as MicIcon, MdPauseCircle as StopIcon } from 'react-icons/md'; import { Section } from '@/components/Section/Section.tsx'; import { cx } from '@/utils/styles.ts'; -import { configureVoiceOrt, VAD_BASE_ASSET_PATH } from '@/domain/voice/model/vadAssetPaths.ts'; import { useVoiceConversation } from '@/domain/voice/model/useVoiceConversation.ts'; type ListeningPhase = 'initializing' | 'ready' | 'speaking'; -type ActiveMicrophoneProps = { - onSpeechSegment: (audio: Float32Array) => Promise; - onPhaseChange: (phase: ListeningPhase) => void; -}; - -function ActiveMicrophone({ onSpeechSegment, onPhaseChange }: ActiveMicrophoneProps) { - useMicVAD({ - startOnLoad: true, - baseAssetPath: VAD_BASE_ASSET_PATH, - ortConfig: configureVoiceOrt, - model: 'v5', - minSpeechMs: 500, - preSpeechPadMs: 1_000, - redemptionMs: 1_600, - onSpeechStart: () => onPhaseChange('speaking'), - onSpeechEnd: (audio) => { - onPhaseChange('ready'); - void onSpeechSegment(audio); - }, - onVADMisfire: () => onPhaseChange('ready'), - }); - - useEffect(() => { - onPhaseChange('ready'); - return () => onPhaseChange('initializing'); - }, [onPhaseChange]); - - return null; -} - function getStatusLabel( enabled: boolean, phase: ListeningPhase, @@ -72,30 +40,18 @@ function getStatusLabel( } export function VoiceWidget() { - const [isEnabled, setIsEnabled] = useState(false); - const [listeningPhase, setListeningPhase] = useState('initializing'); + const listeningPhase: ListeningPhase = 'initializing'; const conversation = useVoiceConversation({ language: 'de', }); - const shouldListen = isEnabled && conversation.status !== 'processing' && conversation.status !== 'playing'; + const isEnabled = false; const statusLabel = useMemo( () => getStatusLabel(isEnabled, listeningPhase, conversation.status, conversation.responseText), [conversation.responseText, conversation.status, isEnabled, listeningPhase], ); - const toggleEnabled = useCallback(() => { - if (isEnabled) { - conversation.stopPlayback(); - setListeningPhase('initializing'); - setIsEnabled(false); - return; - } - - setIsEnabled(true); - }, [conversation, isEnabled]); - return (
@@ -116,14 +72,12 @@ export function VoiceWidget() { type="button" className={cx( 'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition', - isEnabled - ? 'border-rose-500/40 bg-rose-500/10 text-rose-100' - : 'border-emerald-500/40 bg-emerald-500/10 text-emerald-100', + 'cursor-not-allowed border-slate-600/60 bg-slate-800/50 text-slate-400', )} - onClick={toggleEnabled} + disabled > {isEnabled ? : } - {isEnabled ? 'Deaktivieren' : 'Aktivieren'} + VAD deaktiviert
@@ -147,8 +101,8 @@ export function VoiceWidget() {

- Sprich nach dem Aktivieren frei in das Mikrofon. Das Widget trennt Sprache lokal im Browser, sendet nur - erkannte Sprachsegmente an den Assistant-Service und startet die Sprachausgabe bereits waehrend der Antwort. + Voice Activity Detection ist voruebergehend deaktiviert. Es werden keine Mikrofonsegmente erfasst oder an + den Assistant-Service gesendet.

@@ -173,10 +127,6 @@ export function VoiceWidget() {

- - {shouldListen ? ( - - ) : null}
); diff --git a/apps/cockpit/src/domain/voice/model/vadAssetPaths.test.ts b/apps/cockpit/src/domain/voice/model/vadAssetPaths.test.ts deleted file mode 100644 index 069c134..0000000 --- a/apps/cockpit/src/domain/voice/model/vadAssetPaths.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - configureVoiceOrt, - resolveVoiceStaticAssetPath, - type VoiceOrtModule, - VOICE_ORT_WASM_BINARY_URL, - VOICE_ORT_WASM_MODULE_URL, -} from 'src/domain/voice/model/vadAssetPaths.ts'; - -describe.skip('resolveVoiceStaticAssetPath', () => { - it('appends a relative asset path to the normalized base path', () => { - expect(resolveVoiceStaticAssetPath('vendor/vad/', '/')).toBe('/vendor/vad/'); - expect(resolveVoiceStaticAssetPath('vendor/vad/', '/cockpit/')).toBe('/cockpit/vendor/vad/'); - }); - - it('normalizes base paths without a trailing slash', () => { - expect(resolveVoiceStaticAssetPath('vendor/vad/', '/cockpit')).toBe('/cockpit/vendor/vad/'); - }); -}); - -describe.skip('configureVoiceOrt', () => { - it('points onnxruntime-web at Vite-managed self-hosted assets', () => { - const ort: VoiceOrtModule = { - env: { - logLevel: 'warning', - wasm: {}, - }, - }; - - configureVoiceOrt(ort); - - expect(ort.env.logLevel).toBe('error'); - expect(ort.env.wasm.wasmPaths).toEqual({ - mjs: VOICE_ORT_WASM_MODULE_URL, - wasm: VOICE_ORT_WASM_BINARY_URL, - }); - }); -}); diff --git a/apps/cockpit/src/domain/voice/model/vadAssetPaths.ts b/apps/cockpit/src/domain/voice/model/vadAssetPaths.ts deleted file mode 100644 index a8930f3..0000000 --- a/apps/cockpit/src/domain/voice/model/vadAssetPaths.ts +++ /dev/null @@ -1,32 +0,0 @@ -//import ortThreadedJsepModuleUrl from '@/domain/voice/generated/onnxruntime-web/ort-wasm-simd-threaded.jsep.mjs?url'; -//import ortThreadedJsepWasmUrl from '@/domain/voice/generated/onnxruntime-web/ort-wasm-simd-threaded.jsep.wasm?url'; - -function ensureTrailingSlash(value: string): string { - return value.endsWith('/') ? value : `${value}/`; -} - -export function resolveVoiceStaticAssetPath(relativePath: string, basePath: string = import.meta.env.BASE_URL): string { - const normalizedBasePath = ensureTrailingSlash(basePath); - return `${normalizedBasePath}${relativePath}`; -} - -export const VAD_BASE_ASSET_PATH = resolveVoiceStaticAssetPath('vendor/vad/'); -export const VOICE_ORT_WASM_MODULE_URL = ''; //ortThreadedJsepModuleUrl; -export const VOICE_ORT_WASM_BINARY_URL = ''; //ortThreadedJsepWasmUrl; - -export type VoiceOrtModule = { - env: { - logLevel?: string; - wasm: { - wasmPaths?: string | { mjs?: string | URL; wasm?: string | URL }; - }; - }; -}; - -export function configureVoiceOrt(ort: VoiceOrtModule): void { - ort.env.logLevel = 'error'; - ort.env.wasm.wasmPaths = { - mjs: VOICE_ORT_WASM_MODULE_URL, - wasm: VOICE_ORT_WASM_BINARY_URL, - }; -} diff --git a/package.json b/package.json index a34457a..2491715 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,7 @@ "logs:dev": "pnpm nx run i12e-orchestrator:logs-dev", "logs:prod": "pnpm nx run i12e-orchestrator:logs-prod", "ps:dev": "pnpm nx run i12e-orchestrator:ps-dev", - "ps:prod": "pnpm nx run i12e-orchestrator:ps-prod", - "postinstall": "pnpm --dir apps/cockpit run sync:voice-vad-assets" + "ps:prod": "pnpm nx run i12e-orchestrator:ps-prod" }, "private": true, "packageManager": "pnpm@10.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d6d885..837df38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,9 +41,6 @@ importers: '@meteocons/svg': specifier: ^0.1.0 version: 0.1.0 - '@ricky0123/vad-react': - specifier: 0.0.33 - version: 0.0.33(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tailwindcss/vite': specifier: 4.2.1 version: 4.2.1(vite@7.3.1(@types/node@24.1.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -1075,45 +1072,6 @@ packages: '@oxc-resolver/binding-win32-x64-msvc@11.18.0': resolution: {integrity: sha512-EhW8Su3AEACSw5HfzKMmyCtV0oArNrVViPdeOfvVYL9TrkL+/4c8fWHFTBtxUMUyCjhSG5xYNdwty1D/TAgL0Q==} - '@protobufjs/aspromise@1.1.2': - resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} - - '@protobufjs/base64@1.1.2': - resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} - - '@protobufjs/codegen@2.0.4': - resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} - - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} - - '@protobufjs/fetch@1.1.0': - resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} - - '@protobufjs/float@1.0.2': - resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - - '@protobufjs/inquire@1.1.0': - resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} - - '@protobufjs/path@1.1.2': - resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} - - '@protobufjs/pool@1.1.0': - resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - - '@protobufjs/utf8@1.1.0': - resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - - '@ricky0123/vad-react@0.0.33': - resolution: {integrity: sha512-XxZcTbJRxChDK1PBoboGWwTxVdPvV5KpnE+DsIMiWld8Fk4fFoEbheZTq4jQ5rLP9bT1Lc2HT5YZ3Lxg0ZNABQ==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@ricky0123/vad-web@0.0.27': - resolution: {integrity: sha512-4XFng44oj7qFQUrVYFpMnwRYJDFYrGUL0FmPWcrkF0gPneubJbu8KJvp+WaKn+70GNw2gwGZUMvd9hHiCJkUNg==} - '@rolldown/pluginutils@1.0.0-beta.40': resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} @@ -2133,9 +2091,6 @@ packages: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true - flatbuffers@25.9.23: - resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} - follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -2204,9 +2159,6 @@ packages: resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - guid-typescript@1.0.9: - resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} - h3@2.0.1-rc.14: resolution: {integrity: sha512-163qbGmTr/9rqQRNuqMqtgXnOUAkE4KTdauiC9y0E5iG1I65kte9NyfWvZw5RTDMt6eY+DtyoNzrQ9wA2BfvGQ==} engines: {node: '>=20.11.1'} @@ -2441,9 +2393,6 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} - long@5.3.2: - resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -2549,12 +2498,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - onnxruntime-common@1.24.3: - resolution: {integrity: sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==} - - onnxruntime-web@1.24.3: - resolution: {integrity: sha512-41dDq7fxtTm0XzGE7N0d6m8FcOY8EWtUA65GkOixJPB/G7DGzBmiDAnVVXHznRw9bgUZpb+4/1lQK/PNxGpbrQ==} - open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -2625,9 +2568,6 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - platform@1.3.6: - resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2645,10 +2585,6 @@ packages: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - protobufjs@7.5.4: - resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} - engines: {node: '>=12.0.0'} - proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -4345,40 +4281,6 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.18.0': optional: true - '@protobufjs/aspromise@1.1.2': {} - - '@protobufjs/base64@1.1.2': {} - - '@protobufjs/codegen@2.0.4': {} - - '@protobufjs/eventemitter@1.1.0': {} - - '@protobufjs/fetch@1.1.0': - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.0 - - '@protobufjs/float@1.0.2': {} - - '@protobufjs/inquire@1.1.0': {} - - '@protobufjs/path@1.1.2': {} - - '@protobufjs/pool@1.1.0': {} - - '@protobufjs/utf8@1.1.0': {} - - '@ricky0123/vad-react@0.0.33(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@ricky0123/vad-web': 0.0.27 - onnxruntime-web: 1.24.3 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@ricky0123/vad-web@0.0.27': - dependencies: - onnxruntime-web: 1.24.3 - '@rolldown/pluginutils@1.0.0-beta.40': {} '@rolldown/pluginutils@1.0.0-rc.3': {} @@ -5575,8 +5477,6 @@ snapshots: flat@5.0.2: {} - flatbuffers@25.9.23: {} - follow-redirects@1.15.11: {} form-data@4.0.5: @@ -5641,8 +5541,6 @@ snapshots: graphql@16.14.0: optional: true - guid-typescript@1.0.9: {} - h3@2.0.1-rc.14: dependencies: rou3: 0.7.12 @@ -5873,8 +5771,6 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 - long@5.3.2: {} - lru-cache@11.2.6: {} lru-cache@5.1.1: @@ -6020,17 +5916,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - onnxruntime-common@1.24.3: {} - - onnxruntime-web@1.24.3: - dependencies: - flatbuffers: 25.9.23 - guid-typescript: 1.0.9 - long: 5.3.2 - onnxruntime-common: 1.24.3 - platform: 1.3.6 - protobufjs: 7.5.4 - open@8.4.2: dependencies: define-lazy-prop: 2.0.0 @@ -6123,8 +6008,6 @@ snapshots: pirates@4.0.7: {} - platform@1.3.6: {} - postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -6145,21 +6028,6 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 - protobufjs@7.5.4: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 - '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 - '@types/node': 24.1.0 - long: 5.3.2 - proxy-from-env@1.1.0: {} punycode@2.3.1: {} From c95e81a350fb11414cb8d34fdb02194a94857c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:03:58 +0200 Subject: [PATCH 08/10] Upload to GHCR instead of job artifacts and cleanup PR images after merge --- .github/workflows/ci.yml | 191 +++++++++++------- .../0002-single-host-compose-image-release.md | 2 +- docs/toolchain.md | 4 +- 3 files changed, 126 insertions(+), 71 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a8ad05..6f8a05b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,11 @@ name: CI on: pull_request: + types: + - opened + - synchronize + - reopened + - closed push: branches: - main @@ -19,6 +24,7 @@ env: jobs: validate-cockpit: + if: ${{ !(github.event_name == 'pull_request' && github.event.action == 'closed') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -39,6 +45,7 @@ jobs: run: pnpm nx run-many --projects=cockpit -t lint typecheck test validate-backend: + if: ${{ !(github.event_name == 'pull_request' && github.event.action == 'closed') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -63,6 +70,7 @@ jobs: run: pnpm nx run-many --projects=backend -t lint typecheck test validate-i12e: + if: ${{ !(github.event_name == 'pull_request' && github.event.action == 'closed') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -86,6 +94,7 @@ jobs: run: docker compose --env-file i12e/orchestrator/deploy/.env.prod.example --file i12e/orchestrator/deploy/docker-compose.prod.yml config --quiet validate-release-ref: + if: ${{ !(github.event_name == 'pull_request' && github.event.action == 'closed') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -99,6 +108,7 @@ jobs: git merge-base --is-ancestor "$GITHUB_SHA" origin/main build-image-cockpit: + if: ${{ !(github.event_name == 'pull_request' && github.event.action == 'closed') }} runs-on: ubuntu-latest needs: - validate-release-ref @@ -108,28 +118,31 @@ jobs: with: fetch-depth: 0 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build cockpit image shell: bash run: | set -euo pipefail image_base="${REGISTRY}/${IMAGE_NAMESPACE,,}" - sha_tag="sha-${GITHUB_SHA::12}" - image="${image_base}/app-cockpit:${sha_tag}" + image_tag="sha-${GITHUB_SHA::12}" + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + image_tag="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::12}" + fi + image="${image_base}/app-cockpit:${image_tag}" docker build --file apps/cockpit/Dockerfile --tag "$image" . - mkdir -p dist/images - docker save "$image" --output dist/images/app-cockpit.tar - - - name: Upload cockpit image - uses: actions/upload-artifact@v7 - with: - name: image-app-cockpit - path: dist/images/app-cockpit.tar - retention-days: 1 + docker push "$image" build-image-backend: + if: ${{ !(github.event_name == 'pull_request' && github.event.action == 'closed') }} runs-on: ubuntu-latest needs: - validate-release-ref @@ -139,28 +152,31 @@ jobs: with: fetch-depth: 0 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build backend image shell: bash run: | set -euo pipefail image_base="${REGISTRY}/${IMAGE_NAMESPACE,,}" - sha_tag="sha-${GITHUB_SHA::12}" - image="${image_base}/service-backend:${sha_tag}" + image_tag="sha-${GITHUB_SHA::12}" + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + image_tag="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::12}" + fi + image="${image_base}/service-backend:${image_tag}" docker build --file services/backend/Dockerfile --tag "$image" . - mkdir -p dist/images - docker save "$image" --output dist/images/service-backend.tar - - - name: Upload backend image - uses: actions/upload-artifact@v7 - with: - name: image-service-backend - path: dist/images/service-backend.tar - retention-days: 1 + docker push "$image" build-images-i12e: + if: ${{ !(github.event_name == 'pull_request' && github.event.action == 'closed') }} runs-on: ubuntu-latest needs: - validate-release-ref @@ -170,30 +186,34 @@ jobs: with: fetch-depth: 0 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build i12e images shell: bash run: | set -euo pipefail image_base="${REGISTRY}/${IMAGE_NAMESPACE,,}" - sha_tag="sha-${GITHUB_SHA::12}" - postgres_image="${image_base}/i12e-postgres:${sha_tag}" - gateway_image="${image_base}/i12e-gateway:${sha_tag}" + image_tag="sha-${GITHUB_SHA::12}" + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + image_tag="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::12}" + fi + postgres_image="${image_base}/i12e-postgres:${image_tag}" + gateway_image="${image_base}/i12e-gateway:${image_tag}" docker build --file i12e/postgres/Dockerfile --tag "$postgres_image" i12e/postgres docker build --file i12e/gateway/Dockerfile --tag "$gateway_image" . - mkdir -p dist/images - docker save "$postgres_image" "$gateway_image" --output dist/images/i12e.tar - - - name: Upload i12e images - uses: actions/upload-artifact@v7 - with: - name: images-i12e - path: dist/images/i12e.tar - retention-days: 1 + docker push "$postgres_image" + docker push "$gateway_image" test-images-integration: + if: ${{ !(github.event_name == 'pull_request' && github.event.action == 'closed') }} runs-on: ubuntu-latest needs: - build-image-cockpit @@ -204,25 +224,22 @@ jobs: with: fetch-depth: 0 - - name: Download images - uses: actions/download-artifact@v4 - with: - pattern: image-* - path: dist/images - merge-multiple: true - - - name: Download i12e images - uses: actions/download-artifact@v4 + - name: Log in to GHCR + uses: docker/login-action@v3 with: - name: images-i12e - path: dist/images + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Smoke test core image set shell: bash run: | set -euo pipefail - sha_tag="sha-${GITHUB_SHA::12}" + image_tag="sha-${GITHUB_SHA::12}" + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + image_tag="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA::12}" + fi ci_env_file="$(mktemp)" compose_ci() { @@ -236,12 +253,14 @@ jobs: trap cleanup EXIT - docker load --input dist/images/app-cockpit.tar - docker load --input dist/images/service-backend.tar - docker load --input dist/images/i12e.tar + image_base="${REGISTRY}/${IMAGE_NAMESPACE,,}" + docker pull "${image_base}/app-cockpit:${image_tag}" + docker pull "${image_base}/service-backend:${image_tag}" + docker pull "${image_base}/i12e-postgres:${image_tag}" + docker pull "${image_base}/i12e-gateway:${image_tag}" { - echo "CENTRAL_VERSION=$sha_tag" + echo "CENTRAL_VERSION=$image_tag" echo "COMPOSE_PROJECT_NAME=central-ci-${GITHUB_RUN_ID}" echo "SERVICE_RESTART_POLICY=no" echo "GATEWAY_BIND=127.0.0.1" @@ -270,19 +289,6 @@ jobs: needs: - test-images-integration steps: - - name: Download app and service images - uses: actions/download-artifact@v4 - with: - pattern: image-* - path: dist/images - merge-multiple: true - - - name: Download i12e images - uses: actions/download-artifact@v4 - with: - name: images-i12e - path: dist/images - - name: Log in to GHCR uses: docker/login-action@v3 with: @@ -307,15 +313,11 @@ jobs: fi fi - docker load --input dist/images/app-cockpit.tar - docker load --input dist/images/service-backend.tar - docker load --input dist/images/i12e.tar - publish_image() { local name="$1" local image="${image_base}/${name}" - docker push "${image}:${sha_tag}" + docker pull "${image}:${sha_tag}" if [ -n "$version_tag" ]; then docker tag "${image}:${sha_tag}" "${image}:${version_tag}" @@ -333,6 +335,59 @@ jobs: publish_image i12e-postgres publish_image i12e-gateway + cleanup-pr-images: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && github.event.action == 'closed' + steps: + - name: Delete closed PR images + uses: actions/github-script@v8 + with: + script: | + const owner = context.repo.owner; + const ownerType = context.payload.repository.owner.type; + const packageNames = [ + 'central/app-cockpit', + 'central/service-backend', + 'central/i12e-postgres', + 'central/i12e-gateway', + ]; + const prTagPrefix = `pr-${context.payload.pull_request.number}-`; + + for (const packageName of packageNames) { + const listRoute = + ownerType === 'Organization' + ? 'GET /orgs/{org}/packages/{package_type}/{package_name}/versions' + : 'GET /user/packages/{package_type}/{package_name}/versions'; + const deleteRoute = + ownerType === 'Organization' + ? 'DELETE /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}' + : 'DELETE /user/packages/{package_type}/{package_name}/versions/{package_version_id}'; + const packageOwner = ownerType === 'Organization' ? { org: owner } : {}; + + for await (const versions of github.paginate.iterator(listRoute, { + ...packageOwner, + package_type: 'container', + package_name: packageName, + per_page: 100, + })) { + for (const version of versions.data) { + const tags = version.metadata?.container?.tags ?? []; + const hasClosedPrTag = tags.some((tag) => tag.startsWith(prTagPrefix)); + + if (!hasClosedPrTag) { + continue; + } + + await github.request(deleteRoute, { + ...packageOwner, + package_type: 'container', + package_name: packageName, + package_version_id: version.id, + }); + } + } + } + package-deploy-bundle: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') diff --git a/docs/adr/0002-single-host-compose-image-release.md b/docs/adr/0002-single-host-compose-image-release.md index f54992b..dc72b74 100644 --- a/docs/adr/0002-single-host-compose-image-release.md +++ b/docs/adr/0002-single-host-compose-image-release.md @@ -1,6 +1,6 @@ # Single-Host Compose Releases from Tested Images -Central production runs on one Linux host with Docker Compose as the deployment boundary. CI builds the core production image set, boots it in a prod-like integration environment, and only publishes release tags after the image set passes smoke tests; the homeoffice server deploys by selecting a tested version tag such as `stable` or `v1.2.3`, pulling images, and restarting Compose without needing source code or a build toolchain. Each release also publishes a small deploy bundle containing the production Compose file, the update script, and an example environment file. +Central production runs on one Linux host with Docker Compose as the deployment boundary. CI builds the core production image set, pushes it to GHCR under disposable PR tags or immutable SHA tags, boots that image set in a prod-like integration environment, and only publishes release tags after the image set passes smoke tests; the homeoffice server deploys by selecting a tested version tag such as `stable` or `v1.2.3`, pulling images, and restarting Compose without needing source code or a build toolchain. Each release also publishes a small deploy bundle containing the production Compose file, the update script, and an example environment file. The core production stack is Cockpit, Backend, PostgreSQL, migrations, and an Nginx gateway exposed through Tailscale. Assistant, voice, STT, TTS, and LLM services are excluded from the baseline until they are reliable enough to ship as an optional profile. Major SemVer releases signal incompatible changes that may require planned downtime. diff --git a/docs/toolchain.md b/docs/toolchain.md index 3c22b3f..377c910 100644 --- a/docs/toolchain.md +++ b/docs/toolchain.md @@ -11,7 +11,7 @@ - Styling: Tailwind CSS - Unit tests: Vitest + Testing Library - E2E tests: Playwright -- CI: GitHub Actions, publishing tested release images to GHCR for tagged releases +- CI: GitHub Actions, staging tested build images in GHCR and publishing release tags from the tested image set - Node requirement: `>=24` (`package.json`, `.nvmrc` uses `lts/*`) ## Command Reference @@ -218,7 +218,7 @@ Start the production environment: pnpm prod ``` -The long-term production deployment path is the code-free deploy bundle under `i12e/orchestrator/deploy`. CI publishes release images to GHCR and packages: +The long-term production deployment path is the code-free deploy bundle under `i12e/orchestrator/deploy`. CI pushes PR and SHA build images to GHCR for integration testing, deletes PR-tagged images when pull requests close, publishes release tags from the tested SHA image set, and packages: - `docker-compose.prod.yml` - `central-update` From a4f0bb2273054f9899693b8bbbba3cd7f687ff66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:54:41 +0200 Subject: [PATCH 09/10] Remove offline mode to fix ERR_PNPM_NO_OFFLINE_META. --- apps/cockpit/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cockpit/Dockerfile b/apps/cockpit/Dockerfile index e9fee0d..b9b0603 100644 --- a/apps/cockpit/Dockerfile +++ b/apps/cockpit/Dockerfile @@ -31,7 +31,7 @@ FROM base AS build COPY . . RUN pnpm --dir apps/cockpit run build \ - && pnpm --filter @central/cockpit deploy --prod --legacy --offline /workspace/cockpit-deploy + && pnpm --filter @central/cockpit deploy --prod --legacy /workspace/cockpit-deploy FROM node:24-alpine AS prod ARG BACKEND_BASE_URL=http://localhost:5010 From f7a8f2351d78923d2736c16f2b3e1da77ed1f8fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:58:19 +0200 Subject: [PATCH 10/10] Use login-action v4 (uses Node to v24) --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f8a05b..abe5a72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,7 @@ jobs: fetch-depth: 0 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -153,7 +153,7 @@ jobs: fetch-depth: 0 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -187,7 +187,7 @@ jobs: fetch-depth: 0 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -225,7 +225,7 @@ jobs: fetch-depth: 0 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -290,7 +290,7 @@ jobs: - test-images-integration steps: - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }}