wip: snapshot das alterações não commitadas (2026-10-20)

Origem: D:/Documents/Github/LocalAiApp
Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 0
This commit is contained in:
evandro committed 2026-10-20 00:48:09 -03:00
1 parent ac215b4229
commit e08189f640
17 files changed
+2914 -13236

No files matched your search

+27 -1
View File
@@ -1,6 +1,7 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import * as Haptics from 'expo-haptics';
import { Send, Square, X } from 'lucide-react-native';
import { Globe, Send, Square, X } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import {
Platform,
@@ -22,6 +23,8 @@ interface ChatInputProps {
export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit }: ChatInputProps) {
const [text, setText] = useState('');
const colors = useThemeColor();
const isWebSearchEnabled = useAppStore(state => state.isWebSearchEnabled);
const setIsWebSearchEnabled = useAppStore(state => state.setIsWebSearchEnabled);
// When editingText changes, fill the input
useEffect(() => {
@@ -77,6 +80,20 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
{ backgroundColor: colors.inputBg, borderColor: colors.border, borderWidth: 1 },
isEditing && { borderColor: colors.tint, borderWidth: 1.5 }
]}>
<TouchableOpacity
style={styles.webSearchButton}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setIsWebSearchEnabled(!isWebSearchEnabled);
}}
activeOpacity={0.7}
>
<Globe
size={20}
color={isWebSearchEnabled ? colors.tint : colors.textMuted}
/>
</TouchableOpacity>
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Send a message..."
@@ -164,6 +181,15 @@ const styles = StyleSheet.create({
marginLeft: 8,
marginBottom: 2,
},
webSearchButton: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
marginRight: 4,
marginBottom: 2,
},
stopButton: {
width: 34,
height: 34,
+90 -9
View File
@@ -3,9 +3,10 @@ import { ChatMessage } from '@/store/useAppStore';
import Markdown from '@ronradtke/react-native-markdown-display';
import * as Clipboard from 'expo-clipboard';
import { Bot, Check, Copy, User } from 'lucide-react-native';
import React, { useMemo, useState } from 'react';
import React, { useMemo, useState, useEffect } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
import { MessageActions } from './MessageActions';
import { ThinkingBlock } from './ThinkingBlock';
interface MessageBubbleProps {
message: ChatMessage;
@@ -40,6 +41,37 @@ export function MessageBubble({
const showingStreaming = isStreaming && isLastAssistant;
const shouldShowActions = !isUser || showActions;
const [displayedContent, setDisplayedContent] = useState(message.content);
useEffect(() => {
if (!showingStreaming) {
setDisplayedContent(message.content);
return;
}
let animationFrameId: number;
let currentText = displayedContent;
const targetText = message.content;
if (targetText.length < currentText.length) {
setDisplayedContent(targetText);
return;
}
const animate = () => {
if (currentText.length < targetText.length) {
const diff = targetText.length - currentText.length;
const charsToAdd = Math.max(1, Math.ceil(diff / 4));
currentText = targetText.slice(0, currentText.length + charsToAdd);
setDisplayedContent(currentText);
animationFrameId = requestAnimationFrame(animate);
}
};
animationFrameId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrameId);
}, [message.content, showingStreaming]);
// UseMemo for dynamic colors
const markdownStyles = useMemo(() => ({
body: {
@@ -172,22 +204,56 @@ export function MessageBubble({
}), [copiedText]);
const renderContent = () => {
let activeContent = showingStreaming ? displayedContent : message.content;
if (showingStreaming && displayedContent === '' && message.content !== '') {
// Prevent empty bubble flicker on the first chunk
activeContent = message.content.slice(0, 1);
}
if (isUser) {
return (
<Text style={[styles.text, { color: colors.messageUserText }]}>
{message.content}
{activeContent}
</Text>
);
}
if (!message.content && showingStreaming) {
return null; // TypingIndicator handled externally
if (!activeContent && showingStreaming) {
return (
<View style={styles.dotsRow}>
<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>
);
}
const thinkMatch = activeContent.match(/<think>([\s\S]*?)(?:<\/think>|$)/);
let cleanContent = activeContent;
let thinkingContent = null;
let isThinkingComplete = false;
if (thinkMatch) {
thinkingContent = thinkMatch[1].trim();
isThinkingComplete = activeContent.includes('</think>');
cleanContent = activeContent.replace(thinkMatch[0], '').trim();
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
{message.content}
</Markdown>
<>
{thinkingContent !== null && (
<ThinkingBlock
content={thinkingContent}
isComplete={isThinkingComplete}
isStreaming={showingStreaming}
/>
)}
{cleanContent.length > 0 && (
<Markdown style={markdownStyles} rules={markdownRules}>
{cleanContent}
</Markdown>
)}
</>
);
};
@@ -234,10 +300,10 @@ export function MessageBubble({
</View>
{/* Action buttons */}
{(shouldShowActions || !isUser) && message.content.length > 0 && (
{(shouldShowActions || !isUser) && (showingStreaming ? displayedContent : message.content).length > 0 && (
<MessageActions
role={message.role}
content={message.content}
content={showingStreaming ? displayedContent : message.content}
onEdit={isUser ? onEdit : undefined}
onRegenerate={!isUser && isLastAssistant ? onRegenerate : undefined}
onDelete={onDelete}
@@ -357,4 +423,19 @@ const styles = StyleSheet.create({
fontSize: 13,
fontFamily: 'monospace',
},
dotsRow: {
flexDirection: 'row',
gap: 6,
alignItems: 'center',
minHeight: 22,
paddingHorizontal: 4,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
dot1: { opacity: 1.0 },
dot2: { opacity: 0.6 },
dot3: { opacity: 0.3 },
});
+127
View File
@@ -0,0 +1,127 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { BrainCircuit, ChevronDown, ChevronRight } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming
} from 'react-native-reanimated';
interface ThinkingBlockProps {
content: string;
isComplete: boolean;
isStreaming?: boolean;
}
export function ThinkingBlock({ content, isComplete, isStreaming }: ThinkingBlockProps) {
const colors = useThemeColor();
// Auto-expand while streaming/thinking, auto-collapse when complete
const [isExpanded, setIsExpanded] = useState(!isComplete);
// Auto collapse when it completes streaming
useEffect(() => {
if (isComplete && !isStreaming) {
setIsExpanded(false);
} else if (!isComplete && isStreaming) {
setIsExpanded(true);
}
}, [isComplete, isStreaming]);
// Pulse animation value
const pulseOpacity = useSharedValue(1);
useEffect(() => {
if (!isComplete && isStreaming) {
pulseOpacity.value = withRepeat(
withSequence(
withTiming(0.4, { duration: 800 }),
withTiming(1, { duration: 800 })
),
-1, // infinite
true // reverse
);
} else {
pulseOpacity.value = withTiming(1, { duration: 300 });
}
}, [isComplete, isStreaming, pulseOpacity]);
const animatedHeaderStyle = useAnimatedStyle(() => {
return {
opacity: pulseOpacity.value,
};
});
if (!content && isComplete) {
return null;
}
return (
<View style={[styles.container, { borderColor: colors.border }]}>
<TouchableOpacity
style={styles.header}
activeOpacity={0.7}
onPress={() => setIsExpanded(!isExpanded)}
>
<Animated.View style={[styles.titleRow, animatedHeaderStyle]}>
<BrainCircuit size={16} color={colors.textMuted} />
<Text style={[styles.titleText, { color: colors.textMuted }]}>
{!isComplete && isStreaming ? 'Pensando...' : 'Processo de Raciocínio'}
</Text>
</Animated.View>
{isExpanded ? (
<ChevronDown size={18} color={colors.textMuted} />
) : (
<ChevronRight size={18} color={colors.textMuted} />
)}
</TouchableOpacity>
{isExpanded && content.length > 0 && (
<View style={[styles.contentBlock, { borderTopColor: colors.border }]}>
<Text style={[styles.contentText, { color: colors.textMuted }]}>
{content}
</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderWidth: 1,
borderRadius: 8,
marginVertical: 8,
overflow: 'hidden',
width: '100%',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 10,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
titleText: {
fontSize: 13,
fontWeight: '600',
},
contentBlock: {
paddingHorizontal: 12,
paddingVertical: 10,
borderTopWidth: 1,
},
contentText: {
fontSize: 14,
lineHeight: 20,
fontFamily: 'monospace',
},
});