From 828a29748edc3893d592b44615be819122949982 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 3 Apr 2026 14:06:55 +0800 Subject: [PATCH] =?UTF-8?q?fix/=20=E4=BF=AE=E5=A4=8DAI=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=97=B6=E5=9B=9E=E5=A4=8Dmarkdown=E5=AF=BC=E8=87=B4=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=B0=94=E6=B3=A1=E5=B8=83=E5=B1=80=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/agents/chat/output_rules.py | 6 +- api/app/agents/chat/prompts_conversation.py | 4 +- api/app/agents/chat/reply_limits.py | 40 +- api/tests/test_reply_segments.py | 18 + app-expo/src/app/(main)/conversation/[id].tsx | 22 +- .../conversation/chat-bubble-markdown.tsx | 346 ------------------ .../conversation/chat-bubble-text.tsx | 57 +++ 7 files changed, 131 insertions(+), 362 deletions(-) delete mode 100644 app-expo/src/features/conversation/chat-bubble-markdown.tsx create mode 100644 app-expo/src/features/conversation/chat-bubble-text.tsx diff --git a/api/app/agents/chat/output_rules.py b/api/app/agents/chat/output_rules.py index 5fd5d4f..8d6a861 100644 --- a/api/app/agents/chat/output_rules.py +++ b/api/app/agents/chat/output_rules.py @@ -2,9 +2,11 @@ def chat_output_rules() -> str: - """用户可见回复共用禁令(括号/元注释/采访腔/编造等)。""" + """用户可见回复共用禁令(括号/元注释/采访腔/编造/Markdown 等)。""" return ( - "**禁止**输出括号、括号内的策略/舞台说明(例如「(先接住情绪)」「(共情)」)、" + "**禁止**输出 Markdown 或类排版符号:不要出现标题井号、加粗/斜体星号与下划线、" + "反引号代码、`[]()` 链接、列表符号或渲染用符号;只输出连贯口语,**可以**在需要分两气泡时使用字面量 " + "`[SPLIT]`(仅此一处方括号用法);**禁止**输出括号、括号内的策略/舞台说明(例如「(先接住情绪)」「(共情)」)、" "思考过程或任何元注释——这些只存在于系统指令里,**绝不可**出现在你对用户说的话中;" "采访腔(「我注意到」「我想了解」);重复确认对方已经说过或能推断出的信息;编造对方没说的细节。" ) diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index 61fd0fe..937c444 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -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 diff --git a/api/app/agents/chat/reply_limits.py b/api/app/agents/chat/reply_limits.py index ec949a2..17376c8 100644 --- a/api/app/agents/chat/reply_limits.py +++ b/api/app/agents/chat/reply_limits.py @@ -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"(? - @@ -277,8 +277,8 @@ function MessageBubble({ ) : isUser ? ( - @@ -478,8 +478,8 @@ function StreamingBubbles({ streamingTtsActive && styles.bubbleAgentTtsActive, ]} > - @@ -507,8 +507,8 @@ function StreamingBubbles({ { opacity: streamPulse }, ]} > - @@ -521,8 +521,8 @@ function StreamingBubbles({ streamingTtsActive && styles.bubbleAgentTtsActive, ]} > - diff --git a/app-expo/src/features/conversation/chat-bubble-markdown.tsx b/app-expo/src/features/conversation/chat-bubble-markdown.tsx deleted file mode 100644 index c974542..0000000 --- a/app-expo/src/features/conversation/chat-bubble-markdown.tsx +++ /dev/null @@ -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 = {}, - ) => ( - - {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', - }, -}); diff --git a/app-expo/src/features/conversation/chat-bubble-text.tsx b/app-expo/src/features/conversation/chat-bubble-text.tsx new file mode 100644 index 0000000..14f1264 --- /dev/null +++ b/app-expo/src/features/conversation/chat-bubble-text.tsx @@ -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 ( + + + {trimmed} + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + alignSelf: 'stretch', + }, +});