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