chore:AI智能回答特殊文字有特殊显示

This commit is contained in:
2026-09-12 14:43:57 +08:00
parent 3fffab81c8
commit 21ee661cef
4 changed files with 95 additions and 49 deletions

View File

@@ -134,9 +134,9 @@ H5 正常,**只有微信小程序失败**。
**代码侧备选(无法改后台、只想先跑起来时)**:
- 写剪贴板在微信里只有 `wx.setClipboardData` 这一个 JS API,平台拦截 errno 112 时**任何代码都绕不过**,
所以「复制」按钮在没声明剪贴板权限前必然失败。
- 真正不依赖声明的复制方式是**原生长按选区复制**:本组件 AI 文本用 `<text selectable>` 渲染
(`pages_tool/ai-chat/ai-chat-message.vue` 的 `type==='text'` / `'markdown'` 分支),
用户**直接长按 AI 回复文字**→ 系统弹出「复制」即可,不调用 `setClipboardData`,不受隐私协议限制。
- 开发期想让「复制」按钮也能用:把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改成 `false`
(仅本地调试,上线前必须还原;部分基础库下生效,新基础库可能仍报 112,此时用上面的长按方式)。
- 失败时提示已引导用户「长按消息文字手动复制」(`isClipboardPrivacyError()` 识别 errno 112)。
- AI 回复的**展示**走 `<rich-text>` 富文本(豆包风格:加粗标题、悬挂缩进列表、彩色链接),由
`parseMarkdown()` 生成 HTML、`getAIDisplayContent()` 输出,H5 用 `v-html`、微信小程序用 `<rich-text :nodes>`。
- 富文本**不可长按选区复制**(微信 rich-text 不支持),复制只能走「复制」按钮(即 `wx.setClipboardData`),
因此必须声明剪贴板权限,或开发期把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改 `false`
(仅本地调试,上线前必须还原)。
- 失败时提示会引导用户复制不可用(`isClipboardPrivacyError()` 识别 errno 112)。

View File

@@ -35,7 +35,7 @@ export const lang = {
copySelected: 'Copied selected content',
copyFail: 'Copy failed',
copyFailWithReason: 'Copy failed: {reason}',
copyFailPrivacyReason: 'clipboard permission not declared, please long-press the message text to copy',
copyFailPrivacyReason: 'clipboard permission not declared, copy unavailable',
openingFile: 'Opening file...',
openingFileName: 'Opening file: {name}',
openingFileTitle: 'Opening: {title}',

View File

@@ -35,7 +35,7 @@ export const lang = {
copySelected: '已复制选中内容',
copyFail: '复制失败',
copyFailWithReason: '复制失败:{reason}',
copyFailPrivacyReason: '小程序未声明剪贴板权限,请长按消息文字手动复制',
copyFailPrivacyReason: '小程序未声明剪贴板权限,复制不可用',
openingFile: '打开文件中...',
openingFileName: '正在打开文件:{name}',
openingFileTitle: '正在打开:{title}',

View File

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