Initial: Privacy Gateway Projekt mit Team-Implementierung
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,2 @@
|
||||
# API URL fuer den Backend-Server
|
||||
VITE_API_URL=http://localhost:3000
|
||||
@@ -0,0 +1,49 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from builder
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config for SPA
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
server_name localhost; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
\
|
||||
location / { \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
} \
|
||||
\
|
||||
location /api { \
|
||||
proxy_pass http://backend:3000; \
|
||||
proxy_http_version 1.1; \
|
||||
proxy_set_header Upgrade $http_upgrade; \
|
||||
proxy_set_header Connection "upgrade"; \
|
||||
proxy_set_header Host $host; \
|
||||
proxy_set_header X-Real-IP $remote_addr; \
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
|
||||
proxy_set_header X-Forwarded-Proto $scheme; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Privacy Gateway Frontend
|
||||
|
||||
React-basierte Chat-Oberfläche für das Privacy Gateway.
|
||||
|
||||
## Features
|
||||
|
||||
- ✨ Multi-Window Chat-Sessions (wie OpenWebUI)
|
||||
- 🎨 Dark Mode Support
|
||||
- 📝 Markdown-Rendering mit Syntax-Highlighting
|
||||
- 👁️ Markdown-Live-Preview beim Schreiben
|
||||
- 📱 Responsive Design
|
||||
- 🔄 Streaming-Response Support
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 18 + TypeScript
|
||||
- Vite
|
||||
- Tailwind CSS
|
||||
- React Markdown + Syntax Highlighter
|
||||
- Lucide Icons
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Dependencies installieren
|
||||
npm install
|
||||
|
||||
# Development Server starten
|
||||
npm run dev
|
||||
|
||||
# Production Build
|
||||
npm run build
|
||||
```
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
Die Frontend-Anwendung kommuniziert mit dem Backend über:
|
||||
|
||||
- `GET /api/sessions` - Alle Sessions laden
|
||||
- `POST /api/sessions` - Neue Session erstellen
|
||||
- `GET /api/sessions/:id` - Einzelne Session laden
|
||||
- `POST /api/sessions/:id/chat` - Nachricht senden (Stream)
|
||||
- `DELETE /api/sessions/:id` - Session löschen
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Image bauen
|
||||
docker build -t privacy-gateway-frontend .
|
||||
|
||||
# Container starten
|
||||
docker run -p 80:80 privacy-gateway-frontend
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Privacy Gateway - Lokal gehosteter KI-Chat" />
|
||||
<title>Privacy Gateway Chat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "privacy-gateway-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"lucide-react": "^0.344.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/react-syntax-highlighter": "^15.5.11",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { ChatWindow } from './components/ChatWindow';
|
||||
import { Session } from './types';
|
||||
|
||||
const API_URL = 'http://localhost:3000';
|
||||
|
||||
function App() {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load sessions
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/sessions`);
|
||||
if (!response.ok) throw new Error('Failed to load sessions');
|
||||
const data = await response.json();
|
||||
setSessions(data.sessions || []);
|
||||
} catch (err) {
|
||||
setError('Verbindung zum Server fehlgeschlagen');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// Create new session
|
||||
const handleCreateSession = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: `Chat ${sessions.length + 1}` }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create session');
|
||||
const data = await response.json();
|
||||
setSessions((prev) => [data.session, ...prev]);
|
||||
setActiveSessionId(data.session.id);
|
||||
} catch (err) {
|
||||
setError('Konnte keinen neuen Chat erstellen');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete session
|
||||
const handleDeleteSession = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/sessions/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete session');
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
if (activeSessionId === id) {
|
||||
setActiveSessionId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Konnte Chat nicht loeschen');
|
||||
}
|
||||
};
|
||||
|
||||
// Select session
|
||||
const handleSelectSession = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/sessions/${id}`);
|
||||
if (!response.ok) throw new Error('Failed to load session');
|
||||
const data = await response.json();
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.id === id ? data.session : s))
|
||||
);
|
||||
setActiveSessionId(id);
|
||||
} catch (err) {
|
||||
setError('Konnte Chat nicht laden');
|
||||
}
|
||||
};
|
||||
|
||||
// Update session locally
|
||||
const handleSessionUpdate = (updatedSession: Session) => {
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.id === updatedSession.id ? updatedSession : s))
|
||||
);
|
||||
};
|
||||
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId) || null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={`h-screen flex items-center justify-center ${
|
||||
isDarkMode ? 'bg-gray-900' : 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-screen flex ${
|
||||
isDarkMode ? 'bg-gray-900' : 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Sidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSessionSelect={handleSelectSession}
|
||||
onSessionCreate={handleCreateSession}
|
||||
onSessionDelete={handleDeleteSession}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
|
||||
<ChatWindow
|
||||
session={activeSession}
|
||||
isDarkMode={isDarkMode}
|
||||
onToggleDarkMode={() => setIsDarkMode(!isDarkMode)}
|
||||
onSessionUpdate={handleSessionUpdate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,262 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { MessageList } from './MessageList';
|
||||
import { InputArea } from './InputArea';
|
||||
import { Session, Message } from '../types';
|
||||
import { Moon, Sun, Bot } from 'lucide-react';
|
||||
|
||||
const API_URL = 'http://localhost:3000';
|
||||
|
||||
interface ChatWindowProps {
|
||||
session: Session | null;
|
||||
isDarkMode: boolean;
|
||||
onToggleDarkMode: () => void;
|
||||
onSessionUpdate: (session: Session) => void;
|
||||
}
|
||||
|
||||
export const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
session,
|
||||
isDarkMode,
|
||||
onToggleDarkMode,
|
||||
onSessionUpdate,
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!session) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Optimistic user message
|
||||
const userMessage: Message = {
|
||||
id: `temp-${Date.now()}`,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const updatedMessages = [...session.messages, userMessage];
|
||||
onSessionUpdate({
|
||||
...session,
|
||||
messages: updatedMessages,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/sessions/${session.id}/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message: content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
let assistantContent = '';
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const messagesWithAssistant = [...updatedMessages, assistantMessage];
|
||||
onSessionUpdate({
|
||||
...session,
|
||||
messages: messagesWithAssistant,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.content) {
|
||||
assistantContent += parsed.content;
|
||||
onSessionUpdate({
|
||||
...session,
|
||||
messages: [
|
||||
...updatedMessages,
|
||||
{ ...assistantMessage, content: assistantContent },
|
||||
],
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||
// Revert to messages without the failed request
|
||||
onSessionUpdate({
|
||||
...session,
|
||||
messages: session.messages,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[session, onSessionUpdate]
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 flex flex-col items-center justify-center ${
|
||||
isDarkMode ? 'bg-gray-900' : 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`text-center p-8 rounded-2xl ${
|
||||
isDarkMode ? 'bg-gray-800' : 'bg-white'
|
||||
} shadow-lg`}
|
||||
>
|
||||
<Bot size={64} className="mx-auto mb-4 text-blue-500" />
|
||||
<h2
|
||||
className={`text-2xl font-bold mb-4 ${
|
||||
isDarkMode ? 'text-white' : 'text-gray-800'
|
||||
}`}
|
||||
>
|
||||
Willkommen beim Privacy Gateway
|
||||
</h2>
|
||||
<p
|
||||
className={`mb-6 ${
|
||||
isDarkMode ? 'text-gray-400' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
Waehle einen bestehenden Chat oder starte einen neuen.
|
||||
</p>
|
||||
<div
|
||||
className={`text-sm ${
|
||||
isDarkMode ? 'text-gray-500' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<p className="flex items-center justify-center gap-2 mb-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
Lokal gehostet
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2 mb-2">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
Datenschutz-fokussiert
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<span className="w-2 h-2 bg-purple-500 rounded-full"></span>
|
||||
Open Source
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 flex flex-col ${
|
||||
isDarkMode ? 'bg-gray-900' : 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 py-3 border-b ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800 border-gray-700'
|
||||
: 'bg-white border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1
|
||||
className={`font-semibold ${
|
||||
isDarkMode ? 'text-white' : 'text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{session.title || 'Neuer Chat'}
|
||||
</h1>
|
||||
<p
|
||||
className={`text-xs ${
|
||||
isDarkMode ? 'text-gray-500' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{session.messages.length} Nachrichten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onToggleDarkMode}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isDarkMode
|
||||
? 'hover:bg-gray-700 text-gray-400'
|
||||
: 'hover:bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
title={isDarkMode ? 'Hellmodus' : 'Dunkelmodus'}
|
||||
>
|
||||
{isDarkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className={`px-4 py-2 text-sm text-center ${
|
||||
isDarkMode
|
||||
? 'bg-red-900/30 text-red-200 border-b border-red-800'
|
||||
: 'bg-red-50 text-red-700 border-b border-red-200'
|
||||
}`}
|
||||
>
|
||||
Fehler: {error}
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-2 underline"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<MessageList
|
||||
messages={session.messages}
|
||||
isDarkMode={isDarkMode}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Input */}
|
||||
<InputArea
|
||||
onSend={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
isDarkMode={isDarkMode}
|
||||
disabled={!session}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState, KeyboardEvent, useRef, useEffect } from 'react';
|
||||
import { Send, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface InputAreaProps {
|
||||
onSend: (message: string) => void;
|
||||
isLoading: boolean;
|
||||
isDarkMode: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const InputArea: React.FC<InputAreaProps> = ({
|
||||
onSend,
|
||||
isLoading,
|
||||
isDarkMode,
|
||||
disabled,
|
||||
}) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (input.trim() && !isLoading && !disabled) {
|
||||
onSend(input.trim());
|
||||
setInput('');
|
||||
setShowPreview(false);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border-t ${isDarkMode ? 'border-gray-700 bg-gray-900' : 'border-gray-200 bg-white'}`}>
|
||||
{showPreview && input.trim() && (
|
||||
<div className={`mx-4 mt-4 p-4 rounded-lg border ${isDarkMode ? 'bg-gray-800 border-gray-700' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<div className={`text-xs font-medium mb-2 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Markdown-Vorschau:
|
||||
</div>
|
||||
<div className={`prose prose-sm max-w-none dark:prose-invert max-h-48 overflow-y-auto ${isDarkMode ? 'text-gray-200' : 'text-gray-800'}`}>
|
||||
{input.split('\n').map((line, i) => (
|
||||
<div key={i}>{line || '\u00A0'}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
<div
|
||||
className={`flex items-end gap-2 rounded-lg border-2 p-3 transition-colors ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800 border-gray-700 focus-within:border-blue-500'
|
||||
: 'bg-white border-gray-300 focus-within:border-blue-500'
|
||||
}`}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={disabled ? 'Waehle einen Chat aus...' : 'Nachricht schreiben...'}
|
||||
disabled={disabled || isLoading}
|
||||
rows={1}
|
||||
className={`flex-1 resize-none bg-transparent outline-none max-h-[200px] ${
|
||||
isDarkMode ? 'text-gray-100 placeholder-gray-500' : 'text-gray-800 placeholder-gray-400'
|
||||
}`}
|
||||
style={{ minHeight: '24px' }}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
disabled={!input.trim()}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
showPreview
|
||||
? isDarkMode
|
||||
? 'bg-blue-900 text-blue-300'
|
||||
: 'bg-blue-100 text-blue-600'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700 text-gray-400 hover:text-gray-200'
|
||||
: 'hover:bg-gray-100 text-gray-400 hover:text-gray-600'
|
||||
} ${!input.trim() ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={showPreview ? 'Vorschau ausblenden' : 'Markdown-Vorschau'}
|
||||
>
|
||||
{showPreview ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || !input.trim() || disabled}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isLoading || !input.trim() || disabled
|
||||
? isDarkMode
|
||||
? 'bg-gray-700 text-gray-500'
|
||||
: 'bg-gray-200 text-gray-400'
|
||||
: isDarkMode
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
}`}
|
||||
title="Senden (Enter)"
|
||||
>
|
||||
{isLoading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-2 text-xs flex items-center justify-between ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
|
||||
<span>Shift+Enter fuer neue Zeile</span>
|
||||
<span>{input.length} Zeichen</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Message } from '../types';
|
||||
import { User, Bot } from 'lucide-react';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isDarkMode: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const MessageList: React.FC<MessageListProps> = ({
|
||||
messages,
|
||||
isDarkMode,
|
||||
isLoading,
|
||||
}) => {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className={`text-center py-12 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
<Bot size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">Willkommen beim Privacy Gateway</p>
|
||||
<p className="text-sm">Starte eine neue Unterhaltung oder waehle einen bestehenden Chat.</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
message.role === 'user'
|
||||
? isDarkMode
|
||||
? 'bg-blue-600'
|
||||
: 'bg-blue-500'
|
||||
: isDarkMode
|
||||
? 'bg-gray-700'
|
||||
: 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
<User size={18} className="text-white" />
|
||||
) : (
|
||||
<Bot size={18} className={isDarkMode ? 'text-gray-300' : 'text-gray-600'} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl px-4 py-3 ${
|
||||
message.role === 'user'
|
||||
? isDarkMode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-blue-500 text-white'
|
||||
: isDarkMode
|
||||
? 'bg-gray-800 text-gray-100'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
|
||||
return !inline && language ? (
|
||||
<SyntaxHighlighter
|
||||
style={isDarkMode ? vscDarkPlus : oneLight}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={`${isDarkMode ? 'bg-gray-700' : 'bg-gray-200'} px-1 py-0.5 rounded`} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
p({ children }) {
|
||||
return <p className="mb-2 last:mb-0">{children}</p>;
|
||||
},
|
||||
ul({ children }) {
|
||||
return <ul className="list-disc ml-4 mb-2">{children}</ul>;
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ol className="list-decimal ml-4 mb-2">{children}</ol>;
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="mb-1">{children}</li>;
|
||||
},
|
||||
h1({ children }) {
|
||||
return <h1 className="text-lg font-bold mb-2">{children}</h1>;
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-base font-bold mb-2">{children}</h2>;
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-sm font-bold mb-1">{children}</h3>;
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className={`border-l-4 pl-3 my-2 ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
a({ children, href }) {
|
||||
return (
|
||||
<a href={href} className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-2">
|
||||
<table className="min-w-full border-collapse">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className={`${isDarkMode ? 'bg-gray-700' : 'bg-gray-200'}`}>{children}</thead>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="border px-3 py-2 text-left font-semibold">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="border px-3 py-2">{children}</td>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs mt-2 ${
|
||||
message.role === 'user'
|
||||
? 'text-blue-200'
|
||||
: isDarkMode
|
||||
? 'text-gray-500'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{formatTime(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
isDarkMode ? 'bg-gray-700' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Bot size={18} className={isDarkMode ? 'text-gray-300' : 'text-gray-600'} />
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-2xl px-4 py-3 ${
|
||||
isDarkMode ? 'bg-gray-800' : 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full animate-bounce ${
|
||||
isDarkMode ? 'bg-gray-500' : 'bg-gray-400'
|
||||
}`}
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full animate-bounce ${
|
||||
isDarkMode ? 'bg-gray-500' : 'bg-gray-400'
|
||||
}`}
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full animate-bounce ${
|
||||
isDarkMode ? 'bg-gray-500' : 'bg-gray-400'
|
||||
}`}
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { Plus, Trash2, MessageSquare } from 'lucide-react';
|
||||
import { Session } from '../types';
|
||||
|
||||
interface SidebarProps {
|
||||
sessions: Session[];
|
||||
activeSessionId: string | null;
|
||||
onSessionSelect: (id: string) => void;
|
||||
onSessionCreate: () => void;
|
||||
onSessionDelete: (id: string) => void;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onSessionSelect,
|
||||
onSessionCreate,
|
||||
onSessionDelete,
|
||||
isDarkMode,
|
||||
}) => {
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-80 flex flex-col border-r ${isDarkMode ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className={`p-4 border-b ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||
<button
|
||||
onClick={onSessionCreate}
|
||||
className={`w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
isDarkMode
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
}`}
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span>Neuer Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<div className={`p-4 text-center text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Keine Chats vorhanden
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => onSessionSelect(session.id)}
|
||||
className={`group flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${
|
||||
activeSessionId === session.id
|
||||
? isDarkMode
|
||||
? 'bg-blue-900/30 border-l-2 border-blue-500'
|
||||
: 'bg-blue-50 border-l-2 border-blue-500'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-800'
|
||||
: 'hover:bg-gray-100'
|
||||
} ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}
|
||||
>
|
||||
<MessageSquare
|
||||
size={18}
|
||||
className={`flex-shrink-0 ${
|
||||
activeSessionId === session.id
|
||||
? isDarkMode
|
||||
? 'text-blue-400'
|
||||
: 'text-blue-500'
|
||||
: isDarkMode
|
||||
? 'text-gray-500'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium truncate ${
|
||||
activeSessionId === session.id
|
||||
? isDarkMode
|
||||
? 'text-blue-100'
|
||||
: 'text-blue-900'
|
||||
: isDarkMode
|
||||
? 'text-gray-200'
|
||||
: 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{session.title || 'Neuer Chat'}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs ${
|
||||
isDarkMode ? 'text-gray-500' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{formatDate(session.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSessionDelete(session.id);
|
||||
}}
|
||||
className={`opacity-0 group-hover:opacity-100 p-1 rounded transition-all ${
|
||||
isDarkMode
|
||||
? 'hover:bg-red-900/50 text-gray-500 hover:text-red-400'
|
||||
: 'hover:bg-red-50 text-gray-400 hover:text-red-500'
|
||||
}`}
|
||||
title="Chat löschen"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`p-4 border-t text-xs ${isDarkMode ? 'border-gray-700 text-gray-500' : 'border-gray-200 text-gray-400'}`}>
|
||||
<p>Privacy Gateway Chat</p>
|
||||
<p>Alle Daten lokal gespeichert</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
/* Code block styles */
|
||||
pre {
|
||||
border-radius: 0.5rem;
|
||||
margin: 0.5rem 0 !important;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: background-color, border-color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* Animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
message: Message;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'Avenir', 'Helvetica', 'Arial', 'sans-serif'],
|
||||
mono: ['Fira Code', 'Monaco', 'Consolas', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vendor-react': ['react', 'react-dom'],
|
||||
'vendor-markdown': ['react-markdown', 'react-syntax-highlighter', 'remark-gfm'],
|
||||
'vendor-icons': ['lucide-react'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user