fix/ 修复AI聊天时回复markdown导致聊天气泡布局问题

This commit is contained in:
Kevin
2026-04-03 14:06:55 +08:00
parent 4cfa3843a7
commit 828a29748e
7 changed files with 131 additions and 362 deletions

View File

@@ -56,7 +56,7 @@ import {
splitMessageParts,
splitStreamingSegments,
} from '@/features/conversation/message-split';
import { ChatBubbleMarkdown } from '@/features/conversation/chat-bubble-markdown';
import { ChatBubbleText } from '@/features/conversation/chat-bubble-text';
import type { MessageItem } from '@/features/conversation/types';
import { isVoiceMessage } from '@/features/conversation/types';
import type { PlaybackItem } from '@/features/voice/types';
@@ -194,8 +194,8 @@ function MessageBubble({
isAssistantTtsHighlight && styles.bubbleAgentTtsActive,
]}
>
<ChatBubbleMarkdown
markdown={item.content}
<ChatBubbleText
text={item.content}
variant="assistant"
textStyle={bubbleTextStyle}
/>
@@ -277,8 +277,8 @@ function MessageBubble({
</View>
) : isUser ? (
<View style={[styles.bubble, styles.bubbleUser]}>
<ChatBubbleMarkdown
markdown={item.content}
<ChatBubbleText
text={item.content}
variant="user"
textStyle={bubbleTextStyle}
/>
@@ -478,8 +478,8 @@ function StreamingBubbles({
streamingTtsActive && styles.bubbleAgentTtsActive,
]}
>
<ChatBubbleMarkdown
markdown={part}
<ChatBubbleText
text={part}
variant="assistant"
textStyle={bubbleTextStyle}
/>
@@ -507,8 +507,8 @@ function StreamingBubbles({
{ opacity: streamPulse },
]}
>
<ChatBubbleMarkdown
markdown={streamingWithCursor}
<ChatBubbleText
text={streamingWithCursor}
variant="assistant"
textStyle={bubbleTextStyle}
/>
@@ -521,8 +521,8 @@ function StreamingBubbles({
streamingTtsActive && styles.bubbleAgentTtsActive,
]}
>
<ChatBubbleMarkdown
markdown={streamingWithCursor}
<ChatBubbleText
text={streamingWithCursor}
variant="assistant"
textStyle={bubbleTextStyle}
/>

View File

@@ -1,346 +0,0 @@
/**
* 聊天气泡内 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',
},
});

View File

@@ -0,0 +1,57 @@
/**
* 聊天气泡内纯文本(不按 Markdown 解析),避免 RN 上富文本嵌套与继承样式问题。
* 富文本排版仍由回忆录等场景的 Markdown 渲染器负责。
*/
import React, { useMemo } from 'react';
import { StyleSheet, Text, type TextStyle, View } from 'react-native';
const COLORS = {
onSurface: '#1b1b1f',
onPrimary: '#ffffff',
};
const BASE_FONT = 17;
const BASE_LINE = 24;
export function ChatBubbleText({
text,
variant,
textStyle,
}: {
text: string;
variant: 'user' | 'assistant';
textStyle?: TextStyle;
}) {
const primaryColor = variant === 'user' ? COLORS.onPrimary : COLORS.onSurface;
const bodyStyle = useMemo(
(): TextStyle => ({
color: primaryColor,
fontSize: BASE_FONT,
lineHeight: BASE_LINE,
fontWeight: '400',
...textStyle,
}),
[primaryColor, textStyle],
);
const trimmed = (text ?? '').trim();
if (!trimmed) {
return null;
}
return (
<View style={styles.wrap}>
<Text selectable style={bodyStyle}>
{trimmed}
</Text>
</View>
);
}
const styles = StyleSheet.create({
wrap: {
alignSelf: 'stretch',
},
});