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',
+ },
+});