diff --git a/docs/weixin-bug.md b/docs/weixin-bug.md index 57c1457..303f73c 100644 --- a/docs/weixin-bug.md +++ b/docs/weixin-bug.md @@ -134,9 +134,9 @@ H5 正常,**只有微信小程序失败**。 **代码侧备选(无法改后台、只想先跑起来时)**: - 写剪贴板在微信里只有 `wx.setClipboardData` 这一个 JS API,平台拦截 errno 112 时**任何代码都绕不过**, 所以「复制」按钮在没声明剪贴板权限前必然失败。 -- 真正不依赖声明的复制方式是**原生长按选区复制**:本组件 AI 文本用 `` 渲染 - (`pages_tool/ai-chat/ai-chat-message.vue` 的 `type==='text'` / `'markdown'` 分支), - 用户**直接长按 AI 回复文字**→ 系统弹出「复制」即可,不调用 `setClipboardData`,不受隐私协议限制。 -- 开发期想让「复制」按钮也能用:把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改成 `false` - (仅本地调试,上线前必须还原;部分基础库下生效,新基础库可能仍报 112,此时用上面的长按方式)。 -- 失败时提示已引导用户「长按消息文字手动复制」(`isClipboardPrivacyError()` 识别 errno 112)。 +- AI 回复的**展示**走 `` 富文本(豆包风格:加粗标题、悬挂缩进列表、彩色链接),由 + `parseMarkdown()` 生成 HTML、`getAIDisplayContent()` 输出,H5 用 `v-html`、微信小程序用 ``。 +- 富文本**不可长按选区复制**(微信 rich-text 不支持),复制只能走「复制」按钮(即 `wx.setClipboardData`), + 因此必须声明剪贴板权限,或开发期把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改 `false` + (仅本地调试,上线前必须还原)。 +- 失败时提示会引导用户复制不可用(`isClipboardPrivacyError()` 识别 errno 112)。 diff --git a/lang/en-us/ai-chat/index.js b/lang/en-us/ai-chat/index.js index c10f96d..61b6573 100644 --- a/lang/en-us/ai-chat/index.js +++ b/lang/en-us/ai-chat/index.js @@ -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}', diff --git a/lang/zh-cn/ai-chat/index.js b/lang/zh-cn/ai-chat/index.js index 6666241..6eb1e2a 100644 --- a/lang/zh-cn/ai-chat/index.js +++ b/lang/zh-cn/ai-chat/index.js @@ -35,7 +35,7 @@ export const lang = { copySelected: '已复制选中内容', copyFail: '复制失败', copyFailWithReason: '复制失败:{reason}', - copyFailPrivacyReason: '小程序未声明剪贴板权限,请长按消息文字手动复制', + copyFailPrivacyReason: '小程序未声明剪贴板权限,复制不可用', openingFile: '打开文件中...', openingFileName: '正在打开文件:{name}', openingFileTitle: '正在打开:{title}', diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index be35b00..3ce309c 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -69,7 +69,7 @@ - {{ _stripMarkdown(message.content) }} + @@ -84,7 +84,7 @@ - {{ _stripMarkdown(message.content) }} + @@ -1215,25 +1215,51 @@ _handleStreamError(msg) { } } catch (e) { /* 忽略, 走原始 content */ } + // 主文字色 / 链接色 (偏豆包风格的近黑 + 蓝) + const TEXT = '#1a1a1a' + const LINK = '#3a7afe' + // 辅助:内联格式处理 + // 思路: 先把"链接/图片/裸URL/电话"整体抽成占位符保护起来, 再做加粗/斜体等, + // 最后还原, 避免各规则互相串改对方的 href / 数字。 const parseInline = (text) => { - return text - // 行内图片 - .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1') - // 链接 - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') - // 行内代码 - .replace(/`([^`]+)`/g, '$1') - // 粗体(优先匹配**) - .replace(/\*\*(.+?)\*\*/g, '$1') - // 斜体 + 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('' + url + '') + }) + // 3) 手机号 / 400 电话 -> 蓝色链接, 抽离保护 + .replace(/\b(1[3-9]\d{9})\b/g, (m) => hold('' + m + '')) + .replace(/\b(400[\d-]{7,10})\b/g, (m) => hold('' + m + '')) + + // 4) 还原抽出的 markdown, 转成图片 / 蓝色锚点 + t = t.replace(/\u0000(\d+)\u0000/g, (m, i) => { + const orig = stash[Number(i)] + if (orig.charAt(0) === '!') { + return orig.replace(/!\[([^\]]*)\]\(([^)]+)\)/, '$1') + } + return orig.replace(/\[([^\]]+)\]\(([^)]+)\)/, '$1') + }) + + // 5) 行内代码 / 加粗 / 斜体 / 删除线 (此时链接已抽离, 不会被误改) + t = t + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') - // 删除线 .replace(/~~(.+?)~~/g, '$1') - // 手机号 - .replace(/\b(1[3-9]\d{9})\b/g, '$1') - // 400电话 - .replace(/\b(400[\d-]{7,10})\b/g, '$1') + + return t } // 1. 转义HTML特殊字符 @@ -1248,7 +1274,7 @@ _handleStreamError(msg) { const idx = codeBlocks.length const safe = code.replace(//g, '>') codeBlocks.push( - `
` +
+          `
` +
           `${safe}
` ) 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('
') - out.push(`
${content}
`) + out.push(`
${content}
`) buf = [] } const flushTable = () => { if (!inTable || tableRows.length === 0) return const thead = tableRows[0].map(c => - `${parseInline(c)}` + `${parseInline(c)}` ).join('') const tbody = tableRows.slice(1).map(row => '' + row.map(c => - `${parseInline(c)}` + `${parseInline(c)}` ).join('') + '' ).join('') out.push( - `` + + `
` + `${thead}${tbody}
` ) 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(`${parseInline(hMatch[2])}`) + out.push(`${parseInline(hMatch[2])}`) continue } // 分隔线 if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) { flushBuf(); out.push(closeList()) - out.push('
') + out.push('
') continue } @@ -1345,7 +1371,7 @@ _handleStreamError(msg) { const bqMatch = line.match(/^>\s?(.+)$/) if (bqMatch) { flushBuf(); out.push(closeList()) - out.push(`
${parseInline(bqMatch[1])}
`) + out.push(`
${parseInline(bqMatch[1])}
`) continue } @@ -1353,8 +1379,8 @@ _handleStreamError(msg) { const ulMatch = line.match(/^[-*+]\s+(.+)$/) if (ulMatch) { flushBuf() - if (!inList) { out.push('
    '); inList = 'ul' } - out.push(`
  • ${parseInline(ulMatch[1])}
  • `) + if (!inList) { out.push('
      '); inList = 'ul' } + out.push(`
    • ${parseInline(ulMatch[1])}
    • `) continue } @@ -1362,8 +1388,8 @@ _handleStreamError(msg) { const olMatch = line.match(/^\d+\.\s+(.+)$/) if (olMatch) { flushBuf() - if (!inList) { out.push('
        '); inList = 'ol' } - out.push(`
      1. ${parseInline(olMatch[1])}
      2. `) + if (!inList) { out.push('
          '); inList = 'ol' } + out.push(`
        1. ${parseInline(olMatch[1])}
        2. `) 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 {