PWA Mobile-App: API-Server + SvelteKit-Frontend (Phase 1+2)
All checks were successful
Build AppImage / build (push) Has been skipped

Backend (pwa/server/):
- Express + WebSocket API-Server auf Port 3100
- Claude Agent SDK Bridge mit Streaming
- Bearer-Token Authentifizierung
- REST: /api/status, /api/models, /api/sessions, /api/stop
- WebSocket: /ws mit Live-Text-Streaming
- Dockerfile für Container-Deployment

Frontend (pwa/client/):
- SvelteKit 5 PWA mit Dark Theme
- Mobil-optimierter Chat (WhatsApp/Telegram-Feeling)
- Message-Bubbles mit Markdown + Live-Streaming
- Session-Drawer (Swipe von links)
- Settings-Modal (Server/Token/Modell)
- Service Worker für Auto-Updates
- PWA-Manifest für "Add to Homescreen"
- Safe-Area-Insets für Notch-Handys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-20 06:38:12 +02:00
parent 3993387977
commit 4e36b04cc9
25 changed files with 3244 additions and 0 deletions

3
pwa/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
*.log
.env

17
pwa/Dockerfile Normal file
View file

@ -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"]

21
pwa/client/package.json Normal file
View file

@ -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"
}
}

113
pwa/client/src/app.css Normal file
View file

@ -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; }
}

17
pwa/client/src/app.html Normal file
View file

@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#7c3aed" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/favicon.png" />
<title>Claude Chat</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -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<typeof setTimeout> | 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<string, unknown>;
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<Response> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.token}`,
...(options.headers as Record<string, string> || {}),
};
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<StatusResponse> {
const res = await this.fetch('/api/status');
return res.json();
}
/** Verfuegbare Modelle abrufen */
async getModels(): Promise<ModelsResponse> {
const res = await this.fetch('/api/models');
return res.json();
}
/** Modell wechseln */
async setModel(model: string): Promise<void> {
await this.fetch('/api/model', {
method: 'POST',
body: JSON.stringify({ model }),
});
currentModel.set(model);
}
/** Modus wechseln */
async setMode(mode: string): Promise<void> {
await this.fetch('/api/mode', {
method: 'POST',
body: JSON.stringify({ mode }),
});
agentMode.set(mode);
}
/** Sessions abrufen */
async getSessions(): Promise<SessionInfo[]> {
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<SessionInfo | null> {
try {
const res = await this.fetch(`/api/sessions/${id}`);
return res.json();
} catch {
return null;
}
}
/** Neue Session erstellen */
async createSession(title?: string): Promise<SessionInfo> {
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();

View file

@ -0,0 +1,281 @@
<script lang="ts">
// ChatPanel — Mobiloptimierter Chat (WhatsApp/Telegram-Feeling)
import { tick, onMount } from 'svelte';
import { messages, currentInput, isProcessing, connected, showSessionDrawer } from '$lib/stores/app';
import { api } from '$lib/api/client';
import MessageBubble from './MessageBubble.svelte';
let messagesContainer: HTMLDivElement;
let inputTextarea: HTMLTextAreaElement;
// Touch-State fuer Swipe-Erkennung
let touchStartX = 0;
let touchStartY = 0;
// Auto-Scroll nach unten bei neuen Nachrichten
async function scrollToBottom() {
await tick();
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
$effect(() => {
if ($messages.length) scrollToBottom();
});
// Nachricht senden
function sendMessage() {
const text = $currentInput.trim();
if (!text || !$connected) return;
api.sendMessage(text);
$currentInput = '';
// Textarea-Hoehe zuruecksetzen
if (inputTextarea) {
inputTextarea.style.height = 'auto';
}
}
// Enter = Senden, Shift+Enter = Neue Zeile
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
// Textarea dynamisch wachsen lassen (max 4 Zeilen)
function handleInput() {
if (!inputTextarea) return;
inputTextarea.style.height = 'auto';
const maxHeight = 4 * 24; // ~4 Zeilen bei line-height 24px
inputTextarea.style.height = Math.min(inputTextarea.scrollHeight, maxHeight) + 'px';
}
// Stopp-Button
function handleStop() {
api.stop();
}
// Swipe-Erkennung: Rechts-Swipe oeffnet Session-Drawer
function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}
function handleTouchEnd(e: TouchEvent) {
const deltaX = e.changedTouches[0].clientX - touchStartX;
const deltaY = Math.abs(e.changedTouches[0].clientY - touchStartY);
// Rechts-Swipe: mindestens 80px horizontal, maximal 50px vertikal
if (deltaX > 80 && deltaY < 50 && touchStartX < 40) {
showSessionDrawer.set(true);
}
}
onMount(() => {
// Initialen Scroll nach unten
scrollToBottom();
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="chat-panel"
ontouchstart={handleTouchStart}
ontouchend={handleTouchEnd}
>
<!-- Nachrichten-Bereich -->
<div class="messages-area" bind:this={messagesContainer}>
{#if $messages.length === 0}
<div class="empty-state">
<div class="empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<p class="empty-title">Claude Chat</p>
<p class="empty-hint">Starte eine Konversation</p>
</div>
{:else}
{#each $messages as message (message.id)}
<MessageBubble {message} />
{/each}
{/if}
</div>
<!-- Input-Bereich (fixiert unten) -->
<div class="input-area">
<div class="input-container">
<textarea
bind:this={inputTextarea}
bind:value={$currentInput}
onkeydown={handleKeydown}
oninput={handleInput}
placeholder={$connected ? 'Nachricht eingeben...' : 'Nicht verbunden'}
disabled={!$connected}
rows="1"
></textarea>
{#if $isProcessing}
<!-- Stopp-Button waehrend Verarbeitung -->
<button class="action-btn stop-btn" onclick={handleStop} aria-label="Stopp">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<rect x="4" y="4" width="12" height="12" rx="2"/>
</svg>
</button>
{:else}
<!-- Senden-Button -->
<button
class="action-btn send-btn"
onclick={sendMessage}
disabled={!$currentInput.trim() || !$connected}
aria-label="Senden"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M3.105 2.29a1 1 0 011.28-.38l13 6.5a1 1 0 010 1.18l-13 6.5A1 1 0 013 15.28V11l8-1-8-1V4.72a1 1 0 01.105-.43z"/>
</svg>
</button>
{/if}
</div>
</div>
</div>
<style>
.chat-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* Nachrichten-Scroll-Bereich */
.messages-area {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: var(--spacing-md);
padding-bottom: var(--spacing-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
/* Leerer Zustand */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
text-align: center;
gap: 8px;
}
.empty-icon {
margin-bottom: 8px;
}
.empty-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-primary);
}
.empty-hint {
font-size: 0.85rem;
opacity: 0.6;
}
/* Input-Bereich unten fixiert */
.input-area {
flex-shrink: 0;
padding: var(--spacing-sm) var(--spacing-md);
padding-bottom: calc(var(--spacing-sm) + var(--safe-bottom));
background: var(--bg-secondary);
border-top: 1px solid var(--border);
}
.input-container {
display: flex;
align-items: flex-end;
gap: var(--spacing-sm);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 6px 6px 6px 16px;
transition: border-color 0.2s;
}
.input-container:focus-within {
border-color: var(--accent);
}
.input-container textarea {
flex: 1;
resize: none;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 16px; /* Verhindert Auto-Zoom auf iOS */
line-height: 24px;
padding: 6px 0;
min-height: 24px;
max-height: 96px; /* ~4 Zeilen */
overflow-y: auto;
}
.input-container textarea::placeholder {
color: var(--text-secondary);
opacity: 0.6;
}
.input-container textarea:disabled {
opacity: 0.4;
}
/* Senden-/Stopp-Button */
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.send-btn {
background: var(--accent);
color: white;
}
.send-btn:active:not(:disabled) {
background: var(--accent-hover);
transform: scale(0.92);
}
.send-btn:disabled {
background: var(--bg-tertiary);
color: var(--text-secondary);
opacity: 0.5;
}
.stop-btn {
background: var(--danger);
color: white;
animation: pulse 1.5s ease-in-out infinite;
}
.stop-btn:active {
background: #9b2c2c;
transform: scale(0.92);
}
</style>

View file

@ -0,0 +1,271 @@
<script lang="ts">
// Einzelne Chat-Nachricht — mobiloptimierte Bubble
import type { Message } from '$lib/stores/app';
import { renderMarkdown, addCopyButtons } from '$lib/utils/markdown';
interface Props {
message: Message;
}
let { message }: Props = $props();
// Modell-Kurzname fuer die Anzeige
function formatModelName(model?: string): string {
if (!model) return 'Claude';
return model
.replace('claude-', '')
.replace(/-\d{8}$/, '')
.replace(/(\D)-(\d)/g, '$1 $2')
.replace(/(\d)-(\d)/g, '$1.$2')
.replace(/\b[a-z]/g, (c) => c.toUpperCase());
}
// Zeitstempel formatieren
function formatTime(date: Date): string {
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
</script>
<div
class="bubble"
class:user={message.role === 'user'}
class:assistant={message.role === 'assistant'}
class:system={message.role === 'system'}
class:streaming={message.streaming}
>
{#if message.role === 'assistant'}
<div class="bubble-content" use:addCopyButtons>
{#if message.content}
{@html renderMarkdown(message.content)}
{:else}
<span class="typing-indicator">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
{/if}
</div>
<div class="bubble-meta">
<span class="meta-model">{formatModelName(message.model)}</span>
<span class="meta-time">{formatTime(message.timestamp)}</span>
</div>
{:else if message.role === 'user'}
<div class="bubble-content user-text">{message.content}</div>
<div class="bubble-meta">
<span class="meta-time">{formatTime(message.timestamp)}</span>
</div>
{:else}
<!-- System-Nachricht -->
<div class="bubble-content system-text">{message.content}</div>
{/if}
</div>
<style>
.bubble {
max-width: 85%;
padding: 10px 14px;
border-radius: var(--radius-lg);
animation: fadeIn 0.2s ease-out;
word-wrap: break-word;
overflow-wrap: break-word;
}
.bubble.user {
align-self: flex-end;
background: var(--user-bubble);
border-bottom-right-radius: 4px;
margin-left: 15%;
}
.bubble.assistant {
align-self: flex-start;
background: var(--assistant-bubble);
border-bottom-left-radius: 4px;
margin-right: 15%;
}
.bubble.system {
align-self: center;
background: rgba(197, 48, 48, 0.15);
border-left: 3px solid var(--danger);
max-width: 95%;
font-size: 0.85rem;
color: var(--text-secondary);
border-radius: var(--radius-sm);
}
.bubble.streaming {
border: 1px solid rgba(124, 58, 237, 0.3);
}
.bubble-content {
font-size: 0.95rem;
line-height: 1.55;
}
.user-text {
white-space: pre-wrap;
}
.bubble-meta {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
font-size: 0.7rem;
color: var(--text-secondary);
opacity: 0.7;
}
.meta-model {
font-weight: 500;
}
/* Typing-Dots */
.typing-indicator {
display: flex;
gap: 4px;
padding: 4px 0;
}
.dot {
width: 8px;
height: 8px;
background: var(--text-secondary);
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
.dot:nth-child(1) { animation-delay: -0.32s; }
.dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
/* Markdown-Styles innerhalb von Bubbles */
.bubble-content :global(p) {
margin: 0.3em 0;
}
.bubble-content :global(p:first-child) {
margin-top: 0;
}
.bubble-content :global(p:last-child) {
margin-bottom: 0;
}
.bubble-content :global(code) {
font-family: var(--font-mono);
font-size: 0.85rem;
padding: 0.15em 0.4em;
background: rgba(0, 0, 0, 0.2);
border-radius: var(--radius-sm);
}
.bubble-content :global(.code-block-wrapper) {
margin: 0.5em -14px;
border-radius: 0;
background: rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.bubble-content :global(.code-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 14px;
background: rgba(0, 0, 0, 0.15);
font-size: 0.7rem;
}
.bubble-content :global(.code-lang) {
color: var(--text-secondary);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.bubble-content :global(.copy-btn) {
padding: 2px 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.7rem;
cursor: pointer;
min-height: 28px;
}
.bubble-content :global(.copy-btn:active) {
background: rgba(255, 255, 255, 0.2);
}
.bubble-content :global(.code-block-wrapper pre) {
margin: 0;
padding: 10px 14px;
overflow-x: auto;
font-size: 0.8rem;
line-height: 1.5;
-webkit-overflow-scrolling: touch;
}
.bubble-content :global(.code-block-wrapper pre code) {
padding: 0;
background: none;
}
.bubble-content :global(ul), .bubble-content :global(ol) {
margin: 0.3em 0;
padding-left: 1.5em;
}
.bubble-content :global(li) {
margin: 0.15em 0;
}
.bubble-content :global(strong) {
color: var(--text-primary);
}
.bubble-content :global(a) {
color: #93c5fd;
text-decoration: underline;
}
.bubble-content :global(blockquote) {
border-left: 3px solid var(--border);
padding-left: 10px;
margin: 0.3em 0;
color: var(--text-secondary);
}
.bubble-content :global(h1),
.bubble-content :global(h2),
.bubble-content :global(h3) {
margin: 0.5em 0 0.2em;
font-size: 1rem;
}
.bubble-content :global(table) {
border-collapse: collapse;
margin: 0.3em 0;
font-size: 0.8rem;
width: 100%;
overflow-x: auto;
display: block;
}
.bubble-content :global(th),
.bubble-content :global(td) {
border: 1px solid var(--border);
padding: 4px 8px;
}
.bubble-content :global(th) {
background: rgba(0, 0, 0, 0.2);
font-weight: 600;
}
</style>

View file

@ -0,0 +1,266 @@
<script lang="ts">
// SessionDrawer — Slide-in von links (wie Telegram)
import { showSessionDrawer, sessions, currentSessionId, messages } from '$lib/stores/app';
import { api } from '$lib/api/client';
import { onMount } from 'svelte';
let isAnimating = $state(false);
// Sessions laden beim ersten Oeffnen
onMount(async () => {
try {
await api.getSessions();
} catch (err) {
console.error('Sessions konnten nicht geladen werden:', err);
}
});
// Drawer schliessen
function close() {
isAnimating = true;
setTimeout(() => {
showSessionDrawer.set(false);
isAnimating = false;
}, 250);
}
// Neue Session erstellen
async function createNewSession() {
try {
const title = `Chat ${new Date().toLocaleDateString('de-DE')} ${new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
await api.createSession(title);
messages.set([]); // Chat leeren
close();
} catch (err) {
console.error('Session-Erstellung fehlgeschlagen:', err);
}
}
// Session auswaehlen und Nachrichten laden
async function selectSession(id: string) {
if (id === $currentSessionId) {
close();
return;
}
currentSessionId.set(id);
try {
const session = await api.getSession(id);
if (session?.messages) {
// Server-Messages ins Store-Format umwandeln
messages.set(
session.messages.map((m) => ({
id: crypto.randomUUID(),
role: m.role as 'user' | 'assistant' | 'system',
content: m.content,
timestamp: new Date(m.timestamp),
model: m.model,
}))
);
} else {
messages.set([]);
}
} catch (err) {
console.error('Session laden fehlgeschlagen:', err);
}
close();
}
// Datum formatieren
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return 'Heute, ' + date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
if (date.toDateString() === yesterday.toDateString()) {
return 'Gestern, ' + date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) +
' ' + date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
</script>
{#if $showSessionDrawer}
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" class:closing={isAnimating} onclick={close} onkeydown={(e) => e.key === 'Escape' && close()}></div>
<!-- Drawer -->
<aside class="drawer" class:closing={isAnimating}>
<div class="drawer-header">
<h2>Sessions</h2>
<button class="new-session-btn" onclick={createNewSession}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<path d="M9 3a1 1 0 011 1v4h4a1 1 0 110 2h-4v4a1 1 0 11-2 0v-4H4a1 1 0 110-2h4V4a1 1 0 011-1z"/>
</svg>
Neuer Chat
</button>
</div>
<div class="session-list">
{#if $sessions.length === 0}
<div class="empty-sessions">
<p>Noch keine Sessions</p>
<p class="hint">Starte einen neuen Chat</p>
</div>
{:else}
{#each $sessions as session (session.id)}
<button
class="session-item"
class:active={session.id === $currentSessionId}
onclick={() => selectSession(session.id)}
>
<div class="session-title">{session.title}</div>
<div class="session-meta">
<span>{session.messageCount} Nachrichten</span>
<span>{formatDate(session.createdAt)}</span>
</div>
</button>
{/each}
{/if}
</div>
</aside>
{/if}
<style>
.backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
animation: fadeInBackdrop 0.25s ease-out;
}
.backdrop.closing {
animation: fadeOutBackdrop 0.25s ease-in forwards;
}
@keyframes fadeInBackdrop {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOutBackdrop {
from { opacity: 1; }
to { opacity: 0; }
}
.drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(320px, 85vw);
background: var(--bg-secondary);
z-index: 101;
display: flex;
flex-direction: column;
animation: slideInLeft 0.25s ease-out;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
}
.drawer.closing {
animation: slideOutLeft 0.25s ease-in forwards;
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
padding-top: calc(var(--spacing-md) + var(--safe-top));
border-bottom: 1px solid var(--border);
}
.drawer-header h2 {
font-size: 1.1rem;
font-weight: 600;
}
.new-session-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 14px;
background: var(--accent);
color: white;
border-radius: var(--radius);
font-size: 0.85rem;
font-weight: 500;
transition: background 0.15s;
min-height: 44px;
-webkit-tap-highlight-color: transparent;
}
.new-session-btn:active {
background: var(--accent-hover);
}
.session-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.empty-sessions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-secondary);
text-align: center;
gap: 4px;
}
.empty-sessions .hint {
font-size: 0.8rem;
opacity: 0.6;
}
.session-item {
display: block;
width: 100%;
text-align: left;
padding: 14px var(--spacing-md);
border-bottom: 1px solid rgba(51, 51, 85, 0.5);
transition: background 0.15s;
min-height: 60px;
-webkit-tap-highlight-color: transparent;
}
.session-item:active {
background: var(--bg-tertiary);
}
.session-item.active {
background: rgba(124, 58, 237, 0.12);
border-left: 3px solid var(--accent);
}
.session-title {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-meta {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-secondary);
}
</style>

View file

@ -0,0 +1,374 @@
<script lang="ts">
// SettingsModal — Server-/Token-/Modell-Konfiguration
import { showSettings, serverUrl, apiToken, currentModel, agentMode, connected } from '$lib/stores/app';
import { api } from '$lib/api/client';
// Lokale Kopien fuer das Formular
let localServerUrl = $state($serverUrl);
let localApiToken = $state($apiToken);
let localModel = $state($currentModel);
let localMode = $state($agentMode);
let connecting = $state(false);
let error = $state('');
// Beim Oeffnen aktuelle Werte uebernehmen
$effect(() => {
if ($showSettings) {
localServerUrl = $serverUrl;
localApiToken = $apiToken;
localModel = $currentModel;
localMode = $agentMode;
error = '';
}
});
// Schliessen
function close() {
showSettings.set(false);
}
// Verbinden
async function handleConnect() {
if (!localServerUrl.trim() || !localApiToken.trim()) {
error = 'Server-URL und API-Token sind erforderlich';
return;
}
connecting = true;
error = '';
// Stores aktualisieren (wird automatisch in localStorage gespeichert)
serverUrl.set(localServerUrl.trim());
apiToken.set(localApiToken.trim());
// Bestehende Verbindung trennen und neu aufbauen
api.disconnect();
try {
// Erst REST-Status testen
const status = await api.getStatus();
if (status) {
currentModel.set(status.model);
agentMode.set(status.mode);
}
// Dann WebSocket verbinden
api.connect();
// Modell/Modus setzen falls geaendert
if (localModel !== status.model) {
await api.setModel(localModel);
}
if (localMode !== status.mode) {
await api.setMode(localMode);
}
close();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
error = `Verbindung fehlgeschlagen: ${msg}`;
} finally {
connecting = false;
}
}
// Trennen
function handleDisconnect() {
api.disconnect();
}
// Modelle
const models = [
{ id: 'haiku', name: 'Claude Haiku', desc: 'Schnell & guenstig' },
{ id: 'sonnet', name: 'Claude Sonnet', desc: 'Ausgewogen' },
{ id: 'opus', name: 'Claude Opus', desc: 'Leistungsstark' },
];
const modes = [
{ id: 'solo', name: 'Solo', desc: 'Ein Agent' },
{ id: 'handlanger', name: 'Handlanger', desc: 'Koordination + Subagents' },
{ id: 'experten', name: 'Experten', desc: 'Spezialisierte Agents' },
{ id: 'auto', name: 'Auto', desc: 'Automatische Wahl' },
];
</script>
{#if $showSettings}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={close}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h2>Einstellungen</h2>
<button class="close-btn" onclick={close} aria-label="Schliessen">
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<path d="M4.29 4.29a1 1 0 011.42 0L9 7.59l3.29-3.3a1 1 0 111.42 1.42L10.41 9l3.3 3.29a1 1 0 01-1.42 1.42L9 10.41l-3.29 3.3a1 1 0 01-1.42-1.42L7.59 9l-3.3-3.29a1 1 0 010-1.42z"/>
</svg>
</button>
</div>
<div class="modal-body">
<!-- Server-URL -->
<div class="form-group">
<label for="server-url">Server-URL</label>
<input
type="url"
id="server-url"
bind:value={localServerUrl}
placeholder="http://192.168.x.x:3100"
/>
</div>
<!-- API-Token -->
<div class="form-group">
<label for="api-token">API-Token</label>
<input
type="password"
id="api-token"
bind:value={localApiToken}
placeholder="Bearer-Token eingeben"
/>
</div>
<!-- Modell-Auswahl -->
<div class="form-group">
<label for="model-select">Modell</label>
<div class="radio-group">
{#each models as m}
<label class="radio-option" class:selected={localModel === m.id}>
<input type="radio" name="model" value={m.id} bind:group={localModel} />
<span class="radio-label">
<strong>{m.name}</strong>
<small>{m.desc}</small>
</span>
</label>
{/each}
</div>
</div>
<!-- Modus-Auswahl -->
<div class="form-group">
<label for="mode-select">Modus</label>
<div class="radio-group">
{#each modes as m}
<label class="radio-option" class:selected={localMode === m.id}>
<input type="radio" name="mode" value={m.id} bind:group={localMode} />
<span class="radio-label">
<strong>{m.name}</strong>
<small>{m.desc}</small>
</span>
</label>
{/each}
</div>
</div>
<!-- Fehlermeldung -->
{#if error}
<div class="error-box">{error}</div>
{/if}
</div>
<div class="modal-footer">
{#if $connected}
<button class="btn-danger" onclick={handleDisconnect}>Trennen</button>
{/if}
<button class="btn-primary" onclick={handleConnect} disabled={connecting}>
{connecting ? 'Verbinde...' : ($connected ? 'Neu verbinden' : 'Verbinden')}
</button>
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--spacing-md);
}
.modal {
background: var(--bg-primary);
border-radius: var(--radius);
width: 100%;
max-width: 400px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
animation: fadeIn 0.2s ease-out;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 1rem;
font-weight: 600;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: var(--text-secondary);
border-radius: var(--radius-sm);
-webkit-tap-highlight-color: transparent;
}
.close-btn:active {
background: var(--bg-tertiary);
}
.modal-body {
padding: var(--spacing-md);
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.form-group input[type="url"],
.form-group input[type="password"] {
width: 100%;
padding: 12px;
font-size: 16px; /* iOS-Zoom verhindern */
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
min-height: 44px;
}
.form-group input:focus {
border-color: var(--accent);
}
/* Radio-Gruppen */
.radio-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.radio-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
min-height: 44px;
transition: all 0.15s;
-webkit-tap-highlight-color: transparent;
}
.radio-option.selected {
border-color: var(--accent);
background: rgba(124, 58, 237, 0.1);
}
.radio-option input[type="radio"] {
accent-color: var(--accent);
width: 18px;
height: 18px;
}
.radio-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.radio-label strong {
font-size: 0.85rem;
}
.radio-label small {
font-size: 0.75rem;
color: var(--text-secondary);
}
.error-box {
padding: 10px 14px;
background: rgba(197, 48, 48, 0.15);
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
color: var(--danger);
font-size: 0.85rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
}
.btn-primary {
padding: 10px 20px;
font-size: 0.9rem;
font-weight: 500;
background: var(--accent);
color: white;
border-radius: var(--radius-sm);
min-height: 44px;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.btn-primary:active:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.5;
}
.btn-danger {
padding: 10px 20px;
font-size: 0.9rem;
font-weight: 500;
background: transparent;
color: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
min-height: 44px;
-webkit-tap-highlight-color: transparent;
}
.btn-danger:active {
background: rgba(197, 48, 48, 0.15);
}
</style>

View file

@ -0,0 +1,124 @@
<script lang="ts">
// StatusBar — Verbindungsstatus + Modell (kompakt, 40px)
import { connected, reconnecting, currentModel, isProcessing, showSettings, showSessionDrawer } from '$lib/stores/app';
// Modell-Kurzname
function modelShort(model: string): string {
if (!model) return '?';
return model
.replace('claude-', '')
.replace(/-\d{8}$/, '')
.replace(/(\D)-(\d)/g, '$1 $2')
.replace(/(\d)-(\d)/g, '$1.$2')
.replace(/\b[a-z]/g, (c) => c.toUpperCase());
}
</script>
<header class="status-bar">
<button class="hamburger" onclick={() => showSessionDrawer.set(true)} aria-label="Sessions oeffnen">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<rect x="3" y="4" width="14" height="2" rx="1"/>
<rect x="3" y="9" width="14" height="2" rx="1"/>
<rect x="3" y="14" width="14" height="2" rx="1"/>
</svg>
</button>
<div class="center">
<span class="status-dot" class:online={$connected} class:reconnect={$reconnecting} class:offline={!$connected && !$reconnecting}></span>
<span class="status-text">
{#if $isProcessing}
Arbeitet...
{:else if $connected}
Verbunden
{:else if $reconnecting}
Verbindet...
{:else}
Getrennt
{/if}
</span>
{#if $connected && $currentModel}
<span class="model-pill">{modelShort($currentModel)}</span>
{/if}
</div>
<button class="settings-btn" onclick={() => showSettings.set(true)} aria-label="Einstellungen">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 13a3 3 0 100-6 3 3 0 000 6z"/>
<path fill-rule="evenodd" d="M9.99 1.95a1 1 0 01.98.8l.3 1.76a6.03 6.03 0 011.45.84l1.68-.56a1 1 0 011.15.44l1 1.73a1 1 0 01-.17 1.24l-1.38 1.2a6 6 0 010 1.7l1.38 1.2a1 1 0 01.17 1.24l-1 1.73a1 1 0 01-1.15.44l-1.68-.56a6 6 0 01-1.45.84l-.3 1.76a1 1 0 01-.98.8h-2a1 1 0 01-.98-.8l-.3-1.76a6 6 0 01-1.45-.84l-1.68.56a1 1 0 01-1.15-.44l-1-1.73a1 1 0 01.17-1.24l1.38-1.2a6 6 0 010-1.7l-1.38-1.2a1 1 0 01-.17-1.24l1-1.73a1 1 0 011.15-.44l1.68.56a6 6 0 011.45-.84l.3-1.76a1 1 0 01.98-.8h2z" clip-rule="evenodd" opacity="0.3"/>
</svg>
</button>
</header>
<style>
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 var(--spacing-sm);
padding-top: var(--safe-top);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
user-select: none;
-webkit-user-select: none;
flex-shrink: 0;
}
.hamburger, .settings-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
color: var(--text-secondary);
border-radius: var(--radius-sm);
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.hamburger:active, .settings-btn:active {
background: var(--bg-tertiary);
}
.center {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.online {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.status-dot.reconnect {
background: var(--warning);
animation: pulse 1.5s ease-in-out infinite;
}
.status-dot.offline {
background: var(--danger);
}
.status-text {
color: var(--text-secondary);
font-size: 0.8rem;
}
.model-pill {
padding: 2px 8px;
background: var(--bg-tertiary);
border-radius: 10px;
font-size: 0.7rem;
color: var(--accent);
font-weight: 600;
}
</style>

View file

@ -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<string>(
typeof localStorage !== 'undefined'
? localStorage.getItem('serverUrl') || 'http://localhost:3100'
: 'http://localhost:3100'
);
export const apiToken = writable<string>(
typeof localStorage !== 'undefined'
? localStorage.getItem('apiToken') || ''
: ''
);
export const connected = writable<boolean>(false);
export const reconnecting = writable<boolean>(false);
// ============ Chat ============
export const messages = writable<Message[]>([]);
export const isProcessing = writable<boolean>(false);
export const currentInput = writable<string>('');
export const streamingText = writable<string>('');
// ============ Session ============
export const sessions = writable<SessionInfo[]>([]);
export const currentSessionId = writable<string | null>(null);
// ============ Modell & Modus ============
export const currentModel = writable<string>('sonnet');
export const agentMode = writable<string>('solo');
// ============ Aktivitaet ============
export const activeAgents = writable<AgentInfo[]>([]);
export const toolCalls = writable<ToolCallInfo[]>([]);
// ============ UI-State ============
export const showSettings = writable<boolean>(false);
export const showSessionDrawer = writable<boolean>(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);
}
});
}

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<div class="code-block-wrapper" data-lang="${language}"><pre><code class="language-${language}">${escapedCode}</code></pre></div>`;
};
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: <div use:addCopyButtons>...</div>
*/
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();
},
};
}

View file

@ -0,0 +1,67 @@
<script lang="ts">
// App-Shell mit StatusBar, Drawers und Modals
import '../app.css';
import { onMount } from 'svelte';
import { initPersistence, apiToken, connected } from '$lib/stores/app';
import { api } from '$lib/api/client';
import StatusBar from '$lib/components/StatusBar.svelte';
import SessionDrawer from '$lib/components/SessionDrawer.svelte';
import SettingsModal from '$lib/components/SettingsModal.svelte';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
}
let { children }: Props = $props();
onMount(() => {
// localStorage-Persistierung aktivieren
initPersistence();
// Automatisch verbinden wenn Token vorhanden
const token = localStorage.getItem('apiToken');
if (token) {
api.connect();
}
// Service Worker registrieren
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js').catch((err) => {
console.warn('Service Worker Registrierung fehlgeschlagen:', err);
});
}
return () => {
api.disconnect();
};
});
</script>
<div class="app-shell">
<StatusBar />
<main class="main-content">
{@render children()}
</main>
</div>
<!-- Overlays -->
<SessionDrawer />
<SettingsModal />
<style>
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh; /* Dynamic viewport height (mobil) */
overflow: hidden;
}
.main-content {
flex: 1;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,81 @@
<script lang="ts">
// Hauptseite — Chat-View
import ChatPanel from '$lib/components/ChatPanel.svelte';
import { connected, showSettings } from '$lib/stores/app';
</script>
{#if !$connected}
<!-- Nicht verbunden — Einstellungen-Hinweis -->
<div class="connect-prompt">
<div class="connect-card">
<div class="connect-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<h2>Claude Chat</h2>
<p>Verbinde mit deinem Claude API-Server</p>
<button class="connect-btn" onclick={() => showSettings.set(true)}>
Einstellungen oeffnen
</button>
</div>
</div>
{:else}
<ChatPanel />
{/if}
<style>
.connect-prompt {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--spacing-lg);
}
.connect-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--spacing-md);
padding: var(--spacing-lg);
background: var(--bg-secondary);
border-radius: var(--radius);
border: 1px solid var(--border);
max-width: 320px;
width: 100%;
}
.connect-icon {
color: var(--accent);
opacity: 0.6;
}
.connect-card h2 {
font-size: 1.3rem;
font-weight: 600;
}
.connect-card p {
color: var(--text-secondary);
font-size: 0.9rem;
}
.connect-btn {
padding: 12px 28px;
background: var(--accent);
color: white;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 500;
min-height: 48px;
width: 100%;
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.connect-btn:active {
background: var(--accent-hover);
}
</style>

View file

@ -0,0 +1,108 @@
/// <reference lib="webworker" />
// 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<Response> {
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<Response> {
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 {};

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

View file

@ -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" }
]
}

View file

@ -0,0 +1,7 @@
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({ fallback: 'index.html' })
}
};

14
pwa/client/tsconfig.json Normal file
View file

@ -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"
}
}

View file

@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: { port: 5200 }
});

52
pwa/server/auth.js Normal file
View file

@ -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 <token>
*/
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 };

502
pwa/server/bridge.js Normal file
View file

@ -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 };
}

260
pwa/server/index.js Normal file
View file

@ -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=<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));
});

18
pwa/server/package.json Normal file
View file

@ -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"
}
}