feat: Implement core chat functionality with LM Studio integration and theme management.

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-04 16:56:45 -03:00
1 parent 93df87d7e9
commit 0297db110a
13 files changed
+670 -451

No files matched your search

+16 -9
View File
@@ -1,4 +1,5 @@
import { ModelSelector } from '@/components/chat/ModelSelector'; import { ModelSelector } from '@/components/chat/ModelSelector';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore'; import { useAppStore } from '@/store/useAppStore';
import { Stack, useRouter } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import { Plus, Settings } from 'lucide-react-native'; import { Plus, Settings } from 'lucide-react-native';
@@ -8,36 +9,45 @@ export default function ChatLayout() {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const router = useRouter(); const router = useRouter();
const clearChat = useAppStore(state => state.clearChat); const clearChat = useAppStore(state => state.clearChat);
const colors = useThemeColor();
const isLargeScreen = width > 768; const isLargeScreen = width > 768;
const containerStyle = isLargeScreen ? styles.desktopContainer : styles.mobileContainer; const containerStyle = [
isLargeScreen ? styles.desktopContainer : styles.mobileContainer,
{ backgroundColor: isLargeScreen ? colors.border : colors.backgroundColor }
];
const chatMaxWidth = isLargeScreen ? 800 : '100%'; const chatMaxWidth = isLargeScreen ? 800 : '100%';
return ( return (
<View style={containerStyle}> <View style={containerStyle}>
<View style={[styles.chatWrapper, { maxWidth: chatMaxWidth }]}> <View style={[
styles.chatWrapper,
{ maxWidth: chatMaxWidth, backgroundColor: colors.backgroundColor },
isLargeScreen && { borderColor: colors.border, borderWidth: 1 }
]}>
<Stack> <Stack>
<Stack.Screen <Stack.Screen
name="index" name="index"
options={{ options={{
headerTitle: () => <ModelSelector />, headerTitle: () => <ModelSelector />,
headerShadowVisible: false, headerShadowVisible: false,
headerStyle: { backgroundColor: colors.backgroundColor },
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <TouchableOpacity
onPress={clearChat} onPress={clearChat}
style={styles.headerBtn} style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Plus size={22} color="#111827" /> <Plus size={22} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
), ),
headerRight: () => ( headerRight: () => (
<TouchableOpacity <TouchableOpacity
onPress={() => router.push('/settings')} onPress={() => router.push('/settings')}
style={styles.headerBtn} style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Settings size={22} color="#111827" /> <Settings size={22} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
), ),
}} }}
@@ -51,18 +61,15 @@ export default function ChatLayout() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
desktopContainer: { desktopContainer: {
flex: 1, flex: 1,
backgroundColor: '#F3F4F6',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
mobileContainer: { mobileContainer: {
flex: 1, flex: 1,
backgroundColor: '#FFFFFF',
}, },
chatWrapper: { chatWrapper: {
flex: 1, flex: 1,
width: '100%', width: '100%',
backgroundColor: '#FFFFFF',
...Platform.select({ ...Platform.select({
web: { web: {
shadowColor: '#000', shadowColor: '#000',
+56 -64
View File
@@ -2,10 +2,12 @@ import { ChatInput } from '@/components/chat/ChatInput';
import { MessageBubble } from '@/components/chat/MessageBubble'; import { MessageBubble } from '@/components/chat/MessageBubble';
import { TypingIndicator } from '@/components/chat/TypingIndicator'; import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { useLMStudio } from '@/hooks/useLMStudio'; import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore'; import { useAppStore } from '@/store/useAppStore';
import { MessageSquare, Sparkles } from 'lucide-react-native'; import { MessageSquare, Sparkles } from 'lucide-react-native';
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { FlatList, KeyboardAvoidingView, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { FlatList, KeyboardAvoidingView, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
const PROMPT_SUGGESTIONS = [ const PROMPT_SUGGESTIONS = [
'Explain recursion in simple terms', 'Explain recursion in simple terms',
@@ -15,6 +17,7 @@ const PROMPT_SUGGESTIONS = [
]; ];
export default function ChatScreen() { export default function ChatScreen() {
const colors = useThemeColor(); // Dynamic theme
const messages = useAppStore(state => state.messages); const messages = useAppStore(state => state.messages);
const editingMessageId = useAppStore(state => state.editingMessageId); const editingMessageId = useAppStore(state => state.editingMessageId);
const setEditingMessageId = useAppStore(state => state.setEditingMessageId); const setEditingMessageId = useAppStore(state => state.setEditingMessageId);
@@ -31,18 +34,20 @@ export default function ChatScreen() {
const handleSend = useCallback((text: string) => { const handleSend = useCallback((text: string) => {
if (editingMessageId && editingMessage) { if (editingMessageId && editingMessage) {
// Edit mode: update message, delete all after, and resend
editMessage(editingMessageId, text); editMessage(editingMessageId, text);
// Get updated messages, delete everything after the edited message
deleteMessagesAfter(editingMessageId); deleteMessagesAfter(editingMessageId);
setEditingMessageId(null); setEditingMessageId(null);
// Now send the edited message for a new response
// Small delay to allow state update
setTimeout(() => { setTimeout(() => {
const store = useAppStore.getState(); const store = useAppStore.getState();
const msgs = store.messages; const msgs = store.messages;
const apiMsgs = msgs.map(m => ({ role: m.role, content: m.content })); // Add system prompt before mapped messages
const { SYSTEM_PROMPT } = require('@/constants/SystemPrompt');
const apiMsgs = [
{ role: 'system', content: SYSTEM_PROMPT },
...msgs.map(m => ({ role: m.role, content: m.content }))
];
const modelId = store.activeModelId; const modelId = store.activeModelId;
if (!modelId) return; if (!modelId) return;
@@ -67,33 +72,22 @@ export default function ChatScreen() {
} }
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, sendMessage]); }, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, sendMessage]);
const handleEdit = useCallback((id: string) => { const handleEdit = useCallback((id: string) => setEditingMessageId(id), [setEditingMessageId]);
setEditingMessageId(id); const handleCancelEdit = useCallback(() => setEditingMessageId(null), [setEditingMessageId]);
}, [setEditingMessageId]); const handleDelete = useCallback((id: string) => deleteMessage(id), [deleteMessage]);
const handleCancelEdit = useCallback(() => {
setEditingMessageId(null);
}, [setEditingMessageId]);
const handleDelete = useCallback((id: string) => {
deleteMessage(id);
}, [deleteMessage]);
// Find the last assistant message id
const lastAssistantId = messages.length > 0 const lastAssistantId = messages.length > 0
? [...messages].reverse().find(m => m.role === 'assistant')?.id ? [...messages].reverse().find(m => m.role === 'assistant')?.id
: null; : null;
// Auto-scroll
useEffect(() => { useEffect(() => {
if (messages.length > 0 && flatListRef.current) { if (messages.length > 0 && flatListRef.current) {
setTimeout(() => { setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true }); flatListRef.current?.scrollToEnd({ animated: !isStreaming });
}, 100); }, 100);
} }
}, [messages.length, messages[messages.length - 1]?.content?.length]); }, [messages.length, messages[messages.length - 1]?.content?.length, isStreaming]);
// Show typing indicator when streaming and last assistant message is empty
const showTypingIndicator = isStreaming && const showTypingIndicator = isStreaming &&
messages.length > 0 && messages.length > 0 &&
messages[messages.length - 1]?.role === 'assistant' && messages[messages.length - 1]?.role === 'assistant' &&
@@ -101,25 +95,25 @@ export default function ChatScreen() {
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.container} style={[styles.container, { backgroundColor: colors.backgroundColor }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0} keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
> >
{!isConnected && ( {!isConnected && (
<View style={styles.banner}> <View style={[styles.banner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.bannerBg }]}>
<Text style={styles.bannerText}> <Text style={[styles.bannerText, { color: colors.bannerText }]}>
⚠️ Not connected to LM Studio. Check Settings → Server Connection. ⚠️ Server offline. Check Settings → Server Connection.
</Text> </Text>
</View> </View>
)} )}
{messages.length === 0 ? ( {messages.length === 0 ? (
<View style={styles.emptyState}> <View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
<View style={styles.emptyIconWrap}> <View style={[styles.emptyIconWrap, { backgroundColor: colors.cardBackground, shadowColor: colors.shadow }]}>
<MessageSquare size={40} color="#D1D5DB" /> <MessageSquare size={40} color={colors.textMuted} />
</View> </View>
<Text style={styles.emptyTitle}>Start a conversation</Text> <Text style={[styles.emptyTitle, { color: colors.text }]}>Start a conversation</Text>
<Text style={styles.emptySubtitle}> <Text style={[styles.emptySubtitle, { color: colors.textMuted }]}>
Send a message to begin chatting with your local AI model. Send a message to begin chatting with your local AI model.
</Text> </Text>
@@ -127,12 +121,12 @@ export default function ChatScreen() {
{PROMPT_SUGGESTIONS.map((prompt, i) => ( {PROMPT_SUGGESTIONS.map((prompt, i) => (
<TouchableOpacity <TouchableOpacity
key={i} key={i}
style={styles.suggestionChip} style={[styles.suggestionChip, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}
onPress={() => sendMessage(prompt)} onPress={() => sendMessage(prompt)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Sparkles size={14} color="#6B7280" /> <Sparkles size={14} color={colors.textMuted} />
<Text style={styles.suggestionText} numberOfLines={1}>{prompt}</Text> <Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>{prompt}</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
@@ -143,15 +137,17 @@ export default function ChatScreen() {
ref={flatListRef} ref={flatListRef}
data={messages} data={messages}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
renderItem={({ item }) => ( renderItem={({ item, index }) => (
<MessageBubble <Animated.View entering={FadeInDown.duration(300).delay(index > messages.length - 4 ? 100 : 0)}>
message={item} <MessageBubble
onEdit={() => handleEdit(item.id)} message={item}
onRegenerate={regenerateResponse} onEdit={() => handleEdit(item.id)}
onDelete={() => handleDelete(item.id)} onRegenerate={regenerateResponse}
isStreaming={isStreaming} onDelete={() => handleDelete(item.id)}
isLastAssistant={item.id === lastAssistantId} isStreaming={isStreaming}
/> isLastAssistant={item.id === lastAssistantId}
/>
</Animated.View>
)} )}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
/> />
@@ -173,23 +169,20 @@ export default function ChatScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#FFFFFF',
}, },
listContent: { listContent: {
paddingTop: 8, paddingTop: 16,
paddingBottom: 16, paddingBottom: 24,
}, },
banner: { banner: {
backgroundColor: '#FEF3C7',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 10, paddingVertical: 10,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#FDE68A',
}, },
bannerText: { bannerText: {
fontSize: 13, fontSize: 13,
color: '#92400E',
textAlign: 'center', textAlign: 'center',
fontWeight: '500',
}, },
emptyState: { emptyState: {
flex: 1, flex: 1,
@@ -199,46 +192,45 @@ const styles = StyleSheet.create({
paddingBottom: 80, paddingBottom: 80,
}, },
emptyIconWrap: { emptyIconWrap: {
width: 72, width: 80,
height: 72, height: 80,
borderRadius: 36, borderRadius: 40,
backgroundColor: '#F3F4F6',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 16, marginBottom: 20,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
}, },
emptyTitle: { emptyTitle: {
fontSize: 20, fontSize: 22,
fontWeight: '700', fontWeight: '700',
color: '#111827',
marginBottom: 8, marginBottom: 8,
}, },
emptySubtitle: { emptySubtitle: {
fontSize: 15, fontSize: 15,
color: '#6B7280',
textAlign: 'center', textAlign: 'center',
lineHeight: 22, lineHeight: 22,
marginBottom: 28, marginBottom: 32,
}, },
suggestionsWrap: { suggestionsWrap: {
width: '100%', width: '100%',
maxWidth: 400, maxWidth: 400,
gap: 8, gap: 12,
}, },
suggestionChip: { suggestionChip: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 12,
backgroundColor: '#F9FAFB',
borderWidth: 1, borderWidth: 1,
borderColor: '#E5E7EB', borderRadius: 16,
borderRadius: 12,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 14, paddingVertical: 14,
}, },
suggestionText: { suggestionText: {
fontSize: 14, fontSize: 15,
color: '#374151',
flex: 1, flex: 1,
fontWeight: '500',
}, },
}); });
+204 -156
View File
@@ -1,7 +1,9 @@
import { ConnectionBadge } from '@/components/ui/ConnectionBadge'; import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
import { useThemeColor } from '@/hooks/useThemeColor';
import * as api from '@/services/lmStudioApi'; import * as api from '@/services/lmStudioApi';
import { ServerConfig } from '@/services/types'; import { ServerConfig } from '@/services/types';
import { useAppStore } from '@/store/useAppStore'; import { useAppStore } from '@/store/useAppStore';
import { Info, MonitorCog, Moon, Network } from 'lucide-react-native';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
@@ -12,28 +14,31 @@ import {
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
useWindowDimensions, useWindowDimensions,
View View,
} from 'react-native'; } from 'react-native';
export default function SettingsScreen() { export default function SettingsScreen() {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const theme = useAppStore(state => state.theme); const colors = useThemeColor(); // Get current theme colors
// Global State
const themePreference = useAppStore(state => state.theme);
const setTheme = useAppStore(state => state.setTheme); const setTheme = useAppStore(state => state.setTheme);
const serverConfig = useAppStore(state => state.serverConfig); const serverConfig = useAppStore(state => state.serverConfig);
const setServerConfig = useAppStore(state => state.setServerConfig); const setServerConfig = useAppStore(state => state.setServerConfig);
const isConnected = useAppStore(state => state.isConnected); const isConnected = useAppStore(state => state.isConnected);
const setIsConnected = useAppStore(state => state.setIsConnected); const setIsConnected = useAppStore(state => state.setIsConnected);
// Local State
const [host, setHost] = useState(serverConfig.host); const [host, setHost] = useState(serverConfig.host);
const [port, setPort] = useState(serverConfig.port.toString()); const [port, setPort] = useState(serverConfig.port.toString());
const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? ''); const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? '');
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<boolean | null>(null); const [testResult, setTestResult] = useState<boolean | null>(null);
const isDarkMode = theme === 'dark' || theme === 'system';
const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
const isLargeScreen = width > 768; const isLargeScreen = width > 768;
const isDarkMode = themePreference === 'dark' || (themePreference === 'system' && colors.backgroundColor === '#020617'); // Hacky check but robust given our Colors.ts
const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
const currentConfig: ServerConfig = { const currentConfig: ServerConfig = {
host: host.trim() || 'localhost', host: host.trim() || 'localhost',
@@ -64,114 +69,157 @@ export default function SettingsScreen() {
return ( return (
<ScrollView <ScrollView
style={styles.container} style={[styles.container, { backgroundColor: colors.backgroundColor }]}
contentContainerStyle={[ contentContainerStyle={[
styles.scrollContent, styles.scrollContent,
isLargeScreen && styles.desktopContent, isLargeScreen && styles.desktopContent,
]} ]}
> >
{/* Server Connection */} {/* Server Connection Section */}
<View style={[styles.card, isLargeScreen && styles.desktopCard]}> <View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<View style={styles.sectionHeader}> <Text style={[styles.sectionLabel, { color: colors.textMuted }]}>SERVER CONNECTION</Text>
<Text style={styles.sectionTitle}>Server Connection</Text>
<ConnectionBadge isConnected={isConnected} />
</View>
<View style={styles.inputGroup}> <View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<Text style={styles.label}>Host</Text> <View style={styles.cardHeader}>
<TextInput <View style={styles.cardHeaderTitle}>
style={styles.textInput} <Network size={20} color={colors.text} style={styles.headerIcon} />
value={host} <Text style={[styles.cardTitle, { color: colors.text }]}>LM Studio API</Text>
onChangeText={setHost} </View>
placeholder="localhost" <ConnectionBadge isConnected={isConnected} />
placeholderTextColor="#9CA3AF" </View>
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputGroup}> <View style={[styles.divider, { backgroundColor: colors.border }]} />
<Text style={styles.label}>Port</Text>
<TextInput
style={styles.textInput}
value={port}
onChangeText={setPort}
placeholder="1234"
placeholderTextColor="#9CA3AF"
keyboardType="numeric"
/>
</View>
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<Text style={styles.label}>API Key (optional)</Text> <Text style={[styles.label, { color: colors.textMuted }]}>Host IP / Address</Text>
<TextInput <TextInput
style={styles.textInput} style={[
value={apiKey} styles.textInput,
onChangeText={setApiKey} { backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
placeholder="Leave empty for local" ]}
placeholderTextColor="#9CA3AF" value={host}
autoCapitalize="none" onChangeText={setHost}
autoCorrect={false} placeholder="localhost"
secureTextEntry placeholderTextColor={colors.textMuted}
/> autoCapitalize="none"
</View> autoCorrect={false}
/>
</View>
<View style={styles.buttonRow}> <View style={styles.inputGroup}>
{configChanged && ( <Text style={[styles.label, { color: colors.textMuted }]}>Port Number</Text>
<TouchableOpacity style={styles.saveButton} onPress={handleSaveConfig}> <TextInput
<Text style={styles.saveButtonText}>Save</Text> style={[
</TouchableOpacity> styles.textInput,
)} { backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
<TouchableOpacity ]}
style={[styles.testButton, isTesting && styles.testButtonDisabled]} value={port}
onPress={handleTestConnection} onChangeText={setPort}
disabled={isTesting} placeholder="1234"
> placeholderTextColor={colors.textMuted}
{isTesting ? ( keyboardType="numeric"
<ActivityIndicator size="small" color="#FFFFFF" /> />
) : ( </View>
<Text style={styles.testButtonText}>Test Connection</Text>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>API Key (optional)</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={apiKey}
onChangeText={setApiKey}
placeholder="Leave empty for local"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
</View>
<View style={styles.buttonRow}>
{configChanged && (
<TouchableOpacity
style={[styles.saveButton, { borderColor: colors.border, backgroundColor: colors.cardBackground }]}
onPress={handleSaveConfig}
>
<Text style={[styles.saveButtonText, { color: colors.text }]}>Save Changes</Text>
</TouchableOpacity>
)} )}
</TouchableOpacity> <TouchableOpacity
</View> style={[
styles.testButton,
{ backgroundColor: colors.tint },
isTesting && styles.testButtonDisabled
]}
onPress={handleTestConnection}
disabled={isTesting}
>
{isTesting ? (
<ActivityIndicator size="small" color={colors.backgroundColor} />
) : (
<Text style={[styles.testButtonText, { color: colors.backgroundColor }]}>Test Connection</Text>
)}
</TouchableOpacity>
</View>
{testResult !== null && ( {testResult !== null && (
<View style={[styles.resultBox, testResult ? styles.resultSuccess : styles.resultError]}> <View style={[
<Text style={[styles.resultText, testResult ? styles.resultTextSuccess : styles.resultTextError]}> styles.resultBox,
{testResult { backgroundColor: testResult ? colors.successBg : colors.errorBg }
? '✅ Connected successfully!' ]}>
: '❌ Connection failed. Check host, port, and if LM Studio is running.'} <Text style={[
styles.resultText,
{ color: testResult ? colors.successText : colors.errorText }
]}>
{testResult
? '✅ Connected successfully! API is ready.'
: '❌ Connection failed. Check host, port, and ensure LM Studio is running.'}
</Text>
</View>
)}
</View>
</View>
{/* Appearance Section */}
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>APPEARANCE</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.row}>
<View style={styles.rowIconWrap}>
<Moon size={20} color={colors.text} />
</View>
<View style={styles.rowText}>
<Text style={[styles.rowTitle, { color: colors.text }]}>Dark Mode</Text>
<Text style={[styles.rowSubtitle, { color: colors.textMuted }]}>Toggle sleek dark theme</Text>
</View>
<Switch
value={isDarkMode}
onValueChange={toggleTheme}
trackColor={{ false: '#E2E8F0', true: colors.tint }}
thumbColor={'#FFFFFF'}
/>
</View>
</View>
</View>
{/* About Section */}
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>ABOUT</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.rowGroup}>
<Info size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.text }]}>LocalAiApp - Cross Platform Version</Text>
</View>
<View style={[styles.rowGroup, { marginTop: 8 }]}>
<MonitorCog size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.textMuted, fontSize: 13 }]}>
Connected via OpenAPI `/v1/chat/completions` specs
</Text> </Text>
</View> </View>
)}
</View>
{/* Appearance */}
<View style={[styles.card, isLargeScreen && styles.desktopCard]}>
<Text style={styles.sectionTitle}>Appearance</Text>
<View style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowTitle}>Dark Mode</Text>
<Text style={styles.rowSubtitle}>Toggle application theme</Text>
</View>
<Switch
value={isDarkMode}
onValueChange={toggleTheme}
trackColor={{ false: '#D1D5DB', true: '#000000' }}
thumbColor={'#FFFFFF'}
/>
</View> </View>
</View> </View>
{/* About */}
<View style={[styles.card, isLargeScreen && styles.desktopCard]}>
<Text style={styles.sectionTitle}>About</Text>
<Text style={styles.infoText}>LocalAiApp - Cross Platform Version</Text>
<Text style={[styles.infoText, { marginTop: 4, fontSize: 13, color: '#9CA3AF' }]}>
Using OpenAI-compatible API (/v1)
</Text>
</View>
</ScrollView> </ScrollView>
); );
} }
@@ -179,7 +227,6 @@ export default function SettingsScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#F3F4F6',
}, },
scrollContent: { scrollContent: {
padding: 16, padding: 16,
@@ -189,129 +236,130 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingTop: 40, paddingTop: 40,
}, },
card: { section: {
backgroundColor: '#FFFFFF', marginBottom: 24,
borderRadius: 12,
padding: 16,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
}, },
desktopCard: { desktopSection: {
width: '100%', width: '100%',
maxWidth: 600, maxWidth: 600,
}, },
sectionHeader: { sectionLabel: {
fontSize: 13,
fontWeight: '600',
marginLeft: 16,
marginBottom: 8,
letterSpacing: 0.5,
},
card: {
borderRadius: 16,
padding: 16,
borderWidth: 1,
},
cardHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 16, marginBottom: 16,
}, },
sectionTitle: { cardHeaderTitle: {
flexDirection: 'row',
alignItems: 'center',
},
headerIcon: {
marginRight: 8,
},
cardTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
color: '#111827', },
divider: {
height: 1,
width: '100%',
marginBottom: 16,
}, },
inputGroup: { inputGroup: {
marginBottom: 14, marginBottom: 16,
}, },
label: { label: {
fontSize: 14, fontSize: 14,
fontWeight: '500', fontWeight: '500',
color: '#374151', marginBottom: 8,
marginBottom: 6,
}, },
textInput: { textInput: {
backgroundColor: '#F9FAFB',
borderWidth: 1, borderWidth: 1,
borderColor: '#E5E7EB', borderRadius: 12,
borderRadius: 10, paddingHorizontal: 16,
paddingHorizontal: 14, paddingVertical: 12,
paddingVertical: 10, fontSize: 16,
fontSize: 15,
color: '#111827',
}, },
buttonRow: { buttonRow: {
flexDirection: 'row', flexDirection: 'row',
gap: 10, gap: 12,
marginTop: 4, marginTop: 8,
}, },
saveButton: { saveButton: {
flex: 1, flex: 1,
backgroundColor: '#F3F4F6', paddingVertical: 14,
paddingVertical: 12, borderRadius: 12,
borderRadius: 10,
alignItems: 'center', alignItems: 'center',
borderWidth: 1, borderWidth: 1,
borderColor: '#E5E7EB',
}, },
saveButtonText: { saveButtonText: {
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#374151',
}, },
testButton: { testButton: {
flex: 2, flex: 2,
backgroundColor: '#000000', paddingVertical: 14,
paddingVertical: 12, borderRadius: 12,
borderRadius: 10,
alignItems: 'center', alignItems: 'center',
}, },
testButtonDisabled: { testButtonDisabled: {
opacity: 0.6, opacity: 0.7,
}, },
testButtonText: { testButtonText: {
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#FFFFFF',
}, },
resultBox: { resultBox: {
marginTop: 12, marginTop: 16,
borderRadius: 8, borderRadius: 12,
padding: 12, padding: 14,
},
resultSuccess: {
backgroundColor: '#F0FDF4',
},
resultError: {
backgroundColor: '#FEF2F2',
}, },
resultText: { resultText: {
fontSize: 14, fontSize: 14,
textAlign: 'center', textAlign: 'center',
}, fontWeight: '500',
resultTextSuccess: {
color: '#166534',
},
resultTextError: {
color: '#991B1B',
}, },
row: { row: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingVertical: 8, paddingVertical: 4,
},
rowIconWrap: {
marginRight: 12,
}, },
rowText: { rowText: {
flex: 1, flex: 1,
paddingRight: 16,
}, },
rowTitle: { rowTitle: {
fontSize: 16, fontSize: 16,
fontWeight: '500', fontWeight: '600',
color: '#111827',
marginBottom: 4, marginBottom: 4,
}, },
rowSubtitle: { rowSubtitle: {
fontSize: 14, fontSize: 14,
color: '#6B7280', },
rowGroup: {
flexDirection: 'row',
alignItems: 'center',
},
infoIcon: {
marginRight: 10,
}, },
infoText: { infoText: {
fontSize: 15, fontSize: 15,
lineHeight: 22, lineHeight: 22,
color: '#4B5563',
}, },
}); });
+20 -27
View File
@@ -1,3 +1,4 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Send, Square, X } from 'lucide-react-native'; import { Send, Square, X } from 'lucide-react-native';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -20,6 +21,7 @@ interface ChatInputProps {
export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit }: ChatInputProps) { export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit }: ChatInputProps) {
const [text, setText] = useState(''); const [text, setText] = useState('');
const colors = useThemeColor();
// When editingText changes, fill the input // When editingText changes, fill the input
useEffect(() => { useEffect(() => {
@@ -58,23 +60,27 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
const isEditing = editingText !== null && editingText !== undefined; const isEditing = editingText !== null && editingText !== undefined;
return ( return (
<View style={styles.outerContainer}> <View style={[styles.outerContainer, { backgroundColor: colors.backgroundColor, borderTopColor: colors.border }]}>
{/* Edit mode banner */} {/* Edit mode banner */}
{isEditing && ( {isEditing && (
<View style={styles.editBanner}> <View style={[styles.editBanner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.border }]}>
<Text style={styles.editBannerText}>Editing message</Text> <Text style={[styles.editBannerText, { color: colors.bannerText }]}>Editing message</Text>
<TouchableOpacity onPress={handleCancelEdit} style={styles.editCancelBtn}> <TouchableOpacity onPress={handleCancelEdit} style={styles.editCancelBtn}>
<X size={16} color="#6B7280" /> <X size={16} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
<View style={styles.container}> <View style={styles.container}>
<View style={[styles.inputWrapper, isEditing && styles.inputWrapperEditing]}> <View style={[
styles.inputWrapper,
{ backgroundColor: colors.inputBg, borderColor: colors.border, borderWidth: 1 },
isEditing && { borderColor: colors.tint, borderWidth: 1.5 }
]}>
<TextInput <TextInput
style={styles.input} style={[styles.input, { color: colors.text }]}
placeholder="Send a message..." placeholder="Send a message..."
placeholderTextColor="#9CA3AF" placeholderTextColor={colors.textMuted}
value={text} value={text}
onChangeText={setText} onChangeText={setText}
multiline multiline
@@ -85,20 +91,23 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
{/* Send or Stop button */} {/* Send or Stop button */}
{isLoading ? ( {isLoading ? (
<TouchableOpacity <TouchableOpacity
style={styles.stopButton} style={[styles.stopButton, { backgroundColor: colors.errorText }]}
onPress={handleStop} onPress={handleStop}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Square size={16} color="#FFFFFF" /> <Square size={16} color={colors.backgroundColor} />
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<TouchableOpacity <TouchableOpacity
style={[styles.sendButton, !text.trim() && styles.sendButtonDisabled]} style={[
styles.sendButton,
{ backgroundColor: text.trim() ? colors.tint : colors.border }
]}
onPress={handleSend} onPress={handleSend}
disabled={!text.trim()} disabled={!text.trim()}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Send size={18} color={text.trim() ? '#FFFFFF' : '#9CA3AF'} /> <Send size={18} color={text.trim() ? colors.backgroundColor : colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
@@ -109,9 +118,7 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outerContainer: { outerContainer: {
backgroundColor: '#FFFFFF',
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: '#E5E7EB',
}, },
editBanner: { editBanner: {
flexDirection: 'row', flexDirection: 'row',
@@ -119,13 +126,10 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 8, paddingVertical: 8,
backgroundColor: '#EFF6FF',
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#DBEAFE',
}, },
editBannerText: { editBannerText: {
fontSize: 13, fontSize: 13,
color: '#2563EB',
fontWeight: '500', fontWeight: '500',
}, },
editCancelBtn: { editCancelBtn: {
@@ -138,43 +142,32 @@ const styles = StyleSheet.create({
inputWrapper: { inputWrapper: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-end', alignItems: 'flex-end',
backgroundColor: '#F3F4F6',
borderRadius: 24, borderRadius: 24,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 6, paddingVertical: 6,
minHeight: 44, minHeight: 44,
maxHeight: 120, maxHeight: 120,
}, },
inputWrapperEditing: {
borderWidth: 1.5,
borderColor: '#2563EB',
},
input: { input: {
flex: 1, flex: 1,
fontSize: 15, fontSize: 15,
lineHeight: 22, lineHeight: 22,
paddingTop: 8, paddingTop: 8,
paddingBottom: 8, paddingBottom: 8,
color: '#111827',
}, },
sendButton: { sendButton: {
width: 34, width: 34,
height: 34, height: 34,
borderRadius: 17, borderRadius: 17,
backgroundColor: '#111827',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginLeft: 8, marginLeft: 8,
marginBottom: 2, marginBottom: 2,
}, },
sendButtonDisabled: {
backgroundColor: '#E5E7EB',
},
stopButton: { stopButton: {
width: 34, width: 34,
height: 34, height: 34,
borderRadius: 17, borderRadius: 17,
backgroundColor: '#EF4444',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginLeft: 8, marginLeft: 8,
+12 -9
View File
@@ -1,3 +1,4 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Copy, Pencil, RefreshCw, Trash2 } from 'lucide-react-native'; import { Copy, Pencil, RefreshCw, Trash2 } from 'lucide-react-native';
@@ -21,6 +22,8 @@ export function MessageActions({
onDelete, onDelete,
isStreaming, isStreaming,
}: MessageActionsProps) { }: MessageActionsProps) {
const colors = useThemeColor();
const handleCopy = async () => { const handleCopy = async () => {
await Clipboard.setStringAsync(content); await Clipboard.setStringAsync(content);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
@@ -48,24 +51,24 @@ export function MessageActions({
return ( return (
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}> <View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6}> <TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6}>
<Copy size={14} color="#9CA3AF" /> <Copy size={16} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
{isUser && onEdit && ( {isUser && onEdit && (
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6}> <TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6}>
<Pencil size={14} color="#9CA3AF" /> <Pencil size={16} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
)} )}
{!isUser && onRegenerate && ( {!isUser && onRegenerate && (
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6}> <TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6}>
<RefreshCw size={14} color="#9CA3AF" /> <RefreshCw size={16} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
)} )}
{onDelete && ( {onDelete && (
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6}> <TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6}>
<Trash2 size={14} color="#9CA3AF" /> <Trash2 size={16} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
@@ -75,8 +78,8 @@ export function MessageActions({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flexDirection: 'row', flexDirection: 'row',
gap: 2, gap: 6,
marginTop: 4, marginTop: 6,
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
containerUser: { containerUser: {
@@ -86,9 +89,9 @@ const styles = StyleSheet.create({
justifyContent: 'flex-start', justifyContent: 'flex-start',
}, },
actionBtn: { actionBtn: {
width: 32, width: 36,
height: 32, height: 36,
borderRadius: 8, borderRadius: 18,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'transparent', backgroundColor: 'transparent',
+207 -112
View File
@@ -1,8 +1,10 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { ChatMessage } from '@/store/useAppStore'; import { ChatMessage } from '@/store/useAppStore';
import Markdown from '@ronradtke/react-native-markdown-display'; import Markdown from '@ronradtke/react-native-markdown-display';
import { Bot, User } from 'lucide-react-native'; import * as Clipboard from 'expo-clipboard';
import React, { useState } from 'react'; import { Bot, Check, Copy, User } from 'lucide-react-native';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'; import React, { useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
import { MessageActions } from './MessageActions'; import { MessageActions } from './MessageActions';
interface MessageBubbleProps { interface MessageBubbleProps {
@@ -23,17 +25,156 @@ export function MessageBubble({
isLastAssistant, isLastAssistant,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const colors = useThemeColor();
const isLargeScreen = width > 768; const isLargeScreen = width > 768;
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const [showActions, setShowActions] = useState(false); const [showActions, setShowActions] = useState(false);
const [copiedText, setCopiedText] = useState<string | null>(null);
const handleCopy = async (text: string) => {
await Clipboard.setStringAsync(text);
setCopiedText(text);
setTimeout(() => setCopiedText(null), 2000);
};
const showingStreaming = isStreaming && isLastAssistant; const showingStreaming = isStreaming && isLastAssistant;
const shouldShowActions = !isUser || showActions; const shouldShowActions = !isUser || showActions;
// UseMemo for dynamic colors
const markdownStyles = useMemo(() => ({
body: {
fontSize: 15,
lineHeight: 22,
color: colors.messageAssistantText,
},
strong: {
fontWeight: '700',
color: colors.text,
},
em: {
fontStyle: 'italic',
},
code_inline: {
backgroundColor: colors.backgroundColor,
color: colors.text,
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 4,
fontSize: 13,
fontFamily: 'monospace',
},
fence: {
backgroundColor: colors.inputBg,
borderColor: colors.border,
borderWidth: 1,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
overflow: 'hidden',
},
code_block: {
backgroundColor: colors.inputBg,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: colors.border,
paddingLeft: 12,
marginVertical: 4,
color: colors.textMuted,
},
bullet_list: {
marginVertical: 4,
},
ordered_list: {
marginVertical: 4,
},
list_item: {
marginVertical: 2,
color: colors.messageAssistantText,
},
heading1: {
fontSize: 22,
fontWeight: '700',
marginVertical: 8,
color: colors.text,
},
heading2: {
fontSize: 19,
fontWeight: '700',
marginVertical: 6,
color: colors.text,
},
heading3: {
fontSize: 17,
fontWeight: '600',
marginVertical: 4,
color: colors.text,
},
link: {
color: '#3B82F6',
textDecorationLine: 'underline',
},
hr: {
backgroundColor: colors.border,
height: 1,
marginVertical: 12,
},
paragraph: {
marginVertical: 2,
},
} as any), [colors]); // using 'as any' to satisfy internal Markdown types if needed
const markdownRules = useMemo(() => ({
fence: (node: any, children: any, parent: any, parentStyles: any) => {
const content = node.content || '';
const language = node.sourceInfo || 'code';
return (
<View key={node.key} style={styles.codeBlockWrapper}>
<View style={styles.codeBlockHeader}>
<Text style={styles.codeBlockLanguage}>{language}</Text>
<TouchableOpacity onPress={() => handleCopy(content)} style={styles.copyButton}>
{copiedText === content ? <Check size={14} color="#A0A0A0" /> : <Copy size={14} color="#A0A0A0" />}
<Text style={styles.copyText}>{copiedText === content ? 'Copied!' : 'Copy'}</Text>
</TouchableOpacity>
</View>
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
);
},
code_block: (node: any, children: any, parent: any, parentStyles: any) => {
const content = node.content || '';
return (
<View key={node.key} style={styles.codeBlockWrapper}>
<View style={styles.codeBlockHeader}>
<Text style={styles.codeBlockLanguage}>code</Text>
<TouchableOpacity onPress={() => handleCopy(content)} style={styles.copyButton}>
{copiedText === content ? <Check size={14} color="#A0A0A0" /> : <Copy size={14} color="#A0A0A0" />}
<Text style={styles.copyText}>{copiedText === content ? 'Copied!' : 'Copy'}</Text>
</TouchableOpacity>
</View>
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
);
}
}), [copiedText]);
const renderContent = () => { const renderContent = () => {
if (isUser) { if (isUser) {
return ( return (
<Text style={[styles.text, styles.textUser]}> <Text style={[styles.text, { color: colors.messageUserText }]}>
{message.content} {message.content}
</Text> </Text>
); );
@@ -44,7 +185,7 @@ export function MessageBubble({
} }
return ( return (
<Markdown style={markdownStyles}> <Markdown style={markdownStyles} rules={markdownRules}>
{message.content} {message.content}
</Markdown> </Markdown>
); );
@@ -53,6 +194,7 @@ export function MessageBubble({
return ( return (
<View <View
onTouchStart={() => isUser && setShowActions(true)} onTouchStart={() => isUser && setShowActions(true)}
style={styles.outerWrap}
> >
<View style={[ <View style={[
styles.container, styles.container,
@@ -60,8 +202,8 @@ export function MessageBubble({
]}> ]}>
{/* Avatar */} {/* Avatar */}
{!isUser && ( {!isUser && (
<View style={styles.avatarAssistant}> <View style={[styles.avatarAssistant, { backgroundColor: colors.border }]}>
<Bot size={16} color="#6B7280" /> <Bot size={16} color={colors.text} />
</View> </View>
)} )}
@@ -69,13 +211,15 @@ export function MessageBubble({
styles.bubble, styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAssistant, isUser ? styles.bubbleUser : styles.bubbleAssistant,
{ maxWidth: isLargeScreen ? '70%' : '85%' }, { maxWidth: isLargeScreen ? '70%' : '85%' },
{ backgroundColor: isUser ? colors.messageUserBg : colors.messageAssistantBg },
!isUser && { borderWidth: 1, borderColor: colors.border }
]}> ]}>
{renderContent()} {renderContent()}
</View> </View>
{isUser && ( {isUser && (
<View style={styles.avatarUser}> <View style={[styles.avatarUser, { backgroundColor: colors.tint }]}>
<User size={16} color="#FFFFFF" /> <User size={16} color={colors.backgroundColor} />
</View> </View>
)} )}
</View> </View>
@@ -83,7 +227,7 @@ export function MessageBubble({
{/* Timestamp + edited badge */} {/* Timestamp + edited badge */}
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}> <View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{!isUser && <View style={{ width: 32 }} />} {!isUser && <View style={{ width: 32 }} />}
<Text style={styles.timestamp}> <Text style={[styles.timestamp, { color: colors.textMuted }]}>
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{message.isEdited ? ' · edited' : ''} {message.isEdited ? ' · edited' : ''}
</Text> </Text>
@@ -105,10 +249,13 @@ export function MessageBubble({
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outerWrap: {
width: '100%',
marginBottom: 12,
},
container: { container: {
width: '100%', width: '100%',
paddingHorizontal: 16, paddingHorizontal: 16,
marginTop: 12,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-end', alignItems: 'flex-end',
gap: 8, gap: 8,
@@ -120,146 +267,94 @@ const styles = StyleSheet.create({
justifyContent: 'flex-start', justifyContent: 'flex-start',
}, },
avatarAssistant: { avatarAssistant: {
width: 28, width: 32,
height: 28, height: 32,
borderRadius: 14, borderRadius: 16,
backgroundColor: '#F3F4F6',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 2, marginBottom: 2,
}, },
avatarUser: { avatarUser: {
width: 28, width: 32,
height: 28, height: 32,
borderRadius: 14, borderRadius: 16,
backgroundColor: '#374151',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 2, marginBottom: 2,
}, },
bubble: { bubble: {
paddingHorizontal: 14, paddingHorizontal: 16,
paddingVertical: 10, paddingVertical: 12,
borderRadius: 18, borderRadius: 20,
// CRITICAL FOR MARKDOWN IN FLEX ROW:
flexShrink: 1,
}, },
bubbleUser: { bubbleUser: {
backgroundColor: '#111827', borderBottomRightRadius: 6,
borderBottomRightRadius: 4,
}, },
bubbleAssistant: { bubbleAssistant: {
backgroundColor: '#F3F4F6', borderBottomLeftRadius: 6,
borderBottomLeftRadius: 4,
}, },
text: { text: {
fontSize: 15, fontSize: 15,
lineHeight: 22, lineHeight: 22,
}, },
textUser: {
color: '#FFFFFF',
},
metaRow: { metaRow: {
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 16, paddingHorizontal: 16,
marginTop: 2, marginTop: 4,
gap: 8, gap: 8,
}, },
metaRowUser: { metaRowUser: {
justifyContent: 'flex-end', justifyContent: 'flex-end',
paddingRight: 52, paddingRight: 56,
}, },
metaRowAssistant: { metaRowAssistant: {
justifyContent: 'flex-start', justifyContent: 'flex-start',
}, },
timestamp: { timestamp: {
fontSize: 11, fontSize: 11,
color: '#9CA3AF', fontWeight: '500',
}, },
}); codeBlockWrapper: {
backgroundColor: '#1E1E1E',
const markdownStyles = StyleSheet.create({
body: {
fontSize: 15,
lineHeight: 22,
color: '#111827',
},
strong: {
fontWeight: '700',
},
em: {
fontStyle: 'italic',
},
code_inline: {
backgroundColor: '#E5E7EB',
color: '#1F2937',
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 4,
fontSize: 13,
fontFamily: 'monospace',
},
fence: {
backgroundColor: '#1F2937',
color: '#E5E7EB',
padding: 12,
borderRadius: 8, borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8, marginVertical: 8,
overflow: 'hidden', overflow: 'hidden',
width: '100%',
}, },
code_block: { codeBlockHeader: {
backgroundColor: '#1F2937', flexDirection: 'row',
color: '#E5E7EB', justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#2D2D2D',
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#404040',
},
codeBlockLanguage: {
color: '#A0A0A0',
fontSize: 12,
fontFamily: 'monospace',
textTransform: 'lowercase',
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
padding: 4,
},
copyText: {
color: '#A0A0A0',
fontSize: 12,
},
codeScrollView: {
padding: 12, padding: 12,
borderRadius: 8, },
codeText: {
color: '#D4D4D4',
fontSize: 13, fontSize: 13,
fontFamily: 'monospace', fontFamily: 'monospace',
marginVertical: 8,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: '#D1D5DB',
paddingLeft: 12,
marginVertical: 4,
color: '#6B7280',
},
bullet_list: {
marginVertical: 4,
},
ordered_list: {
marginVertical: 4,
},
list_item: {
marginVertical: 2,
},
heading1: {
fontSize: 22,
fontWeight: '700',
marginVertical: 8,
color: '#111827',
},
heading2: {
fontSize: 19,
fontWeight: '700',
marginVertical: 6,
color: '#111827',
},
heading3: {
fontSize: 17,
fontWeight: '600',
marginVertical: 4,
color: '#111827',
},
link: {
color: '#2563EB',
textDecorationLine: 'underline',
},
hr: {
backgroundColor: '#E5E7EB',
height: 1,
marginVertical: 12,
},
paragraph: {
marginVertical: 2,
}, },
}); });
+27 -46
View File
@@ -1,5 +1,6 @@
import { ConnectionBadge } from '@/components/ui/ConnectionBadge'; import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
import { useLMStudio } from '@/hooks/useLMStudio'; import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore'; import { useAppStore } from '@/store/useAppStore';
import { Check, ChevronDown, RefreshCw } from 'lucide-react-native'; import { Check, ChevronDown, RefreshCw } from 'lucide-react-native';
import React, { useState } from 'react'; import React, { useState } from 'react';
@@ -20,6 +21,7 @@ export function ModelSelector() {
const setActiveModelId = useAppStore(state => state.setActiveModelId); const setActiveModelId = useAppStore(state => state.setActiveModelId);
const { availableModels, isConnected, fetchModels } = useLMStudio(); const { availableModels, isConnected, fetchModels } = useLMStudio();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const colors = useThemeColor();
const handleSelect = (modelId: string) => { const handleSelect = (modelId: string) => {
setActiveModelId(modelId); setActiveModelId(modelId);
@@ -37,15 +39,15 @@ export function ModelSelector() {
return ( return (
<> <>
<TouchableOpacity <TouchableOpacity
style={styles.headerButton} style={[styles.headerButton, { backgroundColor: colors.cardBackground }]}
onPress={() => setModalVisible(true)} onPress={() => setModalVisible(true)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<ConnectionBadge isConnected={isConnected} compact /> <ConnectionBadge isConnected={isConnected} compact />
<Text style={styles.headerText} numberOfLines={1}> <Text style={[styles.headerText, { color: colors.text }]} numberOfLines={1}>
{activeModelName} {activeModelName}
</Text> </Text>
<ChevronDown size={16} color="#111827" style={styles.iconOffset} /> <ChevronDown size={16} color={colors.text} style={styles.iconOffset} />
</TouchableOpacity> </TouchableOpacity>
<Modal <Modal
@@ -59,35 +61,35 @@ export function ModelSelector() {
activeOpacity={1} activeOpacity={1}
onPressOut={() => setModalVisible(false)} onPressOut={() => setModalVisible(false)}
> >
<View style={styles.bottomSheet} onStartShouldSetResponder={() => true}> <View style={[styles.bottomSheet, { backgroundColor: colors.backgroundColor }]} onStartShouldSetResponder={() => true}>
<View style={styles.dragHandle} /> <View style={[styles.dragHandle, { backgroundColor: colors.border }]} />
<View style={styles.sheetHeader}> <View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Select Model</Text> <Text style={[styles.sheetTitle, { color: colors.text }]}>Select Model</Text>
<TouchableOpacity <TouchableOpacity
onPress={handleRefresh} onPress={handleRefresh}
style={styles.refreshButton} style={[styles.refreshButton, { backgroundColor: colors.cardBackground }]}
disabled={isRefreshing} disabled={isRefreshing}
> >
{isRefreshing ? ( {isRefreshing ? (
<ActivityIndicator size="small" color="#6B7280" /> <ActivityIndicator size="small" color={colors.textMuted} />
) : ( ) : (
<RefreshCw size={18} color="#6B7280" /> <RefreshCw size={18} color={colors.textMuted} />
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{!isConnected && ( {!isConnected && (
<View style={styles.warningBox}> <View style={[styles.warningBox, { backgroundColor: colors.bannerBg }]}>
<Text style={styles.warningText}> <Text style={[styles.warningText, { color: colors.bannerText }]}>
Not connected to LM Studio. Configure server in Settings. Not connected to LM Studio. Configure server in Settings.
</Text> </Text>
</View> </View>
)} )}
{availableModels.length === 0 && isConnected && ( {availableModels.length === 0 && isConnected && (
<View style={styles.emptyBox}> <View style={[styles.emptyBox, { backgroundColor: colors.cardBackground }]}>
<Text style={styles.emptyText}> <Text style={[styles.emptyText, { color: colors.textMuted }]}>
No models loaded. Load a model in LM Studio first. No models loaded. Load a model in LM Studio first.
</Text> </Text>
</View> </View>
@@ -100,25 +102,29 @@ export function ModelSelector() {
const isSelected = item.id === activeModelId; const isSelected = item.id === activeModelId;
return ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.itemRow, isSelected && styles.itemRowSelected]} style={[
styles.itemRow,
{ backgroundColor: colors.cardBackground, borderColor: colors.border },
isSelected && { borderColor: colors.tint }
]}
onPress={() => handleSelect(item.id)} onPress={() => handleSelect(item.id)}
> >
<View style={styles.itemContent}> <View style={styles.itemContent}>
<Text style={[styles.itemName, isSelected && styles.itemNameSelected]}> <Text style={[styles.itemName, { color: colors.text }]}>
{item.id} {item.id}
</Text> </Text>
<View style={styles.badgeRow}> <View style={styles.badgeRow}>
<View style={styles.badge}> <View style={[styles.badge, { backgroundColor: colors.inputBg }]}>
<Text style={styles.badgeText}>API</Text> <Text style={[styles.badgeText, { color: colors.textMuted }]}>API</Text>
</View> </View>
{item.owned_by && ( {item.owned_by && (
<View style={[styles.badge, styles.badgeSecondary]}> <View style={[styles.badge, { backgroundColor: colors.inputBg }]}>
<Text style={styles.badgeText}>{item.owned_by}</Text> <Text style={[styles.badgeText, { color: colors.textMuted }]}>{item.owned_by}</Text>
</View> </View>
)} )}
</View> </View>
</View> </View>
{isSelected && <Check size={20} color="#000000" />} {isSelected && <Check size={20} color={colors.tint} />}
</TouchableOpacity> </TouchableOpacity>
); );
}} }}
@@ -137,14 +143,12 @@ const styles = StyleSheet.create({
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
borderRadius: 8, borderRadius: 8,
backgroundColor: '#F3F4F6',
gap: 8, gap: 8,
maxWidth: 280, maxWidth: 280,
}, },
headerText: { headerText: {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
color: '#111827',
flexShrink: 1, flexShrink: 1,
}, },
iconOffset: { iconOffset: {
@@ -152,11 +156,10 @@ const styles = StyleSheet.create({
}, },
modalOverlay: { modalOverlay: {
flex: 1, flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)', backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end', justifyContent: 'flex-end',
}, },
bottomSheet: { bottomSheet: {
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 24, borderTopLeftRadius: 24,
borderTopRightRadius: 24, borderTopRightRadius: 24,
padding: 24, padding: 24,
@@ -181,7 +184,6 @@ const styles = StyleSheet.create({
dragHandle: { dragHandle: {
width: 40, width: 40,
height: 4, height: 4,
backgroundColor: '#E5E7EB',
borderRadius: 2, borderRadius: 2,
alignSelf: 'center', alignSelf: 'center',
marginBottom: 16, marginBottom: 16,
@@ -196,33 +198,27 @@ const styles = StyleSheet.create({
sheetTitle: { sheetTitle: {
fontSize: 20, fontSize: 20,
fontWeight: '700', fontWeight: '700',
color: '#111827',
}, },
refreshButton: { refreshButton: {
padding: 8, padding: 8,
borderRadius: 8, borderRadius: 8,
backgroundColor: '#F3F4F6',
}, },
warningBox: { warningBox: {
backgroundColor: '#FEF3C7',
borderRadius: 8, borderRadius: 8,
padding: 12, padding: 12,
marginBottom: 16, marginBottom: 16,
}, },
warningText: { warningText: {
fontSize: 13, fontSize: 13,
color: '#92400E',
textAlign: 'center', textAlign: 'center',
}, },
emptyBox: { emptyBox: {
backgroundColor: '#F3F4F6',
borderRadius: 8, borderRadius: 8,
padding: 16, padding: 16,
marginBottom: 16, marginBottom: 16,
}, },
emptyText: { emptyText: {
fontSize: 14, fontSize: 14,
color: '#6B7280',
textAlign: 'center', textAlign: 'center',
}, },
itemRow: { itemRow: {
@@ -233,13 +229,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
borderRadius: 12, borderRadius: 12,
marginBottom: 8, marginBottom: 8,
backgroundColor: '#FFFFFF',
borderWidth: 1, borderWidth: 1,
borderColor: '#E5E7EB',
},
itemRowSelected: {
borderColor: '#000000',
backgroundColor: '#F9FAFB',
}, },
itemContent: { itemContent: {
flex: 1, flex: 1,
@@ -248,29 +238,20 @@ const styles = StyleSheet.create({
itemName: { itemName: {
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#111827',
marginBottom: 6, marginBottom: 6,
}, },
itemNameSelected: {
color: '#000000',
},
badgeRow: { badgeRow: {
flexDirection: 'row', flexDirection: 'row',
gap: 6, gap: 6,
}, },
badge: { badge: {
alignSelf: 'flex-start', alignSelf: 'flex-start',
backgroundColor: '#DBEAFE',
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 3, paddingVertical: 3,
borderRadius: 6, borderRadius: 6,
}, },
badgeSecondary: {
backgroundColor: '#F3F4F6',
},
badgeText: { badgeText: {
fontSize: 11, fontSize: 11,
fontWeight: '500', fontWeight: '500',
color: '#4B5563',
}, },
}); });
+7 -6
View File
@@ -1,14 +1,17 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react'; import React from 'react';
import { StyleSheet, View } from 'react-native'; import { StyleSheet, View } from 'react-native';
export function TypingIndicator() { export function TypingIndicator() {
const colors = useThemeColor();
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.bubble}> <View style={[styles.bubble, { backgroundColor: colors.messageAssistantBg }]}>
<View style={styles.dotsRow}> <View style={styles.dotsRow}>
<View style={[styles.dot, styles.dot1]} /> <View style={[styles.dot, styles.dot1, { backgroundColor: colors.textMuted }]} />
<View style={[styles.dot, styles.dot2]} /> <View style={[styles.dot, styles.dot2, { backgroundColor: colors.textMuted }]} />
<View style={[styles.dot, styles.dot3]} /> <View style={[styles.dot, styles.dot3, { backgroundColor: colors.textMuted }]} />
</View> </View>
</View> </View>
</View> </View>
@@ -24,7 +27,6 @@ const styles = StyleSheet.create({
justifyContent: 'flex-start', justifyContent: 'flex-start',
}, },
bubble: { bubble: {
backgroundColor: '#F3F4F6',
borderRadius: 20, borderRadius: 20,
borderTopLeftRadius: 4, borderTopLeftRadius: 4,
paddingHorizontal: 16, paddingHorizontal: 16,
@@ -39,7 +41,6 @@ const styles = StyleSheet.create({
width: 8, width: 8,
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
backgroundColor: '#9CA3AF',
opacity: 0.6, opacity: 0.6,
}, },
dot1: { opacity: 1.0 }, dot1: { opacity: 1.0 },
+11 -14
View File
@@ -1,3 +1,4 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
@@ -7,11 +8,19 @@ interface ConnectionBadgeProps {
} }
export function ConnectionBadge({ isConnected, compact }: ConnectionBadgeProps) { export function ConnectionBadge({ isConnected, compact }: ConnectionBadgeProps) {
const colors = useThemeColor();
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} /> <View style={[
styles.dot,
{ backgroundColor: isConnected ? colors.successText : colors.errorText }
]} />
{!compact && ( {!compact && (
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}> <Text style={[
styles.text,
{ color: isConnected ? colors.successText : colors.errorText }
]}>
{isConnected ? 'Connected' : 'Disconnected'} {isConnected ? 'Connected' : 'Disconnected'}
</Text> </Text>
)} )}
@@ -30,20 +39,8 @@ const styles = StyleSheet.create({
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
}, },
dotConnected: {
backgroundColor: '#22C55E',
},
dotDisconnected: {
backgroundColor: '#EF4444',
},
text: { text: {
fontSize: 12, fontSize: 12,
fontWeight: '500', fontWeight: '500',
}, },
textConnected: {
color: '#22C55E',
},
textDisconnected: {
color: '#EF4444',
},
}); });
+55
View File
@@ -0,0 +1,55 @@
const tintColorLight = '#0F172A'; // slate-900
const tintColorDark = '#F8FAFC'; // slate-50
export const Colors = {
light: {
backgroundColor: '#F8FAFC', // slate-50
cardBackground: '#FFFFFF',
text: '#0F172A', // slate-900
textMuted: '#64748B', // slate-500
border: '#E2E8F0', // slate-200
tint: tintColorLight,
icon: '#64748B',
tabIconDefault: '#94A3B8',
tabIconSelected: tintColorLight,
// Chat specific
messageUserText: '#FFFFFF',
messageUserBg: '#0F172A', // slate-900
messageAssistantText: '#0F172A',
messageAssistantBg: '#FFFFFF',
inputBg: '#FFFFFF',
inputBorder: '#E2E8F0',
bannerBg: '#FEF3C7', // amber-100
bannerText: '#92400E', // amber-900
successBg: '#F0FDF4', // green-50
successText: '#166534', // green-800
errorBg: '#FEF2F2', // red-50
errorText: '#991B1B', // red-800
shadow: '#000000',
},
dark: {
backgroundColor: '#020617', // slate-950
cardBackground: '#0F172A', // slate-900
text: '#F8FAFC', // slate-50
textMuted: '#94A3B8', // slate-400
border: '#1E293B', // slate-800
tint: tintColorDark,
icon: '#94A3B8',
tabIconDefault: '#475569',
tabIconSelected: tintColorDark,
// Chat specific
messageUserText: '#020617', // slate-950
messageUserBg: '#F8FAFC', // slate-50
messageAssistantText: '#E2E8F0', // slate-200
messageAssistantBg: '#0F172A', // slate-900
inputBg: '#0F172A', // slate-900
inputBorder: '#1E293B', // slate-800
bannerBg: '#451A03', // amber-950
bannerText: '#FDE68A', // amber-200
successBg: '#052E16', // green-950
successText: '#BBF7D0', // green-200
errorBg: '#450A0A', // red-950
errorText: '#FECACA', // red-200
shadow: '#000000',
},
};
+9
View File
@@ -0,0 +1,9 @@
export const SYSTEM_PROMPT = `You are a highly capable, intelligent, and helpful AI assistant.
Follow these guidelines strictly:
1. Be direct, concise, and highly assertive in your answers. Avoid filler words and preamble.
2. Structure your answers clearly using Markdown formatting (bolding, lists, code blocks).
3. Do not apologize unnecessarily or explain that you are an AI unless explicitly asked.
4. Do not repeat the user's prompt. Get straight to the answer.
5. Provide accurate, factual information. If you do not know the answer or are unsure, say so directly rather than guessing.
6. When writing code, provide clean, functional blocks with minimal essential explanations.
7. Always respond in the same language the user uses.`;
+15 -8
View File
@@ -1,4 +1,5 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { SYSTEM_PROMPT } from '../constants/SystemPrompt';
import * as api from '../services/lmStudioApi'; import * as api from '../services/lmStudioApi';
import { ChatMessage as ApiMessage } from '../services/types'; import { ChatMessage as ApiMessage } from '../services/types';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
@@ -48,10 +49,13 @@ export function useLMStudio() {
addMessage({ role: 'user', content: text }); addMessage({ role: 'user', content: text });
const currentMessages = useAppStore.getState().messages; const currentMessages = useAppStore.getState().messages;
const apiMessages: ApiMessage[] = currentMessages.map((m) => ({ const apiMessages: ApiMessage[] = [
role: m.role, { role: 'system', content: SYSTEM_PROMPT },
content: m.content, ...currentMessages.map((m) => ({
})); role: m.role,
content: m.content,
}))
];
const modelId = useAppStore.getState().activeModelId; const modelId = useAppStore.getState().activeModelId;
if (!modelId) { if (!modelId) {
@@ -112,10 +116,13 @@ export function useLMStudio() {
// Build API messages from remaining // Build API messages from remaining
const remaining = useAppStore.getState().messages; const remaining = useAppStore.getState().messages;
const apiMessages: ApiMessage[] = remaining.map((m) => ({ const apiMessages: ApiMessage[] = [
role: m.role, { role: 'system', content: SYSTEM_PROMPT },
content: m.content, ...remaining.map((m) => ({
})); role: m.role,
content: m.content,
}))
];
const modelId = useAppStore.getState().activeModelId; const modelId = useAppStore.getState().activeModelId;
if (!modelId) return; if (!modelId) return;
+31
View File
@@ -0,0 +1,31 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useAppStore } from '@/store/useAppStore';
type ThemeType = 'light' | 'dark';
type ColorObj = typeof Colors.light & typeof Colors.dark;
// Overloads para resolver o erro TSC
export function useThemeColor(props: { light?: string; dark?: string }, colorName: keyof ColorObj): string;
export function useThemeColor(): ColorObj;
export function useThemeColor(
props?: { light?: string; dark?: string },
colorName?: keyof ColorObj
): string | ColorObj {
const themePreference = useAppStore((state) => state.theme);
const systemColorScheme = useColorScheme() ?? 'light';
const theme: ThemeType = themePreference === 'system'
? systemColorScheme
: (themePreference as ThemeType);
if (props && props[theme]) {
return props[theme] as string;
}
if (colorName) {
return Colors[theme][colorName];
}
return Colors[theme];
}