fix/ 修复AI聊天时回复markdown导致聊天气泡布局问题
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
57
app-expo/src/features/conversation/chat-bubble-text.tsx
Normal file
57
app-expo/src/features/conversation/chat-bubble-text.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user