From 0d999cb76945d462f39ffda00b07622caec0d532 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 27 Mar 2026 17:37:07 +0800 Subject: [PATCH] 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. --- app-expo/src/app/(main)/conversation/[id].tsx | 73 ++-- .../conversation/chat-bubble-markdown.tsx | 346 ++++++++++++++++++ 2 files changed, 373 insertions(+), 46 deletions(-) create mode 100644 app-expo/src/features/conversation/chat-bubble-markdown.tsx diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx index af16b8c..c856d7c 100644 --- a/app-expo/src/app/(main)/conversation/[id].tsx +++ b/app-expo/src/app/(main)/conversation/[id].tsx @@ -59,6 +59,7 @@ import { splitMessageParts, splitStreamingSegments, } from '@/features/conversation/message-split'; +import { ChatBubbleMarkdown } from '@/features/conversation/chat-bubble-markdown'; import type { MessageItem } from '@/features/conversation/types'; import { isVoiceMessage } from '@/features/conversation/types'; import type { PlaybackItem } from '@/features/voice/types'; @@ -196,12 +197,11 @@ function MessageBubble({ isAssistantTtsHighlight && styles.bubbleAgentTtsActive, ]} > - - {item.content} - + {isAssistantTextFirstPart && (ttsUrls.length > 0 || isThisBubbleTtsTarget) ? ( @@ -280,16 +280,11 @@ function MessageBubble({ ) : isUser ? ( - - {item.content} - + ) : ( @@ -460,6 +455,7 @@ function StreamingBubbles({ : []; const streamingPart = segments.length > 0 ? segments[segments.length - 1]! : streamingText; + const streamingWithCursor = streamingPart + (!isComplete ? '▌' : ''); const inner = ( <> @@ -485,16 +481,11 @@ function StreamingBubbles({ streamingTtsActive && styles.bubbleAgentTtsActive, ]} > - - {part} - + @@ -519,16 +510,11 @@ function StreamingBubbles({ { opacity: streamPulse }, ]} > - - {streamingPart}▌ - + ) : ( - - {streamingPart} - + )} diff --git a/app-expo/src/features/conversation/chat-bubble-markdown.tsx b/app-expo/src/features/conversation/chat-bubble-markdown.tsx new file mode 100644 index 0000000..c974542 --- /dev/null +++ b/app-expo/src/features/conversation/chat-bubble-markdown.tsx @@ -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 = {}, + ) => ( + + {node.content} + + ), + textgroup: ( + node: { key: string }, + children: React.ReactNode, + _p: unknown, + styles: { textgroup: object }, + ) => ( + + {children} + + ), + strong: ( + node: { key: string }, + children: React.ReactNode, + _p: unknown, + styles: { strong: object }, + ) => ( + + {children} + + ), + em: ( + node: { key: string }, + children: React.ReactNode, + _p: unknown, + styles: { em: object }, + ) => ( + + {children} + + ), + s: ( + node: { key: string }, + children: React.ReactNode, + _p: unknown, + styles: { s: object }, + ) => ( + + {children} + + ), + link: ( + node: { key: string; attributes: { href?: string } }, + children: React.ReactNode, + _p: unknown, + styles: { link: object }, + onLinkPress?: (url: string) => boolean, + ) => ( + { + 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} + + ), + code_inline: ( + node: { key: string; content: string }, + _c: unknown, + _p: unknown, + styles: { code_inline: object }, + inheritedStyles: Record = {}, + ) => ( + + {node.content} + + ), + code_block: ( + node: { key: string; content?: string }, + _c: unknown, + _p: unknown, + styles: { code_block: object }, + inheritedStyles: Record = {}, + ) => { + 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 ( + + {content} + + ); + }, + fence: ( + node: { key: string; content?: string }, + _c: unknown, + _p: unknown, + styles: { fence: object }, + inheritedStyles: Record = {}, + ) => { + 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 ( + + {content} + + ); + }, + }), + [], + ); + + const trimmed = (markdown ?? '').trim(); + if (!trimmed) { + return null; + } + + return ( + + + {trimmed} + + + ); +} + +const stylesMarkdown = StyleSheet.create({ + wrap: { + alignSelf: 'stretch', + }, +});