diff --git a/app/chat/_layout.tsx b/app/chat/_layout.tsx index 5956b89..6d43cf5 100644 --- a/app/chat/_layout.tsx +++ b/app/chat/_layout.tsx @@ -1,4 +1,5 @@ import { ModelSelector } from '@/components/chat/ModelSelector'; +import { useThemeColor } from '@/hooks/useThemeColor'; import { useAppStore } from '@/store/useAppStore'; import { Stack, useRouter } from 'expo-router'; import { Plus, Settings } from 'lucide-react-native'; @@ -8,36 +9,45 @@ export default function ChatLayout() { const { width } = useWindowDimensions(); const router = useRouter(); const clearChat = useAppStore(state => state.clearChat); + const colors = useThemeColor(); 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%'; return ( - + , headerShadowVisible: false, + headerStyle: { backgroundColor: colors.backgroundColor }, headerLeft: () => ( - + ), headerRight: () => ( router.push('/settings')} - style={styles.headerBtn} + style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]} activeOpacity={0.7} > - + ), }} @@ -51,18 +61,15 @@ export default function ChatLayout() { const styles = StyleSheet.create({ desktopContainer: { flex: 1, - backgroundColor: '#F3F4F6', alignItems: 'center', justifyContent: 'center', }, mobileContainer: { flex: 1, - backgroundColor: '#FFFFFF', }, chatWrapper: { flex: 1, width: '100%', - backgroundColor: '#FFFFFF', ...Platform.select({ web: { shadowColor: '#000', diff --git a/app/chat/index.tsx b/app/chat/index.tsx index 2e42bb7..471275f 100644 --- a/app/chat/index.tsx +++ b/app/chat/index.tsx @@ -2,10 +2,12 @@ import { ChatInput } from '@/components/chat/ChatInput'; import { MessageBubble } from '@/components/chat/MessageBubble'; import { TypingIndicator } from '@/components/chat/TypingIndicator'; import { useLMStudio } from '@/hooks/useLMStudio'; +import { useThemeColor } from '@/hooks/useThemeColor'; import { useAppStore } from '@/store/useAppStore'; import { MessageSquare, Sparkles } from 'lucide-react-native'; import React, { useCallback, useEffect, useRef } from 'react'; import { FlatList, KeyboardAvoidingView, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import Animated, { FadeInDown } from 'react-native-reanimated'; const PROMPT_SUGGESTIONS = [ 'Explain recursion in simple terms', @@ -15,6 +17,7 @@ const PROMPT_SUGGESTIONS = [ ]; export default function ChatScreen() { + const colors = useThemeColor(); // Dynamic theme const messages = useAppStore(state => state.messages); const editingMessageId = useAppStore(state => state.editingMessageId); const setEditingMessageId = useAppStore(state => state.setEditingMessageId); @@ -31,18 +34,20 @@ export default function ChatScreen() { const handleSend = useCallback((text: string) => { if (editingMessageId && editingMessage) { - // Edit mode: update message, delete all after, and resend editMessage(editingMessageId, text); - // Get updated messages, delete everything after the edited message deleteMessagesAfter(editingMessageId); setEditingMessageId(null); - // Now send the edited message for a new response - // Small delay to allow state update setTimeout(() => { const store = useAppStore.getState(); 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; if (!modelId) return; @@ -67,33 +72,22 @@ export default function ChatScreen() { } }, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, sendMessage]); - const handleEdit = useCallback((id: string) => { - setEditingMessageId(id); - }, [setEditingMessageId]); + const handleEdit = useCallback((id: string) => setEditingMessageId(id), [setEditingMessageId]); + const handleCancelEdit = useCallback(() => setEditingMessageId(null), [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 ? [...messages].reverse().find(m => m.role === 'assistant')?.id : null; - // Auto-scroll useEffect(() => { if (messages.length > 0 && flatListRef.current) { setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); + flatListRef.current?.scrollToEnd({ animated: !isStreaming }); }, 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 && messages.length > 0 && messages[messages.length - 1]?.role === 'assistant' && @@ -101,25 +95,25 @@ export default function ChatScreen() { return ( {!isConnected && ( - - - ⚠️ Not connected to LM Studio. Check Settings → Server Connection. + + + ⚠️ Server offline. Check Settings → Server Connection. )} {messages.length === 0 ? ( - - - + + + - Start a conversation - + Start a conversation + Send a message to begin chatting with your local AI model. @@ -127,12 +121,12 @@ export default function ChatScreen() { {PROMPT_SUGGESTIONS.map((prompt, i) => ( sendMessage(prompt)} activeOpacity={0.7} > - - {prompt} + + {prompt} ))} @@ -143,15 +137,17 @@ export default function ChatScreen() { ref={flatListRef} data={messages} keyExtractor={(item) => item.id} - renderItem={({ item }) => ( - handleEdit(item.id)} - onRegenerate={regenerateResponse} - onDelete={() => handleDelete(item.id)} - isStreaming={isStreaming} - isLastAssistant={item.id === lastAssistantId} - /> + renderItem={({ item, index }) => ( + messages.length - 4 ? 100 : 0)}> + handleEdit(item.id)} + onRegenerate={regenerateResponse} + onDelete={() => handleDelete(item.id)} + isStreaming={isStreaming} + isLastAssistant={item.id === lastAssistantId} + /> + )} contentContainerStyle={styles.listContent} /> @@ -173,23 +169,20 @@ export default function ChatScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#FFFFFF', }, listContent: { - paddingTop: 8, - paddingBottom: 16, + paddingTop: 16, + paddingBottom: 24, }, banner: { - backgroundColor: '#FEF3C7', paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, - borderBottomColor: '#FDE68A', }, bannerText: { fontSize: 13, - color: '#92400E', textAlign: 'center', + fontWeight: '500', }, emptyState: { flex: 1, @@ -199,46 +192,45 @@ const styles = StyleSheet.create({ paddingBottom: 80, }, emptyIconWrap: { - width: 72, - height: 72, - borderRadius: 36, - backgroundColor: '#F3F4F6', + width: 80, + height: 80, + borderRadius: 40, alignItems: 'center', justifyContent: 'center', - marginBottom: 16, + marginBottom: 20, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.05, + shadowRadius: 10, + elevation: 2, }, emptyTitle: { - fontSize: 20, + fontSize: 22, fontWeight: '700', - color: '#111827', marginBottom: 8, }, emptySubtitle: { fontSize: 15, - color: '#6B7280', textAlign: 'center', lineHeight: 22, - marginBottom: 28, + marginBottom: 32, }, suggestionsWrap: { width: '100%', maxWidth: 400, - gap: 8, + gap: 12, }, suggestionChip: { flexDirection: 'row', alignItems: 'center', - gap: 10, - backgroundColor: '#F9FAFB', + gap: 12, borderWidth: 1, - borderColor: '#E5E7EB', - borderRadius: 12, + borderRadius: 16, paddingHorizontal: 16, paddingVertical: 14, }, suggestionText: { - fontSize: 14, - color: '#374151', + fontSize: 15, flex: 1, + fontWeight: '500', }, }); diff --git a/app/settings/index.tsx b/app/settings/index.tsx index 083ddba..72ad0a4 100644 --- a/app/settings/index.tsx +++ b/app/settings/index.tsx @@ -1,7 +1,9 @@ import { ConnectionBadge } from '@/components/ui/ConnectionBadge'; +import { useThemeColor } from '@/hooks/useThemeColor'; import * as api from '@/services/lmStudioApi'; import { ServerConfig } from '@/services/types'; import { useAppStore } from '@/store/useAppStore'; +import { Info, MonitorCog, Moon, Network } from 'lucide-react-native'; import React, { useState } from 'react'; import { ActivityIndicator, @@ -12,28 +14,31 @@ import { TextInput, TouchableOpacity, useWindowDimensions, - View + View, } from 'react-native'; export default function SettingsScreen() { 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 serverConfig = useAppStore(state => state.serverConfig); const setServerConfig = useAppStore(state => state.setServerConfig); const isConnected = useAppStore(state => state.isConnected); const setIsConnected = useAppStore(state => state.setIsConnected); + // Local State const [host, setHost] = useState(serverConfig.host); const [port, setPort] = useState(serverConfig.port.toString()); const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? ''); const [isTesting, setIsTesting] = useState(false); const [testResult, setTestResult] = useState(null); - const isDarkMode = theme === 'dark' || theme === 'system'; - const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark'); - 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 = { host: host.trim() || 'localhost', @@ -64,114 +69,157 @@ export default function SettingsScreen() { return ( - {/* Server Connection */} - - - Server Connection - - + {/* Server Connection Section */} + + SERVER CONNECTION - - Host - - + + + + + LM Studio API + + + - - Port - - + - - API Key (optional) - - + + Host IP / Address + + - - {configChanged && ( - - Save - - )} - - {isTesting ? ( - - ) : ( - Test Connection + + Port Number + + + + + API Key (optional) + + + + + {configChanged && ( + + Save Changes + )} - - + + {isTesting ? ( + + ) : ( + Test Connection + )} + + - {testResult !== null && ( - - - {testResult - ? '✅ Connected successfully!' - : '❌ Connection failed. Check host, port, and if LM Studio is running.'} + {testResult !== null && ( + + + {testResult + ? '✅ Connected successfully! API is ready.' + : '❌ Connection failed. Check host, port, and ensure LM Studio is running.'} + + + )} + + + + {/* Appearance Section */} + + APPEARANCE + + + + + + + Dark Mode + Toggle sleek dark theme + + + + + + + {/* About Section */} + + ABOUT + + + + LocalAiApp - Cross Platform Version + + + + + Connected via OpenAPI `/v1/chat/completions` specs - )} - - - {/* Appearance */} - - Appearance - - - - Dark Mode - Toggle application theme - - - - {/* About */} - - About - LocalAiApp - Cross Platform Version - - Using OpenAI-compatible API (/v1) - - ); } @@ -179,7 +227,6 @@ export default function SettingsScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#F3F4F6', }, scrollContent: { padding: 16, @@ -189,129 +236,130 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingTop: 40, }, - card: { - backgroundColor: '#FFFFFF', - borderRadius: 12, - padding: 16, - marginBottom: 16, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.05, - shadowRadius: 8, - elevation: 2, + section: { + marginBottom: 24, }, - desktopCard: { + desktopSection: { width: '100%', 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', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, }, - sectionTitle: { + cardHeaderTitle: { + flexDirection: 'row', + alignItems: 'center', + }, + headerIcon: { + marginRight: 8, + }, + cardTitle: { fontSize: 18, fontWeight: '700', - color: '#111827', + }, + divider: { + height: 1, + width: '100%', + marginBottom: 16, }, inputGroup: { - marginBottom: 14, + marginBottom: 16, }, label: { fontSize: 14, fontWeight: '500', - color: '#374151', - marginBottom: 6, + marginBottom: 8, }, textInput: { - backgroundColor: '#F9FAFB', borderWidth: 1, - borderColor: '#E5E7EB', - borderRadius: 10, - paddingHorizontal: 14, - paddingVertical: 10, - fontSize: 15, - color: '#111827', + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, }, buttonRow: { flexDirection: 'row', - gap: 10, - marginTop: 4, + gap: 12, + marginTop: 8, }, saveButton: { flex: 1, - backgroundColor: '#F3F4F6', - paddingVertical: 12, - borderRadius: 10, + paddingVertical: 14, + borderRadius: 12, alignItems: 'center', borderWidth: 1, - borderColor: '#E5E7EB', }, saveButtonText: { fontSize: 15, fontWeight: '600', - color: '#374151', }, testButton: { flex: 2, - backgroundColor: '#000000', - paddingVertical: 12, - borderRadius: 10, + paddingVertical: 14, + borderRadius: 12, alignItems: 'center', }, testButtonDisabled: { - opacity: 0.6, + opacity: 0.7, }, testButtonText: { fontSize: 15, fontWeight: '600', - color: '#FFFFFF', }, resultBox: { - marginTop: 12, - borderRadius: 8, - padding: 12, - }, - resultSuccess: { - backgroundColor: '#F0FDF4', - }, - resultError: { - backgroundColor: '#FEF2F2', + marginTop: 16, + borderRadius: 12, + padding: 14, }, resultText: { fontSize: 14, textAlign: 'center', - }, - resultTextSuccess: { - color: '#166534', - }, - resultTextError: { - color: '#991B1B', + fontWeight: '500', }, row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingVertical: 8, + paddingVertical: 4, + }, + rowIconWrap: { + marginRight: 12, }, rowText: { flex: 1, - paddingRight: 16, }, rowTitle: { fontSize: 16, - fontWeight: '500', - color: '#111827', + fontWeight: '600', marginBottom: 4, }, rowSubtitle: { fontSize: 14, - color: '#6B7280', + }, + rowGroup: { + flexDirection: 'row', + alignItems: 'center', + }, + infoIcon: { + marginRight: 10, }, infoText: { fontSize: 15, lineHeight: 22, - color: '#4B5563', }, }); diff --git a/components/chat/ChatInput.tsx b/components/chat/ChatInput.tsx index ee07c8f..761ee70 100644 --- a/components/chat/ChatInput.tsx +++ b/components/chat/ChatInput.tsx @@ -1,3 +1,4 @@ +import { useThemeColor } from '@/hooks/useThemeColor'; import * as Haptics from 'expo-haptics'; import { Send, Square, X } from 'lucide-react-native'; import React, { useEffect, useState } from 'react'; @@ -20,6 +21,7 @@ interface ChatInputProps { export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit }: ChatInputProps) { const [text, setText] = useState(''); + const colors = useThemeColor(); // When editingText changes, fill the input useEffect(() => { @@ -58,23 +60,27 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit const isEditing = editingText !== null && editingText !== undefined; return ( - + {/* Edit mode banner */} {isEditing && ( - - Editing message + + Editing message - + )} - + - + ) : ( - + )} @@ -109,9 +118,7 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit const styles = StyleSheet.create({ outerContainer: { - backgroundColor: '#FFFFFF', borderTopWidth: 1, - borderTopColor: '#E5E7EB', }, editBanner: { flexDirection: 'row', @@ -119,13 +126,10 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 8, - backgroundColor: '#EFF6FF', borderBottomWidth: 1, - borderBottomColor: '#DBEAFE', }, editBannerText: { fontSize: 13, - color: '#2563EB', fontWeight: '500', }, editCancelBtn: { @@ -138,43 +142,32 @@ const styles = StyleSheet.create({ inputWrapper: { flexDirection: 'row', alignItems: 'flex-end', - backgroundColor: '#F3F4F6', borderRadius: 24, paddingHorizontal: 16, paddingVertical: 6, minHeight: 44, maxHeight: 120, }, - inputWrapperEditing: { - borderWidth: 1.5, - borderColor: '#2563EB', - }, input: { flex: 1, fontSize: 15, lineHeight: 22, paddingTop: 8, paddingBottom: 8, - color: '#111827', }, sendButton: { width: 34, height: 34, borderRadius: 17, - backgroundColor: '#111827', alignItems: 'center', justifyContent: 'center', marginLeft: 8, marginBottom: 2, }, - sendButtonDisabled: { - backgroundColor: '#E5E7EB', - }, stopButton: { width: 34, height: 34, borderRadius: 17, - backgroundColor: '#EF4444', alignItems: 'center', justifyContent: 'center', marginLeft: 8, diff --git a/components/chat/MessageActions.tsx b/components/chat/MessageActions.tsx index d5fe5ce..9e0804a 100644 --- a/components/chat/MessageActions.tsx +++ b/components/chat/MessageActions.tsx @@ -1,3 +1,4 @@ +import { useThemeColor } from '@/hooks/useThemeColor'; import * as Clipboard from 'expo-clipboard'; import * as Haptics from 'expo-haptics'; import { Copy, Pencil, RefreshCw, Trash2 } from 'lucide-react-native'; @@ -21,6 +22,8 @@ export function MessageActions({ onDelete, isStreaming, }: MessageActionsProps) { + const colors = useThemeColor(); + const handleCopy = async () => { await Clipboard.setStringAsync(content); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); @@ -48,24 +51,24 @@ export function MessageActions({ return ( - + {isUser && onEdit && ( - + )} {!isUser && onRegenerate && ( - + )} {onDelete && ( - + )} @@ -75,8 +78,8 @@ export function MessageActions({ const styles = StyleSheet.create({ container: { flexDirection: 'row', - gap: 2, - marginTop: 4, + gap: 6, + marginTop: 6, paddingHorizontal: 16, }, containerUser: { @@ -86,9 +89,9 @@ const styles = StyleSheet.create({ justifyContent: 'flex-start', }, actionBtn: { - width: 32, - height: 32, - borderRadius: 8, + width: 36, + height: 36, + borderRadius: 18, alignItems: 'center', justifyContent: 'center', backgroundColor: 'transparent', diff --git a/components/chat/MessageBubble.tsx b/components/chat/MessageBubble.tsx index 4bfbdd5..24c1fb1 100644 --- a/components/chat/MessageBubble.tsx +++ b/components/chat/MessageBubble.tsx @@ -1,8 +1,10 @@ +import { useThemeColor } from '@/hooks/useThemeColor'; import { ChatMessage } from '@/store/useAppStore'; import Markdown from '@ronradtke/react-native-markdown-display'; -import { Bot, User } from 'lucide-react-native'; -import React, { useState } from 'react'; -import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { Bot, Check, Copy, User } from 'lucide-react-native'; +import React, { useMemo, useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native'; import { MessageActions } from './MessageActions'; interface MessageBubbleProps { @@ -23,17 +25,156 @@ export function MessageBubble({ isLastAssistant, }: MessageBubbleProps) { const { width } = useWindowDimensions(); + const colors = useThemeColor(); const isLargeScreen = width > 768; const isUser = message.role === 'user'; const [showActions, setShowActions] = useState(false); + const [copiedText, setCopiedText] = useState(null); + + const handleCopy = async (text: string) => { + await Clipboard.setStringAsync(text); + setCopiedText(text); + setTimeout(() => setCopiedText(null), 2000); + }; const showingStreaming = isStreaming && isLastAssistant; 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 ( + + + {language} + handleCopy(content)} style={styles.copyButton}> + {copiedText === content ? : } + {copiedText === content ? 'Copied!' : 'Copy'} + + + + {content} + + + ); + }, + code_block: (node: any, children: any, parent: any, parentStyles: any) => { + const content = node.content || ''; + return ( + + + code + handleCopy(content)} style={styles.copyButton}> + {copiedText === content ? : } + {copiedText === content ? 'Copied!' : 'Copy'} + + + + {content} + + + ); + } + }), [copiedText]); + const renderContent = () => { if (isUser) { return ( - + {message.content} ); @@ -44,7 +185,7 @@ export function MessageBubble({ } return ( - + {message.content} ); @@ -53,6 +194,7 @@ export function MessageBubble({ return ( isUser && setShowActions(true)} + style={styles.outerWrap} > {/* Avatar */} {!isUser && ( - - + + )} @@ -69,13 +211,15 @@ export function MessageBubble({ styles.bubble, isUser ? styles.bubbleUser : styles.bubbleAssistant, { maxWidth: isLargeScreen ? '70%' : '85%' }, + { backgroundColor: isUser ? colors.messageUserBg : colors.messageAssistantBg }, + !isUser && { borderWidth: 1, borderColor: colors.border } ]}> {renderContent()} {isUser && ( - - + + )} @@ -83,7 +227,7 @@ export function MessageBubble({ {/* Timestamp + edited badge */} {!isUser && } - + {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {message.isEdited ? ' · edited' : ''} @@ -105,10 +249,13 @@ export function MessageBubble({ } const styles = StyleSheet.create({ + outerWrap: { + width: '100%', + marginBottom: 12, + }, container: { width: '100%', paddingHorizontal: 16, - marginTop: 12, flexDirection: 'row', alignItems: 'flex-end', gap: 8, @@ -120,146 +267,94 @@ const styles = StyleSheet.create({ justifyContent: 'flex-start', }, avatarAssistant: { - width: 28, - height: 28, - borderRadius: 14, - backgroundColor: '#F3F4F6', + width: 32, + height: 32, + borderRadius: 16, alignItems: 'center', justifyContent: 'center', marginBottom: 2, }, avatarUser: { - width: 28, - height: 28, - borderRadius: 14, - backgroundColor: '#374151', + width: 32, + height: 32, + borderRadius: 16, alignItems: 'center', justifyContent: 'center', marginBottom: 2, }, bubble: { - paddingHorizontal: 14, - paddingVertical: 10, - borderRadius: 18, + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 20, + // CRITICAL FOR MARKDOWN IN FLEX ROW: + flexShrink: 1, }, bubbleUser: { - backgroundColor: '#111827', - borderBottomRightRadius: 4, + borderBottomRightRadius: 6, }, bubbleAssistant: { - backgroundColor: '#F3F4F6', - borderBottomLeftRadius: 4, + borderBottomLeftRadius: 6, }, text: { fontSize: 15, lineHeight: 22, }, - textUser: { - color: '#FFFFFF', - }, metaRow: { flexDirection: 'row', paddingHorizontal: 16, - marginTop: 2, + marginTop: 4, gap: 8, }, metaRowUser: { justifyContent: 'flex-end', - paddingRight: 52, + paddingRight: 56, }, metaRowAssistant: { justifyContent: 'flex-start', }, timestamp: { fontSize: 11, - color: '#9CA3AF', + fontWeight: '500', }, -}); - -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, + codeBlockWrapper: { + backgroundColor: '#1E1E1E', borderRadius: 8, - fontSize: 13, - fontFamily: 'monospace', marginVertical: 8, overflow: 'hidden', + width: '100%', }, - code_block: { - backgroundColor: '#1F2937', - color: '#E5E7EB', + codeBlockHeader: { + flexDirection: 'row', + 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, - borderRadius: 8, + }, + codeText: { + color: '#D4D4D4', fontSize: 13, 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, }, }); diff --git a/components/chat/ModelSelector.tsx b/components/chat/ModelSelector.tsx index cf109ac..ddefbce 100644 --- a/components/chat/ModelSelector.tsx +++ b/components/chat/ModelSelector.tsx @@ -1,5 +1,6 @@ import { ConnectionBadge } from '@/components/ui/ConnectionBadge'; import { useLMStudio } from '@/hooks/useLMStudio'; +import { useThemeColor } from '@/hooks/useThemeColor'; import { useAppStore } from '@/store/useAppStore'; import { Check, ChevronDown, RefreshCw } from 'lucide-react-native'; import React, { useState } from 'react'; @@ -20,6 +21,7 @@ export function ModelSelector() { const setActiveModelId = useAppStore(state => state.setActiveModelId); const { availableModels, isConnected, fetchModels } = useLMStudio(); const [isRefreshing, setIsRefreshing] = useState(false); + const colors = useThemeColor(); const handleSelect = (modelId: string) => { setActiveModelId(modelId); @@ -37,15 +39,15 @@ export function ModelSelector() { return ( <> setModalVisible(true)} activeOpacity={0.7} > - + {activeModelName} - + setModalVisible(false)} > - true}> - + true}> + - Select Model + Select Model {isRefreshing ? ( - + ) : ( - + )} {!isConnected && ( - - + + Not connected to LM Studio. Configure server in Settings. )} {availableModels.length === 0 && isConnected && ( - - + + No models loaded. Load a model in LM Studio first. @@ -100,25 +102,29 @@ export function ModelSelector() { const isSelected = item.id === activeModelId; return ( handleSelect(item.id)} > - + {item.id} - - API + + API {item.owned_by && ( - - {item.owned_by} + + {item.owned_by} )} - {isSelected && } + {isSelected && } ); }} @@ -137,14 +143,12 @@ const styles = StyleSheet.create({ paddingVertical: 8, paddingHorizontal: 12, borderRadius: 8, - backgroundColor: '#F3F4F6', gap: 8, maxWidth: 280, }, headerText: { fontSize: 14, fontWeight: '600', - color: '#111827', flexShrink: 1, }, iconOffset: { @@ -152,11 +156,10 @@ const styles = StyleSheet.create({ }, modalOverlay: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.4)', + backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end', }, bottomSheet: { - backgroundColor: '#FFFFFF', borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, @@ -181,7 +184,6 @@ const styles = StyleSheet.create({ dragHandle: { width: 40, height: 4, - backgroundColor: '#E5E7EB', borderRadius: 2, alignSelf: 'center', marginBottom: 16, @@ -196,33 +198,27 @@ const styles = StyleSheet.create({ sheetTitle: { fontSize: 20, fontWeight: '700', - color: '#111827', }, refreshButton: { padding: 8, borderRadius: 8, - backgroundColor: '#F3F4F6', }, warningBox: { - backgroundColor: '#FEF3C7', borderRadius: 8, padding: 12, marginBottom: 16, }, warningText: { fontSize: 13, - color: '#92400E', textAlign: 'center', }, emptyBox: { - backgroundColor: '#F3F4F6', borderRadius: 8, padding: 16, marginBottom: 16, }, emptyText: { fontSize: 14, - color: '#6B7280', textAlign: 'center', }, itemRow: { @@ -233,13 +229,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, borderRadius: 12, marginBottom: 8, - backgroundColor: '#FFFFFF', borderWidth: 1, - borderColor: '#E5E7EB', - }, - itemRowSelected: { - borderColor: '#000000', - backgroundColor: '#F9FAFB', }, itemContent: { flex: 1, @@ -248,29 +238,20 @@ const styles = StyleSheet.create({ itemName: { fontSize: 15, fontWeight: '600', - color: '#111827', marginBottom: 6, }, - itemNameSelected: { - color: '#000000', - }, badgeRow: { flexDirection: 'row', gap: 6, }, badge: { alignSelf: 'flex-start', - backgroundColor: '#DBEAFE', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, }, - badgeSecondary: { - backgroundColor: '#F3F4F6', - }, badgeText: { fontSize: 11, fontWeight: '500', - color: '#4B5563', }, }); diff --git a/components/chat/TypingIndicator.tsx b/components/chat/TypingIndicator.tsx index 0513c9d..8b10132 100644 --- a/components/chat/TypingIndicator.tsx +++ b/components/chat/TypingIndicator.tsx @@ -1,14 +1,17 @@ +import { useThemeColor } from '@/hooks/useThemeColor'; import React from 'react'; import { StyleSheet, View } from 'react-native'; export function TypingIndicator() { + const colors = useThemeColor(); + return ( - + - - - + + + @@ -24,7 +27,6 @@ const styles = StyleSheet.create({ justifyContent: 'flex-start', }, bubble: { - backgroundColor: '#F3F4F6', borderRadius: 20, borderTopLeftRadius: 4, paddingHorizontal: 16, @@ -39,7 +41,6 @@ const styles = StyleSheet.create({ width: 8, height: 8, borderRadius: 4, - backgroundColor: '#9CA3AF', opacity: 0.6, }, dot1: { opacity: 1.0 }, diff --git a/components/ui/ConnectionBadge.tsx b/components/ui/ConnectionBadge.tsx index c1a8d87..a8003e6 100644 --- a/components/ui/ConnectionBadge.tsx +++ b/components/ui/ConnectionBadge.tsx @@ -1,3 +1,4 @@ +import { useThemeColor } from '@/hooks/useThemeColor'; import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; @@ -7,11 +8,19 @@ interface ConnectionBadgeProps { } export function ConnectionBadge({ isConnected, compact }: ConnectionBadgeProps) { + const colors = useThemeColor(); + return ( - + {!compact && ( - + {isConnected ? 'Connected' : 'Disconnected'} )} @@ -30,20 +39,8 @@ const styles = StyleSheet.create({ height: 8, borderRadius: 4, }, - dotConnected: { - backgroundColor: '#22C55E', - }, - dotDisconnected: { - backgroundColor: '#EF4444', - }, text: { fontSize: 12, fontWeight: '500', }, - textConnected: { - color: '#22C55E', - }, - textDisconnected: { - color: '#EF4444', - }, }); diff --git a/constants/Colors.ts b/constants/Colors.ts new file mode 100644 index 0000000..1db2cd0 --- /dev/null +++ b/constants/Colors.ts @@ -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', + }, +}; diff --git a/constants/SystemPrompt.ts b/constants/SystemPrompt.ts new file mode 100644 index 0000000..0edbf72 --- /dev/null +++ b/constants/SystemPrompt.ts @@ -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.`; diff --git a/hooks/useLMStudio.ts b/hooks/useLMStudio.ts index ca1c8b2..e58d3ca 100644 --- a/hooks/useLMStudio.ts +++ b/hooks/useLMStudio.ts @@ -1,4 +1,5 @@ import { useCallback, useRef } from 'react'; +import { SYSTEM_PROMPT } from '../constants/SystemPrompt'; import * as api from '../services/lmStudioApi'; import { ChatMessage as ApiMessage } from '../services/types'; import { useAppStore } from '../store/useAppStore'; @@ -48,10 +49,13 @@ export function useLMStudio() { addMessage({ role: 'user', content: text }); const currentMessages = useAppStore.getState().messages; - const apiMessages: ApiMessage[] = currentMessages.map((m) => ({ - role: m.role, - content: m.content, - })); + const apiMessages: ApiMessage[] = [ + { role: 'system', content: SYSTEM_PROMPT }, + ...currentMessages.map((m) => ({ + role: m.role, + content: m.content, + })) + ]; const modelId = useAppStore.getState().activeModelId; if (!modelId) { @@ -112,10 +116,13 @@ export function useLMStudio() { // Build API messages from remaining const remaining = useAppStore.getState().messages; - const apiMessages: ApiMessage[] = remaining.map((m) => ({ - role: m.role, - content: m.content, - })); + const apiMessages: ApiMessage[] = [ + { role: 'system', content: SYSTEM_PROMPT }, + ...remaining.map((m) => ({ + role: m.role, + content: m.content, + })) + ]; const modelId = useAppStore.getState().activeModelId; if (!modelId) return; diff --git a/hooks/useThemeColor.ts b/hooks/useThemeColor.ts new file mode 100644 index 0000000..e8260e2 --- /dev/null +++ b/hooks/useThemeColor.ts @@ -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]; +}