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 { 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 (
<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.Screen
name="index"
options={{
headerTitle: () => <ModelSelector />,
headerShadowVisible: false,
headerStyle: { backgroundColor: colors.backgroundColor },
headerLeft: () => (
<TouchableOpacity
onPress={clearChat}
style={styles.headerBtn}
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
<Plus size={22} color="#111827" />
<Plus size={22} color={colors.text} />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
onPress={() => router.push('/settings')}
style={styles.headerBtn}
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
<Settings size={22} color="#111827" />
<Settings size={22} color={colors.text} />
</TouchableOpacity>
),
}}
@@ -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',
+56 -64
View File
@@ -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 (
<KeyboardAvoidingView
style={styles.container}
style={[styles.container, { backgroundColor: colors.backgroundColor }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
{!isConnected && (
<View style={styles.banner}>
<Text style={styles.bannerText}>
⚠️ Not connected to LM Studio. Check Settings → Server Connection.
<View style={[styles.banner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.bannerBg }]}>
<Text style={[styles.bannerText, { color: colors.bannerText }]}>
⚠️ Server offline. Check Settings → Server Connection.
</Text>
</View>
)}
{messages.length === 0 ? (
<View style={styles.emptyState}>
<View style={styles.emptyIconWrap}>
<MessageSquare size={40} color="#D1D5DB" />
<View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.emptyIconWrap, { backgroundColor: colors.cardBackground, shadowColor: colors.shadow }]}>
<MessageSquare size={40} color={colors.textMuted} />
</View>
<Text style={styles.emptyTitle}>Start a conversation</Text>
<Text style={styles.emptySubtitle}>
<Text style={[styles.emptyTitle, { color: colors.text }]}>Start a conversation</Text>
<Text style={[styles.emptySubtitle, { color: colors.textMuted }]}>
Send a message to begin chatting with your local AI model.
</Text>
@@ -127,12 +121,12 @@ export default function ChatScreen() {
{PROMPT_SUGGESTIONS.map((prompt, i) => (
<TouchableOpacity
key={i}
style={styles.suggestionChip}
style={[styles.suggestionChip, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}
onPress={() => sendMessage(prompt)}
activeOpacity={0.7}
>
<Sparkles size={14} color="#6B7280" />
<Text style={styles.suggestionText} numberOfLines={1}>{prompt}</Text>
<Sparkles size={14} color={colors.textMuted} />
<Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>{prompt}</Text>
</TouchableOpacity>
))}
</View>
@@ -143,15 +137,17 @@ export default function ChatScreen() {
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<MessageBubble
message={item}
onEdit={() => handleEdit(item.id)}
onRegenerate={regenerateResponse}
onDelete={() => handleDelete(item.id)}
isStreaming={isStreaming}
isLastAssistant={item.id === lastAssistantId}
/>
renderItem={({ item, index }) => (
<Animated.View entering={FadeInDown.duration(300).delay(index > messages.length - 4 ? 100 : 0)}>
<MessageBubble
message={item}
onEdit={() => handleEdit(item.id)}
onRegenerate={regenerateResponse}
onDelete={() => handleDelete(item.id)}
isStreaming={isStreaming}
isLastAssistant={item.id === lastAssistantId}
/>
</Animated.View>
)}
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',
},
});
+204 -156
View File
@@ -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<boolean | null>(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 (
<ScrollView
style={styles.container}
style={[styles.container, { backgroundColor: colors.backgroundColor }]}
contentContainerStyle={[
styles.scrollContent,
isLargeScreen && styles.desktopContent,
]}
>
{/* Server Connection */}
<View style={[styles.card, isLargeScreen && styles.desktopCard]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Server Connection</Text>
<ConnectionBadge isConnected={isConnected} />
</View>
{/* Server Connection Section */}
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>SERVER CONNECTION</Text>
<View style={styles.inputGroup}>
<Text style={styles.label}>Host</Text>
<TextInput
style={styles.textInput}
value={host}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor="#9CA3AF"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.cardHeader}>
<View style={styles.cardHeaderTitle}>
<Network size={20} color={colors.text} style={styles.headerIcon} />
<Text style={[styles.cardTitle, { color: colors.text }]}>LM Studio API</Text>
</View>
<ConnectionBadge isConnected={isConnected} />
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Port</Text>
<TextInput
style={styles.textInput}
value={port}
onChangeText={setPort}
placeholder="1234"
placeholderTextColor="#9CA3AF"
keyboardType="numeric"
/>
</View>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<View style={styles.inputGroup}>
<Text style={styles.label}>API Key (optional)</Text>
<TextInput
style={styles.textInput}
value={apiKey}
onChangeText={setApiKey}
placeholder="Leave empty for local"
placeholderTextColor="#9CA3AF"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>Host IP / Address</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={host}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.buttonRow}>
{configChanged && (
<TouchableOpacity style={styles.saveButton} onPress={handleSaveConfig}>
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.testButton, isTesting && styles.testButtonDisabled]}
onPress={handleTestConnection}
disabled={isTesting}
>
{isTesting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.testButtonText}>Test Connection</Text>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>Port Number</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={port}
onChangeText={setPort}
placeholder="1234"
placeholderTextColor={colors.textMuted}
keyboardType="numeric"
/>
</View>
<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>
</View>
<TouchableOpacity
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 && (
<View style={[styles.resultBox, testResult ? styles.resultSuccess : styles.resultError]}>
<Text style={[styles.resultText, testResult ? styles.resultTextSuccess : styles.resultTextError]}>
{testResult
? '✅ Connected successfully!'
: '❌ Connection failed. Check host, port, and if LM Studio is running.'}
{testResult !== null && (
<View style={[
styles.resultBox,
{ backgroundColor: testResult ? colors.successBg : colors.errorBg }
]}>
<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>
</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>
{/* 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>
);
}
@@ -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',
},
});
+20 -27
View File
@@ -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 (
<View style={styles.outerContainer}>
<View style={[styles.outerContainer, { backgroundColor: colors.backgroundColor, borderTopColor: colors.border }]}>
{/* Edit mode banner */}
{isEditing && (
<View style={styles.editBanner}>
<Text style={styles.editBannerText}>Editing message</Text>
<View style={[styles.editBanner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.border }]}>
<Text style={[styles.editBannerText, { color: colors.bannerText }]}>Editing message</Text>
<TouchableOpacity onPress={handleCancelEdit} style={styles.editCancelBtn}>
<X size={16} color="#6B7280" />
<X size={16} color={colors.textMuted} />
</TouchableOpacity>
</View>
)}
<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
style={styles.input}
style={[styles.input, { color: colors.text }]}
placeholder="Send a message..."
placeholderTextColor="#9CA3AF"
placeholderTextColor={colors.textMuted}
value={text}
onChangeText={setText}
multiline
@@ -85,20 +91,23 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
{/* Send or Stop button */}
{isLoading ? (
<TouchableOpacity
style={styles.stopButton}
style={[styles.stopButton, { backgroundColor: colors.errorText }]}
onPress={handleStop}
activeOpacity={0.7}
>
<Square size={16} color="#FFFFFF" />
<Square size={16} color={colors.backgroundColor} />
</TouchableOpacity>
) : (
<TouchableOpacity
style={[styles.sendButton, !text.trim() && styles.sendButtonDisabled]}
style={[
styles.sendButton,
{ backgroundColor: text.trim() ? colors.tint : colors.border }
]}
onPress={handleSend}
disabled={!text.trim()}
activeOpacity={0.7}
>
<Send size={18} color={text.trim() ? '#FFFFFF' : '#9CA3AF'} />
<Send size={18} color={text.trim() ? colors.backgroundColor : colors.textMuted} />
</TouchableOpacity>
)}
</View>
@@ -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,
+12 -9
View File
@@ -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 (
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6}>
<Copy size={14} color="#9CA3AF" />
<Copy size={16} color={colors.textMuted} />
</TouchableOpacity>
{isUser && onEdit && (
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6}>
<Pencil size={14} color="#9CA3AF" />
<Pencil size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
{!isUser && onRegenerate && (
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6}>
<RefreshCw size={14} color="#9CA3AF" />
<RefreshCw size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6}>
<Trash2 size={14} color="#9CA3AF" />
<Trash2 size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
</View>
@@ -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',
+207 -112
View File
@@ -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<string | null>(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 (
<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 = () => {
if (isUser) {
return (
<Text style={[styles.text, styles.textUser]}>
<Text style={[styles.text, { color: colors.messageUserText }]}>
{message.content}
</Text>
);
@@ -44,7 +185,7 @@ export function MessageBubble({
}
return (
<Markdown style={markdownStyles}>
<Markdown style={markdownStyles} rules={markdownRules}>
{message.content}
</Markdown>
);
@@ -53,6 +194,7 @@ export function MessageBubble({
return (
<View
onTouchStart={() => isUser && setShowActions(true)}
style={styles.outerWrap}
>
<View style={[
styles.container,
@@ -60,8 +202,8 @@ export function MessageBubble({
]}>
{/* Avatar */}
{!isUser && (
<View style={styles.avatarAssistant}>
<Bot size={16} color="#6B7280" />
<View style={[styles.avatarAssistant, { backgroundColor: colors.border }]}>
<Bot size={16} color={colors.text} />
</View>
)}
@@ -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()}
</View>
{isUser && (
<View style={styles.avatarUser}>
<User size={16} color="#FFFFFF" />
<View style={[styles.avatarUser, { backgroundColor: colors.tint }]}>
<User size={16} color={colors.backgroundColor} />
</View>
)}
</View>
@@ -83,7 +227,7 @@ export function MessageBubble({
{/* Timestamp + edited badge */}
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{!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' })}
{message.isEdited ? ' · edited' : ''}
</Text>
@@ -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,
},
});
+27 -46
View File
@@ -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 (
<>
<TouchableOpacity
style={styles.headerButton}
style={[styles.headerButton, { backgroundColor: colors.cardBackground }]}
onPress={() => setModalVisible(true)}
activeOpacity={0.7}
>
<ConnectionBadge isConnected={isConnected} compact />
<Text style={styles.headerText} numberOfLines={1}>
<Text style={[styles.headerText, { color: colors.text }]} numberOfLines={1}>
{activeModelName}
</Text>
<ChevronDown size={16} color="#111827" style={styles.iconOffset} />
<ChevronDown size={16} color={colors.text} style={styles.iconOffset} />
</TouchableOpacity>
<Modal
@@ -59,35 +61,35 @@ export function ModelSelector() {
activeOpacity={1}
onPressOut={() => setModalVisible(false)}
>
<View style={styles.bottomSheet} onStartShouldSetResponder={() => true}>
<View style={styles.dragHandle} />
<View style={[styles.bottomSheet, { backgroundColor: colors.backgroundColor }]} onStartShouldSetResponder={() => true}>
<View style={[styles.dragHandle, { backgroundColor: colors.border }]} />
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Select Model</Text>
<Text style={[styles.sheetTitle, { color: colors.text }]}>Select Model</Text>
<TouchableOpacity
onPress={handleRefresh}
style={styles.refreshButton}
style={[styles.refreshButton, { backgroundColor: colors.cardBackground }]}
disabled={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>
</View>
{!isConnected && (
<View style={styles.warningBox}>
<Text style={styles.warningText}>
<View style={[styles.warningBox, { backgroundColor: colors.bannerBg }]}>
<Text style={[styles.warningText, { color: colors.bannerText }]}>
Not connected to LM Studio. Configure server in Settings.
</Text>
</View>
)}
{availableModels.length === 0 && isConnected && (
<View style={styles.emptyBox}>
<Text style={styles.emptyText}>
<View style={[styles.emptyBox, { backgroundColor: colors.cardBackground }]}>
<Text style={[styles.emptyText, { color: colors.textMuted }]}>
No models loaded. Load a model in LM Studio first.
</Text>
</View>
@@ -100,25 +102,29 @@ export function ModelSelector() {
const isSelected = item.id === activeModelId;
return (
<TouchableOpacity
style={[styles.itemRow, isSelected && styles.itemRowSelected]}
style={[
styles.itemRow,
{ backgroundColor: colors.cardBackground, borderColor: colors.border },
isSelected && { borderColor: colors.tint }
]}
onPress={() => handleSelect(item.id)}
>
<View style={styles.itemContent}>
<Text style={[styles.itemName, isSelected && styles.itemNameSelected]}>
<Text style={[styles.itemName, { color: colors.text }]}>
{item.id}
</Text>
<View style={styles.badgeRow}>
<View style={styles.badge}>
<Text style={styles.badgeText}>API</Text>
<View style={[styles.badge, { backgroundColor: colors.inputBg }]}>
<Text style={[styles.badgeText, { color: colors.textMuted }]}>API</Text>
</View>
{item.owned_by && (
<View style={[styles.badge, styles.badgeSecondary]}>
<Text style={styles.badgeText}>{item.owned_by}</Text>
<View style={[styles.badge, { backgroundColor: colors.inputBg }]}>
<Text style={[styles.badgeText, { color: colors.textMuted }]}>{item.owned_by}</Text>
</View>
)}
</View>
</View>
{isSelected && <Check size={20} color="#000000" />}
{isSelected && <Check size={20} color={colors.tint} />}
</TouchableOpacity>
);
}}
@@ -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',
},
});
+7 -6
View File
@@ -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 (
<View style={styles.container}>
<View style={styles.bubble}>
<View style={[styles.bubble, { backgroundColor: colors.messageAssistantBg }]}>
<View style={styles.dotsRow}>
<View style={[styles.dot, styles.dot1]} />
<View style={[styles.dot, styles.dot2]} />
<View style={[styles.dot, styles.dot3]} />
<View style={[styles.dot, styles.dot1, { backgroundColor: colors.textMuted }]} />
<View style={[styles.dot, styles.dot2, { backgroundColor: colors.textMuted }]} />
<View style={[styles.dot, styles.dot3, { backgroundColor: colors.textMuted }]} />
</View>
</View>
</View>
@@ -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 },
+11 -14
View File
@@ -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 (
<View style={styles.container}>
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
<View style={[
styles.dot,
{ backgroundColor: isConnected ? colors.successText : colors.errorText }
]} />
{!compact && (
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
<Text style={[
styles.text,
{ color: isConnected ? colors.successText : colors.errorText }
]}>
{isConnected ? 'Connected' : 'Disconnected'}
</Text>
)}
@@ -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',
},
});
+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 { 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;
+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];
}