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

@@ -2,9 +2,11 @@
def chat_output_rules() -> str:
"""用户可见回复共用禁令(括号/元注释/采访腔/编造等)。"""
"""用户可见回复共用禁令(括号/元注释/采访腔/编造/Markdown 等)。"""
return (
"**禁止**输出括号、括号内的策略/舞台说明(例如「(先接住情绪)」「(共情)」)"
"**禁止**输出 Markdown 或类排版符号:不要出现标题井号、加粗/斜体星号与下划线"
"反引号代码、`[]()` 链接、列表符号或渲染用符号;只输出连贯口语,**可以**在需要分两气泡时使用字面量 "
"`[SPLIT]`(仅此一处方括号用法);**禁止**输出括号、括号内的策略/舞台说明(例如「(先接住情绪)」「(共情)」)、"
"思考过程或任何元注释——这些只存在于系统指令里,**绝不可**出现在你对用户说的话中;"
"采访腔(「我注意到」「我想了解」);重复确认对方已经说过或能推断出的信息;编造对方没说的细节。"
)

View File

@@ -181,7 +181,7 @@ def get_opening_prompt(
{style_examples}
直接输出(仅自然口语):"""
直接输出(仅自然口语,无 Markdown"""
def _build_era_context(current_stage: str, user_profile_context: str) -> str:
@@ -455,6 +455,6 @@ def get_guided_conversation_prompt(
## 不要做的
{chat_output_rules()}
直接输出(仅自然口语,无任何括号前缀或旁白):"""
直接输出(仅自然口语,无 Markdown任何括号前缀或旁白):"""
return prompt

View File

@@ -5,6 +5,44 @@ from __future__ import annotations
import re
def strip_markdown_for_chat(text: str) -> str:
"""
将模型偶然输出的常见 Markdown 剥成纯文本,供 App 聊天气泡展示。
保留换行与字面量 [SPLIT];不做完整 MD 解析,以简单可预测为主。
"""
if not text:
return text
s = text
# 围栏代码块(含首行语言标记):整段替换为块内正文,去掉栅栏
s = re.sub(
r"```(?:[^\n`]*)\n([\s\S]*?)```",
r"\1",
s,
flags=re.MULTILINE,
)
s = s.replace("```", "")
# 图片 ![alt](url) → alt链接 [label](url) → label
s = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", s)
s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s)
# ATX 标题
s = re.sub(r"(?m)^#{1,6}\s+", "", s)
# 无序列表行首(仅限行首减号/星号/+ 后接空格,避免误判「—」)
s = re.sub(r"(?m)^\s*[-*+]\s+", "", s)
# 有序列表「数字. 」仅行首
s = re.sub(r"(?m)^\s*\d+\.\s+", "", s)
# 粗体/删除线常见标记
s = s.replace("**", "").replace("__", "")
s = s.replace("~~", "")
# 行内反引号
s = s.replace("`", "")
# 孤立 emphasis*词* 或 _词_不含跨行
s = re.sub(r"(?<![*])\*([^*\n]+)\*(?![*])", r"\1", s)
s = re.sub(r"(?<![_])_([^_\n]+)_(?![_])", r"\1", s)
# 分割线
s = re.sub(r"(?m)^\s*---+\s*$", "", s)
return s
def segments_from_llm_response(
response_text: str,
*,
@@ -15,7 +53,7 @@ def segments_from_llm_response(
优先按字面 [SPLIT] 拆段;若模型只输出一段、但用空行写了多段,再按段落拆。
解决「两段话 + 换行」却未写 [SPLIT] 时仍要拆气泡 / 多段 TTS 的情况。
"""
text = (response_text or "").strip()
text = strip_markdown_for_chat((response_text or "").strip())
if not text:
return []
primary = [p.strip() for p in text.split("[SPLIT]") if p.strip()]

View File

@@ -3,6 +3,7 @@
from app.agents.chat.reply_limits import (
nonempty_segments_or_fallback,
segments_from_llm_response,
strip_markdown_for_chat,
)
@@ -23,3 +24,20 @@ def test_short_paragraphs_not_split():
def test_nonempty_fallback_when_all_blank():
assert nonempty_segments_or_fallback(["", " "], fallback="ok") == ["ok"]
def test_split_marker_strips_markdown():
assert segments_from_llm_response("**A**[SPLIT]_B_", max_segments=3) == ["A", "B"]
def test_paragraph_split_strips_markdown():
a = "**太为你高兴了!在上海大剧院的舞台绽放,聚光灯下的你。**"
b = "[详情](https://e.com)说到舞台,我忽然想起你黄浦江边的童年。"
assert segments_from_llm_response(f"{a}\n\n{b}", max_segments=3) == [
"太为你高兴了!在上海大剧院的舞台绽放,聚光灯下的你。",
"详情说到舞台,我忽然想起你黄浦江边的童年。",
]
def test_strip_markdown_for_chat_preserves_split_token():
assert "[SPLIT]" in strip_markdown_for_chat("a **b** [SPLIT] c")

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