feat: enhance MessageBubble with thinking indicator and reasoning content display
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
Pressable,
|
Pressable,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { Message } from '../../store/chatSlice';
|
import type { Message } from '../../store/chatSlice';
|
||||||
@@ -29,8 +31,13 @@ export default function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
? (message.metadata?.agentName as string | undefined) : undefined;
|
? (message.metadata?.agentName as string | undefined) : undefined;
|
||||||
const modelName = !isUser
|
const modelName = !isUser
|
||||||
? (message.metadata?.modelName as string | undefined) : undefined;
|
? (message.metadata?.modelName as string | undefined) : undefined;
|
||||||
|
const isThinking = !isUser
|
||||||
|
? (message.metadata?.isThinking as boolean | undefined) : undefined;
|
||||||
|
const reasoningContent = !isUser
|
||||||
|
? (message.metadata?.reasoningContent as string | undefined) : undefined;
|
||||||
|
|
||||||
const [showModel, setShowModel] = useState(false);
|
const [showModel, setShowModel] = useState(false);
|
||||||
|
const [showThinking, setShowThinking] = useState(false);
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const themeStyles = createStyles(colors);
|
const themeStyles = createStyles(colors);
|
||||||
|
|
||||||
@@ -39,6 +46,37 @@ export default function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
{agentName && (
|
{agentName && (
|
||||||
<Text style={themeStyles.agentLabel}>🤖 {agentName}</Text>
|
<Text style={themeStyles.agentLabel}>🤖 {agentName}</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Thinking indicator ────────────────────────────────────────── */}
|
||||||
|
{!isUser && isThinking && (
|
||||||
|
<View style={themeStyles.thinkingSpinnerRow}>
|
||||||
|
<ActivityIndicator size="small" color={colors.textTertiary as string} />
|
||||||
|
<Text style={themeStyles.thinkingSpinnerText}>Thinking...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{!isUser && !isThinking && reasoningContent ? (
|
||||||
|
<View style={themeStyles.thinkingWrapper}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={themeStyles.thinkingBadge}
|
||||||
|
onPress={() => setShowThinking(v => !v)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={themeStyles.thinkingBadgeText}>
|
||||||
|
🧠 Thinking {showThinking ? '▾' : '▸'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{showThinking && (
|
||||||
|
<View style={themeStyles.thinkingCard}>
|
||||||
|
<ScrollView style={themeStyles.thinkingScroll} nestedScrollEnabled>
|
||||||
|
<Text style={themeStyles.thinkingCardText}>{reasoningContent}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Main message bubble (hidden while thinking, always shown after) ── */}
|
||||||
|
{!isThinking && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onLongPress={() =>
|
onLongPress={() =>
|
||||||
navigation.navigate('MessageDetails', { messageId: message.id })
|
navigation.navigate('MessageDetails', { messageId: message.id })
|
||||||
@@ -57,6 +95,8 @@ export default function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
{time}
|
{time}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={themeStyles.metaRow}>
|
<View style={themeStyles.metaRow}>
|
||||||
{liveRate !== undefined && (
|
{liveRate !== undefined && (
|
||||||
<Text style={themeStyles.rate}>{liveRate.toFixed(1)} tok/s</Text>
|
<Text style={themeStyles.rate}>{liveRate.toFixed(1)} tok/s</Text>
|
||||||
@@ -144,4 +184,54 @@ const createStyles = (colors: Record<string, unknown>) =>
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
paddingHorizontal: spacing.xs,
|
paddingHorizontal: spacing.xs,
|
||||||
},
|
},
|
||||||
|
// ── Thinking styles ──────────────────────────────────────────────────
|
||||||
|
thinkingSpinnerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
},
|
||||||
|
thinkingSpinnerText: {
|
||||||
|
fontSize: typography.sizes.sm,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
thinkingWrapper: {
|
||||||
|
maxWidth: '80%',
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
thinkingBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.surfaceTertiary ?? colors.surfaceSecondary,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 4,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
thinkingBadgeText: {
|
||||||
|
fontSize: typography.sizes.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: typography.weights.semibold,
|
||||||
|
},
|
||||||
|
thinkingCard: {
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
backgroundColor: colors.surfaceTertiary ?? colors.surfaceSecondary,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: colors.textTertiary as string,
|
||||||
|
padding: spacing.sm,
|
||||||
|
maxHeight: 220,
|
||||||
|
},
|
||||||
|
thinkingScroll: {
|
||||||
|
flexGrow: 0,
|
||||||
|
},
|
||||||
|
thinkingCardText: {
|
||||||
|
fontSize: typography.sizes.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 16,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ export const generateResponse = createAsyncThunk(
|
|||||||
let liveTokenCount = 0;
|
let liveTokenCount = 0;
|
||||||
const generationStart = Date.now();
|
const generationStart = Date.now();
|
||||||
let lastRateDispatch = 0;
|
let lastRateDispatch = 0;
|
||||||
|
// Track think-block state for live UI feedback
|
||||||
|
let lastCleanLength = 0;
|
||||||
|
let isThinkingActive = false;
|
||||||
|
|
||||||
const result: CompletionResult = await llamaContext.completion(
|
const result: CompletionResult = await llamaContext.completion(
|
||||||
samplingParams,
|
samplingParams,
|
||||||
@@ -134,16 +137,46 @@ export const generateResponse = createAsyncThunk(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// data.token is always the incremental raw piece — safe to append.
|
}
|
||||||
// data.content is cumulative parsed text (think-tags stripped) so
|
// Accumulate reasoning tokens and signal thinking state
|
||||||
// we do NOT use it here; the final clean version is applied via
|
if (data?.reasoning_content) {
|
||||||
// finalizeAssistantMessage after completion.
|
rawStreamReasoning += data.reasoning_content;
|
||||||
|
if (!isThinkingActive) {
|
||||||
|
isThinkingActive = true;
|
||||||
|
dispatch({
|
||||||
|
type: 'chat/setMessageMeta',
|
||||||
|
payload: { id: params.assistantMessageId, meta: { isThinking: true } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Forward only the clean response via delta of cumulative data.content
|
||||||
|
// (think-tags already stripped by llama.rn with reasoning_format:'auto')
|
||||||
|
if (data?.content !== undefined && data.content.length > lastCleanLength) {
|
||||||
|
const delta = data.content.slice(lastCleanLength);
|
||||||
|
lastCleanLength = data.content.length;
|
||||||
|
if (isThinkingActive) {
|
||||||
|
isThinkingActive = false;
|
||||||
|
dispatch({
|
||||||
|
type: 'chat/setMessageMeta',
|
||||||
|
payload: { id: params.assistantMessageId, meta: { isThinking: false } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (params.onToken) { params.onToken(delta); }
|
||||||
|
} else if (token && !data?.reasoning_content && data?.content === undefined) {
|
||||||
|
// Fallback: model without thinking support — forward raw token directly
|
||||||
if (params.onToken) { params.onToken(token); }
|
if (params.onToken) { params.onToken(token); }
|
||||||
}
|
}
|
||||||
if (data?.reasoning_content) { rawStreamReasoning += data.reasoning_content; }
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Ensure isThinking is cleared if model ended inside a think block
|
||||||
|
if (isThinkingActive) {
|
||||||
|
dispatch({
|
||||||
|
type: 'chat/setMessageMeta',
|
||||||
|
payload: { id: params.assistantMessageId, meta: { isThinking: false } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const elapsed = (Date.now() - generationStart) / 1000;
|
const elapsed = (Date.now() - generationStart) / 1000;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'chat/setMessageMeta',
|
type: 'chat/setMessageMeta',
|
||||||
|
|||||||
Reference in New Issue
Block a user