diff --git a/pwa/.dockerignore b/pwa/.dockerignore
new file mode 100644
index 0000000..1373ff3
--- /dev/null
+++ b/pwa/.dockerignore
@@ -0,0 +1,3 @@
+node_modules
+*.log
+.env
diff --git a/pwa/Dockerfile b/pwa/Dockerfile
new file mode 100644
index 0000000..5bdd687
--- /dev/null
+++ b/pwa/Dockerfile
@@ -0,0 +1,17 @@
+FROM node:22-slim
+
+WORKDIR /app
+
+# Abhaengigkeiten zuerst (Docker Layer-Cache)
+COPY pwa/server/package*.json ./
+RUN npm ci --production
+
+# Quellcode kopieren
+COPY pwa/server/ ./
+
+ENV PORT=3100
+ENV CHAT_API_TOKEN=""
+
+EXPOSE 3100
+
+CMD ["node", "index.js"]
diff --git a/pwa/client/package.json b/pwa/client/package.json
new file mode 100644
index 0000000..aba4b43
--- /dev/null
+++ b/pwa/client/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "claude-chat-pwa",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev --port 5200",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-static": "^3.0.0",
+ "@sveltejs/kit": "^2.0.0",
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
+ "svelte": "^5.0.0",
+ "typescript": "^5.0.0",
+ "vite": "^5.0.0"
+ },
+ "dependencies": {
+ "marked": "^18.0.0"
+ }
+}
diff --git a/pwa/client/src/app.css b/pwa/client/src/app.css
new file mode 100644
index 0000000..90b1518
--- /dev/null
+++ b/pwa/client/src/app.css
@@ -0,0 +1,113 @@
+/* Claude Chat PWA — Globales Dark Theme */
+
+:root {
+ --bg-primary: #1a1a2e;
+ --bg-secondary: #16213e;
+ --bg-tertiary: #0f3460;
+ --text-primary: #e0e0e0;
+ --text-secondary: #a0a0a0;
+ --accent: #7c3aed;
+ --accent-hover: #6d28d9;
+ --user-bubble: #2563eb;
+ --assistant-bubble: #2d2d44;
+ --danger: #c53030;
+ --success: #38a169;
+ --warning: #d69e2e;
+ --border: #333355;
+ --radius: 12px;
+ --radius-sm: 6px;
+ --radius-lg: 18px;
+ --spacing-xs: 4px;
+ --spacing-sm: 8px;
+ --spacing-md: 16px;
+ --spacing-lg: 24px;
+ --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+
+ /* Safe-Area fuer Notch-Handys */
+ --safe-top: env(safe-area-inset-top, 0px);
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
+ --safe-left: env(safe-area-inset-left, 0px);
+ --safe-right: env(safe-area-inset-right, 0px);
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html, body {
+ height: 100%;
+ overflow: hidden;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
+ font-size: 16px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ -webkit-text-size-adjust: 100%;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ overscroll-behavior: none;
+ touch-action: manipulation;
+}
+
+/* Scrollbar-Styling */
+::-webkit-scrollbar {
+ width: 4px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 2px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+/* Input-/Textarea-Reset */
+input, textarea, select, button {
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ border: none;
+ outline: none;
+ background: none;
+}
+
+button {
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+/* Globale Animationen */
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes slideInLeft {
+ from { transform: translateX(-100%); }
+ to { transform: translateX(0); }
+}
+
+@keyframes slideOutLeft {
+ from { transform: translateX(0); }
+ to { transform: translateX(-100%); }
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
diff --git a/pwa/client/src/app.html b/pwa/client/src/app.html
new file mode 100644
index 0000000..d24be3f
--- /dev/null
+++ b/pwa/client/src/app.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+ Claude Chat
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/pwa/client/src/lib/api/client.ts b/pwa/client/src/lib/api/client.ts
new file mode 100644
index 0000000..98218d5
--- /dev/null
+++ b/pwa/client/src/lib/api/client.ts
@@ -0,0 +1,440 @@
+// Claude Chat PWA — API-Client (HTTP + WebSocket)
+//
+// Kommuniziert mit dem pwa/server auf Port 3100.
+// WebSocket fuer Streaming, REST fuer Status/Modell/Sessions.
+
+import {
+ connected,
+ reconnecting,
+ isProcessing,
+ messages,
+ streamingText,
+ sessions,
+ currentModel,
+ agentMode,
+ activeAgents,
+ toolCalls,
+ currentSessionId,
+ serverUrl,
+ apiToken,
+} from '$lib/stores/app';
+import { get } from 'svelte/store';
+
+// ============ Typen ============
+
+export interface StatusResponse {
+ processing: boolean;
+ model: string;
+ mode: string;
+ agentId: string | null;
+ uptime: number;
+}
+
+export interface ModelInfo {
+ id: string;
+ name: string;
+ description: string;
+}
+
+export interface ModelsResponse {
+ current: string;
+ available: ModelInfo[];
+}
+
+export interface SessionInfo {
+ id: string;
+ title: string;
+ createdAt: string;
+ messageCount: number;
+ messages?: Array<{
+ role: string;
+ content: string;
+ timestamp: string;
+ model?: string;
+ }>;
+}
+
+// ============ API-Client Klasse ============
+
+class ClaudeAPI {
+ private ws: WebSocket | null = null;
+ private reconnectTimer: ReturnType | null = null;
+ private intentionalClose = false;
+
+ // Aktuelle Konfiguration aus Stores lesen
+ private get baseUrl(): string {
+ return get(serverUrl);
+ }
+
+ private get token(): string {
+ return get(apiToken);
+ }
+
+ // ============ WebSocket ============
+
+ /** WebSocket-Verbindung aufbauen */
+ connect(): void {
+ if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
+ return; // Bereits verbunden oder am Verbinden
+ }
+
+ this.intentionalClose = false;
+ const wsUrl = this.baseUrl.replace(/^http/, 'ws') + `/ws?token=${encodeURIComponent(this.token)}`;
+
+ try {
+ this.ws = new WebSocket(wsUrl);
+ } catch (err) {
+ console.error('[ws] Verbindungsfehler:', err);
+ connected.set(false);
+ this.scheduleReconnect();
+ return;
+ }
+
+ this.ws.onopen = () => {
+ console.log('[ws] Verbunden');
+ connected.set(true);
+ reconnecting.set(false);
+ };
+
+ this.ws.onclose = () => {
+ console.log('[ws] Getrennt');
+ connected.set(false);
+ this.ws = null;
+
+ if (!this.intentionalClose) {
+ this.scheduleReconnect();
+ }
+ };
+
+ this.ws.onerror = (err) => {
+ console.error('[ws] Fehler:', err);
+ // onclose wird automatisch danach gefeuert
+ };
+
+ this.ws.onmessage = (event) => {
+ this.handleMessage(event.data);
+ };
+ }
+
+ /** Verbindung trennen */
+ disconnect(): void {
+ this.intentionalClose = true;
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+ connected.set(false);
+ reconnecting.set(false);
+ }
+
+ /** Auto-Reconnect nach 5 Sekunden */
+ private scheduleReconnect(): void {
+ if (this.intentionalClose) return;
+ reconnecting.set(true);
+ console.log('[ws] Reconnect in 5s...');
+ this.reconnectTimer = setTimeout(() => {
+ this.reconnectTimer = null;
+ this.connect();
+ }, 5000);
+ }
+
+ /** Eingehende WebSocket-Nachricht verarbeiten */
+ private handleMessage(raw: string): void {
+ let msg: Record;
+ try {
+ msg = JSON.parse(raw);
+ } catch {
+ console.error('[ws] Ungueltiges JSON:', raw);
+ return;
+ }
+
+ switch (msg.type) {
+ // Willkommensnachricht bei Verbindung
+ case 'connected': {
+ const status = msg.status as StatusResponse;
+ if (status?.model) currentModel.set(status.model);
+ if (status?.mode) agentMode.set(status.mode as string);
+ break;
+ }
+
+ // Text-Streaming (Delta)
+ case 'text': {
+ const content = msg.content as string;
+ streamingText.update((t) => t + content);
+
+ // Letzte assistant-Message live aktualisieren oder neue erstellen
+ messages.update((msgs) => {
+ const last = msgs[msgs.length - 1];
+ if (last && last.role === 'assistant' && last.streaming) {
+ // Bestehende Streaming-Nachricht aktualisieren
+ return [
+ ...msgs.slice(0, -1),
+ { ...last, content: last.content + content },
+ ];
+ } else {
+ // Neue Streaming-Nachricht erstellen
+ return [
+ ...msgs,
+ {
+ id: crypto.randomUUID(),
+ role: 'assistant' as const,
+ content,
+ timestamp: new Date(),
+ streaming: true,
+ },
+ ];
+ }
+ });
+ break;
+ }
+
+ // Endergebnis — Streaming abgeschlossen
+ case 'result': {
+ const resultContent = msg.content as string;
+ const resultModel = msg.model as string | undefined;
+
+ // Streaming-Nachricht finalisieren
+ messages.update((msgs) => {
+ const last = msgs[msgs.length - 1];
+ if (last && last.role === 'assistant' && last.streaming) {
+ return [
+ ...msgs.slice(0, -1),
+ { ...last, content: resultContent || last.content, streaming: false, model: resultModel },
+ ];
+ }
+ // Fallback: Wenn keine Streaming-Nachricht existiert
+ if (resultContent) {
+ return [
+ ...msgs,
+ {
+ id: crypto.randomUUID(),
+ role: 'assistant' as const,
+ content: resultContent,
+ timestamp: new Date(),
+ streaming: false,
+ model: resultModel,
+ },
+ ];
+ }
+ return msgs;
+ });
+
+ // Zuruecksetzen
+ streamingText.set('');
+ isProcessing.set(false);
+
+ // Session-ID speichern falls vorhanden
+ if (msg.session_id) {
+ currentSessionId.set(msg.session_id as string);
+ }
+
+ break;
+ }
+
+ // Tool-Aufruf
+ case 'tool_call': {
+ toolCalls.update((calls) => [
+ ...calls,
+ {
+ id: msg.id as string,
+ name: msg.name as string,
+ summary: msg.summary as string || '',
+ status: 'running',
+ },
+ ]);
+ break;
+ }
+
+ // Tool-Ergebnis
+ case 'tool_result': {
+ toolCalls.update((calls) =>
+ calls.map((c) =>
+ c.id === msg.id
+ ? { ...c, status: (msg.success as boolean) ? 'completed' : 'failed' }
+ : c
+ )
+ );
+ break;
+ }
+
+ // Agent gestartet
+ case 'agent_started': {
+ activeAgents.update((agents) => [
+ ...agents,
+ {
+ id: msg.id as string,
+ name: msg.name as string || 'Agent',
+ },
+ ]);
+ break;
+ }
+
+ // Agent gestoppt
+ case 'agent_stopped': {
+ activeAgents.update((agents) => agents.filter((a) => a.id !== msg.id));
+ break;
+ }
+
+ // Gestoppt (via Stop-Befehl)
+ case 'stopped': {
+ isProcessing.set(false);
+ streamingText.set('');
+ break;
+ }
+
+ // Fehler
+ case 'error': {
+ console.error('[ws] Server-Fehler:', msg.message);
+ isProcessing.set(false);
+ streamingText.set('');
+
+ // Fehlermeldung als System-Nachricht anzeigen
+ messages.update((msgs) => [
+ ...msgs,
+ {
+ id: crypto.randomUUID(),
+ role: 'system' as const,
+ content: `Fehler: ${msg.message}`,
+ timestamp: new Date(),
+ },
+ ]);
+ break;
+ }
+
+ default:
+ console.log('[ws] Unbekannter Nachrichtentyp:', msg.type);
+ }
+ }
+
+ // ============ WebSocket Senden ============
+
+ /** Nachricht an Claude senden */
+ sendMessage(text: string, sessionId?: string): void {
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
+ console.error('[ws] Nicht verbunden');
+ return;
+ }
+
+ // User-Nachricht zum Chat hinzufuegen
+ messages.update((msgs) => [
+ ...msgs,
+ {
+ id: crypto.randomUUID(),
+ role: 'user' as const,
+ content: text,
+ timestamp: new Date(),
+ },
+ ]);
+
+ // Streaming-State zuruecksetzen
+ streamingText.set('');
+ isProcessing.set(true);
+ toolCalls.set([]);
+
+ // An Server senden
+ this.ws.send(
+ JSON.stringify({
+ type: 'message',
+ content: text,
+ sessionId: sessionId || get(currentSessionId) || undefined,
+ })
+ );
+ }
+
+ /** Aktuelle Verarbeitung stoppen */
+ stop(): void {
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
+ this.ws.send(JSON.stringify({ type: 'stop' }));
+ }
+
+ // ============ REST-Endpoints ============
+
+ /** HTTP-Request mit Auth-Header */
+ private async fetch(path: string, options: RequestInit = {}): Promise {
+ const url = `${this.baseUrl}${path}`;
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${this.token}`,
+ ...(options.headers as Record || {}),
+ };
+
+ const res = await fetch(url, { ...options, headers });
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({ error: res.statusText }));
+ throw new Error(body.error || `HTTP ${res.status}`);
+ }
+
+ return res;
+ }
+
+ /** Server-Status abrufen */
+ async getStatus(): Promise {
+ const res = await this.fetch('/api/status');
+ return res.json();
+ }
+
+ /** Verfuegbare Modelle abrufen */
+ async getModels(): Promise {
+ const res = await this.fetch('/api/models');
+ return res.json();
+ }
+
+ /** Modell wechseln */
+ async setModel(model: string): Promise {
+ await this.fetch('/api/model', {
+ method: 'POST',
+ body: JSON.stringify({ model }),
+ });
+ currentModel.set(model);
+ }
+
+ /** Modus wechseln */
+ async setMode(mode: string): Promise {
+ await this.fetch('/api/mode', {
+ method: 'POST',
+ body: JSON.stringify({ mode }),
+ });
+ agentMode.set(mode);
+ }
+
+ /** Sessions abrufen */
+ async getSessions(): Promise {
+ const res = await this.fetch('/api/sessions');
+ const list: SessionInfo[] = await res.json();
+ sessions.set(list);
+ return list;
+ }
+
+ /** Einzelne Session mit Nachrichten laden */
+ async getSession(id: string): Promise {
+ try {
+ const res = await this.fetch(`/api/sessions/${id}`);
+ return res.json();
+ } catch {
+ return null;
+ }
+ }
+
+ /** Neue Session erstellen */
+ async createSession(title?: string): Promise {
+ const res = await this.fetch('/api/sessions', {
+ method: 'POST',
+ body: JSON.stringify({ title }),
+ });
+ const session: SessionInfo = await res.json();
+
+ // Sessions-Liste aktualisieren
+ sessions.update((list) => [session, ...list]);
+ currentSessionId.set(session.id);
+
+ return session;
+ }
+}
+
+// Singleton-Instanz
+export const api = new ClaudeAPI();
diff --git a/pwa/client/src/lib/components/ChatPanel.svelte b/pwa/client/src/lib/components/ChatPanel.svelte
new file mode 100644
index 0000000..a4c79af
--- /dev/null
+++ b/pwa/client/src/lib/components/ChatPanel.svelte
@@ -0,0 +1,281 @@
+
+
+
+
+
+
+ {#if $messages.length === 0}
+
+
+
Claude Chat
+
Starte eine Konversation
+
+ {:else}
+ {#each $messages as message (message.id)}
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+
diff --git a/pwa/client/src/lib/components/MessageBubble.svelte b/pwa/client/src/lib/components/MessageBubble.svelte
new file mode 100644
index 0000000..3280c58
--- /dev/null
+++ b/pwa/client/src/lib/components/MessageBubble.svelte
@@ -0,0 +1,271 @@
+
+
+
+ {#if message.role === 'assistant'}
+
+ {#if message.content}
+ {@html renderMarkdown(message.content)}
+ {:else}
+
+
+
+
+
+ {/if}
+
+
+ {formatModelName(message.model)}
+ {formatTime(message.timestamp)}
+
+ {:else if message.role === 'user'}
+
{message.content}
+
+ {formatTime(message.timestamp)}
+
+ {:else}
+
+
{message.content}
+ {/if}
+
+
+
diff --git a/pwa/client/src/lib/components/SessionDrawer.svelte b/pwa/client/src/lib/components/SessionDrawer.svelte
new file mode 100644
index 0000000..85431f6
--- /dev/null
+++ b/pwa/client/src/lib/components/SessionDrawer.svelte
@@ -0,0 +1,266 @@
+
+
+{#if $showSessionDrawer}
+
+
+ e.key === 'Escape' && close()}>
+
+
+
+{/if}
+
+
diff --git a/pwa/client/src/lib/components/SettingsModal.svelte b/pwa/client/src/lib/components/SettingsModal.svelte
new file mode 100644
index 0000000..5eaf130
--- /dev/null
+++ b/pwa/client/src/lib/components/SettingsModal.svelte
@@ -0,0 +1,374 @@
+
+
+{#if $showSettings}
+
+
+
+
e.stopPropagation()}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if error}
+
{error}
+ {/if}
+
+
+
+
+
+{/if}
+
+
diff --git a/pwa/client/src/lib/components/StatusBar.svelte b/pwa/client/src/lib/components/StatusBar.svelte
new file mode 100644
index 0000000..36a28c1
--- /dev/null
+++ b/pwa/client/src/lib/components/StatusBar.svelte
@@ -0,0 +1,124 @@
+
+
+
+
+
diff --git a/pwa/client/src/lib/stores/app.ts b/pwa/client/src/lib/stores/app.ts
new file mode 100644
index 0000000..759575d
--- /dev/null
+++ b/pwa/client/src/lib/stores/app.ts
@@ -0,0 +1,94 @@
+// Claude Chat PWA — App-State (Svelte Stores)
+
+import { writable } from 'svelte/store';
+
+// ============ Typen ============
+
+export interface Message {
+ id: string;
+ role: 'user' | 'assistant' | 'system';
+ content: string;
+ timestamp: Date;
+ streaming?: boolean;
+ model?: string;
+}
+
+export interface SessionInfo {
+ id: string;
+ title: string;
+ createdAt: string;
+ messageCount: number;
+}
+
+export interface ToolCallInfo {
+ id: string;
+ name: string;
+ summary: string;
+ status: 'running' | 'completed' | 'failed';
+}
+
+export interface AgentInfo {
+ id: string;
+ name: string;
+}
+
+// ============ Verbindung ============
+
+export const serverUrl = writable(
+ typeof localStorage !== 'undefined'
+ ? localStorage.getItem('serverUrl') || 'http://localhost:3100'
+ : 'http://localhost:3100'
+);
+export const apiToken = writable(
+ typeof localStorage !== 'undefined'
+ ? localStorage.getItem('apiToken') || ''
+ : ''
+);
+export const connected = writable(false);
+export const reconnecting = writable(false);
+
+// ============ Chat ============
+
+export const messages = writable([]);
+export const isProcessing = writable(false);
+export const currentInput = writable('');
+export const streamingText = writable('');
+
+// ============ Session ============
+
+export const sessions = writable([]);
+export const currentSessionId = writable(null);
+
+// ============ Modell & Modus ============
+
+export const currentModel = writable('sonnet');
+export const agentMode = writable('solo');
+
+// ============ Aktivitaet ============
+
+export const activeAgents = writable([]);
+export const toolCalls = writable([]);
+
+// ============ UI-State ============
+
+export const showSettings = writable(false);
+export const showSessionDrawer = writable(false);
+
+// ============ localStorage-Persistierung ============
+// Wird in +layout.svelte via $effect() aktiviert
+
+export function initPersistence(): void {
+ // serverUrl speichern
+ serverUrl.subscribe((val) => {
+ if (typeof localStorage !== 'undefined') {
+ localStorage.setItem('serverUrl', val);
+ }
+ });
+
+ // apiToken speichern
+ apiToken.subscribe((val) => {
+ if (typeof localStorage !== 'undefined') {
+ localStorage.setItem('apiToken', val);
+ }
+ });
+}
diff --git a/pwa/client/src/lib/utils/markdown.ts b/pwa/client/src/lib/utils/markdown.ts
new file mode 100644
index 0000000..8025e22
--- /dev/null
+++ b/pwa/client/src/lib/utils/markdown.ts
@@ -0,0 +1,93 @@
+// Claude Chat PWA — Markdown-Rendering
+//
+// Nutzt `marked` fuer sicheres HTML-Rendering von Assistant-Nachrichten.
+// Code-Bloecke bekommen einen Copy-Button via DOM-Manipulation.
+
+import { marked, type Tokens } from 'marked';
+
+// Custom Renderer fuer Code-Bloecke mit Wrapper
+const renderer = new marked.Renderer();
+
+renderer.code = function ({ text, lang }: Tokens.Code): string {
+ const language = lang || '';
+ const escapedCode = text
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+ return ``;
+};
+
+marked.setOptions({
+ breaks: true,
+ gfm: true,
+ renderer,
+});
+
+/** Markdown-Text zu HTML rendern */
+export function renderMarkdown(text: string): string {
+ try {
+ return marked.parse(text) as string;
+ } catch {
+ return text;
+ }
+}
+
+/**
+ * Svelte Action: Fuegt Copy-Buttons zu Code-Bloecken hinzu.
+ * Nutzung: ...
+ */
+export function addCopyButtons(node: HTMLElement) {
+ function processCodeBlocks() {
+ const wrappers = node.querySelectorAll('.code-block-wrapper:not([data-copy-added])');
+ wrappers.forEach((wrapper) => {
+ wrapper.setAttribute('data-copy-added', 'true');
+
+ const lang = wrapper.getAttribute('data-lang') || '';
+ const codeEl = wrapper.querySelector('code');
+ const codeText = codeEl?.textContent || '';
+
+ // Header mit Sprache und Copy-Button erstellen
+ const header = document.createElement('div');
+ header.className = 'code-header';
+
+ if (lang) {
+ const langSpan = document.createElement('span');
+ langSpan.className = 'code-lang';
+ langSpan.textContent = lang;
+ header.appendChild(langSpan);
+ }
+
+ const copyBtn = document.createElement('button');
+ copyBtn.className = 'copy-btn';
+ copyBtn.title = 'Code kopieren';
+ copyBtn.textContent = 'Kopieren';
+ copyBtn.onclick = async () => {
+ try {
+ await navigator.clipboard.writeText(codeText);
+ copyBtn.textContent = 'Kopiert!';
+ setTimeout(() => {
+ copyBtn.textContent = 'Kopieren';
+ }, 2000);
+ } catch (err) {
+ console.error('Kopieren fehlgeschlagen:', err);
+ }
+ };
+ header.appendChild(copyBtn);
+
+ wrapper.insertBefore(header, wrapper.firstChild);
+ });
+ }
+
+ // Initial verarbeiten
+ processCodeBlocks();
+
+ // MutationObserver fuer dynamische Inhalte (Streaming)
+ const observer = new MutationObserver(processCodeBlocks);
+ observer.observe(node, { childList: true, subtree: true });
+
+ return {
+ destroy() {
+ observer.disconnect();
+ },
+ };
+}
diff --git a/pwa/client/src/routes/+layout.svelte b/pwa/client/src/routes/+layout.svelte
new file mode 100644
index 0000000..947dba1
--- /dev/null
+++ b/pwa/client/src/routes/+layout.svelte
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+ {@render children()}
+
+
+
+
+
+
+
+
diff --git a/pwa/client/src/routes/+page.svelte b/pwa/client/src/routes/+page.svelte
new file mode 100644
index 0000000..52d4f52
--- /dev/null
+++ b/pwa/client/src/routes/+page.svelte
@@ -0,0 +1,81 @@
+
+
+{#if !$connected}
+
+
+
+
+
Claude Chat
+
Verbinde mit deinem Claude API-Server
+
+
+
+{:else}
+
+{/if}
+
+
diff --git a/pwa/client/src/service-worker.ts b/pwa/client/src/service-worker.ts
new file mode 100644
index 0000000..ed10d40
--- /dev/null
+++ b/pwa/client/src/service-worker.ts
@@ -0,0 +1,108 @@
+///
+
+// Claude Chat PWA — Service Worker
+// Cache-First fuer statische Assets, Network-First fuer API-Calls
+
+declare const self: ServiceWorkerGlobalScope;
+
+const CACHE_NAME = 'claude-chat-v1';
+
+// Statische Assets die gecacht werden sollen
+const STATIC_ASSETS = [
+ '/',
+ '/favicon.png',
+ '/manifest.json',
+];
+
+// ============ Install — Statische Assets vorladen ============
+
+self.addEventListener('install', (event: ExtendableEvent) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => {
+ return cache.addAll(STATIC_ASSETS);
+ })
+ );
+ // Sofort aktivieren, nicht auf alte Tabs warten
+ self.skipWaiting();
+});
+
+// ============ Activate — Alte Caches loeschen ============
+
+self.addEventListener('activate', (event: ExtendableEvent) => {
+ event.waitUntil(
+ caches.keys().then((names) => {
+ return Promise.all(
+ names
+ .filter((name) => name !== CACHE_NAME)
+ .map((name) => caches.delete(name))
+ );
+ })
+ );
+ // Sofort alle Clients uebernehmen
+ self.clients.claim();
+});
+
+// ============ Fetch — Caching-Strategie ============
+
+self.addEventListener('fetch', (event: FetchEvent) => {
+ const url = new URL(event.request.url);
+
+ // WebSocket-Requests ignorieren
+ if (url.protocol === 'ws:' || url.protocol === 'wss:') {
+ return;
+ }
+
+ // API-Calls: Network-First mit Cache-Fallback
+ if (url.pathname.startsWith('/api/')) {
+ event.respondWith(networkFirst(event.request));
+ return;
+ }
+
+ // Statische Assets: Cache-First mit Network-Fallback
+ event.respondWith(cacheFirst(event.request));
+});
+
+/** Cache-First: Zuerst im Cache suchen, dann Netzwerk */
+async function cacheFirst(request: Request): Promise {
+ const cached = await caches.match(request);
+ if (cached) return cached;
+
+ try {
+ const response = await fetch(request);
+ // Erfolgreiche Responses cachen
+ if (response.ok) {
+ const cache = await caches.open(CACHE_NAME);
+ cache.put(request, response.clone());
+ }
+ return response;
+ } catch {
+ // Offline-Fallback fuer HTML-Seiten
+ if (request.headers.get('accept')?.includes('text/html')) {
+ const cached = await caches.match('/');
+ if (cached) return cached;
+ }
+ return new Response('Offline', { status: 503 });
+ }
+}
+
+/** Network-First: Zuerst Netzwerk versuchen, dann Cache */
+async function networkFirst(request: Request): Promise {
+ try {
+ const response = await fetch(request);
+ // Erfolgreiche GET-Responses cachen
+ if (response.ok && request.method === 'GET') {
+ const cache = await caches.open(CACHE_NAME);
+ cache.put(request, response.clone());
+ }
+ return response;
+ } catch {
+ const cached = await caches.match(request);
+ if (cached) return cached;
+ return new Response(JSON.stringify({ error: 'Offline' }), {
+ status: 503,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+}
+
+export {};
diff --git a/pwa/client/static/favicon.png b/pwa/client/static/favicon.png
new file mode 100644
index 0000000..2f36700
Binary files /dev/null and b/pwa/client/static/favicon.png differ
diff --git a/pwa/client/static/manifest.json b/pwa/client/static/manifest.json
new file mode 100644
index 0000000..5475106
--- /dev/null
+++ b/pwa/client/static/manifest.json
@@ -0,0 +1,14 @@
+{
+ "name": "Claude Chat",
+ "short_name": "Claude",
+ "description": "Claude AI Chat - Mobile PWA",
+ "start_url": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#1a1a2e",
+ "theme_color": "#7c3aed",
+ "icons": [
+ { "src": "/favicon.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/favicon.png", "sizes": "512x512", "type": "image/png" }
+ ]
+}
diff --git a/pwa/client/svelte.config.js b/pwa/client/svelte.config.js
new file mode 100644
index 0000000..16ce9ab
--- /dev/null
+++ b/pwa/client/svelte.config.js
@@ -0,0 +1,7 @@
+import adapter from '@sveltejs/adapter-static';
+
+export default {
+ kit: {
+ adapter: adapter({ fallback: 'index.html' })
+ }
+};
diff --git a/pwa/client/tsconfig.json b/pwa/client/tsconfig.json
new file mode 100644
index 0000000..4344710
--- /dev/null
+++ b/pwa/client/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
diff --git a/pwa/client/vite.config.ts b/pwa/client/vite.config.ts
new file mode 100644
index 0000000..7eb43c6
--- /dev/null
+++ b/pwa/client/vite.config.ts
@@ -0,0 +1,7 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ server: { port: 5200 }
+});
diff --git a/pwa/server/auth.js b/pwa/server/auth.js
new file mode 100644
index 0000000..221f192
--- /dev/null
+++ b/pwa/server/auth.js
@@ -0,0 +1,52 @@
+// Claude Chat API — Authentifizierung
+// Einfache Bearer-Token-Middleware fuer Express + WebSocket
+
+import { randomBytes } from 'node:crypto';
+
+// Token aus ENV oder zufaellig generiert
+let API_TOKEN = process.env.CHAT_API_TOKEN;
+
+if (!API_TOKEN) {
+ API_TOKEN = randomBytes(32).toString('hex');
+ console.log('========================================');
+ console.log('Kein CHAT_API_TOKEN gesetzt — zufaelliges Token generiert:');
+ console.log(` ${API_TOKEN}`);
+ console.log('Setze CHAT_API_TOKEN als ENV-Variable fuer einen festen Wert.');
+ console.log('========================================');
+}
+
+/**
+ * Express-Middleware: prueft Authorization-Header
+ * Erwartet: Authorization: Bearer
+ */
+export function authMiddleware(req, res, next) {
+ const authHeader = req.headers.authorization;
+
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({ error: 'Authorization-Header fehlt oder ungueltig' });
+ }
+
+ const token = authHeader.slice(7); // "Bearer " abschneiden
+ if (token !== API_TOKEN) {
+ return res.status(403).json({ error: 'Ungueltiges Token' });
+ }
+
+ next();
+}
+
+/**
+ * WebSocket-Upgrade: prueft Token als Query-Parameter ?token=xxx
+ * Gibt true zurueck wenn authentifiziert, sonst false
+ */
+export function verifyWsToken(url) {
+ try {
+ // URL kann relativ sein (/ws?token=xxx), daher Basis-URL ergaenzen
+ const parsed = new URL(url, 'http://localhost');
+ const token = parsed.searchParams.get('token');
+ return token === API_TOKEN;
+ } catch {
+ return false;
+ }
+}
+
+export { API_TOKEN };
diff --git a/pwa/server/bridge.js b/pwa/server/bridge.js
new file mode 100644
index 0000000..10be60c
--- /dev/null
+++ b/pwa/server/bridge.js
@@ -0,0 +1,502 @@
+// Claude Chat API — Bridge zum Claude Agent SDK
+//
+// Adaptiert die Logik aus scripts/claude-bridge.js fuer HTTP/WebSocket-Nutzung.
+// Nutzt @anthropic-ai/claude-agent-sdk (query-Funktion) mit OAuth-Auth (Claude Max Abo).
+
+import { query } from '@anthropic-ai/claude-agent-sdk';
+import { randomUUID } from 'node:crypto';
+import { EventEmitter } from 'node:events';
+
+// ============ State ============
+
+let activeAbort = null;
+let currentAgentId = null;
+let currentModel = process.env.CLAUDE_MODEL || 'sonnet';
+let agentMode = 'solo'; // solo | handlanger | experten | auto
+
+// In-Memory Sessions
+// Map: sessionId → { id, title, createdAt, messages: [], claudeSessionId }
+const sessions = new Map();
+
+// ============ Orchestrator Prompts ============
+
+const ORCHESTRATOR_PROMPTS = {
+ handlanger: `
+Du bist der HAUPT-AGENT im HANDLANGER-MODUS.
+
+KRITISCH: Dir stehen NUR Task + TodoWrite zur Verfuegung.
+Du kannst NICHT direkt lesen, suchen oder ausfuehren — du MUSST delegieren!
+
+Task-Tool mit den RICHTIGEN Sub-Agent-Typen:
+- "general-purpose" — Standard-Agent mit VOLLEM Tool-Zugriff (Bash, Read, Write, Grep, Glob).
+- "Explore" — read-only Agent. NUR fuer reine Code-/Dateisuche.
+
+Arbeitsweise:
+1. Waehle den RICHTIGEN subagent_type basierend auf der Aufgabe.
+2. Rufe das Task-Tool auf mit EXAKTER Anweisung.
+3. Pruefe im Ergebnis das "tool_uses"-Feld. Wenn tool_uses:0 → Sub-Agent hat halluziniert!
+4. Verarbeite das Ergebnis und gib dem User die Zusammenfassung.
+`,
+
+ experten: `
+Du bist der HAUPT-AGENT und arbeitest im EXPERTEN-MODUS.
+
+Task-Tool Sub-Agent-Typen (autonome Experten):
+- **research**: Durchsucht Code/Docs, findet Infos.
+- **implement**: Schreibt Code nach Best-Practices.
+- **test**: Schreibt und fuehrt Tests.
+- **review**: Prueft Code auf Qualitaet/Sicherheit.
+
+Arbeitsweise:
+1. ZERLEGE die Aufgabe in Experten-Bereiche
+2. DELEGIERE via Task(subagent_type: "research"|"implement"|"test"|"review", prompt: "...")
+3. Formuliere das WAS, nicht das WIE
+4. Integriere die Zusammenfassungen
+`,
+
+ auto: `
+Du analysierst Aufgaben und waehlst den optimalen Arbeitsmodus.
+
+Entscheide basierend auf:
+- SOLO: Einfache, schnelle Aufgaben
+- HANDLANGER: Koordinations-intensive Aufgaben
+- EXPERTEN: Komplexe Features
+`,
+};
+
+// ============ Verfuegbare Modelle ============
+
+const AVAILABLE_MODELS = [
+ { id: 'haiku', name: 'Claude Haiku', description: 'Schnell & guenstig' },
+ { id: 'sonnet', name: 'Claude Sonnet', description: 'Ausgewogen' },
+ { id: 'opus', name: 'Claude Opus', description: 'Leistungsstark' },
+];
+
+// Tools die Subagents spawnen
+const SUBAGENT_TOOLS = ['Task', 'Agent', 'spawn_agent', 'launch_agent'];
+
+// ============ Hilfsfunktionen ============
+
+// AUTO-Modus: Heuristik waehlt passenden Modus
+function chooseAutoMode(message) {
+ const text = (message || '').toLowerCase();
+ const charCount = text.length;
+
+ const expertKeywords = [
+ 'implementiere', 'implementier ', 'refactor', 'architektur', 'entwickle',
+ 'erstelle feature', 'feature ', 'design', 'baue ', 'optimiere',
+ 'migration', 'umbau', 'umstruktur',
+ ];
+
+ const handlangerKeywords = [
+ 'lies ', 'suche ', 'finde ', 'zeig mir ', 'untersuche',
+ 'analysiere', 'durchsuche', 'alle dateien', 'sammle',
+ 'liste alle', 'vergleiche',
+ ];
+
+ if (charCount < 80) return 'solo';
+ if (expertKeywords.some(kw => text.includes(kw))) return 'experten';
+ if (handlangerKeywords.some(kw => text.includes(kw)) && charCount > 120) return 'handlanger';
+ if (charCount > 300) return 'handlanger';
+
+ return 'solo';
+}
+
+// Subagent-Typ aus Tool-Input ermitteln
+function getSubagentType(toolName, input) {
+ if (input?.subagent_type) return input.subagent_type.toLowerCase();
+ if (input?.agent_type) return input.agent_type.toLowerCase();
+
+ const desc = (input?.description || input?.prompt || '').toLowerCase();
+ if (desc.includes('explore') || desc.includes('search') || desc.includes('find')) return 'explore';
+ if (desc.includes('implement') || desc.includes('write') || desc.includes('code')) return 'code';
+ if (desc.includes('test') || desc.includes('verify')) return 'test';
+ if (desc.includes('review') || desc.includes('check')) return 'review';
+
+ return 'explore';
+}
+
+// Tool-Input fuer Logging kuerzen
+function summarizeToolInput(tool, input) {
+ if (!input) return '';
+ if (tool === 'Read') return input.file_path || '';
+ if (tool === 'Edit' || tool === 'Write') {
+ const path = input.file_path || '';
+ const size = input.content ? `(${input.content.length} chars)` : '';
+ return `${path} ${size}`;
+ }
+ if (tool === 'Grep') return `"${input.pattern}" in ${input.path || '.'}`;
+ if (tool === 'Bash') {
+ const cmd = input.command || '';
+ return cmd.length > 50 ? cmd.substring(0, 50) + '...' : cmd;
+ }
+ if (tool === 'Task') return input.description || input.prompt || '';
+
+ const firstString = Object.values(input).find(v => typeof v === 'string');
+ if (firstString) return firstString.length > 50 ? firstString.substring(0, 50) + '...' : firstString;
+ return '';
+}
+
+// ============ Haupt-Funktion: Nachricht senden ============
+
+/**
+ * Sendet eine Nachricht an Claude und streamt Events zurueck.
+ * Gibt einen EventEmitter zurueck der folgende Events feuert:
+ * 'text' → { content: "..." }
+ * 'tool_call' → { name: "...", args: {...}, id: "..." }
+ * 'tool_result' → { id: "...", success: boolean }
+ * 'agent_started' → { id: "...", name: "...", type: "..." }
+ * 'agent_stopped' → { id: "...", success: boolean }
+ * 'result' → { content: "...", cost, tokens, session_id, duration_ms, model }
+ * 'error' → { message: "..." }
+ *
+ * @param {string} text - Nachricht des Users
+ * @param {string} [model] - Modell-Override (sonst currentModel)
+ * @param {string} [mode] - Modus-Override (sonst agentMode)
+ * @param {string} [sessionId] - Session-ID fuer Kontext
+ * @returns {EventEmitter}
+ */
+export function sendMessage(text, model, mode, sessionId) {
+ const emitter = new EventEmitter();
+
+ // Asynchron starten, damit der Caller den Emitter sofort bekommt
+ setImmediate(() => _processMessage(emitter, text, model, mode, sessionId));
+
+ return emitter;
+}
+
+async function _processMessage(emitter, text, model, mode, sessionId) {
+ const useModel = model || currentModel;
+ const useMode = mode || agentMode;
+
+ currentAgentId = randomUUID();
+ activeAbort = new AbortController();
+
+ // Session finden oder erstellen
+ let session = null;
+ if (sessionId && sessions.has(sessionId)) {
+ session = sessions.get(sessionId);
+ }
+
+ // User-Nachricht in Session speichern
+ if (session) {
+ session.messages.push({
+ role: 'user',
+ content: text,
+ timestamp: new Date().toISOString(),
+ });
+ }
+
+ // Subagent-Tracking fuer diesen Request
+ const activeSubagents = new Map();
+ const handledTools = new Set();
+
+ emitter.emit('agent_started', {
+ id: currentAgentId,
+ name: 'Main',
+ type: useMode,
+ model: useModel,
+ });
+
+ // AUTO-Modus: effektiven Modus bestimmen
+ let effectiveMode = useMode;
+ if (useMode === 'auto') {
+ effectiveMode = chooseAutoMode(text);
+ }
+
+ // Orchestrator-Prompt fuer nicht-Solo Modi
+ let fullPrompt = text;
+ if (effectiveMode !== 'solo' && ORCHESTRATOR_PROMPTS[effectiveMode]) {
+ fullPrompt = `${ORCHESTRATOR_PROMPTS[effectiveMode]}\n\n---\n\n${text}`;
+ }
+
+ const startTime = Date.now();
+ let fullText = '';
+ let usedModel = useModel;
+
+ try {
+ // Query-Optionen
+ const queryOptions = {
+ model: useModel,
+ maxTurns: 25,
+ abortController: activeAbort,
+ tools: { type: 'preset', preset: 'claude_code' },
+ allowedTools: ['Task', 'TodoWrite', 'Read', 'Grep', 'Glob', 'Write', 'Edit', 'Bash'],
+ };
+
+ // Claude-Session-ID fuer Fortsetzung
+ if (session?.claudeSessionId) {
+ queryOptions.resume = session.claudeSessionId;
+ }
+
+ let conversation = query({
+ prompt: fullPrompt,
+ options: queryOptions,
+ });
+
+ // Tool-Use handhaben
+ function handleToolUse(ev) {
+ const toolId = ev.tool_use_id || ev.id || randomUUID();
+ if (handledTools.has(toolId)) return;
+ handledTools.add(toolId);
+
+ const toolName = ev.name || 'unknown';
+ const toolInput = ev.input || {};
+
+ // Subagent-Erkennung
+ if (SUBAGENT_TOOLS.includes(toolName)) {
+ const subagentId = randomUUID();
+ const subagentType = getSubagentType(toolName, toolInput);
+ const subagentTask = toolInput.description || toolInput.prompt || toolInput.task || '';
+
+ activeSubagents.set(toolId, {
+ agentId: subagentId,
+ type: subagentType,
+ task: subagentTask,
+ });
+
+ emitter.emit('agent_started', {
+ id: subagentId,
+ name: subagentType,
+ type: subagentType,
+ });
+ }
+
+ emitter.emit('tool_call', {
+ name: toolName,
+ args: toolInput,
+ id: toolId,
+ summary: summarizeToolInput(toolName, toolInput),
+ });
+ }
+
+ // Tool-Result handhaben
+ function handleToolResult(ev) {
+ const toolId = ev.tool_use_id || '';
+
+ if (activeSubagents.has(toolId)) {
+ const subagent = activeSubagents.get(toolId);
+ emitter.emit('agent_stopped', {
+ id: subagent.agentId,
+ success: !ev.is_error,
+ });
+ activeSubagents.delete(toolId);
+ }
+
+ emitter.emit('tool_result', {
+ id: toolId,
+ success: !ev.is_error,
+ });
+ }
+
+ // Iteration mit Fallback bei ungueltiger Session-ID
+ async function* iterateWithRetry() {
+ try {
+ for await (const ev of conversation) yield ev;
+ } catch (err) {
+ if (queryOptions.resume) {
+ console.log('[bridge] Resume fehlgeschlagen, starte neue Session:', err.message);
+ delete queryOptions.resume;
+ conversation = query({ prompt: fullPrompt, options: queryOptions });
+ for await (const ev of conversation) yield ev;
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ for await (const event of iterateWithRetry()) {
+ switch (event.type) {
+ case 'assistant':
+ if (event.message?.content) {
+ for (const block of event.message.content) {
+ if (block.type === 'text' && block.text) {
+ fullText += block.text;
+ emitter.emit('text', { content: block.text });
+ } else if (block.type === 'tool_use') {
+ handleToolUse(block);
+ }
+ }
+ }
+ if (event.message?.model) {
+ usedModel = event.message.model;
+ }
+ break;
+
+ case 'tool_use':
+ handleToolUse(event);
+ break;
+
+ case 'tool_result':
+ handleToolResult(event);
+ break;
+
+ case 'user':
+ if (event.message?.content) {
+ for (const block of event.message.content) {
+ if (block.type === 'tool_result') {
+ handleToolResult(block);
+ }
+ }
+ }
+ break;
+
+ case 'result': {
+ const durationMs = Date.now() - startTime;
+ const usage = event.usage || {};
+ const inputTokens = usage.input_tokens || 0;
+ const cacheRead = usage.cache_read_input_tokens || 0;
+ const cacheCreation = usage.cache_creation_input_tokens || 0;
+ const contextTokens = inputTokens + cacheRead + cacheCreation;
+ const outputTokens = usage.output_tokens || 0;
+ const cost = event.total_cost_usd || 0;
+
+ // Claude-Session-ID speichern fuer spaetere Fortsetzung
+ if (session && event.session_id) {
+ session.claudeSessionId = event.session_id;
+ }
+
+ // Antwort in Session speichern
+ if (session) {
+ session.messages.push({
+ role: 'assistant',
+ content: fullText,
+ timestamp: new Date().toISOString(),
+ model: usedModel,
+ cost,
+ });
+ }
+
+ emitter.emit('result', {
+ content: fullText,
+ cost,
+ tokens: {
+ input: contextTokens,
+ output: outputTokens,
+ cache_read: cacheRead,
+ cache_creation: cacheCreation,
+ },
+ session_id: event.session_id || '',
+ duration_ms: durationMs,
+ model: usedModel,
+ });
+ break;
+ }
+
+ default:
+ break;
+ }
+ }
+ } catch (err) {
+ if (err.name === 'AbortError') {
+ emitter.emit('result', {
+ content: fullText || '(Abgebrochen)',
+ cost: 0,
+ tokens: { input: 0, output: 0 },
+ session_id: '',
+ duration_ms: Date.now() - startTime,
+ model: usedModel,
+ aborted: true,
+ });
+ } else {
+ console.error('[bridge] Fehler:', err.message);
+ emitter.emit('error', { message: err.message || String(err) });
+ }
+ } finally {
+ // Verbleibende Subagents beenden
+ for (const [toolId, subagent] of activeSubagents) {
+ emitter.emit('agent_stopped', {
+ id: subagent.agentId,
+ success: false,
+ });
+ }
+ activeSubagents.clear();
+
+ emitter.emit('agent_stopped', { id: currentAgentId, success: true });
+ currentAgentId = null;
+ activeAbort = null;
+ }
+}
+
+// ============ Steuerungs-Funktionen ============
+
+/** Stoppt alle aktiven Agents */
+export function stopAll() {
+ if (activeAbort) {
+ activeAbort.abort();
+ return true;
+ }
+ return false;
+}
+
+/** Aktueller Status */
+export function getStatus() {
+ return {
+ processing: !!currentAgentId,
+ model: currentModel,
+ mode: agentMode,
+ agentId: currentAgentId,
+ uptime: Math.floor(process.uptime()),
+ };
+}
+
+/** Verfuegbare Modelle */
+export function listModels() {
+ return {
+ current: currentModel,
+ available: AVAILABLE_MODELS,
+ };
+}
+
+/** Modell wechseln */
+export function setModel(model) {
+ const validIds = AVAILABLE_MODELS.map(m => m.id);
+ if (!validIds.includes(model)) {
+ throw new Error(`Ungueltiges Modell: ${model}. Verfuegbar: ${validIds.join(', ')}`);
+ }
+ currentModel = model;
+ return { model: currentModel, status: 'Modell geaendert' };
+}
+
+/** Modus wechseln */
+export function setMode(mode) {
+ const validModes = ['solo', 'handlanger', 'experten', 'auto'];
+ if (!validModes.includes(mode)) {
+ throw new Error(`Ungueltiger Modus: ${mode}. Verfuegbar: ${validModes.join(', ')}`);
+ }
+ agentMode = mode;
+ return { mode: agentMode, status: 'Modus geaendert' };
+}
+
+// ============ Session-Management ============
+
+/** Neue Session erstellen */
+export function createSession(title) {
+ const id = randomUUID();
+ const session = {
+ id,
+ title: title || `Session ${sessions.size + 1}`,
+ createdAt: new Date().toISOString(),
+ messages: [],
+ claudeSessionId: null,
+ };
+ sessions.set(id, session);
+ return session;
+}
+
+/** Session-Liste (ohne Messages fuer Uebersicht) */
+export function listSessions() {
+ return Array.from(sessions.values()).map(s => ({
+ id: s.id,
+ title: s.title,
+ createdAt: s.createdAt,
+ messageCount: s.messages.length,
+ }));
+}
+
+/** Einzelne Session mit Messages */
+export function getSession(id) {
+ const session = sessions.get(id);
+ if (!session) return null;
+ return { ...session };
+}
diff --git a/pwa/server/index.js b/pwa/server/index.js
new file mode 100644
index 0000000..19f6b5a
--- /dev/null
+++ b/pwa/server/index.js
@@ -0,0 +1,260 @@
+// Claude Chat API — Express + WebSocket Server
+//
+// Exponiert die Claude Bridge als HTTP-REST-API und WebSocket fuer Streaming.
+// Port: 3100 (ENV: PORT)
+
+import express from 'express';
+import cors from 'cors';
+import { createServer } from 'node:http';
+import { WebSocketServer } from 'ws';
+import { authMiddleware, verifyWsToken } from './auth.js';
+import {
+ sendMessage,
+ stopAll,
+ getStatus,
+ listModels,
+ setModel,
+ setMode,
+ createSession,
+ listSessions,
+ getSession,
+} from './bridge.js';
+
+const PORT = parseInt(process.env.PORT || '3100', 10);
+const app = express();
+const server = createServer(app);
+
+// ============ Middleware ============
+
+app.use(cors());
+app.use(express.json());
+
+// ============ REST Endpoints ============
+
+// Status — oeffentlich fuer Health-Checks (optional: mit Auth schuetzen)
+app.get('/api/status', authMiddleware, (req, res) => {
+ const status = getStatus();
+ res.json(status);
+});
+
+// Modelle auflisten
+app.get('/api/models', authMiddleware, (req, res) => {
+ res.json(listModels());
+});
+
+// Modell setzen
+app.post('/api/model', authMiddleware, (req, res) => {
+ const { model } = req.body;
+ if (!model) {
+ return res.status(400).json({ error: 'Feld "model" fehlt' });
+ }
+ try {
+ const result = setModel(model);
+ res.json(result);
+ } catch (err) {
+ res.status(400).json({ error: err.message });
+ }
+});
+
+// Modus setzen
+app.post('/api/mode', authMiddleware, (req, res) => {
+ const { mode } = req.body;
+ if (!mode) {
+ return res.status(400).json({ error: 'Feld "mode" fehlt' });
+ }
+ try {
+ const result = setMode(mode);
+ res.json(result);
+ } catch (err) {
+ res.status(400).json({ error: err.message });
+ }
+});
+
+// Sessions auflisten
+app.get('/api/sessions', authMiddleware, (req, res) => {
+ res.json(listSessions());
+});
+
+// Neue Session erstellen
+app.post('/api/sessions', authMiddleware, (req, res) => {
+ const { title } = req.body || {};
+ const session = createSession(title);
+ res.status(201).json(session);
+});
+
+// Einzelne Session abrufen
+app.get('/api/sessions/:id', authMiddleware, (req, res) => {
+ const session = getSession(req.params.id);
+ if (!session) {
+ return res.status(404).json({ error: 'Session nicht gefunden' });
+ }
+ res.json(session);
+});
+
+// Stopp-Endpoint (auch ueber REST erreichbar)
+app.post('/api/stop', authMiddleware, (req, res) => {
+ const stopped = stopAll();
+ res.json({ stopped });
+});
+
+// ============ WebSocket ============
+
+const wss = new WebSocketServer({ noServer: true });
+
+// Upgrade-Handler: Token aus Query-Parameter pruefen
+server.on('upgrade', (request, socket, head) => {
+ // Nur /ws akzeptieren
+ const url = new URL(request.url, `http://${request.headers.host}`);
+ if (url.pathname !== '/ws') {
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
+ socket.destroy();
+ return;
+ }
+
+ // Token pruefen
+ if (!verifyWsToken(request.url)) {
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+ socket.destroy();
+ return;
+ }
+
+ wss.handleUpgrade(request, socket, head, (ws) => {
+ wss.emit('connection', ws, request);
+ });
+});
+
+wss.on('connection', (ws) => {
+ console.log('[ws] Client verbunden');
+
+ ws.on('message', (data) => {
+ let msg;
+ try {
+ msg = JSON.parse(data.toString());
+ } catch {
+ ws.send(JSON.stringify({ type: 'error', message: 'Ungueltiges JSON' }));
+ return;
+ }
+
+ switch (msg.type) {
+ case 'message': {
+ if (!msg.content) {
+ ws.send(JSON.stringify({ type: 'error', message: 'Feld "content" fehlt' }));
+ return;
+ }
+
+ const emitter = sendMessage(msg.content, msg.model, msg.mode, msg.sessionId);
+
+ // Alle Events an den WebSocket-Client weiterleiten
+ emitter.on('text', (payload) => {
+ safeSend(ws, { type: 'text', content: payload.content });
+ });
+
+ emitter.on('tool_call', (payload) => {
+ safeSend(ws, {
+ type: 'tool_call',
+ name: payload.name,
+ args: payload.args,
+ id: payload.id,
+ summary: payload.summary,
+ });
+ });
+
+ emitter.on('tool_result', (payload) => {
+ safeSend(ws, {
+ type: 'tool_result',
+ id: payload.id,
+ success: payload.success,
+ });
+ });
+
+ emitter.on('agent_started', (payload) => {
+ safeSend(ws, {
+ type: 'agent_started',
+ id: payload.id,
+ name: payload.name || payload.type,
+ });
+ });
+
+ emitter.on('agent_stopped', (payload) => {
+ safeSend(ws, {
+ type: 'agent_stopped',
+ id: payload.id,
+ success: payload.success,
+ });
+ });
+
+ emitter.on('result', (payload) => {
+ safeSend(ws, {
+ type: 'result',
+ content: payload.content,
+ cost: payload.cost,
+ tokens: payload.tokens,
+ session_id: payload.session_id,
+ duration_ms: payload.duration_ms,
+ model: payload.model,
+ });
+ });
+
+ emitter.on('error', (payload) => {
+ safeSend(ws, { type: 'error', message: payload.message });
+ });
+
+ break;
+ }
+
+ case 'stop': {
+ const stopped = stopAll();
+ safeSend(ws, { type: 'stopped', success: stopped });
+ break;
+ }
+
+ default:
+ safeSend(ws, { type: 'error', message: `Unbekannter Nachrichtentyp: ${msg.type}` });
+ }
+ });
+
+ ws.on('close', () => {
+ console.log('[ws] Client getrennt');
+ });
+
+ ws.on('error', (err) => {
+ console.error('[ws] Fehler:', err.message);
+ });
+
+ // Willkommens-Nachricht
+ safeSend(ws, {
+ type: 'connected',
+ status: getStatus(),
+ models: listModels(),
+ });
+});
+
+/** Sicher an WebSocket senden (nur wenn offen) */
+function safeSend(ws, data) {
+ if (ws.readyState === ws.OPEN) {
+ ws.send(JSON.stringify(data));
+ }
+}
+
+// ============ Server starten ============
+
+server.listen(PORT, () => {
+ console.log(`[claude-chat-api] Server laeuft auf Port ${PORT}`);
+ console.log(` REST: http://localhost:${PORT}/api/status`);
+ console.log(` WebSocket: ws://localhost:${PORT}/ws?token=`);
+});
+
+// Graceful Shutdown
+process.on('SIGTERM', () => {
+ console.log('[server] SIGTERM empfangen, fahre herunter...');
+ stopAll();
+ wss.close();
+ server.close(() => process.exit(0));
+});
+
+process.on('SIGINT', () => {
+ console.log('[server] SIGINT empfangen, fahre herunter...');
+ stopAll();
+ wss.close();
+ server.close(() => process.exit(0));
+});
diff --git a/pwa/server/package.json b/pwa/server/package.json
new file mode 100644
index 0000000..778d836
--- /dev/null
+++ b/pwa/server/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "claude-chat-api",
+ "version": "1.0.0",
+ "description": "API-Server fuer Claude Desktop PWA",
+ "main": "index.js",
+ "type": "module",
+ "scripts": {
+ "start": "node index.js",
+ "dev": "node --watch index.js"
+ },
+ "dependencies": {
+ "express": "^4.21.0",
+ "ws": "^8.18.0",
+ "cors": "^2.8.5",
+ "@anthropic-ai/claude-agent-sdk": "^0.2.104",
+ "uuid": "^10.0.0"
+ }
+}