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>
260 lines
6.4 KiB
JavaScript
260 lines
6.4 KiB
JavaScript
// 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));
|
|
});
|