@@ -69,7 +69,7 @@
<!-- 文本消息 : 小程序用 text selectable , H5 用可选择的 HTML 渲染 -- >
<!-- 文本消息 : 小程序用 text selectable , H5 用可选择的 HTML 渲染 -- >
< view v-if = "message.type === 'text'" class="text-content" >
< view v-if = "message.type === 'text'" class="text-content" >
< ! - - # ifdef MP -WEIXIN - - >
< ! - - # ifdef MP -WEIXIN - - >
< text class = "ai-message-text" selectable > { { _stripMarkdown ( message. content ) } } < / text >
< rich- text class = "ai-message-rich" :nodes = "getAIDisplayContent( message. content)" > < / rich- text>
<!-- # endif -- >
<!-- # endif -- >
<!-- # ifdef H5 -- >
<!-- # ifdef H5 -- >
< view class = "ai-message-text selectable-md" v-html = "getAIDisplayContent(message.content)" > < / view >
< view class = "ai-message-text selectable-md" v-html = "getAIDisplayContent(message.content)" > < / view >
@@ -84,7 +84,7 @@
< / view >
< / view >
< view class = "markdown-body" >
< view class = "markdown-body" >
<!-- # ifdef MP - WEIXIN -- >
<!-- # ifdef MP - WEIXIN -- >
< text class = "ai-message-text" selectable > { { _stripMarkdown ( message. content ) } } < / text >
< rich- text class = "ai-message-rich" :nodes = "getAIDisplayContent( message. content)" > < / rich- text>
<!-- # endif -- >
<!-- # endif -- >
<!-- # ifdef H5 -- >
<!-- # ifdef H5 -- >
< view class = "selectable-md" v-html = "getAIDisplayContent(message.content)" > < / view >
< view class = "selectable-md" v-html = "getAIDisplayContent(message.content)" > < / view >
@@ -1215,25 +1215,51 @@ _handleStreamError(msg) {
}
}
} catch ( e ) { /* 忽略, 走原始 content */ }
} catch ( e ) { /* 忽略, 走原始 content */ }
// 主文字色 / 链接色 (偏豆包风格的近黑 + 蓝)
const TEXT = '#1a1a1a'
const LINK = '#3a7afe'
// 辅助:内联格式处理
// 辅助:内联格式处理
// 思路: 先把"链接/图片/裸URL/电话"整体抽成占位符保护起来, 再做加粗/斜体等,
// 最后还原, 避免各规则互相串改对方的 href / 数字。
const parseInline = ( text ) => {
const parseInline = ( text ) => {
return text
const stash = [ ]
// 行内图片
const hold = ( html ) => { stash . push ( html ) ; return '\u0000' + ( stash . length - 1 ) + '\u0000' }
. replace ( /!\[([^\]]*)\]\(([^)]+)\)/g , '<img src="$2" alt="$1" style="max-width:100%;border-radius:8rpx;margin:8rpx 0;display:block;" />' )
// 链接
let t = text
. replace ( /\[([^\]]+)\]\(([^)]+)\)/g , '<a href="$2" style="color:#4a90d9;text-decoration:none;">$1</a>' )
// 1) 已有的 markdown 链接 / 图片先抽出
// 行内代码
. replace ( /!\[([^\]]*)\]\(([^)]+)\)/g , ( m ) => hold ( m ) )
. replace ( /` ([^` ]+)` /g , '<code style="background:#f0f4f8;padding:2rpx 10rpx;border-radius:6rpx;font-size:24rpx;font-family:monospace;color:#d63384;">$1</code>' )
. replace ( /\[([^\]]+)\]\( ([^) ]+)\) /g , ( m ) => hold ( m ) )
// 粗体(优先匹配**)
// 2) 裸 URL(含 www.) -> 蓝色链接, 并抽离保护
. replace ( /\*\*(.+?)\*\* /g , '<strong style="font-weight:700;">$1</strong>' )
. replace ( /(https?:\/\/[^\s<]+|www\.[^\s<]+) /gi , ( m ) => {
// 斜体
let url = m
// 去掉结尾可能被一并匹配的中英文标点
const trail = url . match ( /[,。、!?;:,!?;:'")\]】》」』]+$/ )
if ( trail ) url = url . slice ( 0 , url . length - trail [ 0 ] . length )
const href = /^https?:\/\//i . test ( url ) ? url : 'https://' + url
return hold ( '<a href="' + href + '" style="color:' + LINK + ';text-decoration:none;">' + url + '</a>' )
} )
// 3) 手机号 / 400 电话 -> 蓝色链接, 抽离保护
. replace ( /\b(1[3-9]\d{9})\b/g , ( m ) => hold ( '<a href="tel:' + m + '" style="color:' + LINK + ';text-decoration:none;">' + m + '</a>' ) )
. replace ( /\b(400[\d-]{7,10})\b/g , ( m ) => hold ( '<a href="tel:' + m + '" style="color:' + LINK + ';text-decoration:none;">' + m + '</a>' ) )
// 4) 还原抽出的 markdown, 转成图片 / 蓝色锚点
t = t . replace ( /\u0000(\d+)\u0000/g , ( m , i ) => {
const orig = stash [ Number ( i ) ]
if ( orig . charAt ( 0 ) === '!' ) {
return orig . replace ( /!\[([^\]]*)\]\(([^)]+)\)/ , '<img src="$2" alt="$1" style="max-width:100%;border-radius:8rpx;margin:8rpx 0;display:block;" />' )
}
return orig . replace ( /\[([^\]]+)\]\(([^)]+)\)/ , '<a href="$2" style="color:' + LINK + ';text-decoration:none;">$1</a>' )
} )
// 5) 行内代码 / 加粗 / 斜体 / 删除线 (此时链接已抽离, 不会被误改)
t = t
. replace ( /`([^`]+)`/g , '<code style="background:#f2f3f5;padding:2rpx 10rpx;border-radius:6rpx;font-size:24rpx;font-family:monospace;color:#d63384;">$1</code>' )
. replace ( /\*\*(.+?)\*\*/g , '<strong style="font-weight:700;color:' + TEXT + ';">$1</strong>' )
. replace ( /\*(.+?)\*/g , '<em style="font-style:italic;">$1</em>' )
. replace ( /\*(.+?)\*/g , '<em style="font-style:italic;">$1</em>' )
// 删除线
. replace ( /~~(.+?)~~/g , '<del style="text-decoration:line-through;color:#999;">$1</del>' )
. replace ( /~~(.+?)~~/g , '<del style="text-decoration:line-through;color:#999;">$1</del>' )
// 手机号
. replace ( /\b(1[3-9]\d{9})\b/g , '<a href="tel:$1" style="color:#4a90d9;text-decoration:none;">$1</a>' )
return t
// 400电话
. replace ( /\b(400[\d-]{7,10})\b/g , '<a href="tel:$1" style="color:#4a90d9;text-decoration:none;">$1</a>' )
}
}
// 1. 转义HTML特殊字符
// 1. 转义HTML特殊字符
@@ -1248,7 +1274,7 @@ _handleStreamError(msg) {
const idx = codeBlocks . length
const idx = codeBlocks . length
const safe = code . replace ( /</g , '<' ) . replace ( />/g , '>' )
const safe = code . replace ( /</g , '<' ) . replace ( />/g , '>' )
codeBlocks . push (
codeBlocks . push (
` <pre style="background:#f5f5f5 ;padding:24 rpx;border-radius:8rpx;overflow-x:auto;font-size:24rpx;line-height:1.6;margin:16 rpx 0;white-space:pre-wrap;"> ` +
` <pre style="background:#f7f8fa ;padding:20 rpx;border-radius:8rpx;overflow-x:auto;font-size:24rpx;line-height:1.6;margin:12 rpx 0;white-space:pre-wrap;"> ` +
` <code style="background:transparent;padding:0;font-family:monospace;color:#333;font-size:24rpx;"> ${ safe } </code></pre> `
` <code style="background:transparent;padding:0;font-family:monospace;color:#333;font-size:24rpx;"> ${ safe } </code></pre> `
)
)
return ` %%CODEBLOCK_ ${ idx } %% `
return ` %%CODEBLOCK_ ${ idx } %% `
@@ -1274,21 +1300,21 @@ _handleStreamError(msg) {
while ( buf . length > 0 && buf [ buf . length - 1 ] === '' ) buf . pop ( )
while ( buf . length > 0 && buf [ buf . length - 1 ] === '' ) buf . pop ( )
if ( buf . length === 0 ) return
if ( buf . length === 0 ) return
const content = buf . map ( l => parseInline ( l ) ) . join ( '<br>' )
const content = buf . map ( l => parseInline ( l ) ) . join ( '<br>' )
out . push ( ` <div style="line-height:1.8 ;font-size:26 rpx;"> ${ content } </div> ` )
out . push ( ` <div style="line-height:1.75 ;font-size:30rpx;color: ${ TEXT } ;margin: 2rpx 0 ;"> ${ content } </div> ` )
buf = [ ]
buf = [ ]
}
}
const flushTable = ( ) => {
const flushTable = ( ) => {
if ( ! inTable || tableRows . length === 0 ) return
if ( ! inTable || tableRows . length === 0 ) return
const thead = tableRows [ 0 ] . map ( c =>
const thead = tableRows [ 0 ] . map ( c =>
` <th style="padding:8rpx 12rpx;border:1rpx solid #ddd ;font-weight:600;background:#f5f5f5 ;text-align:left;font-size:24rpx;"> ${ parseInline ( c ) } </th> `
` <th style="padding:8rpx 12rpx;border:1rpx solid #eaecef ;font-weight:600;background:#f6f8fa ;text-align:left;font-size:24rpx;color: ${ TEXT } ; "> ${ parseInline ( c ) } </th> `
) . join ( '' )
) . join ( '' )
const tbody = tableRows . slice ( 1 ) . map ( row =>
const tbody = tableRows . slice ( 1 ) . map ( row =>
'<tr>' + row . map ( c =>
'<tr>' + row . map ( c =>
` <td style="padding:8rpx 12rpx;border:1rpx solid #ddd ;line-height:1.6;font-size:24rpx;"> ${ parseInline ( c ) } </td> `
` <td style="padding:8rpx 12rpx;border:1rpx solid #eaecef ;line-height:1.6;font-size:24rpx;color: ${ TEXT } ; "> ${ parseInline ( c ) } </td> `
) . join ( '' ) + '</tr>'
) . join ( '' ) + '</tr>'
) . join ( '' )
) . join ( '' )
out . push (
out . push (
` <table style="border-collapse:collapse;margin:8 rpx 0;width:100%;font-size:24rpx;"> ` +
` <table style="border-collapse:collapse;margin:12 rpx 0;width:100%;font-size:24rpx;color: ${ TEXT } ; "> ` +
` <thead><tr> ${ thead } </tr></thead><tbody> ${ tbody } </tbody></table> `
` <thead><tr> ${ thead } </tr></thead><tbody> ${ tbody } </tbody></table> `
)
)
tableRows = [ ]
tableRows = [ ]
@@ -1327,17 +1353,17 @@ _handleStreamError(msg) {
if ( hMatch ) {
if ( hMatch ) {
flushBuf ( ) ; out . push ( closeList ( ) )
flushBuf ( ) ; out . push ( closeList ( ) )
const lv = hMatch [ 1 ] . length
const lv = hMatch [ 1 ] . length
const size = [ '' , '40rpx' , '36rpx' , '32rpx' , '28rpx' , '26rpx' , '24rpx' ] [ lv ]
const size = [ '' , '36rpx' , '32rpx' , '30rpx' , '28rpx' , '26rpx' , '24rpx' ] [ lv ]
const wgt = [ '' , '700' , '700' , '600' , '600' , '500' , '500' ] [ lv ]
const wgt = [ '' , '700' , '700' , '600' , '600' , '500' , '500' ] [ lv ]
const mg = [ '' , '20rpx 0 10rpx' , '16rpx 0 8rpx' , '14rpx 0 6rpx' , '12rpx 0 6rpx' , '10rpx 0 4rpx' , '8rpx 0 4rpx' ] [ lv ]
const mg = [ '' , '20rpx 0 10rpx' , '16rpx 0 8rpx' , '14rpx 0 6rpx' , '12rpx 0 6rpx' , '10rpx 0 4rpx' , '8rpx 0 4rpx' ] [ lv ]
out . push ( ` <h ${ lv } style="font-size: ${ size } ;font-weight: ${ wgt } ;margin: ${ mg } ;line-height:1.5 ;"> ${ parseInline ( hMatch [ 2 ] ) } </h ${ lv } > ` )
out . push ( ` <h ${ lv } style="font-size: ${ size } ;font-weight: ${ wgt } ;margin: ${ mg } ;line-height:1.35;color: ${ TEXT } ;"> ${ parseInline ( hMatch [ 2 ] ) } </h ${ lv } > ` )
continue
continue
}
}
// 分隔线
// 分隔线
if ( /^(-{3,}|\*{3,}|_{3,})\s*$/ . test ( trimmed ) ) {
if ( /^(-{3,}|\*{3,}|_{3,})\s*$/ . test ( trimmed ) ) {
flushBuf ( ) ; out . push ( closeList ( ) )
flushBuf ( ) ; out . push ( closeList ( ) )
out . push ( '<hr style="border:none;border-top:2rpx solid #e8e8e8 ;margin:20 rpx 0;">' )
out . push ( '<hr style="border:none;border-top:2rpx solid #eaecef ;margin:16 rpx 0;">' )
continue
continue
}
}
@@ -1345,7 +1371,7 @@ _handleStreamError(msg) {
const bqMatch = line . match ( /^>\s?(.+)$/ )
const bqMatch = line . match ( /^>\s?(.+)$/ )
if ( bqMatch ) {
if ( bqMatch ) {
flushBuf ( ) ; out . push ( closeList ( ) )
flushBuf ( ) ; out . push ( closeList ( ) )
out . push ( ` <blockquote style="border-left:4rpx solid #4a90d9 ;padding:10 rpx 20 rpx;margin:8rpx 0;background:#f8f9 fa;color:#666 ;line-height:1.8 ;border-radius:0 6rpx 6rpx 0;"> ${ parseInline ( bqMatch [ 1 ] ) } </blockquote> ` )
out . push ( ` <blockquote style="border-left:4rpx solid #d0d7de ;padding:8 rpx 16 rpx;margin:8rpx 0;background:#f6f8 fa;color:#57606a ;line-height:1.7;font-size:28rpx ;border-radius:0 6rpx 6rpx 0;"> ${ parseInline ( bqMatch [ 1 ] ) } </blockquote> ` )
continue
continue
}
}
@@ -1353,8 +1379,8 @@ _handleStreamError(msg) {
const ulMatch = line . match ( /^[-*+]\s+(.+)$/ )
const ulMatch = line . match ( /^[-*+]\s+(.+)$/ )
if ( ulMatch ) {
if ( ulMatch ) {
flushBuf ( )
flushBuf ( )
if ( ! inList ) { out . push ( '<ul style="padding-left:36 rpx;margin:6 rpx 0;">' ) ; inList = 'ul' }
if ( ! inList ) { out . push ( '<ul style="padding-left:30 rpx;margin:8 rpx 0;color:' + TEXT + '; ">' ) ; inList = 'ul' }
out . push ( ` <li style="margin:3 rpx 0;line-height:1.8 ;"> ${ parseInline ( ulMatch [ 1 ] ) } </li> ` )
out . push ( ` <li style="margin:5 rpx 0;line-height:1.75;font-size:30rpx ;"> ${ parseInline ( ulMatch [ 1 ] ) } </li> ` )
continue
continue
}
}
@@ -1362,8 +1388,8 @@ _handleStreamError(msg) {
const olMatch = line . match ( /^\d+\.\s+(.+)$/ )
const olMatch = line . match ( /^\d+\.\s+(.+)$/ )
if ( olMatch ) {
if ( olMatch ) {
flushBuf ( )
flushBuf ( )
if ( ! inList ) { out . push ( '<ol style="padding-left:36 rpx;margin:6 rpx 0;">' ) ; inList = 'ol' }
if ( ! inList ) { out . push ( '<ol style="padding-left:30 rpx;margin:8 rpx 0;color:' + TEXT + '; ">' ) ; inList = 'ol' }
out . push ( ` <li style="margin:3 rpx 0;line-height:1.8 ;"> ${ parseInline ( olMatch [ 1 ] ) } </li> ` )
out . push ( ` <li style="margin:5 rpx 0;line-height:1.75;font-size:30rpx ;"> ${ parseInline ( olMatch [ 1 ] ) } </li> ` )
continue
continue
}
}
@@ -1808,6 +1834,7 @@ _handleStreamError(msg) {
. trim ( )
. trim ( )
} ,
} ,
// 复制消息内容到剪贴板(兼容 H5 / 微信小程序)
// 复制消息内容到剪贴板(兼容 H5 / 微信小程序)
copyMessage ( message ) {
copyMessage ( message ) {
let copyText = message . content || ''
let copyText = message . content || ''
@@ -2397,7 +2424,7 @@ _handleStreamError(msg) {
}
}
. message - item {
. message - item {
margin - bottom : 28 rpx ;
margin - bottom : 20 rpx ;
scroll - margin - top : 300 rpx ;
scroll - margin - top : 300 rpx ;
scroll - margin - bottom : 200 rpx ;
scroll - margin - bottom : 200 rpx ;
@@ -2497,19 +2524,19 @@ _handleStreamError(msg) {
. message - text {
. message - text {
font - size : 30 rpx ;
font - size : 30 rpx ;
line - height : 1.6 ;
line - height : 1.5 ;
position : relative ;
position : relative ;
z - index : 1 ;
z - index : 1 ;
letter - spacing : 0.3 rpx ;
letter - spacing : 0.3 rpx ;
color : # 1 f1f1f ;
color : # 1 f1f1f ;
}
}
// 微信小程序: AI 文本消息降级为 selectable text,支持长按选择部分文字
// 微信小程序: AI 文本消息(H5 端走 v-html 时的外层容器)
. ai - message - text {
. ai - message - text {
font - size : 30 rpx ;
font - size : 30 rpx ;
line - height : 1.6 ;
line - height : 1.75 ;
color : # 1 f1f1f ;
color : # 1 a1a1a ;
white - space : pre - wrap ;
font - family : - apple - system , BlinkMacSystemFont , "PingFang SC" , "Helvetica Neue" , "Microsoft YaHei" , sans - serif ;
word - break : break - word ;
word - break : break - word ;
}
}
}
}
@@ -3127,7 +3154,7 @@ _handleStreamError(msg) {
word - wrap : break - word ;
word - wrap : break - word ;
/* pre-line: 保留后端 \n 的段落分隔, 但合并多个连续空白, 配合 parseMarkdown normalize 避免每字一行 */
/* pre-line: 保留后端 \n 的段落分隔, 但合并多个连续空白, 配合 parseMarkdown normalize 避免每字一行 */
white - space : pre - line ;
white - space : pre - line ;
line - height : 1.6 ;
line - height : 1.5 ;
& : : before {
& : : before {
display : none ! important ;
display : none ! important ;
@@ -3135,6 +3162,24 @@ _handleStreamError(msg) {
}
}
}
}
// 微信端 AI 富文本(豆包风格)容器: 行高/字号/字色统一兜底
. ai - message - rich {
display : block ;
font - size : 30 rpx ;
line - height : 1.75 ;
color : # 1 a1a1a ;
letter - spacing : 0.2 rpx ;
font - family : - apple - system , BlinkMacSystemFont , "PingFang SC" , "Helvetica Neue" , "Microsoft YaHei" , sans - serif ;
word - break : break - word ;
overflow - wrap : break - word ;
margin : 2 rpx 0 ;
}
// 富文本模式下隐藏 markdown 角标,更接近豆包观感
. markdown - header {
display : none ;
}
/* AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 朗读 / 更多 */
/* AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 朗读 / 更多 */
. ai - actions {
. ai - actions {
margin - top : 12 rpx ;
margin - top : 12 rpx ;
@@ -3665,7 +3710,7 @@ _handleStreamError(msg) {
padding : 16 rpx 24 rpx ;
padding : 16 rpx 24 rpx ;
. message - item {
. message - item {
margin - bottom : 24 rpx ;
margin - bottom : 16 rpx ;
. user - message , . ai - message {
. user - message , . ai - message {
. avatar {
. avatar {
@@ -3878,9 +3923,10 @@ _handleStreamError(msg) {
}
}
. selectable - md {
. selectable - md {
font - size : 28 rpx ;
font - size : 30 rpx ;
line - height : 1.7 ;
line - height : 1.75 ;
color : # 333 ;
color : # 1 a1a1a ;
font - family : - apple - system , BlinkMacSystemFont , "PingFang SC" , "Helvetica Neue" , "Microsoft YaHei" , sans - serif ;
// 保证 v-html 内部段落、列表等不影响选择
// 保证 v-html 内部段落、列表等不影响选择
p , ul , ol , li , h1 , h2 , h3 , h4 , h5 , h6 , code , pre {
p , ul , ol , li , h1 , h2 , h3 , h4 , h5 , h6 , code , pre {