PWA Mobile-App: API-Server + SvelteKit-Frontend (Phase 1+2)
All checks were successful
Build AppImage / build (push) Has been skipped
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:
parent
3993387977
commit
4e36b04cc9
25 changed files with 3244 additions and 0 deletions
3
pwa/.dockerignore
Normal file
3
pwa/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
*.log
|
||||
.env
|
||||
17
pwa/Dockerfile
Normal file
17
pwa/Dockerfile
Normal 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
21
pwa/client/package.json
Normal 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
113
pwa/client/src/app.css
Normal 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
17
pwa/client/src/app.html
Normal 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>
|
||||
440
pwa/client/src/lib/api/client.ts
Normal file
440
pwa/client/src/lib/api/client.ts
Normal 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();
|
||||
281
pwa/client/src/lib/components/ChatPanel.svelte
Normal file
281
pwa/client/src/lib/components/ChatPanel.svelte
Normal 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>
|
||||
271
pwa/client/src/lib/components/MessageBubble.svelte
Normal file
271
pwa/client/src/lib/components/MessageBubble.svelte
Normal 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>
|
||||
266
pwa/client/src/lib/components/SessionDrawer.svelte
Normal file
266
pwa/client/src/lib/components/SessionDrawer.svelte
Normal 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>
|
||||
374
pwa/client/src/lib/components/SettingsModal.svelte
Normal file
374
pwa/client/src/lib/components/SettingsModal.svelte
Normal 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>
|
||||
124
pwa/client/src/lib/components/StatusBar.svelte
Normal file
124
pwa/client/src/lib/components/StatusBar.svelte
Normal 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>
|
||||
94
pwa/client/src/lib/stores/app.ts
Normal file
94
pwa/client/src/lib/stores/app.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
93
pwa/client/src/lib/utils/markdown.ts
Normal file
93
pwa/client/src/lib/utils/markdown.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
67
pwa/client/src/routes/+layout.svelte
Normal file
67
pwa/client/src/routes/+layout.svelte
Normal 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>
|
||||
81
pwa/client/src/routes/+page.svelte
Normal file
81
pwa/client/src/routes/+page.svelte
Normal 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>
|
||||
108
pwa/client/src/service-worker.ts
Normal file
108
pwa/client/src/service-worker.ts
Normal 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 {};
|
||||
BIN
pwa/client/static/favicon.png
Normal file
BIN
pwa/client/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 593 B |
14
pwa/client/static/manifest.json
Normal file
14
pwa/client/static/manifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
7
pwa/client/svelte.config.js
Normal file
7
pwa/client/svelte.config.js
Normal 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
14
pwa/client/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
pwa/client/vite.config.ts
Normal file
7
pwa/client/vite.config.ts
Normal 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
52
pwa/server/auth.js
Normal 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
502
pwa/server/bridge.js
Normal 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
260
pwa/server/index.js
Normal 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
18
pwa/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue