diff --git a/.env.dev b/.env.dev
new file mode 100644
index 0000000..b2ee630
--- /dev/null
+++ b/.env.dev
@@ -0,0 +1 @@
+VITE_TENANT=dev
diff --git a/.env.loop42 b/.env.loop42
new file mode 100644
index 0000000..ba459df
--- /dev/null
+++ b/.env.loop42
@@ -0,0 +1 @@
+VITE_TENANT=loop42
diff --git a/.env.production b/.env.production
index 23fe3a5..95048b2 100644
--- a/.env.production
+++ b/.env.production
@@ -1 +1,2 @@
-VITE_WS_URL=wss://assay.loop42.de/ws
+# Default production tenant (overridden by --mode loop42 or --mode dev)
+VITE_TENANT=loop42
diff --git a/Dockerfile b/Dockerfile
index 4515507..376ad74 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,9 +1,10 @@
FROM node:22-alpine AS build
+ARG TENANT=loop42
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
-RUN npm run build
+RUN npx vite build --mode $TENANT
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
diff --git a/src/App.vue b/src/App.vue
index bf37e40..8ceb125 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -14,7 +14,7 @@
-
+
@@ -33,6 +33,7 @@ import { useRoute } from 'vue-router';
import { ws, auth, agents } from './store';
import { THEME_LOGOS, useTheme } from './composables/useTheme';
import { useChatStore } from './store/chat';
+import tenant from './tenant';
import { defineAsyncComponent } from 'vue';
@@ -44,11 +45,14 @@ import router from './router';
import TtsPlayerBar from './components/TtsPlayerBar.vue';
const AgentsView = defineAsyncComponent(() => import('./views/AgentsView.vue'));
-const ViewerView = defineAsyncComponent(() => import('./views/ViewerView.vue'));
+const ViewerView = tenant.features.viewer
+ ? defineAsyncComponent(() => import('./views/ViewerView.vue'))
+ : null;
const chatStore = useChatStore();
const visited = reactive({ nyx: false, viewer: false });
-const isSocketRoute = computed(() => ['nyx', 'viewer'].includes(route.name as string));
+const socketRoutes = ['nyx', ...(tenant.features.viewer ? ['viewer'] : [])];
+const isSocketRoute = computed(() => socketRoutes.includes(route.name as string));
const routerReady = ref(false);
router.isReady().then(() => { routerReady.value = true; });
diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts
index c25ee0d..6cfcd4d 100644
--- a/src/composables/useTheme.ts
+++ b/src/composables/useTheme.ts
@@ -1,5 +1,6 @@
import { ref, watch, type Component } from 'vue';
import { CommandLineIcon, SunIcon, CubeTransparentIcon } from '@heroicons/vue/24/outline';
+import tenant from '../tenant';
export type Theme = 'titan' | 'eras' | 'loop42';
@@ -37,9 +38,10 @@ export function agentLogo(agentId: string): string | null {
}
const stored = localStorage.getItem(STORAGE_KEY);
+const defaultTheme = (tenant.defaultTheme || 'loop42') as Theme;
const theme = ref
(
stored === 'workhorse' ? 'loop42' : // migrate legacy name
- (stored as Theme) || 'loop42'
+ (stored as Theme) || defaultTheme
);
const THEME_FAVICONS: Record = {
diff --git a/src/router.ts b/src/router.ts
index fde05dc..03980f1 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -1,20 +1,28 @@
-import { createRouter, createWebHistory } from 'vue-router';
+import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import LoginView from './views/LoginView.vue';
import { THEME_NAMES, useTheme } from './composables/useTheme';
+import tenant from './tenant';
+import { HomePage, ImpressumPage, DatenschutzPage } from './tenantPages';
+
+const routes: RouteRecordRaw[] = [
+ { path: '/', name: 'home', component: HomePage, meta: { suffix: '' } },
+ { path: '/impressum', name: 'impressum', component: ImpressumPage, meta: { suffix: 'Impressum' } },
+ { path: '/datenschutz', name: 'datenschutz', component: DatenschutzPage, meta: { suffix: 'Datenschutz' } },
+ { path: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } },
+ { path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } },
+ { path: '/agents', redirect: '/nyx' },
+ { path: '/chat', redirect: '/nyx' },
+];
+
+if (tenant.features.viewer) {
+ routes.push({ path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresAuth: true } });
+}
+
+routes.push({ path: '/:pathMatch(.*)*', redirect: '/' });
const router = createRouter({
history: createWebHistory(),
- routes: [
- { path: '/', name: 'home', component: () => import('./views/CompanyView.vue'), meta: { suffix: '' } },
- { path: '/impressum', name: 'impressum', component: () => import('./views/ImpressumView.vue'), meta: { suffix: 'Impressum' } },
- { path: '/datenschutz', name: 'datenschutz', component: () => import('./views/DatenschutzView.vue'), meta: { suffix: 'Datenschutz' } },
- { path: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } },
- { path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } },
- { path: '/agents', redirect: '/nyx' },
- { path: '/chat', redirect: '/nyx' },
- { path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresAuth: true } },
- { path: '/:pathMatch(.*)*', redirect: '/' },
- ],
+ routes,
});
// Auth guard — redirect to /login for protected routes
diff --git a/src/tenant.ts b/src/tenant.ts
new file mode 100644
index 0000000..24ca157
--- /dev/null
+++ b/src/tenant.ts
@@ -0,0 +1,34 @@
+/**
+ * tenant.ts — Build-time tenant configuration
+ *
+ * VITE_TENANT selects which tenant config to load.
+ * Vite's static analysis + tree-shaking ensures only the
+ * active tenant's code ends up in the bundle.
+ */
+
+export interface TenantFeatures {
+ graph: boolean;
+ trace: boolean;
+ viewer: boolean;
+ devTools: boolean;
+}
+
+export interface TenantConfig {
+ id: string;
+ name: string;
+ domain: string;
+ defaultTheme: string;
+ features: TenantFeatures;
+}
+
+const modules = import.meta.glob('../tenants/*/config.ts', { eager: true, import: 'default' });
+
+const tenantId = import.meta.env.VITE_TENANT || 'loop42';
+const key = `../tenants/${tenantId}/config.ts`;
+const config = modules[key] as TenantConfig;
+
+if (!config) {
+ throw new Error(`Unknown tenant: ${tenantId}. Available: ${Object.keys(modules).join(', ')}`);
+}
+
+export default config;
diff --git a/src/tenantPages.ts b/src/tenantPages.ts
new file mode 100644
index 0000000..15fa01a
--- /dev/null
+++ b/src/tenantPages.ts
@@ -0,0 +1,21 @@
+/**
+ * tenantPages.ts — Dynamic page imports per tenant
+ *
+ * Uses Vite's glob import to resolve tenant-specific pages.
+ * Pages not found in the active tenant fall back to loop42's pages.
+ */
+
+const tenantId = import.meta.env.VITE_TENANT || 'loop42';
+
+// All tenant pages as lazy loaders
+const allPages = import.meta.glob('../tenants/*/pages/*.vue');
+
+function resolve(page: string) {
+ const tenantKey = `../tenants/${tenantId}/pages/${page}.vue`;
+ const fallbackKey = `../tenants/loop42/pages/${page}.vue`;
+ return allPages[tenantKey] || allPages[fallbackKey];
+}
+
+export const HomePage = resolve('HomePage');
+export const ImpressumPage = resolve('ImpressumPage');
+export const DatenschutzPage = resolve('DatenschutzPage');
diff --git a/tenants/dev/config.ts b/tenants/dev/config.ts
new file mode 100644
index 0000000..2520af0
--- /dev/null
+++ b/tenants/dev/config.ts
@@ -0,0 +1,16 @@
+import type { TenantConfig } from '../../src/tenant';
+
+const config: TenantConfig = {
+ id: 'dev',
+ name: 'loop42 Dev',
+ domain: 'loop42.dev',
+ defaultTheme: 'titan',
+ features: {
+ graph: true,
+ trace: true,
+ viewer: true,
+ devTools: true,
+ },
+};
+
+export default config;
diff --git a/tenants/dev/pages/HomePage.vue b/tenants/dev/pages/HomePage.vue
new file mode 100644
index 0000000..5cb1ad6
--- /dev/null
+++ b/tenants/dev/pages/HomePage.vue
@@ -0,0 +1,55 @@
+
+
+
+
loop42 Dev
+
Development environment with full debug tools.
+
Open nyx
+
+
+
+
+
diff --git a/tenants/loop42/config.ts b/tenants/loop42/config.ts
new file mode 100644
index 0000000..1104563
--- /dev/null
+++ b/tenants/loop42/config.ts
@@ -0,0 +1,16 @@
+import type { TenantConfig } from '../../src/tenant';
+
+const config: TenantConfig = {
+ id: 'loop42',
+ name: 'loop42',
+ domain: 'loop42.de',
+ defaultTheme: 'loop42',
+ features: {
+ graph: false,
+ trace: false,
+ viewer: false,
+ devTools: false,
+ },
+};
+
+export default config;
diff --git a/src/views/DatenschutzView.vue b/tenants/loop42/pages/DatenschutzPage.vue
similarity index 100%
rename from src/views/DatenschutzView.vue
rename to tenants/loop42/pages/DatenschutzPage.vue
diff --git a/src/views/CompanyView.vue b/tenants/loop42/pages/HomePage.vue
similarity index 100%
rename from src/views/CompanyView.vue
rename to tenants/loop42/pages/HomePage.vue
diff --git a/src/views/ImpressumView.vue b/tenants/loop42/pages/ImpressumPage.vue
similarity index 100%
rename from src/views/ImpressumView.vue
rename to tenants/loop42/pages/ImpressumPage.vue