Initial: Privacy Gateway Projekt mit Team-Implementierung

This commit is contained in:
root
2026-05-09 05:58:21 +00:00
commit a25b234405
49 changed files with 5107 additions and 0 deletions
+11
View File
@@ -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*
+2
View File
@@ -0,0 +1,2 @@
# API URL fuer den Backend-Server
VITE_API_URL=http://localhost:3000
+49
View File
@@ -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;"]
+53
View File
@@ -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
```
+14
View File
@@ -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>
+30
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+127
View File
@@ -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;
+262
View File
@@ -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>
);
};
+127
View File
@@ -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>
);
};
+215
View File
@@ -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>
);
};
+129
View File
@@ -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>
);
};
+72
View File
@@ -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;
}
+10
View File
@@ -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>
);
+26
View File
@@ -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;
}
+17
View File
@@ -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: [],
}
+25
View File
@@ -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" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+30
View File
@@ -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'],
},
},
},
},
});