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