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:
1 parent
ac215b4229
commit
e08189f640
17 files changed
+2914
-13236
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user