feat(app-expo): render chat bubbles as Markdown with correct bold
- Add ChatBubbleMarkdown using react-native-markdown-display; disable images in bubbles for safety. - Strip fontWeight from leaf text styles so **strong** is not overridden by chatBubbleTextStyle (nested Text on RN). - Wire MessageBubble and StreamingBubbles; keep streaming cursor appended to markdown string.
This commit is contained in:
@@ -59,6 +59,7 @@ import {
|
|||||||
splitMessageParts,
|
splitMessageParts,
|
||||||
splitStreamingSegments,
|
splitStreamingSegments,
|
||||||
} from '@/features/conversation/message-split';
|
} from '@/features/conversation/message-split';
|
||||||
|
import { ChatBubbleMarkdown } from '@/features/conversation/chat-bubble-markdown';
|
||||||
import type { MessageItem } from '@/features/conversation/types';
|
import type { MessageItem } from '@/features/conversation/types';
|
||||||
import { isVoiceMessage } from '@/features/conversation/types';
|
import { isVoiceMessage } from '@/features/conversation/types';
|
||||||
import type { PlaybackItem } from '@/features/voice/types';
|
import type { PlaybackItem } from '@/features/voice/types';
|
||||||
@@ -196,12 +197,11 @@ function MessageBubble({
|
|||||||
isAssistantTtsHighlight && styles.bubbleAgentTtsActive,
|
isAssistantTtsHighlight && styles.bubbleAgentTtsActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text
|
<ChatBubbleMarkdown
|
||||||
selectable
|
markdown={item.content}
|
||||||
style={[styles.bubbleText, styles.bubbleTextAgent, bubbleTextStyle]}
|
variant="assistant"
|
||||||
>
|
textStyle={bubbleTextStyle}
|
||||||
{item.content}
|
/>
|
||||||
</Text>
|
|
||||||
{isAssistantTextFirstPart &&
|
{isAssistantTextFirstPart &&
|
||||||
(ttsUrls.length > 0 || isThisBubbleTtsTarget) ? (
|
(ttsUrls.length > 0 || isThisBubbleTtsTarget) ? (
|
||||||
<View style={styles.readAloudRow}>
|
<View style={styles.readAloudRow}>
|
||||||
@@ -280,16 +280,11 @@ function MessageBubble({
|
|||||||
</View>
|
</View>
|
||||||
) : isUser ? (
|
) : isUser ? (
|
||||||
<View style={[styles.bubble, styles.bubbleUser]}>
|
<View style={[styles.bubble, styles.bubbleUser]}>
|
||||||
<Text
|
<ChatBubbleMarkdown
|
||||||
selectable
|
markdown={item.content}
|
||||||
style={[
|
variant="user"
|
||||||
styles.bubbleText,
|
textStyle={bubbleTextStyle}
|
||||||
styles.bubbleTextUser,
|
/>
|
||||||
bubbleTextStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{item.content}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.assistantBubbleWrap}>
|
<View style={styles.assistantBubbleWrap}>
|
||||||
@@ -460,6 +455,7 @@ function StreamingBubbles({
|
|||||||
: [];
|
: [];
|
||||||
const streamingPart =
|
const streamingPart =
|
||||||
segments.length > 0 ? segments[segments.length - 1]! : streamingText;
|
segments.length > 0 ? segments[segments.length - 1]! : streamingText;
|
||||||
|
const streamingWithCursor = streamingPart + (!isComplete ? '▌' : '');
|
||||||
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<>
|
<>
|
||||||
@@ -485,16 +481,11 @@ function StreamingBubbles({
|
|||||||
streamingTtsActive && styles.bubbleAgentTtsActive,
|
streamingTtsActive && styles.bubbleAgentTtsActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text
|
<ChatBubbleMarkdown
|
||||||
selectable
|
markdown={part}
|
||||||
style={[
|
variant="assistant"
|
||||||
styles.bubbleText,
|
textStyle={bubbleTextStyle}
|
||||||
styles.bubbleTextAgent,
|
/>
|
||||||
bubbleTextStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{part}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -519,16 +510,11 @@ function StreamingBubbles({
|
|||||||
{ opacity: streamPulse },
|
{ opacity: streamPulse },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text
|
<ChatBubbleMarkdown
|
||||||
selectable
|
markdown={streamingWithCursor}
|
||||||
style={[
|
variant="assistant"
|
||||||
styles.bubbleText,
|
textStyle={bubbleTextStyle}
|
||||||
styles.bubbleTextAgent,
|
/>
|
||||||
bubbleTextStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{streamingPart}▌
|
|
||||||
</Text>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
@@ -538,16 +524,11 @@ function StreamingBubbles({
|
|||||||
streamingTtsActive && styles.bubbleAgentTtsActive,
|
streamingTtsActive && styles.bubbleAgentTtsActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text
|
<ChatBubbleMarkdown
|
||||||
selectable
|
markdown={streamingWithCursor}
|
||||||
style={[
|
variant="assistant"
|
||||||
styles.bubbleText,
|
textStyle={bubbleTextStyle}
|
||||||
styles.bubbleTextAgent,
|
/>
|
||||||
bubbleTextStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{streamingPart}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
346
app-expo/src/features/conversation/chat-bubble-markdown.tsx
Normal file
346
app-expo/src/features/conversation/chat-bubble-markdown.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* 聊天气泡内 Markdown:与回忆录正文共用 react-native-markdown-display,样式为紧凑气泡。
|
||||||
|
* 不渲染图片,避免聊天里加载任意 URL。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Markdown, { renderRules } from 'react-native-markdown-display';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Linking, StyleSheet, Text, TextStyle, View } from 'react-native';
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
onSurface: '#1b1b1f',
|
||||||
|
onPrimary: '#ffffff',
|
||||||
|
primary: '#8177A6',
|
||||||
|
onSurfaceVariant: '#8D8C90',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_FONT = 17;
|
||||||
|
const BASE_LINE = 24;
|
||||||
|
|
||||||
|
/** 叶子 `text` 节点若带 fontWeight,会盖住外层 `strong` 的粗体(RN 嵌套 Text 规则)。字号/行高仍从设置传入。 */
|
||||||
|
function textStyleWithoutFontWeight(
|
||||||
|
style: TextStyle | undefined,
|
||||||
|
): TextStyle | undefined {
|
||||||
|
if (!style) return undefined;
|
||||||
|
const flat = StyleSheet.flatten(style) as TextStyle & {
|
||||||
|
fontWeight?: unknown;
|
||||||
|
};
|
||||||
|
const { fontWeight: _w, ...rest } = flat;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatBubbleMarkdown({
|
||||||
|
markdown,
|
||||||
|
variant,
|
||||||
|
textStyle,
|
||||||
|
}: {
|
||||||
|
markdown: string;
|
||||||
|
variant: 'user' | 'assistant';
|
||||||
|
textStyle?: TextStyle;
|
||||||
|
}) {
|
||||||
|
const primaryColor = variant === 'user' ? COLORS.onPrimary : COLORS.onSurface;
|
||||||
|
const linkColor =
|
||||||
|
variant === 'user' ? 'rgba(255,255,255,0.92)' : COLORS.primary;
|
||||||
|
const muted =
|
||||||
|
variant === 'user' ? 'rgba(255,255,255,0.85)' : COLORS.onSurfaceVariant;
|
||||||
|
const codeBg =
|
||||||
|
variant === 'user' ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.06)';
|
||||||
|
|
||||||
|
const textStyleLeaf = useMemo(
|
||||||
|
() => textStyleWithoutFontWeight(textStyle),
|
||||||
|
[textStyle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const styles = useMemo(
|
||||||
|
() =>
|
||||||
|
StyleSheet.create({
|
||||||
|
body: {
|
||||||
|
color: primaryColor,
|
||||||
|
fontSize: BASE_FONT,
|
||||||
|
lineHeight: BASE_LINE,
|
||||||
|
fontWeight: '400',
|
||||||
|
...textStyle,
|
||||||
|
},
|
||||||
|
paragraph: {
|
||||||
|
marginTop: 0,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: primaryColor,
|
||||||
|
fontSize: BASE_FONT,
|
||||||
|
lineHeight: BASE_LINE,
|
||||||
|
fontWeight: '400',
|
||||||
|
...textStyle,
|
||||||
|
},
|
||||||
|
textgroup: {
|
||||||
|
color: primaryColor,
|
||||||
|
fontSize: BASE_FONT,
|
||||||
|
lineHeight: BASE_LINE,
|
||||||
|
...textStyle,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: primaryColor,
|
||||||
|
fontSize: BASE_FONT,
|
||||||
|
lineHeight: BASE_LINE,
|
||||||
|
...textStyleLeaf,
|
||||||
|
},
|
||||||
|
strong: {
|
||||||
|
fontWeight: '700',
|
||||||
|
color: primaryColor,
|
||||||
|
},
|
||||||
|
em: {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: primaryColor,
|
||||||
|
},
|
||||||
|
s: {
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
color: primaryColor,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: linkColor,
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
},
|
||||||
|
bullet_list: {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
ordered_list: {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
list_item: {
|
||||||
|
marginBottom: 4,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
},
|
||||||
|
bullet_list_icon: {
|
||||||
|
marginLeft: 0,
|
||||||
|
marginRight: 8,
|
||||||
|
color: muted,
|
||||||
|
},
|
||||||
|
code_inline: {
|
||||||
|
backgroundColor: codeBg,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: BASE_FONT - 1,
|
||||||
|
color: primaryColor,
|
||||||
|
},
|
||||||
|
fence: {
|
||||||
|
backgroundColor: codeBg,
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginVertical: 4,
|
||||||
|
fontSize: BASE_FONT - 1,
|
||||||
|
color: primaryColor,
|
||||||
|
},
|
||||||
|
code_block: {
|
||||||
|
backgroundColor: codeBg,
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginVertical: 4,
|
||||||
|
fontSize: BASE_FONT - 1,
|
||||||
|
color: primaryColor,
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor:
|
||||||
|
variant === 'user'
|
||||||
|
? 'rgba(255,255,255,0.45)'
|
||||||
|
: 'rgba(129,119,166,0.45)',
|
||||||
|
paddingLeft: 10,
|
||||||
|
marginVertical: 4,
|
||||||
|
color: muted,
|
||||||
|
},
|
||||||
|
heading1: {
|
||||||
|
fontSize: BASE_FONT + 2,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 6,
|
||||||
|
marginTop: 2,
|
||||||
|
color: primaryColor,
|
||||||
|
},
|
||||||
|
heading2: {
|
||||||
|
fontSize: BASE_FONT + 1,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 4,
|
||||||
|
marginTop: 2,
|
||||||
|
color: primaryColor,
|
||||||
|
},
|
||||||
|
heading3: {
|
||||||
|
fontSize: BASE_FONT,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 4,
|
||||||
|
marginTop: 2,
|
||||||
|
color: primaryColor,
|
||||||
|
},
|
||||||
|
hr: {
|
||||||
|
backgroundColor: muted,
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[primaryColor, linkColor, muted, codeBg, variant, textStyle, textStyleLeaf],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rules = useMemo(
|
||||||
|
() => ({
|
||||||
|
...renderRules,
|
||||||
|
image: () => null,
|
||||||
|
text: (
|
||||||
|
node: { key: string; content: string },
|
||||||
|
_c: unknown,
|
||||||
|
_p: unknown,
|
||||||
|
styles: { text: object },
|
||||||
|
inheritedStyles: Record<string, unknown> = {},
|
||||||
|
) => (
|
||||||
|
<Text key={node.key} selectable style={[inheritedStyles, styles.text]}>
|
||||||
|
{node.content}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
textgroup: (
|
||||||
|
node: { key: string },
|
||||||
|
children: React.ReactNode,
|
||||||
|
_p: unknown,
|
||||||
|
styles: { textgroup: object },
|
||||||
|
) => (
|
||||||
|
<Text key={node.key} selectable style={styles.textgroup}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
strong: (
|
||||||
|
node: { key: string },
|
||||||
|
children: React.ReactNode,
|
||||||
|
_p: unknown,
|
||||||
|
styles: { strong: object },
|
||||||
|
) => (
|
||||||
|
<Text key={node.key} selectable style={styles.strong}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
em: (
|
||||||
|
node: { key: string },
|
||||||
|
children: React.ReactNode,
|
||||||
|
_p: unknown,
|
||||||
|
styles: { em: object },
|
||||||
|
) => (
|
||||||
|
<Text key={node.key} selectable style={styles.em}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
s: (
|
||||||
|
node: { key: string },
|
||||||
|
children: React.ReactNode,
|
||||||
|
_p: unknown,
|
||||||
|
styles: { s: object },
|
||||||
|
) => (
|
||||||
|
<Text key={node.key} selectable style={styles.s}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
link: (
|
||||||
|
node: { key: string; attributes: { href?: string } },
|
||||||
|
children: React.ReactNode,
|
||||||
|
_p: unknown,
|
||||||
|
styles: { link: object },
|
||||||
|
onLinkPress?: (url: string) => boolean,
|
||||||
|
) => (
|
||||||
|
<Text
|
||||||
|
key={node.key}
|
||||||
|
selectable
|
||||||
|
style={styles.link}
|
||||||
|
onPress={() => {
|
||||||
|
const url = node.attributes.href;
|
||||||
|
if (onLinkPress) {
|
||||||
|
const result = onLinkPress(url as string);
|
||||||
|
if (url && result && typeof result === 'boolean') {
|
||||||
|
void Linking.openURL(url);
|
||||||
|
}
|
||||||
|
} else if (url) {
|
||||||
|
void Linking.openURL(url);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
code_inline: (
|
||||||
|
node: { key: string; content: string },
|
||||||
|
_c: unknown,
|
||||||
|
_p: unknown,
|
||||||
|
styles: { code_inline: object },
|
||||||
|
inheritedStyles: Record<string, unknown> = {},
|
||||||
|
) => (
|
||||||
|
<Text
|
||||||
|
key={node.key}
|
||||||
|
selectable
|
||||||
|
style={[inheritedStyles, styles.code_inline]}
|
||||||
|
>
|
||||||
|
{node.content}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
code_block: (
|
||||||
|
node: { key: string; content?: string },
|
||||||
|
_c: unknown,
|
||||||
|
_p: unknown,
|
||||||
|
styles: { code_block: object },
|
||||||
|
inheritedStyles: Record<string, unknown> = {},
|
||||||
|
) => {
|
||||||
|
let content = node.content;
|
||||||
|
if (
|
||||||
|
typeof node.content === 'string' &&
|
||||||
|
node.content.charAt(node.content.length - 1) === '\n'
|
||||||
|
) {
|
||||||
|
content = node.content.substring(0, node.content.length - 1);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={node.key}
|
||||||
|
selectable
|
||||||
|
style={[inheritedStyles, styles.code_block]}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
fence: (
|
||||||
|
node: { key: string; content?: string },
|
||||||
|
_c: unknown,
|
||||||
|
_p: unknown,
|
||||||
|
styles: { fence: object },
|
||||||
|
inheritedStyles: Record<string, unknown> = {},
|
||||||
|
) => {
|
||||||
|
let content = node.content;
|
||||||
|
if (
|
||||||
|
typeof node.content === 'string' &&
|
||||||
|
node.content.charAt(node.content.length - 1) === '\n'
|
||||||
|
) {
|
||||||
|
content = node.content.substring(0, node.content.length - 1);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={node.key}
|
||||||
|
selectable
|
||||||
|
style={[inheritedStyles, styles.fence]}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const trimmed = (markdown ?? '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={stylesMarkdown.wrap}>
|
||||||
|
<Markdown style={styles} rules={rules} mergeStyle>
|
||||||
|
{trimmed}
|
||||||
|
</Markdown>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stylesMarkdown = StyleSheet.create({
|
||||||
|
wrap: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user