feat(chat): integrate chat project into monorepo with full app structure

- Restructure chat as apps/mobile, apps/web, apps/landing, backend
- Add NestJS backend for secure Azure OpenAI API calls
- Remove exposed API key from mobile app (security fix)
- Add shared chat-types package
- Create SvelteKit web app scaffold
- Create Astro landing page scaffold
- Update pnpm workspace configuration
- Add project-level CLAUDE.md documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 13:48:24 +01:00
parent fcf3a344b1
commit c638a7ffee
155 changed files with 22622 additions and 348 deletions

View file

@ -0,0 +1,22 @@
import { forwardRef } from 'react';
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
type ButtonProps = {
title: string;
} & TouchableOpacityProps;
export const Button = forwardRef<View, ButtonProps>(({ title, ...touchableProps }, ref) => {
return (
<TouchableOpacity
ref={ref}
{...touchableProps}
className={`${styles.button} ${touchableProps.className}`}>
<Text className={styles.buttonText}>{title}</Text>
</TouchableOpacity>
);
});
const styles = {
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
buttonText: 'text-white text-lg font-semibold text-center',
};

View file

@ -0,0 +1,93 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
type ChatHeaderProps = {
title?: string;
modelName: string;
conversationMode: string;
onBackPress?: () => void;
};
export default function ChatHeader({
title,
modelName,
conversationMode,
onBackPress
}: ChatHeaderProps) {
const { colors } = useTheme();
const router = useRouter();
const handleBackPress = () => {
if (onBackPress) {
onBackPress();
} else {
router.back();
}
};
return (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: colors.text }]}>
{title || 'Neuer Chat'}
</Text>
<View style={styles.subtitleContainer}>
<Text style={[styles.modelName, { color: colors.text + '80' }]}>
{modelName}
</Text>
<Text style={[styles.modeText, { color: colors.text + '80' }]}>
{conversationMode === 'frei' ? 'Freier Modus' :
conversationMode === 'geführt' ? 'Geführter Modus' : 'Vorlagen-Modus'}
</Text>
</View>
</View>
<TouchableOpacity style={styles.menuButton}>
<Ionicons name="ellipsis-vertical" size={24} color={colors.text} />
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(0,0,0,0.1)',
width: '100%',
maxWidth: 1200,
alignSelf: 'center',
},
backButton: {
padding: 4,
},
titleContainer: {
flex: 1,
},
title: {
fontSize: 18,
fontWeight: '600',
},
subtitleContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
},
modelName: {
fontSize: 13,
fontWeight: '500',
},
modeText: {
fontSize: 13,
marginLeft: 8,
},
menuButton: {
padding: 4,
},
});

View file

@ -0,0 +1,122 @@
import React from 'react';
import { View, TextInput, TouchableOpacity, Text, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import useChatInput from '../hooks/useChatInput';
import ModelDropdown from './ModelDropdown';
interface ChatInputProps {
onSend: (message: string) => void;
isLoading?: boolean;
placeholder?: string;
showModelSelection?: boolean;
selectedModelId?: string;
onSelectModel?: (id: string) => void;
showAttachments?: boolean;
showSearch?: boolean;
}
export default function ChatInput({
onSend,
isLoading = false,
placeholder = 'Nachricht eingeben...',
showModelSelection = false,
selectedModelId = '550e8400-e29b-41d4-a716-446655440000',
onSelectModel = () => {},
showAttachments = false,
showSearch = false,
}: ChatInputProps) {
const {
text,
setText,
handleSend,
canSend,
isDarkMode,
} = useChatInput({
onSend,
isLoading,
placeholder,
});
return (
<View className="w-full px-4">
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
{showModelSelection && (
<View className="flex-row justify-between items-center mb-3">
<Text className={`text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Modell:
</Text>
<ModelDropdown
selectedModelId={selectedModelId}
onSelectModel={onSelectModel}
/>
</View>
)}
<TextInput
className={`w-full min-h-[40px] text-base rounded-lg px-4 py-2 ${
isDarkMode
? 'text-white bg-[#1C1C1E]'
: 'text-black bg-gray-100'
}`}
placeholder={placeholder}
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
value={text}
onChangeText={setText}
multiline
maxLength={1000}
editable={!isLoading}
/>
<View className="flex-row justify-between items-center mt-4">
{(showAttachments || showSearch) && (
<View className="flex-row space-x-4">
{showAttachments && (
<TouchableOpacity className="flex-row items-center">
<Ionicons name="attach" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Attach</Text>
</TouchableOpacity>
)}
{showSearch && (
<TouchableOpacity className="flex-row items-center">
<Ionicons name="search" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Search</Text>
</TouchableOpacity>
)}
</View>
)}
<TouchableOpacity
className={`flex-row items-center px-3 py-2 rounded-full ${
canSend ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]/20'
}`}
onPress={handleSend}
disabled={!canSend}
>
{isLoading ? (
<View className="flex-row items-center">
<View className="h-4 w-4 mr-1">
<ActivityIndicator size="small" color="#FFFFFF" />
</View>
<Text className="text-white">Wird gesendet...</Text>
</View>
) : (
<>
<Ionicons
name="send"
size={18}
color={canSend ? '#FFFFFF' : '#0A84FF'}
/>
<Text
className={`ml-1 ${canSend ? 'text-white' : 'text-[#0A84FF]'}`}
>
Senden
</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</View>
);
}

View file

@ -0,0 +1,338 @@
import React, { useState, forwardRef, useImperativeHandle, useRef, useEffect } from 'react';
import { View, TextInput, TouchableOpacity, Text, ScrollView, StyleSheet, ActivityIndicator } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { useAppTheme } from '../theme/ThemeProvider';
import ModelDropdown from './ModelDropdown';
import { useRouter } from 'expo-router';
import { createConversation, addMessage } from '../services/conversation';
import { supabase } from '../utils/supabase';
import { useAuth } from '../context/AuthProvider';
import { Template, getTemplates } from '../services/template';
type ConversationStarterProps = {
onSend?: (message: string) => void;
placeholder?: string;
};
// Definiere die Ref-Methoden, die von außen aufgerufen werden können
export interface ConversationStarterRef {
focus: () => void;
}
const ConversationStarter = forwardRef<ConversationStarterRef, ConversationStarterProps>(({ onSend, placeholder = 'Ask anything' }, ref) => {
const [text, setText] = useState('');
const [selectedModelId, setSelectedModelId] = useState('550e8400-e29b-41d4-a716-446655440000'); // Default to Azure OpenAI GPT-O3-Mini
const [isCreatingConversation, setIsCreatingConversation] = useState(false);
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { user } = useAuth();
const inputRef = useRef<TextInput>(null);
// Expose methods via ref
useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current) {
inputRef.current.focus();
}
}
}));
// Laden der Vorlagen beim ersten Rendern
useEffect(() => {
const loadTemplates = async () => {
if (!user) return;
setIsLoadingTemplates(true);
try {
const userTemplates = await getTemplates(user.id);
setTemplates(userTemplates);
} catch (error) {
console.error('Fehler beim Laden der Vorlagen:', error);
} finally {
setIsLoadingTemplates(false);
}
};
loadTemplates();
}, [user]);
const handleSend = async () => {
if (text.trim()) {
console.log("handleSend wird aufgerufen mit Text:", text.trim());
// Prüfen ob onSend-Prop existiert, aber für jetzt ignorieren
if (onSend && false) { // Deaktiviert: wir wollen immer unseren eigenen Code ausführen
console.log("onSend-Prop gefunden, rufe diese auf");
onSend(text.trim());
setText('');
return;
}
// Andernfalls starte eine neue Konversation
try {
setIsCreatingConversation(true);
console.log("Starte Erstellung einer neuen Konversation...");
// Verwende den Benutzer aus dem Auth-Kontext
if (!user) {
console.error('Kein Benutzer angemeldet');
router.replace('/auth/login');
return;
}
console.log(`Chat starten mit Modell-ID: ${selectedModelId}`);
const trimmedText = text.trim();
// WICHTIG: Setze Text zurück, bevor wir navigieren (UI-Block vermeiden)
setText('');
const mode = selectedTemplate ? 'template' : 'free';
const templateId = selectedTemplate?.id;
const modelToUse = selectedTemplate?.model_id || selectedModelId;
// Versuche zwei verschiedene Methoden, damit eine davon funktioniert
try {
// 1. Methode: Mit Route-Parametern im Objekt
console.log(`Methode 1: Mit Parametern im Objekt (${mode}, ${templateId || 'keine Vorlage'})`);
router.push({
pathname: '/conversation/new',
params: {
initialMessage: trimmedText,
modelId: modelToUse,
mode: mode,
...(templateId && { templateId })
}
});
} catch (routerError) {
console.error("Fehler bei Methode 1:", routerError);
// 2. Methode: Mit Query-String
console.log(`Methode 2: Mit Query-String`);
let queryParams = `?initialMessage=${encodeURIComponent(
trimmedText
)}&modelId=${encodeURIComponent(
modelToUse
)}&mode=${encodeURIComponent(mode)}`;
if (templateId) {
queryParams += `&templateId=${encodeURIComponent(templateId)}`;
}
router.push(`/conversation/new${queryParams}`);
}
// Zurücksetzen der ausgewählten Vorlage nach Navigation
setSelectedTemplate(null);
console.log(`Navigation zur Konversation ausgeführt`);
} catch (error) {
console.error('Fehler beim Erstellen der Konversation:', error);
alert(`Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
} finally {
setIsCreatingConversation(false);
}
} else {
console.log("Text ist leer, keine Aktion");
}
};
// Handler für das Auswählen einer Vorlage
const handleTemplateSelect = (template: Template) => {
// Wenn die Vorlage bereits ausgewählt ist, deaktivieren wir sie
if (selectedTemplate?.id === template.id) {
setSelectedTemplate(null);
// Zurücksetzen des Texts, wenn es die Vorschau war
if (text.startsWith('Frage: ')) {
setText('');
}
return;
}
// Sonst wählen wir die Vorlage aus
setSelectedTemplate(template);
setSelectedModelId(template.model_id || selectedModelId);
// Vorschau der initialen Frage im Eingabefeld anzeigen, wenn vorhanden
if (text.trim() === '') {
if (template.initial_question) {
setText(`Frage: ${template.initial_question}`);
}
}
};
return (
<View className="w-full px-4 max-w-3xl self-center">
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
<View className="flex-row justify-between items-center mb-3">
<Text className={`text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>Modell:</Text>
<ModelDropdown
selectedModelId={selectedModelId}
onSelectModel={setSelectedModelId}
/>
</View>
<TextInput
ref={inputRef}
className={`w-full min-h-[40px] text-base ${isDarkMode ? 'text-white' : 'text-black'}`}
placeholder={placeholder}
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
value={text}
onChangeText={setText}
multiline
maxLength={1000}
/>
<View className="flex-row justify-between items-center mt-4">
<View className="flex-row space-x-4">
<TouchableOpacity className="flex-row items-center">
<Ionicons name="attach" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Attach</Text>
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center">
<Ionicons name="search" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Search</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
className={`flex-row items-center px-3 py-2 rounded-full ${text.trim() ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]/20'}`}
onPress={() => {
console.log("Senden-Button gedrückt");
handleSend();
}}
disabled={!text.trim() || isCreatingConversation}
activeOpacity={0.7}
>
{isCreatingConversation ? (
<View className="flex-row items-center">
<View className="h-4 w-4 mr-1">
<ActivityIndicator size="small" color="#FFFFFF" />
</View>
<Text className="text-white">Wird erstellt...</Text>
</View>
) : (
<>
<Ionicons name="send" size={18} color={text.trim() ? '#FFFFFF' : '#0A84FF'} />
<Text className={`ml-1 ${text.trim() ? 'text-white' : 'text-[#0A84FF]'}`}>Senden</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
<View className="mt-4">
<View>
<Text className={`text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Vorlagen:
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="flex-row"
>
{isLoadingTemplates ? (
<View className={`flex-row items-center justify-center mr-2 px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}>
<ActivityIndicator size="small" color={isDarkMode ? '#FFFFFF' : '#0A84FF'} style={{marginRight: 6}} />
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Laden...
</Text>
</View>
) : templates.length > 0 ? (
templates.map((template) => (
<TouchableOpacity
key={template.id}
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
selectedTemplate?.id === template.id
? isDarkMode
? 'bg-[#0A84FF]80 border-[#0A84FF]'
: 'bg-[#0A84FF]40 border-[#0A84FF]'
: isDarkMode
? 'bg-[#2C2C2E] border-[#38383A]'
: 'bg-white border-[#E5E5EA]'
}`}
onPress={() => handleTemplateSelect(template)}
>
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: template.color || '#0A84FF',
marginRight: 6
}}
/>
<Text className={`text-sm ${
selectedTemplate?.id === template.id
? isDarkMode ? 'text-white font-medium' : 'text-[#0A84FF] font-medium'
: isDarkMode ? 'text-white' : 'text-black'
}`}>
{template.name}
</Text>
{selectedTemplate?.id === template.id && (
<Ionicons
name="checkmark-circle"
size={14}
color={isDarkMode ? '#FFFFFF' : '#0A84FF'}
style={{marginLeft: 4}}
/>
)}
</TouchableOpacity>
))
) : (
<TouchableOpacity
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}
onPress={() => router.push('/templates')}
>
<Ionicons
name="add-circle-outline"
size={16}
color={isDarkMode ? '#FFFFFF' : '#000000'}
style={styles.chipIcon}
/>
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Vorlage erstellen
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
className={`flex-row items-center px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}
onPress={() => router.push('/templates')}
>
<Ionicons
name="settings-outline"
size={16}
color={isDarkMode ? '#FFFFFF' : '#000000'}
style={styles.chipIcon}
/>
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Verwalten
</Text>
</TouchableOpacity>
</ScrollView>
</View>
</View>
</View>
);
});
// Styles für Elemente, die nicht mit NativeWind gestylt werden können
const styles = StyleSheet.create({
chipIcon: {
marginRight: 6,
},
});
export default ConversationStarter;

View file

@ -0,0 +1,9 @@
import { SafeAreaView } from 'react-native';
export const Container = ({ children }: { children: React.ReactNode }) => {
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
};
const styles = {
container: 'flex flex-1 m-6',
};

View file

@ -0,0 +1,442 @@
import React, { useState, forwardRef, useImperativeHandle, useRef, useEffect } from 'react';
import { View, TextInput, TouchableOpacity, Text, ScrollView, StyleSheet, ActivityIndicator } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { useAppTheme } from '../theme/ThemeProvider';
import ModelDropdown from './ModelDropdown';
import { useRouter } from 'expo-router';
import { createConversation, addMessage } from '../services/conversation';
import { supabase } from '../utils/supabase';
import { useAuth } from '../context/AuthProvider';
import { Template, getTemplates } from '../services/template';
import { Space, getUserSpaces } from '../services/space';
type ConversationStarterProps = {
onSend?: (message: string) => void;
placeholder?: string;
spaceId?: string | null;
};
// Definiere die Ref-Methoden, die von außen aufgerufen werden können
export interface ConversationStarterRef {
focus: () => void;
}
const ConversationStarter = forwardRef<ConversationStarterRef, ConversationStarterProps>(({ onSend, placeholder = 'Was möchtest du wissen?', spaceId }, ref) => {
const [text, setText] = useState('');
const [selectedModelId, setSelectedModelId] = useState('550e8400-e29b-41d4-a716-446655440000'); // Default to Azure OpenAI GPT-O3-Mini
const [isCreatingConversation, setIsCreatingConversation] = useState(false);
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [documentMode, setDocumentMode] = useState(false);
const [currentSpace, setCurrentSpace] = useState<Space | null>(null);
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { user } = useAuth();
const inputRef = useRef<TextInput>(null);
// Expose methods via ref
useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current) {
inputRef.current.focus();
}
}
}));
// Laden der Vorlagen und des aktuellen Space beim ersten Rendern
useEffect(() => {
const loadTemplates = async () => {
if (!user) return;
setIsLoadingTemplates(true);
try {
const userTemplates = await getTemplates(user.id);
setTemplates(userTemplates);
} catch (error) {
console.error('Fehler beim Laden der Vorlagen:', error);
} finally {
setIsLoadingTemplates(false);
}
};
loadTemplates();
}, [user]);
// Laden des Space-Namens, wenn eine spaceId vorhanden ist
useEffect(() => {
const loadSpace = async () => {
if (!spaceId) {
setCurrentSpace(null);
return;
}
try {
const space = await getSpace(spaceId);
setCurrentSpace(space);
} catch (error) {
console.error('Fehler beim Laden des Space:', error);
setCurrentSpace(null);
}
};
loadSpace();
}, [spaceId]);
// Tastatur-Event-Handler für Enter-Taste (besonders wichtig für Web)
const handleKeyPress = (e: any) => {
// Prüfen auf Enter ohne Shift für Submit
if (e.nativeEvent.key === 'Enter' && !e.nativeEvent.shiftKey) {
e.preventDefault(); // Verhindert Zeilenumbruch
handleSend();
}
};
const handleSend = async () => {
if (text.trim()) {
console.log("handleSend wird aufgerufen mit Text:", text.trim());
// Prüfen ob onSend-Prop existiert, aber für jetzt ignorieren
if (onSend && false) { // Deaktiviert: wir wollen immer unseren eigenen Code ausführen
console.log("onSend-Prop gefunden, rufe diese auf");
onSend(text.trim());
setText('');
return;
}
// Andernfalls starte eine neue Konversation
try {
setIsCreatingConversation(true);
console.log("Starte Erstellung einer neuen Konversation...");
// Verwende den Benutzer aus dem Auth-Kontext
if (!user) {
console.error('Kein Benutzer angemeldet');
router.replace('/auth/login');
return;
}
console.log(`Chat starten mit Modell-ID: ${selectedModelId}`);
const trimmedText = text.trim();
// WICHTIG: Setze Text zurück, bevor wir navigieren (UI-Block vermeiden)
setText('');
const mode = selectedTemplate ? 'template' : 'free';
const templateId = selectedTemplate?.id;
const modelToUse = selectedTemplate?.model_id || selectedModelId;
// Versuche zwei verschiedene Methoden, damit eine davon funktioniert
try {
// 1. Methode: Mit Route-Parametern im Objekt
console.log(`Methode 1: Mit Parametern im Objekt (${mode}, ${templateId || 'keine Vorlage'}, documentMode: ${documentMode}, spaceId: ${spaceId || 'keiner'})`);
router.push({
pathname: '/conversation/new',
params: {
initialMessage: trimmedText,
modelId: modelToUse,
mode: mode,
documentMode: documentMode ? 'true' : 'false',
...(templateId && { templateId }),
...(spaceId && { spaceId })
}
});
} catch (routerError) {
console.error("Fehler bei Methode 1:", routerError);
// 2. Methode: Mit Query-String
console.log(`Methode 2: Mit Query-String`);
let queryParams = `?initialMessage=${encodeURIComponent(
trimmedText
)}&modelId=${encodeURIComponent(
modelToUse
)}&mode=${encodeURIComponent(mode)}&documentMode=${encodeURIComponent(documentMode ? 'true' : 'false')}`;
if (templateId) {
queryParams += `&templateId=${encodeURIComponent(templateId)}`;
}
if (spaceId) {
queryParams += `&spaceId=${encodeURIComponent(spaceId)}`;
}
router.push(`/conversation/new${queryParams}`);
}
// Zurücksetzen der ausgewählten Vorlage nach Navigation
setSelectedTemplate(null);
console.log(`Navigation zur Konversation ausgeführt`);
} catch (error) {
console.error('Fehler beim Erstellen der Konversation:', error);
alert(`Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
} finally {
setIsCreatingConversation(false);
}
} else {
console.log("Text ist leer, keine Aktion");
}
};
// Handler für das Auswählen einer Vorlage
const handleTemplateSelect = (template: Template) => {
// Wenn die Vorlage bereits ausgewählt ist, deaktivieren wir sie
if (selectedTemplate?.id === template.id) {
setSelectedTemplate(null);
// Auch den Dokumentmodus zurücksetzen
setDocumentMode(false);
} else {
// Sonst wählen wir die Vorlage aus
setSelectedTemplate(template);
// Modell automatisch auswählen, wenn die Vorlage eines definiert
if (template.model_id) {
setSelectedModelId(template.model_id);
}
// Dokumentmodus automatisch übernehmen, wenn die Vorlage ihn aktiviert hat
setDocumentMode(template.document_mode || false);
console.log(`Template ${template.name} ausgewählt, Dokumentmodus: ${template.document_mode}`);
}
// Nach der Auswahl/Abwahl einer Vorlage das Eingabefeld fokussieren
// Kurze Verzögerung, um UI-Updates abzuschließen
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 50);
};
return (
<View className="w-full px-4 max-w-3xl self-center">
{/* Container für den Titel mit fester Höhe - verhindert Layout-Verschiebung */}
<View className="h-7 flex-row items-center">
{selectedTemplate && (
<Text
className={`text-base font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}
numberOfLines={1}
>
{selectedTemplate.name}
</Text>
)}
{currentSpace && (
<View className="flex-row items-center ml-auto">
<Ionicons
name="folder-open"
size={16}
color={colors.primary}
style={{ marginRight: 4 }}
/>
<Text
className={`text-sm font-medium`}
style={{ color: colors.primary }}
numberOfLines={1}
>
Space: {currentSpace.name}
</Text>
</View>
)}
</View>
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
<TextInput
ref={inputRef}
className={`w-full min-h-[40px] text-base ${isDarkMode ? 'text-white' : 'text-black'}`}
placeholder={selectedTemplate?.initial_question ? selectedTemplate.initial_question : placeholder}
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
value={text}
onChangeText={setText}
multiline
maxLength={1000}
onSubmitEditing={() => {
if (text.trim()) {
handleSend();
}
}}
blurOnSubmit={false}
onKeyPress={handleKeyPress}
/>
<View className="flex-row justify-between items-center mt-4">
<View className="flex-row flex-wrap">
<TouchableOpacity
className={`flex-row items-center py-1 px-2 rounded-md mr-4 ${
documentMode
? 'bg-[#0A84FF]40 border border-[#0A84FF]'
: isDarkMode
? 'bg-[#2C2C2E] border border-[#38383A]'
: 'bg-[#F2F2F7] border border-[#E5E5EA]'
}`}
onPress={() => setDocumentMode(!documentMode)}
>
<Ionicons
name={documentMode ? "document" : "document-outline"}
size={18}
color={documentMode ? '#0A84FF' : (isDarkMode ? '#FFFFFF' : '#000000')}
/>
<Text className={`ml-1 ${documentMode ? 'text-[#0A84FF] font-medium' : (isDarkMode ? 'text-white' : 'text-black')}`}>
Dokument
</Text>
{documentMode && (
<Ionicons name="checkmark-circle" size={14} color="#0A84FF" style={{marginLeft: 4}} />
)}
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center mr-4">
<Ionicons name="attach" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Attach</Text>
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center mr-4">
<Ionicons name="search" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Search</Text>
</TouchableOpacity>
<View className="flex-row items-center">
<ModelDropdown
selectedModelId={selectedModelId}
onSelectModel={setSelectedModelId}
/>
</View>
</View>
<TouchableOpacity
className={`flex-row items-center px-3 py-2 rounded-full ${text.trim() ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]/20'}`}
onPress={() => {
console.log("Senden-Button gedrückt");
handleSend();
}}
disabled={!text.trim() || isCreatingConversation}
activeOpacity={0.7}
>
{isCreatingConversation ? (
<View className="flex-row items-center">
<View className="h-4 w-4 mr-1">
<ActivityIndicator size="small" color="#FFFFFF" />
</View>
<Text className="text-white">Wird erstellt...</Text>
</View>
) : (
<>
<Ionicons name="send" size={18} color={text.trim() ? '#FFFFFF' : '#0A84FF'} />
<Text className={`ml-1 ${text.trim() ? 'text-white' : 'text-[#0A84FF]'}`}>Senden</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
<View className="mt-4">
<View>
<Text className={`text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Vorlagen:
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="flex-row"
>
{isLoadingTemplates ? (
<View className={`flex-row items-center justify-center mr-2 px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}>
<ActivityIndicator size="small" color={isDarkMode ? '#FFFFFF' : '#0A84FF'} style={{marginRight: 6}} />
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Laden...
</Text>
</View>
) : templates.length > 0 ? (
templates.map((template) => (
<TouchableOpacity
key={template.id}
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
selectedTemplate?.id === template.id
? isDarkMode
? 'bg-[#0A84FF]80 border-[#0A84FF]'
: 'bg-[#0A84FF]40 border-[#0A84FF]'
: isDarkMode
? 'bg-[#2C2C2E] border-[#38383A]'
: 'bg-white border-[#E5E5EA]'
}`}
onPress={() => handleTemplateSelect(template)}
>
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: template.color || '#0A84FF',
marginRight: 6
}}
/>
<Text className={`text-sm ${
selectedTemplate?.id === template.id
? isDarkMode ? 'text-white font-medium' : 'text-[#0A84FF] font-medium'
: isDarkMode ? 'text-white' : 'text-black'
}`}>
{template.name}
</Text>
{selectedTemplate?.id === template.id && (
<Ionicons
name="checkmark-circle"
size={14}
color={isDarkMode ? '#FFFFFF' : '#0A84FF'}
style={{marginLeft: 4}}
/>
)}
</TouchableOpacity>
))
) : (
<TouchableOpacity
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}
onPress={() => router.push('/templates')}
>
<Ionicons
name="add-circle-outline"
size={16}
color={isDarkMode ? '#FFFFFF' : '#000000'}
style={styles.chipIcon}
/>
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Vorlage erstellen
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
className={`flex-row items-center px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}
onPress={() => router.push('/templates')}
>
<Ionicons
name="settings-outline"
size={16}
color={isDarkMode ? '#FFFFFF' : '#000000'}
style={styles.chipIcon}
/>
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Verwalten
</Text>
</TouchableOpacity>
</ScrollView>
</View>
</View>
</View>
);
});
// Styles für Elemente, die nicht mit NativeWind gestylt werden können
const styles = StyleSheet.create({
chipIcon: {
marginRight: 6,
},
});
export default ConversationStarter;

View file

@ -0,0 +1,490 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
Pressable,
ScrollView,
Dimensions,
StatusBar,
ActivityIndicator,
SafeAreaView,
Platform
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '@react-navigation/native';
import { useAppTheme } from '../theme/ThemeProvider';
import { useAuth } from '../context/AuthProvider';
import { getConversations } from '../services/conversation';
const DRAWER_WIDTH = 260; // Breite des Drawer-Menüs
interface CustomDrawerProps {
isVisible: boolean;
focusInputOnHomeNavigate?: () => void;
onClose?: () => void;
}
export default function CustomDrawer({
isVisible,
focusInputOnHomeNavigate,
onClose
}: CustomDrawerProps) {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { user, signOut } = useAuth();
const [recentChats, setRecentChats] = useState<{id: string, title: string}[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Lade die letzten Chats
useEffect(() => {
const loadRecentChats = async () => {
if (!user || !isVisible) return;
setIsLoading(true);
try {
const conversations = await getConversations(user.id);
// Nimm nur die letzten 10 Konversationen
const recentOnes = conversations.slice(0, 10).map(conv => ({
id: conv.id,
title: conv.title || 'Unbenannte Konversation'
}));
setRecentChats(recentOnes);
} catch (error) {
console.error('Fehler beim Laden der letzten Chats:', error);
} finally {
setIsLoading(false);
}
};
if (isVisible) {
loadRecentChats();
}
}, [user, isVisible]);
// Navigation zum Home-Screen (mit Input-Fokus)
const navigateToHome = () => {
router.push('/');
if (focusInputOnHomeNavigate) {
// Verzögerung, um sicherzustellen, dass der Bildschirm geladen ist
setTimeout(() => {
focusInputOnHomeNavigate();
}, 100);
}
};
// Navigation zu einer Konversation
const navigateToConversation = (id: string) => {
router.push(`/conversation/${id}`);
};
// Navigation zur Archiv-Seite
const navigateToArchive = () => {
router.push('/archive');
};
// Navigation zur Vorlagen-Seite
const navigateToTemplates = () => {
router.push('/templates');
};
// Navigation zur Dokumente-Seite
const navigateToDocuments = () => {
router.push('/documents');
};
// Navigation zur Profilseite
const navigateToProfile = () => {
router.push('/profile');
};
// Styling für das Drawer-Menü
const bgColor = isDarkMode ? '#1C1C1E' : '#FFFFFF';
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
const separatorColor = isDarkMode ? '#38383A' : '#E5E5EA';
const activeColor = '#0A84FF';
// Wenn der Drawer nicht sichtbar sein soll, gib nichts zurück
if (!isVisible) {
return null;
}
return (
<SafeAreaView
style={[
styles.drawer,
{
backgroundColor: bgColor,
width: DRAWER_WIDTH,
borderRightWidth: 1,
borderRightColor: separatorColor
}
]}
>
{/* Drawer-Header */}
<View style={styles.drawerHeader}>
<Text style={[styles.drawerTitle, { color: textColor }]}>
Menu
</Text>
<Pressable
onPress={onClose}
style={({ pressed, hovered }) => [
styles.iconButton,
hovered && { backgroundColor: colors.menuItemHover }
]}
>
{({ pressed, hovered }) => (
<Ionicons
name="close"
size={24}
color={textColor}
style={{ opacity: pressed ? 0.7 : 1 }}
/>
)}
</Pressable>
</View>
{/* Hauptaktionen */}
<View style={styles.mainActions}>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{ backgroundColor: activeColor },
pressed && { opacity: 0.85 }
]}
onPress={navigateToHome}
>
<Ionicons name="add-circle-outline" size={20} color="white" />
<Text style={styles.mainActionText}>Neuen Chat starten</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={navigateToArchive}
>
<Ionicons name="archive-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Archiv ansehen</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={() => router.push('/conversations')}
>
<Ionicons name="chatbubbles-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Konversationen</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={navigateToDocuments}
>
<Ionicons name="document-text-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Dokumente ansehen</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={navigateToTemplates}
>
<Ionicons name="file-tray-full-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Vorlagen verwalten</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={() => router.push('/spaces')}
>
<Ionicons name="people-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Spaces</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={navigateToProfile}
>
<Ionicons name="person-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Profil & Statistiken</Text>
</Pressable>
</View>
{/* Trennlinie */}
<View style={[styles.separator, { backgroundColor: separatorColor }]} />
{/* Letzte Chats */}
<View style={styles.recentChatsHeader}>
<Text style={[styles.recentChatsTitle, { color: textColor }]}>
Letzte Chats
</Text>
</View>
{/* Liste der letzten Chats */}
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={activeColor} />
<Text style={[styles.loadingText, { color: textColor + '80' }]}>
Chats werden geladen...
</Text>
</View>
) : (
<ScrollView style={styles.recentChatsList}>
{recentChats.length > 0 ? (
recentChats.map((chat) => (
<Pressable
key={chat.id}
style={({ pressed, hovered }) => [
styles.chatItem,
hovered && { backgroundColor: colors.menuItemHover },
pressed && { opacity: 0.7 }
]}
onPress={() => navigateToConversation(chat.id)}
>
{({ pressed, hovered }) => (
<>
<Ionicons
name="chatbubble-ellipses-outline"
size={20}
color={textColor + '99'}
style={styles.chatIcon}
/>
<Text
style={[
styles.chatTitle,
{ color: textColor }
]}
numberOfLines={1}
ellipsizeMode="tail"
>
{chat.title}
</Text>
</>
)}
</Pressable>
))
) : (
<View style={styles.emptyChatsContainer}>
<Text style={[styles.emptyChatsText, { color: textColor + '80' }]}>
Keine Chats vorhanden
</Text>
</View>
)}
</ScrollView>
)}
{/* Benutzerinformationen und Logout-Button */}
<View style={styles.userSection}>
<View style={styles.separator} />
<View style={styles.userContainer}>
{user && (
<View style={styles.userInfo}>
<Ionicons name="person-circle-outline" size={24} color={textColor} />
<Text style={[styles.userEmail, { color: textColor }]}>
{user.email}
</Text>
</View>
)}
<Pressable
style={({ pressed, hovered }) => [
styles.logoutButton,
{ borderColor: separatorColor },
hovered && { backgroundColor: colors.dangerHover },
pressed && { opacity: 0.8 }
]}
onPress={() => {
signOut().then(() => router.replace('/auth/login'));
}}
>
{({ pressed, hovered }) => (
<>
<Ionicons name="log-out-outline" size={20} color={textColor} />
<Text style={[styles.logoutText, { color: textColor }]}>
Abmelden
</Text>
</>
)}
</Pressable>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
drawer: {
height: '100%',
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 2, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
drawerHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
},
drawerTitle: {
fontSize: 22,
fontWeight: 'bold',
},
iconButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
mainActions: {
paddingHorizontal: 20,
paddingVertical: 16,
},
mainActionButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 8,
},
mainActionText: {
color: 'white',
fontSize: 16,
fontWeight: '500',
marginLeft: 8,
},
separator: {
height: 1,
marginVertical: 8,
},
recentChatsHeader: {
paddingHorizontal: 20,
paddingVertical: 12,
},
recentChatsTitle: {
fontSize: 16,
fontWeight: '600',
},
recentChatsList: {
flex: 1,
},
chatItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
marginHorizontal: 8,
},
chatIcon: {
marginRight: 12,
},
chatTitle: {
fontSize: 15,
flex: 1,
},
loadingContainer: {
padding: 20,
alignItems: 'center',
},
loadingText: {
marginTop: 8,
fontSize: 14,
},
emptyChatsContainer: {
padding: 20,
alignItems: 'center',
},
emptyChatsText: {
fontSize: 14,
},
userSection: {
paddingHorizontal: 20,
paddingVertical: 16,
marginTop: 'auto',
},
userContainer: {
marginTop: 10,
},
userInfo: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
userEmail: {
fontSize: 14,
marginLeft: 8,
},
logoutButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
borderRadius: 8,
borderWidth: 1,
marginTop: 4,
},
logoutText: {
fontSize: 15,
fontWeight: '500',
marginLeft: 8,
},
});

View file

@ -0,0 +1,385 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
StyleSheet,
TouchableOpacity,
ScrollView,
ActivityIndicator,
useWindowDimensions,
Platform,
Alert,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { Document } from '../services/document';
import Markdown from 'react-native-markdown-display';
interface DocumentPanelProps {
document: Document | null;
isLoading?: boolean;
versionCount: number;
onSave?: (content: string) => void;
onShowVersions?: () => void;
onNextVersion?: () => void;
onPreviousVersion?: () => void;
onDeleteVersion?: (document: Document) => void;
}
// Hilfsfunktion, um zu prüfen, ob der Dark Mode aktiv ist
const isDarkMode = (colors: any) => {
return colors.background === '#000' ||
colors.background === '#121212' ||
colors.background.includes('rgba(0,0,0') ||
colors.text === '#fff' ||
colors.text === '#ffffff';
};
export default function DocumentPanel({
document,
isLoading = false,
versionCount,
onSave,
onShowVersions,
onNextVersion,
onPreviousVersion,
onDeleteVersion
}: DocumentPanelProps) {
const { colors } = useTheme();
const [content, setContent] = useState<string>(document?.content || '');
const [editing, setEditing] = useState<boolean>(false);
const { width } = useWindowDimensions();
// Aktualisiere den Content, wenn sich das Dokument ändert
useEffect(() => {
if (document) {
setContent(document.content);
}
}, [document]);
const handleEdit = () => {
setEditing(true);
};
const handleCancel = () => {
setContent(document?.content || '');
setEditing(false);
};
const handleSave = () => {
if (onSave) {
onSave(content);
}
setEditing(false);
};
const renderVersionControls = () => {
// Aktuelle Version und Versionszählung
const currentVersion = document?.version || 1;
const hasMultipleVersions = versionCount > 1;
const canGoBack = currentVersion > 1;
const canGoForward = currentVersion < versionCount;
return (
<View style={styles.versionControls}>
{/* Pfeil zurück */}
<TouchableOpacity
style={[
styles.versionArrow,
!canGoBack && styles.versionArrowDisabled
]}
onPress={canGoBack ? onPreviousVersion : undefined}
disabled={!canGoBack}
>
<Ionicons
name="chevron-back"
size={16}
color={canGoBack ? '#666' : '#CCC'}
/>
</TouchableOpacity>
{/* Version Badge */}
<TouchableOpacity
style={styles.versionBadge}
onPress={onShowVersions}
>
<Text style={styles.versionText}>v{currentVersion}</Text>
{hasMultipleVersions && (
<Text style={styles.versionCount}>{versionCount}</Text>
)}
</TouchableOpacity>
{/* Pfeil vorwärts */}
<TouchableOpacity
style={[
styles.versionArrow,
!canGoForward && styles.versionArrowDisabled
]}
onPress={canGoForward ? onNextVersion : undefined}
disabled={!canGoForward}
>
<Ionicons
name="chevron-forward"
size={16}
color={canGoForward ? '#666' : '#CCC'}
/>
</TouchableOpacity>
</View>
);
};
if (isLoading) {
return (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>Dokument</Text>
</View>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.text }]}>
Dokument wird geladen...
</Text>
</View>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>Dokument</Text>
{renderVersionControls()}
<View style={styles.actions}>
{editing ? (
<>
<TouchableOpacity style={styles.actionButton} onPress={handleCancel}>
<Ionicons name="close" size={22} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton} onPress={handleSave}>
<Ionicons name="checkmark" size={22} color={colors.primary} />
</TouchableOpacity>
</>
) : (
<>
{document && onDeleteVersion && versionCount > 1 && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => {
if (document) {
console.log('Löschen-Button in DocumentPanel gedrückt für Version:', document.version);
Alert.alert(
"Version löschen",
`Möchtest du die Version ${document.version} wirklich löschen?`,
[
{
text: "Abbrechen",
style: "cancel"
},
{
text: "Löschen",
style: "destructive",
onPress: () => {
console.log('Löschvorgang bestätigt für Version:', document.version);
if (onDeleteVersion) {
onDeleteVersion(document);
} else {
console.error('onDeleteVersion Funktion ist nicht definiert');
}
}
}
]
);
}
}}
>
<Ionicons name="trash-outline" size={22} color="#ff3b30" />
<Text style={{fontSize: 10, color: '#ff3b30', marginLeft: 4}}>Löschen</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.actionButton} onPress={handleEdit}>
<Ionicons name="create-outline" size={22} color={colors.text} />
</TouchableOpacity>
</>
)}
</View>
</View>
{editing ? (
<TextInput
style={[
styles.editor,
{
color: colors.text,
backgroundColor: colors.background,
borderColor: colors.border
}
]}
multiline
value={content}
onChangeText={setContent}
autoFocus
textAlignVertical="top"
/>
) : (
<ScrollView style={styles.contentContainer}>
{document?.content ? (
<Markdown
style={{
body: {
color: colors.text,
fontSize: 15,
lineHeight: 22
},
heading1: {
color: colors.text,
borderBottomWidth: 1,
borderBottomColor: colors.border,
paddingBottom: 8,
marginBottom: 12
},
heading2: {
color: colors.text,
borderBottomWidth: 1,
borderBottomColor: colors.border + '60',
paddingBottom: 6,
marginBottom: 10
},
heading3: { color: colors.text },
heading4: { color: colors.text },
heading5: { color: colors.text },
heading6: { color: colors.text },
paragraph: {
color: colors.text,
marginBottom: 12
},
list_item: { color: colors.text },
blockquote: {
backgroundColor: colors.card,
borderLeftColor: colors.primary,
borderLeftWidth: 4,
paddingHorizontal: 12,
paddingVertical: 8,
marginVertical: 8
},
code_block: {
backgroundColor: isDarkMode(colors) ? '#1E1E1E' : '#F5F5F5',
padding: 10,
borderRadius: 6
},
link: { color: colors.primary }
}}
>
{document.content}
</Markdown>
) : (
<Text style={[styles.content, { color: colors.text }]}>
Noch kein Dokument erstellt.
</Text>
)}
</ScrollView>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
borderRadius: 12,
overflow: 'hidden',
marginHorizontal: 16,
marginBottom: 16,
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.1)',
},
title: {
fontSize: 18,
fontWeight: '600',
flex: 1,
},
versionControls: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 8,
},
versionArrow: {
width: 24,
height: 24,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.05)',
},
versionArrowDisabled: {
backgroundColor: 'rgba(0,0,0,0.02)',
},
versionBadge: {
backgroundColor: 'rgba(0,0,0,0.1)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 4,
},
versionText: {
fontSize: 12,
fontWeight: '500',
color: '#666',
},
versionCount: {
fontSize: 10,
fontWeight: '700',
color: '#fff',
backgroundColor: '#666',
width: 16,
height: 16,
borderRadius: 8,
textAlign: 'center',
lineHeight: 16,
marginLeft: 4,
},
actions: {
flexDirection: 'row',
},
actionButton: {
padding: 6,
marginLeft: 8,
},
contentContainer: {
padding: 16,
flex: 1,
paddingBottom: 60, // Extra padding für besseres Scrollen
},
content: {
fontSize: 15,
lineHeight: 22,
},
editor: {
flex: 1,
padding: 16,
fontSize: 15,
lineHeight: 24,
borderWidth: 1,
borderRadius: 8,
margin: 8,
textAlignVertical: 'top',
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
loadingText: {
marginTop: 10,
fontSize: 14,
},
});

View file

@ -0,0 +1,243 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Modal,
SafeAreaView,
Alert,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { Document } from '../services/document';
interface DocumentVersionsProps {
isVisible: boolean;
documents: Document[];
onClose: () => void;
onSelectVersion: (document: Document) => void;
onDeleteVersion?: (document: Document) => void;
}
export default function DocumentVersions({
isVisible,
documents,
onClose,
onSelectVersion,
onDeleteVersion
}: DocumentVersionsProps) {
const { colors } = useTheme();
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const renderVersionItem = (document: Document, isLatest: boolean) => {
// Löschen nur anzeigen, wenn es mehr als eine Version gibt und es nicht die neueste ist
// oder wenn es die einzige Version ist (nur zur Konsistenz)
const canDelete = documents.length > 1 || !isLatest;
return (
<View
key={document.id}
style={[
styles.versionItem,
{ borderBottomColor: colors.border }
]}
>
<TouchableOpacity
style={{flex: 1}}
activeOpacity={0.6}
onPress={() => {
console.log('Version auswählen:', document.id);
onSelectVersion(document);
}}
>
<View style={styles.versionHeader}>
<View style={styles.versionBadge}>
<Text style={styles.versionNumber}>v{document.version}</Text>
</View>
{isLatest && (
<View style={[styles.latestBadge, { backgroundColor: colors.primary }]}>
<Text style={styles.latestText}>Aktuell</Text>
</View>
)}
</View>
<Text style={[styles.versionDate, { color: colors.text + '99' }]}>
{formatDate(document.created_at)}
</Text>
<Text
style={[styles.versionPreview, { color: colors.text }]}
numberOfLines={2}
>
{document.content.substring(0, 150)}
{document.content.length > 150 ? '...' : ''}
</Text>
</TouchableOpacity>
{/* Löschen-Button außerhalb der Touchable-Fläche für den Artikel */}
{canDelete && onDeleteVersion && (
<TouchableOpacity
style={[styles.deleteSeparateButton, { backgroundColor: colors.card }]}
activeOpacity={0.7}
onPress={() => {
console.log("Löschen-Button separat wurde gedrückt für:", document.id);
// Direkter Aufruf für Testzwecke
if (onDeleteVersion) {
console.log("Rufe onDeleteVersion direkt auf für Dokument ID:", document.id);
onDeleteVersion(document);
// Schließe das Modal nach einer kurzen Verzögerung
setTimeout(() => {
onClose();
}, 100);
} else {
console.error("onDeleteVersion ist nicht definiert!");
}
}}
>
<Ionicons name="trash" size={18} color="red" />
<Text style={styles.deleteButtonText}>Löschen</Text>
</TouchableOpacity>
)}
</View>
);
};
return (
<Modal
visible={isVisible}
animationType="slide"
transparent={false}
>
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>Dokumentversionen</Text>
</View>
<ScrollView style={styles.versionsList}>
{documents.map((document, index) => renderVersionItem(document, index === 0))}
{documents.length === 0 && (
<View style={styles.emptyState}>
<Ionicons name="document-outline" size={48} color={colors.text + '40'} />
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
Keine Dokumentversionen verfügbar
</Text>
</View>
)}
</ScrollView>
</SafeAreaView>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.1)',
},
closeButton: {
padding: 8,
marginRight: 8,
},
title: {
fontSize: 18,
fontWeight: '600',
},
versionsList: {
flex: 1,
},
versionItem: {
padding: 16,
borderBottomWidth: 1,
},
versionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
versionBadge: {
backgroundColor: '#e0e0e0',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
},
versionNumber: {
fontSize: 12,
fontWeight: '600',
color: '#333',
},
latestBadge: {
marginLeft: 8,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
},
latestText: {
fontSize: 12,
fontWeight: '600',
color: 'white',
},
deleteSeparateButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
marginHorizontal: 8,
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#ff3b30',
},
deleteButtonText: {
color: 'red',
marginLeft: 6,
fontSize: 14,
fontWeight: '500',
},
versionDate: {
fontSize: 13,
marginBottom: 8,
},
versionPreview: {
fontSize: 14,
lineHeight: 20,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
padding: 40,
},
emptyText: {
marginTop: 16,
fontSize: 16,
textAlign: 'center',
},
});

View file

@ -0,0 +1,29 @@
import { Text, View } from 'react-native';
export const EditScreenInfo = ({ path }: { path: string }) => {
const title = 'Open up the code for this screen:';
const description =
'Change any of the text, save the file, and your app will automatically update.';
return (
<View>
<View className={styles.getStartedContainer}>
<Text className={styles.getStartedText}>{title}</Text>
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
<Text>{path}</Text>
</View>
<Text className={styles.getStartedText}>{description}</Text>
</View>
</View>
);
};
const styles = {
codeHighlightContainer: `rounded-md px-1`,
getStartedContainer: `items-center mx-12`,
getStartedText: `text-lg leading-6 text-center`,
helpContainer: `items-center mx-5 mt-4`,
helpLink: `py-4`,
helpLinkText: `text-center`,
homeScreenFilename: `my-2`,
};

View file

@ -0,0 +1,108 @@
import React, { useState, forwardRef, useImperativeHandle, useRef } from 'react';
import { View, TextInput, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@react-navigation/native';
type MessageInputProps = {
onSend: (message: string) => void;
isLoading?: boolean;
};
// Öffentliche Methoden über Ref
export interface MessageInputRef {
focus: () => void;
}
const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
function MessageInput({ onSend, isLoading = false }, ref) {
const [message, setMessage] = useState('');
const { colors } = useTheme();
const inputRef = useRef<TextInput>(null);
// Stellt die focus-Methode über ref zur Verfügung
useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current) {
inputRef.current.focus();
}
}
}));
const handleSend = () => {
if (message.trim() && !isLoading) {
onSend(message.trim());
setMessage('');
}
};
// Tastatur-Event-Handler für Enter-Taste (besonders wichtig für Web)
const handleKeyPress = (e: any) => {
// Prüfen auf Enter ohne Shift für Submit
if (e.nativeEvent.key === 'Enter' && !e.nativeEvent.shiftKey) {
e.preventDefault(); // Verhindert Zeilenumbruch
handleSend();
}
};
return (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<TextInput
ref={inputRef}
style={[styles.input, { color: colors.text, backgroundColor: colors.background }]}
placeholder="Nachricht eingeben..."
placeholderTextColor={colors.text + '80'}
value={message}
onChangeText={setMessage}
multiline
maxLength={1000}
editable={!isLoading}
onSubmitEditing={handleSend}
blurOnSubmit={false}
onKeyPress={handleKeyPress}
/>
<TouchableOpacity
style={[styles.sendButton, { backgroundColor: colors.primary }]}
onPress={handleSend}
disabled={!message.trim() || isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Ionicons name="send" size={20} color="#fff" />
)}
</TouchableOpacity>
</View>
);
}
);
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(0,0,0,0.1)',
width: '100%',
maxWidth: 1200,
alignSelf: 'center',
},
input: {
flex: 1,
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
maxHeight: 120,
marginRight: 8,
},
sendButton: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
});
export default MessageInput;

View file

@ -0,0 +1,97 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useTheme } from '@react-navigation/native';
import SkeletonLoader from './SkeletonLoader';
import TypingIndicator from './TypingIndicator';
type MessageProps = {
text: string;
sender: 'user' | 'ai';
timestamp: Date;
isLoading?: boolean;
};
export default function MessageItem({
text,
sender,
timestamp,
isLoading = false
}: MessageProps) {
const { colors } = useTheme();
const isUser = sender === 'user';
return (
<View style={[
styles.container,
isUser ? styles.userContainer : styles.aiContainer,
{ backgroundColor: isUser ? colors.primary : colors.card }
]}>
{isLoading && sender === 'ai' ? (
// Zeige Skeleton oder TypingIndicator wenn geladen wird
<>
<SkeletonLoader
lines={4}
style={styles.skeletonContainer}
/>
<TypingIndicator
dotColor={colors.text + '80'}
style={styles.typingIndicator}
/>
</>
) : (
// Zeige die eigentliche Nachricht
<Text style={[
styles.messageText,
{ color: isUser ? '#fff' : colors.text }
]}>
{text}
</Text>
)}
<Text style={[
styles.timestamp,
{ color: isUser ? 'rgba(255,255,255,0.7)' : colors.text + '80' }
]}>
{timestamp.getHours().toString().padStart(2, '0')}:{timestamp.getMinutes().toString().padStart(2, '0')}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 12,
borderRadius: 16,
marginVertical: 4,
marginHorizontal: 12,
},
userContainer: {
maxWidth: '80%',
alignSelf: 'flex-start',
borderBottomLeftRadius: 4,
},
aiContainer: {
width: '95%',
alignSelf: 'flex-end',
borderBottomRightRadius: 4,
},
messageText: {
fontSize: 16,
lineHeight: 22,
},
timestamp: {
fontSize: 12,
marginTop: 4,
alignSelf: 'flex-end',
},
skeletonContainer: {
padding: 0,
margin: 0,
opacity: 0.8,
},
typingIndicator: {
marginLeft: -5,
marginTop: 5,
}
});

View file

@ -0,0 +1,64 @@
import React from 'react';
import { FlatList, StyleSheet, View } from 'react-native';
import MessageItem from './MessageItem';
type Message = {
id: string;
text: string;
sender: 'user' | 'ai';
timestamp: Date;
isLoading?: boolean;
};
type MessageListProps = {
messages: Message[];
isLoading?: boolean;
};
export default function MessageList({ messages, isLoading = false }: MessageListProps) {
const renderMessageItem = ({ item, index }: { item: Message, index: number }) => {
// Wenn die Nachricht die letzte ist und vom KI-Assistenten stammt,
// zeigen wir den Lade-Indikator an, wenn isLoading=true ist
const isLastMessage = index === messages.length - 1;
const isLastAIMessage = isLastMessage && item.sender === 'ai';
const shouldShowLoading = isLoading && isLastAIMessage;
return (
<MessageItem
text={item.text}
sender={item.sender}
timestamp={item.timestamp}
isLoading={shouldShowLoading || item.isLoading}
/>
);
};
return (
<FlatList
data={messages}
keyExtractor={(item) => item.id}
renderItem={renderMessageItem}
style={styles.container}
contentContainerStyle={styles.contentContainer}
inverted={false}
showsVerticalScrollIndicator={false}
ListFooterComponent={<View style={styles.footer} />}
/>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: '100%',
maxWidth: 800,
alignSelf: 'center',
},
contentContainer: {
paddingVertical: 16,
paddingHorizontal: 16,
},
footer: {
height: 20,
},
});

View file

@ -0,0 +1,122 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { Model } from '../types';
type ModelCardProps = {
id: string;
name: string;
description: string;
deployment?: string;
isSelected?: boolean;
onSelect: (id: string) => void;
model?: Model; // Optionales komplettes Model-Objekt
};
export default function ModelCard({
id,
name,
description,
isSelected = false,
onSelect,
model
}: ModelCardProps) {
const { colors } = useTheme();
const deployment = model?.parameters?.deployment;
return (
<TouchableOpacity
style={[
styles.container,
{
backgroundColor: colors.card,
borderColor: isSelected ? colors.primary : 'transparent',
}
]}
onPress={() => onSelect(id)}
>
<View style={styles.iconContainer}>
<Ionicons
name="chatbubble-ellipses-outline"
size={24}
color={colors.primary}
/>
</View>
<View style={styles.contentContainer}>
<Text style={[styles.name, { color: colors.text }]}>{name}</Text>
<Text
style={[styles.description, { color: colors.text + '80' }]}
numberOfLines={2}
>
{description}
</Text>
{deployment && (
<Text
style={[styles.deployment, { color: colors.primary + 'CC' }]}
numberOfLines={1}
>
{deployment}
</Text>
)}
</View>
{isSelected && (
<View style={[styles.checkmark, { backgroundColor: colors.primary }]}>
<Ionicons name="checkmark" size={16} color="#fff" />
</View>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 12,
marginBottom: 12,
borderWidth: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.05)',
},
contentContainer: {
flex: 1,
marginLeft: 16,
},
name: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
description: {
fontSize: 14,
lineHeight: 20,
marginBottom: 4,
},
deployment: {
fontSize: 12,
fontWeight: '500',
},
checkmark: {
width: 24,
height: 24,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
});

View file

@ -0,0 +1,161 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, Modal, FlatList, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAppTheme } from '../theme/ThemeProvider';
import { Model } from '../types';
import { availableModels } from '../config/azure';
import { getModels } from '../services/modelService';
// Verwende Modelle aus der Konfiguration
const FALLBACK_MODELS: Model[] = availableModels;
type ModelDropdownProps = {
selectedModelId: string;
onSelectModel: (id: string) => void;
};
export default function ModelDropdown({ selectedModelId, onSelectModel }: ModelDropdownProps) {
const { isDarkMode } = useAppTheme();
const [isModalVisible, setIsModalVisible] = useState(false);
const [models, setModels] = useState<Model[]>(FALLBACK_MODELS);
const [loading, setLoading] = useState(false);
// Lade die Modelle vom ModelService
useEffect(() => {
const fetchModels = async () => {
try {
setLoading(true);
const modelsList = await getModels();
setModels(modelsList);
} catch (err) {
console.error('Fehler beim Laden der Modelle:', err);
setModels(FALLBACK_MODELS);
} finally {
setLoading(false);
}
};
fetchModels();
}, []);
const selectedModel = models.find(model => model.id === selectedModelId) || models[0];
return (
<View>
<TouchableOpacity
onPress={() => setIsModalVisible(true)}
className={`flex-row items-center rounded-lg px-2 py-1 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-gray-100'}`}
>
<Text className={`text-sm font-medium ${isDarkMode ? 'text-white' : 'text-black'}`}>
{selectedModel.name}
</Text>
<Ionicons
name="chevron-down"
size={16}
color={isDarkMode ? '#FFFFFF' : '#000000'}
style={{ marginLeft: 4 }}
/>
</TouchableOpacity>
<Modal
visible={isModalVisible}
transparent={true}
animationType="fade"
onRequestClose={() => setIsModalVisible(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setIsModalVisible(false)}
>
<View
className={`mx-4 rounded-xl p-4 ${isDarkMode ? 'bg-[#1C1C1E]' : 'bg-white'}`}
style={styles.modalContent}
>
<Text className={`text-lg font-bold mb-4 ${isDarkMode ? 'text-white' : 'text-black'}`}>
Modell auswählen
</Text>
{loading ? (
<View className="py-4 items-center">
<Text className={`${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Modelle werden geladen...
</Text>
</View>
) : (
<FlatList
data={models}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
className={`flex-row items-center p-3 mb-2 rounded-lg ${
item.id === selectedModelId
? isDarkMode ? 'bg-blue-900/30' : 'bg-blue-100'
: isDarkMode ? 'bg-[#2C2C2E]' : 'bg-gray-100'
}`}
onPress={() => {
onSelectModel(item.id);
setIsModalVisible(false);
}}
>
<View className="w-8 h-8 rounded-full bg-blue-500/20 items-center justify-center mr-3">
<Ionicons
name="chatbubble-ellipses-outline"
size={16}
color="#0A84FF"
/>
</View>
<View className="flex-1">
<Text className={`font-medium ${isDarkMode ? 'text-white' : 'text-black'}`}>
{item.name}
</Text>
<Text
className={`text-xs mt-1 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
numberOfLines={1}
>
{item.description}
</Text>
{item.parameters?.deployment && (
<Text
className={`text-xs mt-1 ${isDarkMode ? 'text-blue-400' : 'text-blue-500'}`}
numberOfLines={1}
>
{item.parameters.deployment}
</Text>
)}
</View>
{item.id === selectedModelId && (
<View className="w-6 h-6 rounded-full bg-blue-500 items-center justify-center">
<Ionicons name="checkmark" size={14} color="#FFFFFF" />
</View>
)}
</TouchableOpacity>
)}
/>
)}
<TouchableOpacity
className={`mt-3 py-3 rounded-lg items-center ${isDarkMode ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]'}`}
onPress={() => setIsModalVisible(false)}
>
<Text className="text-white font-medium">Schließen</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
},
modalContent: {
maxHeight: '80%',
},
});

View file

@ -0,0 +1,46 @@
import React from 'react';
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@react-navigation/native';
type NewChatButtonProps = {
onPress: () => void;
};
export default function NewChatButton({ onPress }: NewChatButtonProps) {
const { colors } = useTheme();
return (
<TouchableOpacity
style={[styles.button, { backgroundColor: colors.primary }]}
onPress={onPress}
>
<Ionicons name="add" size={24} color="#fff" style={styles.icon} />
<Text style={styles.text}>Neuer Chat</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 30,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
icon: {
marginRight: 8,
},
text: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});

View file

@ -0,0 +1,81 @@
import React, { useEffect, useState } from 'react';
import { View, Animated, Easing, StyleSheet } from 'react-native';
import { useTheme } from '@react-navigation/native';
type SkeletonLoaderProps = {
lines?: number;
animated?: boolean;
style?: any;
};
export default function SkeletonLoader({
lines = 3,
animated = true,
style
}: SkeletonLoaderProps) {
const { colors } = useTheme();
const [fadeAnim] = useState(new Animated.Value(0.3));
useEffect(() => {
if (animated) {
Animated.loop(
Animated.sequence([
Animated.timing(fadeAnim, {
toValue: 0.8,
duration: 800,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0.3,
duration: 800,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
])
).start();
}
}, [fadeAnim, animated]);
// Erstelle verschiedene Längen für die Zeilen
const getRandomWidth = (index: number) => {
// Erste und letzte Zeile sind kürzer
if (index === 0) return { width: '70%' };
if (index === lines - 1) return { width: '40%' };
// Zufällige Breite für die Zeilen dazwischen
const widths = ['85%', '90%', '75%', '95%'];
return { width: widths[index % widths.length] };
};
return (
<View style={[styles.container, style]}>
{Array.from({ length: lines }).map((_, index) => (
<Animated.View
key={index}
style={[
styles.line,
getRandomWidth(index),
{
backgroundColor: colors.text + '20',
opacity: fadeAnim,
marginBottom: index === lines - 1 ? 0 : 8
},
]}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
maxWidth: '80%',
alignSelf: 'flex-start',
},
line: {
height: 15,
borderRadius: 4,
},
});

View file

@ -0,0 +1,180 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@react-navigation/native';
import { useAppTheme } from '../theme/ThemeProvider';
// Typ für die Template-Props
interface TemplateCardProps {
id: string;
name: string;
description?: string | null;
systemPrompt: string;
color?: string;
isDefault?: boolean;
onPress: (id: string) => void;
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
onSetDefault?: (id: string) => void;
}
export default function TemplateCard({
id,
name,
description,
systemPrompt,
color = '#0A84FF',
isDefault = false,
onPress,
onEdit,
onDelete,
onSetDefault
}: TemplateCardProps) {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const backgroundColor = isDarkMode ? '#2C2C2E' : '#FFFFFF';
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
const secondaryTextColor = isDarkMode ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)';
// Kürze den System-Prompt für die Anzeige
const truncatedPrompt = systemPrompt.length > 80
? systemPrompt.substring(0, 80) + '...'
: systemPrompt;
return (
<TouchableOpacity
style={[
styles.container,
{ backgroundColor },
isDefault && styles.defaultContainer
]}
onPress={() => onPress(id)}
>
{/* Farbiger Indikator am linken Rand */}
<View style={[styles.colorIndicator, { backgroundColor: color }]} />
<View style={styles.content}>
<View style={styles.header}>
<Text style={[styles.name, { color: textColor }]}>{name}</Text>
{isDefault && (
<View style={styles.defaultBadge}>
<Text style={styles.defaultText}>Standard</Text>
</View>
)}
</View>
{description && (
<Text
style={[styles.description, { color: secondaryTextColor }]}
numberOfLines={2}
>
{description}
</Text>
)}
<Text
style={[styles.prompt, { color: secondaryTextColor }]}
numberOfLines={2}
>
{truncatedPrompt}
</Text>
</View>
{/* Aktionen */}
<View style={styles.actions}>
{onSetDefault && !isDefault && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onSetDefault(id)}
>
<Ionicons name="star-outline" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
</TouchableOpacity>
)}
{onEdit && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onEdit(id)}
>
<Ionicons name="pencil" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onDelete(id)}
>
<Ionicons name="trash-outline" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
</TouchableOpacity>
)}
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
borderRadius: 12,
marginBottom: 12,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
defaultContainer: {
borderWidth: 1,
borderColor: '#0A84FF',
},
colorIndicator: {
width: 8,
alignSelf: 'stretch',
},
content: {
flex: 1,
padding: 16,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
name: {
fontSize: 16,
fontWeight: '600',
flex: 1,
},
defaultBadge: {
backgroundColor: '#0A84FF',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
marginLeft: 8,
},
defaultText: {
color: 'white',
fontSize: 10,
fontWeight: '600',
},
description: {
fontSize: 14,
marginBottom: 8,
},
prompt: {
fontSize: 12,
fontStyle: 'italic',
},
actions: {
padding: 8,
justifyContent: 'center',
},
actionButton: {
padding: 8,
},
});

View file

@ -0,0 +1,417 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
Alert
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@react-navigation/native';
import { useAppTheme } from '../theme/ThemeProvider';
import ModelDropdown from './ModelDropdown';
import { Template } from '../services/template';
// Verfügbare Farben für Vorlagen
const TEMPLATE_COLORS = [
'#0A84FF', // Blau
'#32D74B', // Grün
'#FF375F', // Rot
'#FF9F0A', // Orange
'#5E5CE6', // Lila
'#BF5AF2', // Pink
'#64D2FF', // Hellblau
'#30D158', // Grün
'#FF453A', // Rot
];
interface TemplateFormProps {
initialData?: Partial<Template>;
onSubmit: (data: Partial<Template>) => void;
onCancel: () => void;
}
export default function TemplateForm({
initialData,
onSubmit,
onCancel
}: TemplateFormProps) {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
// Form state
const [name, setName] = useState(initialData?.name || '');
const [description, setDescription] = useState(initialData?.description || '');
const [systemPrompt, setSystemPrompt] = useState(initialData?.system_prompt || '');
const [initialQuestion, setInitialQuestion] = useState(initialData?.initial_question || '');
const [selectedColor, setSelectedColor] = useState(initialData?.color || TEMPLATE_COLORS[0]);
const [selectedModelId, setSelectedModelId] = useState(initialData?.model_id || '');
const [documentMode, setDocumentMode] = useState(initialData?.document_mode || false);
// Validierung
const [errors, setErrors] = useState<{
name?: string;
systemPrompt?: string;
}>({});
// Helpers
const isEditMode = !!initialData?.id;
const bgColor = isDarkMode ? '#1C1C1E' : '#FFFFFF';
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
const placeholderColor = isDarkMode ? '#8E8E93' : '#C7C7CC';
const borderColor = isDarkMode ? '#38383A' : '#E5E5EA';
// Validiere das Formular vor dem Absenden
const validateForm = (): boolean => {
const newErrors: {
name?: string;
systemPrompt?: string;
} = {};
if (!name.trim()) {
newErrors.name = 'Bitte gib einen Namen ein.';
}
if (!systemPrompt.trim()) {
newErrors.systemPrompt = 'Der System-Prompt darf nicht leer sein.';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle submit
const handleSubmit = () => {
if (!validateForm()) return;
onSubmit({
id: initialData?.id,
name,
description: description.trim() || null,
system_prompt: systemPrompt,
initial_question: initialQuestion.trim() || null,
color: selectedColor,
model_id: selectedModelId || null,
document_mode: documentMode
});
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={[styles.container, { backgroundColor: bgColor }]}
>
<ScrollView style={styles.scrollView}>
<View style={styles.form}>
{/* Titel */}
<Text style={[styles.title, { color: textColor }]}>
{isEditMode ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'}
</Text>
{/* Name */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Name *</Text>
<TextInput
style={[
styles.input,
{
color: textColor,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: errors.name ? '#FF3B30' : borderColor
}
]}
placeholder="Name der Vorlage"
placeholderTextColor={placeholderColor}
value={name}
onChangeText={setName}
maxLength={50}
/>
{errors.name && (
<Text style={styles.errorText}>{errors.name}</Text>
)}
</View>
{/* Beschreibung */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Beschreibung (optional)</Text>
<TextInput
style={[
styles.input,
styles.textArea,
{
color: textColor,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor
}
]}
placeholder="Kurze Beschreibung dieser Vorlage"
placeholderTextColor={placeholderColor}
value={description}
onChangeText={setDescription}
multiline
numberOfLines={2}
maxLength={200}
/>
</View>
{/* System-Prompt */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>System-Prompt *</Text>
<TextInput
style={[
styles.input,
styles.textArea,
{
color: textColor,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: errors.systemPrompt ? '#FF3B30' : borderColor,
height: 150
}
]}
placeholder="System-Prompt für die KI"
placeholderTextColor={placeholderColor}
value={systemPrompt}
onChangeText={setSystemPrompt}
multiline
textAlignVertical="top"
/>
{errors.systemPrompt && (
<Text style={styles.errorText}>{errors.systemPrompt}</Text>
)}
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
Der System-Prompt definiert die Rolle und das Verhalten der KI.
</Text>
</View>
{/* Initiale Frage */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Beispielfrage (optional)</Text>
<TextInput
style={[
styles.input,
styles.textArea,
{
color: textColor,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: borderColor,
height: 80
}
]}
placeholder="Beispiel für eine passende Frage oder Anweisung"
placeholderTextColor={placeholderColor}
value={initialQuestion}
onChangeText={setInitialQuestion}
multiline
textAlignVertical="top"
/>
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
Diese Frage wird als Vorschlag angezeigt, wenn die Vorlage ausgewählt wird.
</Text>
</View>
{/* Farbe auswählen */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Farbe</Text>
<View style={styles.colorPicker}>
{TEMPLATE_COLORS.map((color) => (
<TouchableOpacity
key={color}
style={[
styles.colorOption,
{ backgroundColor: color },
selectedColor === color && styles.selectedColorOption
]}
onPress={() => setSelectedColor(color)}
>
{selectedColor === color && (
<Ionicons name="checkmark" size={16} color="white" />
)}
</TouchableOpacity>
))}
</View>
</View>
{/* Modell auswählen */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Bevorzugtes Modell (optional)</Text>
<ModelDropdown
selectedModelId={selectedModelId}
onSelectModel={setSelectedModelId}
/>
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
Falls ausgewählt, wird dieses Modell automatisch mit der Vorlage verwendet.
</Text>
</View>
{/* Dokumentmodus */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Dokumentmodus</Text>
<TouchableOpacity
style={[
styles.switchContainer,
{
backgroundColor: documentMode ? colors.primary + '20' : isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: documentMode ? colors.primary : borderColor
}
]}
onPress={() => setDocumentMode(!documentMode)}
>
<View style={styles.switchText}>
<Text style={[styles.switchLabel, { color: textColor }]}>
Dokumentmodus aktivieren
</Text>
<Text style={[styles.switchDescription, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
Ermöglicht die Bearbeitung eines Dokuments während der Konversation
</Text>
</View>
<View style={[
styles.switchButton,
{ backgroundColor: documentMode ? colors.primary : isDarkMode ? '#636366' : '#C7C7CC' }
]}>
{documentMode ? (
<Ionicons name="checkmark" size={14} color="white" />
) : (
<Ionicons name="close" size={14} color="white" />
)}
</View>
</TouchableOpacity>
</View>
{/* Buttons */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.button, styles.cancelButton, { borderColor }]}
onPress={onCancel}
>
<Text style={[styles.buttonText, { color: textColor }]}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.submitButton, { backgroundColor: colors.primary }]}
onPress={handleSubmit}
>
<Text style={[styles.buttonText, { color: 'white' }]}>
{isEditMode ? 'Speichern' : 'Erstellen'}
</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
form: {
padding: 20,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
},
formGroup: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: '500',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
helperText: {
fontSize: 12,
marginTop: 6,
},
errorText: {
fontSize: 12,
color: '#FF3B30',
marginTop: 6,
},
colorPicker: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 10,
},
colorOption: {
width: 36,
height: 36,
borderRadius: 18,
margin: 5,
justifyContent: 'center',
alignItems: 'center',
},
selectedColorOption: {
borderWidth: 2,
borderColor: 'white',
},
switchContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderRadius: 8,
padding: 12,
},
switchText: {
flex: 1,
marginRight: 12,
},
switchLabel: {
fontSize: 16,
fontWeight: '500',
marginBottom: 4,
},
switchDescription: {
fontSize: 12,
},
switchButton: {
width: 24,
height: 24,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
buttonContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 30,
},
button: {
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
minWidth: 120,
alignItems: 'center',
},
cancelButton: {
borderWidth: 1,
},
submitButton: {
backgroundColor: '#0A84FF',
},
buttonText: {
fontSize: 16,
fontWeight: '500',
},
});

View file

@ -0,0 +1,103 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Animated, Easing } from 'react-native';
import { useTheme } from '@react-navigation/native';
type TypingIndicatorProps = {
dotCount?: number;
dotSize?: number;
dotColor?: string;
style?: any;
};
export default function TypingIndicator({
dotCount = 3,
dotSize = 8,
dotColor,
style,
}: TypingIndicatorProps) {
const { colors } = useTheme();
const [animations] = useState(() =>
Array.from({ length: dotCount }).map(() => new Animated.Value(0))
);
// Dotfarbe wird entweder von Prop oder vom Theme übernommen
const actualDotColor = dotColor || colors.text;
useEffect(() => {
// Animiere jeden Punkt mit einer Verzögerung
const animateDots = () => {
const animationSequence = animations.map((anim, i) =>
Animated.sequence([
// Verzögerung für jeden Punkt
Animated.delay(i * 150),
// Animation nach oben
Animated.timing(anim, {
toValue: 1,
duration: 400,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
// Animation zurück nach unten
Animated.timing(anim, {
toValue: 0,
duration: 400,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
// Verzögerung am Ende
Animated.delay((dotCount - i - 1) * 150),
])
);
// Starte alle Animationen parallel und in einer Schleife
Animated.loop(Animated.parallel(animationSequence)).start();
};
animateDots();
// Cleanup beim Unmount
return () => {
animations.forEach(anim => anim.stopAnimation());
};
}, [animations, dotCount]);
return (
<View style={[styles.container, style]}>
{animations.map((anim, index) => (
<Animated.View
key={index}
style={[
styles.dot,
{
width: dotSize,
height: dotSize,
backgroundColor: actualDotColor,
borderRadius: dotSize / 2,
marginHorizontal: dotSize / 3,
transform: [
{
translateY: anim.interpolate({
inputRange: [0, 1],
outputRange: [0, -dotSize],
}),
},
],
},
]}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
dot: {
opacity: 0.6,
},
});