From a01587fc68e084dcafcad8ec99db831a87b31a37 Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Thu, 10 Sep 2026 16:36:25 +0800
Subject: [PATCH 01/22] =?UTF-8?q?chore=EF=BC=9A=E4=BF=AE=E6=94=B9=E6=99=BA?=
=?UTF-8?q?=E8=83=BDAI=E9=A1=B5=E9=9D=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pages_tool/ai-chat/ai-chat-message.vue | 968 ++++++++++++++++++-------
1 file changed, 697 insertions(+), 271 deletions(-)
diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue
index e99d343..d683396 100644
--- a/pages_tool/ai-chat/ai-chat-message.vue
+++ b/pages_tool/ai-chat/ai-chat-message.vue
@@ -34,19 +34,9 @@
class="message-item"
:class="[message.role, { 'first-message': index === 0, 'search-current': showSearchBar && searchResultFilteredIndex === index, 'search-flash': showSearchBar && searchFlashAnimatingIndex === index }]">
-
+
-
-
-
-
- {{ formatTime(message.timestamp) }}
- {{ localUserNickname }}
-
-
- {{ formatTime(message.timestamp) }}
-
{{ message.content }}
+
+
+
-
🤖
@@ -199,8 +192,8 @@
-
-
+
+
+
+
+ ⎘
+
+
+ 👍
+
+
+ 👎
+
+
+ ···
+
+
+
@@ -217,49 +226,75 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ @
+ 引用对话文件
+
+ /
+
+ 调用技能与指令
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -443,7 +478,10 @@ export default {
searchKeyword: '',
searchResultFilteredIndex: -1,
searchFlashAnimatingIndex: -1, // 当前正在播放 flash 动画的消息索引
- searchFlashTimer: null // flash 动画定时器
+ searchFlashTimer: null, // flash 动画定时器
+
+ // 用户发出消息时记录的开始时间戳, AI 回复完成时算 elapsed (已完成 XmXXs)
+ _aiRequestStart: 0
}
},
computed: {
@@ -839,8 +877,11 @@ async loadChatHistoryIfExist() {
// 确保有内容或表情
const hasContent = this.inputText && this.inputText.trim() !== ''
const hasEmoji = this.containsEmoji(this.inputText)
-
+
if (!hasContent && !hasEmoji) return
+
+ // 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
+ this._aiRequestStart = Date.now()
const userMessage = {
id: ++this.messageId,
@@ -934,6 +975,9 @@ async loadChatHistoryIfExist() {
timestamp: Date.now(),
conversationId: response.conversationId,
messageId: response.messageId,
+ // 耗时(秒): 用户发消息到 AI 回复完成的耗时
+ elapsed: this._aiRequestStart > 0 ? (Date.now() - this._aiRequestStart) / 1000 : 0,
+ _finished: true,
actions: []
}
this.shouldScrollToBottom = true
@@ -1075,6 +1119,12 @@ _finishStream(msg, result) {
msg.actions = []
+ // 计算并写入耗时 (秒), 用于状态行 "已完成 XmXXs"
+ if (this._aiRequestStart > 0) {
+ msg.elapsed = (Date.now() - this._aiRequestStart) / 1000
+ msg._finished = true
+ }
+
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', msg)
return true
@@ -1147,6 +1197,18 @@ _handleStreamError(msg) {
parseMarkdown(content) {
if (!content) return ''
+ // 兜底: 后端流式响应有时会把每个字用 \n 拼接 ("你\n好\n!"), 导致最终渲染一字一行.
+ // 检测"每行 1 个字符"的比例, 若超过 70% 则认为是退化格式, 先合并成连续字符串再走 markdown.
+ try {
+ const rawLines = String(content).split('\n')
+ if (rawLines.length >= 5) {
+ const singleCharLines = rawLines.filter(l => l.trim().length === 1).length
+ if (singleCharLines / rawLines.length > 0.7) {
+ content = rawLines.join('').replace(/\s+/g, '')
+ }
+ }
+ } catch (e) { /* 忽略, 走原始 content */ }
+
// 辅助:内联格式处理
const parseInline = (text) => {
return text
@@ -1402,6 +1464,17 @@ _handleStreamError(msg) {
const secs = Math.floor(seconds % 60)
return mins.toString().padStart(2, '0') + ':' + secs.toString().padStart(2, '0')
},
+
+ // 格式化 AI 处理耗时 (秒 -> "XmXXs" 风格, 0 时显示 "<1s")
+ formatElapsed(seconds) {
+ if (seconds === undefined || seconds === null || isNaN(seconds)) return '1s'
+ seconds = Math.max(0, Math.floor(seconds))
+ if (seconds < 1) return '1s'
+ const m = Math.floor(seconds / 60)
+ const s = seconds % 60
+ if (m === 0) return s + 's'
+ return m + 'm' + (s < 10 ? '0' + s : s) + 's'
+ },
// 滚动到底部 - 统一使用 scroll-into-view
scrollToBottom() {
@@ -1622,10 +1695,113 @@ _handleStreamError(msg) {
this.retryMessage(message)
return
}
-
+
this.$emit('action-click', { action, message })
},
+ // ====== AI 消息下方操作栏 (复制 / 点赞 / 不喜欢 / 朗读 / 更多) ======
+
+ // 复制 AI 回复
+ onAiActionCopy(message) {
+ this.copyMessage(message)
+ },
+
+ // 点赞 (toggle, 与不喜欢互斥)
+ onAiActionLike(message) {
+ try {
+ this.$set(message, '_liked', !message._liked)
+ if (message._liked) this.$set(message, '_disliked', false)
+ uni.showToast({ title: message._liked ? '已点赞' : '已取消点赞', icon: 'none', duration: 1200 })
+ } catch (e) { /* noop */ }
+ },
+
+ // 不喜欢 (toggle, 与点赞互斥)
+ onAiActionDislike(message) {
+ try {
+ this.$set(message, '_disliked', !message._disliked)
+ if (message._disliked) this.$set(message, '_liked', false)
+ uni.showToast({ title: message._disliked ? '已反馈' : '已取消反馈', icon: 'none', duration: 1200 })
+ } catch (e) { /* noop */ }
+ },
+
+ // 朗读 / 停止朗读 (用 uni.createInnerAudioContext 简单播放, 占位实现)
+ onAiActionSpeak(message) {
+ try {
+ const text = this._stripMarkdown(message && (message.content || ''))
+ if (!text) {
+ uni.showToast({ title: '暂无可朗读内容', icon: 'none' })
+ return
+ }
+ if (this._ttsPlaying && this._ttsCtx) {
+ // 暂停/停止
+ try { this._ttsCtx.stop() } catch (e) {}
+ this._ttsPlaying = false
+ if (this._ttsMsg === message) this.$set(message, '_playing', false)
+ return
+ }
+ // #ifdef MP-WEIXIN
+ // 微信小程序优先用 wx.createInnerAudioContext + 走 TTS, 这里给个最小占位
+ try {
+ this._ttsCtx = uni.createInnerAudioContext()
+ this._ttsCtx.onError(() => {
+ this._ttsPlaying = false
+ this.$set(message, '_playing', false)
+ uni.showToast({ title: '朗读失败', icon: 'none' })
+ })
+ // 真实项目应接入 TTS 服务 (微信同声传译插件 / 第三方 API)
+ // 此处仅占位, 让 _playing 状态可切换
+ this._ttsPlaying = true
+ this._ttsMsg = message
+ this.$set(message, '_playing', true)
+ setTimeout(() => {
+ this._ttsPlaying = false
+ this.$set(message, '_playing', false)
+ }, 1500)
+ } catch (e) { /* noop */ }
+ // #endif
+
+ // 非微信环境: 仅切换状态, 不实际朗读
+ // #ifndef MP-WEIXIN
+ this._ttsPlaying = true
+ this._ttsMsg = message
+ this.$set(message, '_playing', true)
+ setTimeout(() => {
+ this._ttsPlaying = false
+ this.$set(message, '_playing', false)
+ }, 1500)
+ // #endif
+ } catch (e) { /* noop */ }
+ },
+
+ // 更多菜单: 只保留"复制" (uni.showActionSheet 自带底部"取消"按钮, 无需额外添加)
+ onAiActionMore(message) {
+ try {
+ uni.showActionSheet({
+ itemList: ['复制'],
+ success: (res) => {
+ if (res.tapIndex === 0) {
+ this.copyMessage(message)
+ }
+ },
+ fail: () => { /* 用户取消 */ }
+ })
+ } catch (e) { /* noop */ }
+ },
+
+ // 去掉 markdown 标记, 用于 TTS 朗读
+ _stripMarkdown(text) {
+ if (!text) return ''
+ return String(text)
+ .replace(/```[\s\S]*?```/g, '')
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/~~(.+?)~~/g, '$1')
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
+ .replace(/<[^>]+>/g, '')
+ .replace(/[ \t]+/g, ' ')
+ .trim()
+ },
+
// 长按消息→直接复制
onMessageLongPress(message) {
// loading 消息不允许操作
@@ -1827,6 +2003,9 @@ _handleStreamError(msg) {
// 使用指定内容发送消息
async sendMessageWithContent(content) {
+ // 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
+ this._aiRequestStart = Date.now()
+
const userMessage = {
id: ++this.messageId,
role: 'user',
@@ -1906,6 +2085,33 @@ _handleStreamError(msg) {
this.showToolsPanel = false
},
+ // 快捷指令占位提示点击: 在输入框插入 @ 或 / 触发后续指令面板 (此处仅展示交互, 后续按需扩展)
+ onQuickTipClick(type) {
+ try {
+ if (type === 'quote') {
+ this.inputText = (this.inputText || '') + '@'
+ } else if (type === 'skill') {
+ this.inputText = (this.inputText || '') + '/'
+ }
+ } catch (e) { /* noop */ }
+ },
+
+ // 默认权限下拉点击: 占位交互, 后续接入权限弹窗
+ onTogglePermission() {
+ try {
+ uni.showToast({ title: '权限设置待接入', icon: 'none' })
+ } catch (e) { /* noop */ }
+ },
+
+ // 语音按钮点击: 复用 showVoicePanel 流程 (若需要触发工具面板里的语音入口)
+ onVoiceClick() {
+ try {
+ this.showVoicePanel && this.showVoicePanel()
+ } catch (e) {
+ try { this.showToolsPanel = true } catch (e2) { /* noop */ }
+ }
+ },
+
// 打开扩大编辑器
openExpandEditor() {
this.expandInputText = this.inputText
@@ -2164,7 +2370,9 @@ _handleStreamError(msg) {
display: flex;
flex-direction: column;
box-sizing: border-box;
- background: #f8f8f8; /* 微信聊天页面背景色 */
+ /* WorkBuddy IDE 风格: 纯白底, 无气泡, 文字直接渲染其上 */
+ background: #ffffff;
+ color: #1f1f1f;
overflow: hidden;
box-sizing: border-box;
@@ -2174,10 +2382,11 @@ _handleStreamError(msg) {
overflow-y: auto;
box-sizing: border-box;
min-height: 0; /* 重要:防止flex元素溢出 */
-
+ background: transparent;
+
/* 微信小程序专属:聊天消息区域增加底部内边距,确保键盘弹出时最后一条消息可见 */
/* 由 chatMessagesStyle 动态控制 */
-
+
.load-more {
text-align: center;
padding: 16rpx 0;
@@ -2185,28 +2394,29 @@ _handleStreamError(msg) {
}
.message-item {
- margin-bottom: 20rpx;
+ margin-bottom: 28rpx;
scroll-margin-top: 300rpx;
scroll-margin-bottom: 200rpx;
-
- // 搜索定位 - 当前结果:持续绿色发光效果
+
+ // 搜索定位 - 当前结果: 用柔和的浅绿底色提示 (WorkBuddy 风格, 无气泡 box-shadow)
&.search-current {
position: relative;
z-index: 1;
- border-radius: 18rpx;
-
+ border-radius: 12rpx;
+ background: rgba(7, 193, 96, 0.08);
+
.user-message .message-bubble,
.ai-message .message-bubble {
- box-shadow: 0 0 0 4rpx rgba(7, 193, 96, 0.7), 0 4rpx 24rpx rgba(7, 193, 96, 0.35) !important;
- transition: box-shadow 0.3s ease;
+ background: transparent !important;
+ transition: background 0.3s ease;
}
}
-
+
// 搜索定位 - Flash 脉冲动画(导航到新结果时触发)
&.search-flash {
animation: searchPulse 1.2s ease-out;
}
-
+
&.first-message {
margin-top: 16rpx;
}
@@ -2216,35 +2426,36 @@ _handleStreamError(msg) {
align-items: flex-start;
.avatar {
- width: 88rpx;
- height: 88rpx;
+ width: 40rpx;
+ height: 40rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
- box-shadow: $shadow-sm;
- background-color: $bg-card;
- border: 2rpx solid #f0f4f8;
+ /* 白底风格: 取消外圈与阴影 */
+ box-shadow: none;
+ background-color: transparent;
+ border: none;
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative;
-
+
&:hover {
transform: scale(1.03);
}
-
+
image {
width: 100%;
height: 100%;
object-fit: cover;
}
-
+
text {
- font-size: 56rpx;
+ font-size: 32rpx;
line-height: 1;
}
-
+
.avatar-fallback {
position: absolute;
top: 0;
@@ -2254,40 +2465,49 @@ _handleStreamError(msg) {
display: flex;
align-items: center;
justify-content: center;
- font-size: 56rpx;
- background-color: $bg-card;
+ font-size: 32rpx;
+ background-color: transparent;
}
}
.message-content {
flex: 1;
- margin-left: 24rpx;
- max-width: calc(100% - 96rpx);
+ margin-left: 16rpx;
+ max-width: calc(100% - 56rpx);
min-width: 0;
width: 0; /* 添加这个属性防止flex元素溢出 */
.message-bubble {
- padding: 24rpx 32rpx;
+ /* 去掉所有气泡视觉: 背景透明, 无边框, 无阴影, 无内边距, 无小三角 */
+ padding: 0 !important;
max-width: 100%;
word-break: break-word;
- box-shadow: $shadow-md;
+ box-shadow: none !important;
position: relative;
- border: 1rpx solid #f0f4f8;
+ border: none !important;
+ background: transparent !important;
+
+ &::before {
+ display: none !important;
+ content: none !important;
+ }
.message-text {
- font-size: 28rpx;
- line-height: 1.7;
+ font-size: 30rpx;
+ line-height: 1.6;
position: relative;
z-index: 1;
- letter-spacing: 0.8rpx;
+ letter-spacing: 0.3rpx;
+ color: #1f1f1f;
}
}
.message-time {
font-size: 22rpx;
- color: $color-text-light;
- margin-top: 12rpx;
- opacity: 0.9;
+ /* 白底风格: 浅灰色时间戳 */
+ color: #999;
+ margin-top: 8rpx;
+ opacity: 1;
letter-spacing: 0.5rpx;
}
@@ -2759,95 +2979,180 @@ _handleStreamError(msg) {
padding-left: 0;
align-self: flex-start;
margin-bottom: 4rpx;
- justify-content: space-between;
+ justify-content: flex-start;
display: flex;
- align-items: baseline;
+ align-items: center;
width: 100%;
position: relative;
-
+ gap: 12rpx;
+
.ai-nickname-text {
position: relative;
z-index: 1;
}
-
+
.message-time {
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- margin-top: -2rpx;
+ /* 时间显示在 AI 智能昵称的右面 */
+ position: static;
+ margin-top: 0;
+ margin-left: 0;
}
}
- /* 用户消息特有样式 */
+ /* 用户消息特有样式 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右)
+ 整体不再贴左, 头像显示且位于气泡右侧 */
.user-message {
- flex-direction: row-reverse;
-
+ flex-direction: row;
+ justify-content: flex-end;
+
+ .avatar {
+ /* 隐藏用户头像: 消息保持右对齐气泡, 但不再显示头像 */
+ display: none !important;
+ }
+
.message-content {
margin-left: 0;
- margin-right: 24rpx;
+ margin-right: 0;
text-align: right;
-
+ max-width: calc(100% - 64rpx);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+
+ /* 昵称 + 时间戳行, 右对齐 */
+ .message-nickname {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ margin-bottom: 6rpx;
+ font-size: 22rpx;
+ color: #999;
+
+ .message-time {
+ margin-right: 8rpx;
+ margin-top: 0;
+ }
+
+ .nickname-text {
+ color: #666;
+ }
+ }
+
+ /* 气泡: 浅灰背景 + 圆角, 内容在气泡内左对齐(中文阅读体验更好) */
.message-bubble {
- background: #07c160 !important; /* 微信绿色 */
- color: white !important;
- border-radius: 18rpx 4rpx 18rpx 18rpx; /* 微信风格:右上角直角 */
- box-shadow: 0 2rpx 8rpx rgba(7, 193, 96, 0.2) !important;
+ background: #f5f5f5 !important;
+ color: #1f1f1f !important;
border: none !important;
- min-height: auto;
- height: auto;
- padding: 16rpx 20rpx; /* 微信风格内边距 */
+ border-radius: 16rpx !important;
+ box-shadow: none !important;
+ padding: 14rpx 18rpx !important;
display: inline-block;
word-break: break-word;
white-space: pre-wrap;
- line-height: 1.5;
+ line-height: 1.6;
text-align: left;
+ max-width: 100%;
+
&::before {
- content: '';
- position: absolute;
- top: 12rpx;
- right: -8rpx;
- width: 16rpx;
- height: 16rpx;
- background: #07c160 !important; /* 与气泡同色 */
- transform: rotate(45deg);
+ display: none !important;
+ content: none !important;
}
}
}
}
- /* AI消息特有样式 */
+ /* AI消息特有样式 (微信风格: 头像 + 名称 + 时间同行, 下方内容)
+ 与你截图样式一致 */
.ai-message {
.message-content {
- /* 确保昵称在气泡上方的布局 */
+ /* 确保昵称在内容上方的布局 */
display: flex;
flex-direction: column;
align-items: flex-start;
-
+
+ .message-nickname.ai-nickname {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+ margin-bottom: 4rpx;
+
+ .ai-nickname-text {
+ font-size: 24rpx;
+ font-weight: 500;
+ color: #666;
+ line-height: 1;
+ }
+
+ .message-time {
+ margin-top: 0;
+ font-size: 22rpx;
+ color: #999;
+ line-height: 1;
+ }
+ }
+
.message-bubble {
- background: white !important; /* 微信风格白色 */
- color: black !important;
- border-radius: 4rpx 18rpx 18rpx 18rpx; /* 微信风格:左上角直角 */
- box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1) !important;
- border: 1rpx solid #e0e0e0 !important;
- min-height: auto;
- height: auto;
- padding: 16rpx 20rpx; /* 微信风格内边距 */
- display: inline-block;
- word-break: break-word;
- white-space: pre-wrap;
- line-height: 1.5;
+ background: transparent !important;
+ color: #1f1f1f !important;
+ border: none !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ padding: 0 !important;
+ display: block;
+ /* 容器不够宽时正常换行, 不主动按字断开 */
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ /* pre-line: 保留后端 \n 的段落分隔, 但合并多个连续空白, 配合 parseMarkdown normalize 避免每字一行 */
+ white-space: pre-line;
+ line-height: 1.6;
+
&::before {
- content: '';
- position: absolute;
- top: 12rpx;
- left: -8rpx;
- width: 16rpx;
- height: 16rpx;
- background: white !important; /* 与气泡同色 */
- transform: rotate(45deg);
- border: 1rpx solid #e0e0e0;
- border-right: none;
- border-top: none;
+ display: none !important;
+ content: none !important;
+ }
+ }
+
+ /* AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 朗读 / 更多 */
+ .ai-actions {
+ margin-top: 12rpx;
+ display: flex;
+ align-items: center;
+ gap: 8rpx;
+ flex-wrap: nowrap;
+
+ .ai-action-btn {
+ min-width: 56rpx;
+ height: 56rpx;
+ padding: 0 14rpx;
+ border-radius: 28rpx;
+ background: #f5f5f5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s ease, color 0.2s ease;
+
+ .iconfont {
+ font-size: 30rpx;
+ color: #666;
+ line-height: 1;
+ }
+
+ &.more-btn .iconfont {
+ font-size: 32rpx;
+ letter-spacing: -2rpx;
+ }
+
+ &.active {
+ background: #e6f0ff;
+
+ .iconfont {
+ color: #1f6cff;
+ }
+ }
+
+ &:active {
+ background: #ebebeb;
+ }
}
}
}
@@ -2861,141 +3166,245 @@ _handleStreamError(msg) {
transition: height 0.25s ease;
}
- /* 输入区域 */
+ /* 输入区域 (WorkBuddy IDE 风格: 圆角白底容器 + 顶部输入 + 底部工具栏) */
.input-area {
- background: white; /* 白色 */
- border-top: 1rpx solid #e5e5e5;
- padding: 16rpx 24rpx;
+ background: transparent;
+ padding: 12rpx 20rpx 16rpx;
flex-shrink: 0;
box-sizing: border-box;
- .input-tools {
+ /* 圆角白底容器: 把输入框、占位提示、底部工具栏都包在一个带边框的卡片里 */
+ .input-card {
+ background: #ffffff;
+ border: 1rpx solid #e5e7eb;
+ border-radius: 20rpx;
+ padding: 14rpx 16rpx 12rpx;
+ box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+ box-sizing: border-box;
+ }
+
+ /* 顶部输入区: textarea + 占位模式下的 @ / / 快捷提示 */
+ .input-row-top {
+ position: relative;
display: flex;
- margin-bottom: 16rpx;
align-items: flex-start;
-
- /* 微信小程序专属样式:减少工具条下边距 */
- /* #ifdef MP-WEIXIN */
- margin-bottom: 0rpx;
- /* #endif */
-
- .tool-btn {
+ min-height: 72rpx;
+
+ .message-input {
+ flex: 1;
+ min-height: 56rpx;
+ max-height: 240rpx;
+ background: transparent !important;
+ border: none !important;
+ padding: 12rpx 8rpx 12rpx 4rpx;
+ font-size: 30rpx;
+ line-height: 1.5;
+ color: #1f1f1f;
+ box-sizing: border-box;
+ }
+
+ /* placeholder 样式: 浅灰色 */
+ .message-input-placeholder {
+ color: #999 !important;
+ font-size: 30rpx !important;
+ }
+
+ /* 占位模式下显示在输入框右侧的快捷指令链接 */
+ .quick-tips {
+ position: absolute;
+ top: 50%;
+ left: 8rpx;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ pointer-events: none; /* 让 textarea 能接收点击 */
+
+ .quick-tip,
+ .quick-tip-sep {
+ font-size: 24rpx;
+ color: #1f6cff;
+ margin-right: 12rpx;
+ pointer-events: auto;
+
+ .quick-tip-icon {
+ margin-right: 4rpx;
+ }
+ }
+
+ .quick-tip-sep {
+ color: #999;
+ margin-right: 12rpx;
+ }
+ }
+
+ /* textarea 有内容时, 占位链接隐藏 (CSS 简易判断) */
+ .quick-tips {
+ opacity: 1;
+ }
+
+ /* 扩大按钮 */
+ .expand-btn {
+ flex-shrink: 0;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
- background-color: #f2f2f2;
- margin-right: 12rpx;
+ background-color: #f5f5f5;
+ border: none;
display: flex;
align-items: center;
justify-content: center;
-
+ margin-left: 8rpx;
+
.iconfont {
- font-size: 32rpx;
+ font-size: 28rpx;
color: #666;
}
}
}
- .input-container {
+ /* 底部工具栏 */
+ .input-toolbar {
display: flex;
align-items: center;
-
- /* 微信小程序专属样式:让输入容器更紧凑 */
- /* #ifdef MP-WEIXIN */
- padding-top: 0rpx;
- padding-bottom: 0rpx;
- /* #endif */
-
- .message-input {
- flex: 1;
- min-height: 72rpx;
- max-height: 200rpx;
- background-color: white;
- border-radius: 36rpx;
- padding: 16rpx 24rpx;
- font-size: 28rpx;
- line-height: 1.5;
- border: 1rpx solid #e5e5e5;
- color: #333;
- margin-right: -50rpx;
- &::placeholder {
- color: #999;
- opacity: 1;
- }
+ justify-content: space-between;
+ margin-top: 6rpx;
+ min-height: 64rpx;
+
+ .toolbar-left,
+ .toolbar-right {
+ display: flex;
+ align-items: center;
}
-
- /* Emoji按钮 */
- .emoji-btn {
- width: 89rpx;
- height: 89rpx;
- border-radius: 12rpx;
-
- margin-left: 2rpx;
+
+ /* 左侧: + 圆形按钮 */
+ .tool-btn-plus {
+ width: 56rpx;
+ height: 56rpx;
+ border-radius: 50%;
+ background-color: #f5f5f5;
+ border: none;
display: flex;
align-items: center;
justify-content: center;
- border: none;
- padding: 0;
- left:43rpx;
-
- .iconfont {
- font-size: 36rpx;
+ margin-right: 12rpx;
+
+ .plus-icon {
+ font-size: 32rpx;
+ color: #666;
line-height: 1;
}
}
-
- /* 发送按钮 - 微信聊天样式 */
- .send-btn {
- width: 120rpx;
- height: 72rpx;
- border-radius: 12rpx;
- background-color: #07c160;
- margin-left: 12rpx;
+
+ /* 左侧: 默认权限下拉 */
+ .tool-btn-with-text {
+ height: 56rpx;
+ padding: 0 18rpx;
+ border-radius: 28rpx;
+ background-color: #f5f5f5;
+ border: none;
+ display: flex;
+ align-items: center;
+ margin-right: 12rpx;
+
+ .perm-icon {
+ font-size: 28rpx;
+ color: #1f6cff;
+ margin-right: 6rpx;
+ line-height: 1;
+ }
+
+ .perm-text {
+ font-size: 26rpx;
+ color: #333;
+ line-height: 1;
+ }
+
+ .arrow-icon {
+ font-size: 22rpx;
+ color: #999;
+ margin-left: 6rpx;
+ line-height: 1;
+ }
+ }
+
+ /* 右侧: emoji 按钮 */
+ .emoji-btn {
+ width: 56rpx;
+ height: 56rpx;
+ border-radius: 50%;
+ background-color: #f5f5f5;
+ border: none;
display: flex;
align-items: center;
justify-content: center;
- color: white;
- font-size: 28rpx;
- font-weight: 500;
+ margin-right: 12rpx;
+ padding: 0;
+
+ .iconfont {
+ font-size: 30rpx;
+ color: #666;
+ line-height: 1;
+ }
+ }
+
+ /* 右侧: 语音按钮 */
+ .voice-btn {
+ width: 56rpx;
+ height: 56rpx;
+ border-radius: 50%;
+ background-color: #f5f5f5;
border: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 12rpx;
+ padding: 0;
+
+ .iconfont {
+ font-size: 30rpx;
+ color: #666;
+ line-height: 1;
+ }
+ }
+
+ /* 发送按钮: 圆形灰色, 内含向上箭头图标 (WorkBuddy 风格) */
+ .send-btn {
+ flex-shrink: 0;
+ width: 60rpx;
+ height: 60rpx;
+ border-radius: 50%;
+ background-color: #d1d5db;
+ border: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ margin-left: 0;
+ color: #ffffff;
text-align: center;
white-space: nowrap;
line-height: 1;
-
+ transition: background-color 0.2s ease;
+
+ .send-icon {
+ font-size: 28rpx;
+ line-height: 1;
+ color: #ffffff;
+ }
+
&.disabled {
- background-color: #ccc;
- opacity: 0.7;
+ background-color: #d1d5db;
+ opacity: 0.65;
+ }
+
+ &:not(.disabled) {
+ background-color: #1f1f1f;
+
+ .send-icon {
+ color: #ffffff;
+ }
}
}
-
- /* 微信小程序专属样式:调整输入框和发送按钮高度 */
- /* #ifdef MP-WEIXIN */
- .message-input {
- min-height: 64rpx;
- padding: 12rpx 16rpx;
- margin-top: 0rpx;
- margin-bottom: 0rpx;
- }
-
- .send-btn {
- width: 120rpx;
- height: 72rpx;
- border-radius: 12rpx;
- font-size: 33rpx;
- margin-top: 0rpx;
- margin-bottom: 0rpx;
- }
- /* #endif */
-
- /* H5专属样式:发送按钮为微信聊天样式 */
- /* #ifdef H5 */
- .send-btn {
- width: 120rpx;
- height: 72rpx;
- border-radius: 12rpx;
- font-size: 28rpx;
- }
- /* #endif */
}
}
@@ -3232,25 +3641,26 @@ _handleStreamError(msg) {
.ai-chat-container {
.chat-messages {
padding: 16rpx 24rpx;
-
+
.message-item {
margin-bottom: 24rpx;
-
+
.user-message, .ai-message {
.avatar {
- width: 72rpx;
- height: 72rpx;
+ width: 40rpx;
+ height: 40rpx;
}
-
+
.message-content {
margin-left: 16rpx;
- max-width: calc(100% - 72rpx);
-
+ max-width: calc(100% - 56rpx);
+
.message-bubble {
- padding: 16rpx 24rpx;
-
+ /* 移动端也保持无气泡样式, 文字直接渲染在深色背景上 */
+ padding: 0 !important;
+
.message-text {
- font-size: 26rpx;
+ font-size: 28rpx;
}
}
}
@@ -3259,20 +3669,36 @@ _handleStreamError(msg) {
}
.input-area {
- padding: 16rpx 24rpx;
-
- .input-container {
+ padding: 10rpx 16rpx 12rpx;
+
+ .input-card {
+ padding: 12rpx 14rpx 10rpx;
+ border-radius: 18rpx;
+ }
+
+ .input-row-top {
+ min-height: 64rpx;
+
.message-input {
- min-height: 72rpx;
- padding: 16rpx 24rpx;
- font-size: 26rpx;
+ min-height: 52rpx;
+ font-size: 28rpx;
}
-
+
+ .message-input-placeholder {
+ font-size: 28rpx !important;
+ }
+ }
+
+ .input-toolbar {
+ min-height: 56rpx;
+
.send-btn {
- width: 120rpx;
- height: 72rpx;
- border-radius: 12rpx;
- font-size: 33rpx;
+ width: 56rpx;
+ height: 56rpx;
+
+ .send-icon {
+ font-size: 26rpx;
+ }
}
}
}
From f09fffb7ad9f5d9a1fc5c096366fe555827e284d Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Thu, 10 Sep 2026 16:43:51 +0800
Subject: [PATCH 02/22] =?UTF-8?q?chore=EF=BC=9A=E7=82=B9=E8=BF=9B=E6=99=BA?=
=?UTF-8?q?=E8=83=BDAI=E9=A1=B5=E9=9D=A2=EF=BC=8C=E8=BE=93=E5=85=A5?=
=?UTF-8?q?=E6=A1=86=E7=A7=BB=E5=8A=A8=E5=88=B0=E5=BA=95=E9=83=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pages_tool/ai-chat/index.vue | 53 +++++++++++++-----------------------
1 file changed, 19 insertions(+), 34 deletions(-)
diff --git a/pages_tool/ai-chat/index.vue b/pages_tool/ai-chat/index.vue
index 08e466b..aa2ac60 100644
--- a/pages_tool/ai-chat/index.vue
+++ b/pages_tool/ai-chat/index.vue
@@ -13,8 +13,9 @@
-
-
+
+
-
-
+
+
@@ -119,12 +115,9 @@ export default {
// 事件处理器引用(用于清理)
safeEventHandlers: new Map(),
- // 是否从浮动按钮进入
- // 用来在运行时区分两种场景:
- // 1) 后台把 ai-chat 配为底部导航栏 tabBar 项 → 用户点击该项进入 → 底部 tabBar 显示并高亮
- // 2) 浮动按钮(diy-float-btn)的链接指向 ai-chat → 用户点击浮动按钮进入 → 底部 tabBar 隐藏
- // 通过 onLoad(options).from 判定;同时会通过 $store.state.bottomNavHidden 临时隐藏 tabBar,
- // 并在 onUnload 还原,避免影响其他页面。
+ // 进入 AI 智能页时是否临时隐藏了底部 tabBar。
+ // 标记进入时已调用 setBottomNavHidden(true),在 onUnload 中根据此标记还原,
+ // 避免返回其他 tabBar 页时底部导航栏保持隐藏状态。
isFromFloatBtn: false,
}
},
@@ -171,24 +164,16 @@ export default {
this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
- // ====== 区分底部导航栏/浮动按钮两种进入来源 ======
- // diy-float-btn 跳转时会在 link 上注入 __dyu=FloatBtn
- // (见 diy-float-btn.vue#onFloatBtnClick)
- // 这种情况应当隐藏底部 tabBar(AI 客服页是全屏聊天体验,不应被 tabBar 占用屏幕)
- // 而点击底部导航栏 tabBar 项进入时,通常不会携带 __dyu query,
- // 此时 tabBar 正常显示并由 checkTabBarActive 步骤2精确命中高亮当前项。
- // 用 __dyu 这种 3 字符 + 双下划线前缀的命名,几乎不可能与业务 page_params 冲突
- // (业务方不会使用 __ 开头的短变量名)。
- const dyuEntry = (options && options.__dyu) || '';
- if (dyuEntry === 'FloatBtn') {
- this.isFromFloatBtn = true;
- // 临时隐藏底部导航栏;onUnload 会还原。
- try {
- this.$store.commit('setBottomNavHidden', true);
- } catch (e) { /* 兼容没有该 mutation 的旧 store */ }
- } else {
- this.isFromFloatBtn = false;
- }
+ // ====== 进入 AI 智能页始终隐藏底部 tabBar ======
+ // 原因:AI 智能页是全屏聊天体验,不论是
+ // 1) 底部导航栏"智能客服"项直接点击进入
+ // 2) diy-float-btn 浮动按钮(注入 __dyu=FloatBtn)进入
+ // 都不应被 tabBar 占用屏幕,否则输入框会被挤到 tabBar 上方。
+ // 进入时 setBottomNavHidden(true) 临时隐藏,onUnload 还原,避免污染其他页面。
+ this.isFromFloatBtn = true; // 标记进入时已隐藏,onUnload 据此还原
+ try {
+ this.$store.commit('setBottomNavHidden', true);
+ } catch (e) { /* 兼容没有该 mutation 的旧 store */ }
this.initChat();
},
From 596e7cdb0323e02377cc494e9797fe07aaa55447 Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Thu, 10 Sep 2026 17:20:10 +0800
Subject: [PATCH 03/22] =?UTF-8?q?chore=EF=BC=9A=E8=BE=93=E5=85=A5=E6=A1=86?=
=?UTF-8?q?=E7=9A=84=E6=9B=B4=E5=A4=9A=E5=8A=9F=E8=83=BD=E5=8A=A0=E4=B8=8A?=
=?UTF-8?q?=E5=85=B3=E9=97=AD=E6=8C=89=E9=92=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pages_tool/ai-chat/components/ai-chat-tools-panel.vue | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/pages_tool/ai-chat/components/ai-chat-tools-panel.vue b/pages_tool/ai-chat/components/ai-chat-tools-panel.vue
index 4931fc0..dbcbdd5 100644
--- a/pages_tool/ai-chat/components/ai-chat-tools-panel.vue
+++ b/pages_tool/ai-chat/components/ai-chat-tools-panel.vue
@@ -209,6 +209,7 @@ export default {
margin-bottom: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 16rpx;
+ position: relative; // 为 .close-btn 绝对定位提供参考
text {
font-size: 34rpx;
@@ -225,7 +226,9 @@ export default {
align-items: center;
justify-content: center;
border: none;
-
+ position: absolute; // 绝对定位: 紧贴 panel 顶部、放在"位置"图标左上方
+ top: -8rpx;
+ right: 10rpx; //
.iconfont {
font-size: 28rpx;
color: $color-text-light;
From bc127a45e818dc09e646bbd8953f53dfbbf594b1 Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Fri, 11 Sep 2026 09:15:12 +0800
Subject: [PATCH 04/22] =?UTF-8?q?chore=EF=BC=9A=E8=BE=93=E5=85=A5=E6=A1=86?=
=?UTF-8?q?=E6=A0=B7=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pages_tool/ai-chat/ai-chat-message.vue | 564 ++++++++++++++-----------
1 file changed, 314 insertions(+), 250 deletions(-)
diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue
index d683396..fd9ced8 100644
--- a/pages_tool/ai-chat/ai-chat-message.vue
+++ b/pages_tool/ai-chat/ai-chat-message.vue
@@ -226,73 +226,112 @@
-
+
-
+
-
-
-
-
-
- @
- 引用对话文件
-
- /
-
- 调用技能与指令
-
+
+
+
-
-
-
+
+
+
+
+
+
+ @
+
+ {{ selectedModel }}
+ ▾
+
+
-
-
-
-
-
-
-
-
- 😊
-
-
- 🎤
-
-
-
- ▲
-
+
+
+
+
+
+
+
+ 任务
+
+
+
+
+
+
+ 自动化
+
+
+
+
+
+
+ 资料库
+
+
+
+
+
+
+ 专家
+
+
+
+
+
+
+ 更多
@@ -456,6 +495,9 @@ export default {
showNicknameEditor: false, // 昵称编辑弹窗控制
// AI头像错误状态
avatarError: false,
+ // 当前选中的 AI 模型, 显示在输入框左侧(如 Auto)
+ selectedModel: 'Auto',
+ availableModels: ['Auto', 'GPT-4', 'Claude', '文心一言', '通义千问', 'Kimi'],
// AiChat API相关
currentConversationId: null,
@@ -512,6 +554,9 @@ export default {
const hasContent = this.inputText && this.inputText.trim() !== ''
const hasEmoji = this.containsEmoji(this.inputText)
return hasContent || hasEmoji
+ },
+ hasInputText() {
+ return this.inputText && this.inputText.trim() !== ''
}
},
// 新增:组件销毁时清除所有定时器
@@ -2096,6 +2141,25 @@ _handleStreamError(msg) {
} catch (e) { /* noop */ }
},
+ // 底部 5 图标工具栏点击: 任务 / 专家 / 资料库 / 自动化 / 更多
+ // - task / expert / library / automation: 触发 toolbar-click 事件(占位)
+ // - more: 复用 showMoreTools 触发更多功能弹窗
+ onToolbarItem(type) {
+ try {
+ switch (type) {
+ case 'task':
+ case 'expert':
+ case 'library':
+ case 'automation':
+ this.$emit('toolbar-click', { type })
+ break
+ case 'more':
+ this.showMoreTools()
+ break
+ }
+ } catch (e) { /* noop */ }
+ },
+
// 默认权限下拉点击: 占位交互, 后续接入权限弹窗
onTogglePermission() {
try {
@@ -2112,6 +2176,14 @@ _handleStreamError(msg) {
}
},
+ // 切换模型选择器: 在 availableModels 列表中循环选择
+ onToggleModelSelector() {
+ const list = this.availableModels || ['Auto']
+ const idx = list.indexOf(this.selectedModel)
+ const nextIdx = (idx + 1) % list.length
+ this.selectedModel = list[nextIdx]
+ },
+
// 打开扩大编辑器
openExpandEditor() {
this.expandInputText = this.inputText
@@ -3043,16 +3115,20 @@ _handleStreamError(msg) {
background: #f5f5f5 !important;
color: #1f1f1f !important;
border: none !important;
- border-radius: 16rpx !important;
+ border-radius: 20rpx !important;
box-shadow: none !important;
- padding: 14rpx 18rpx !important;
+ padding: 20rpx 30rpx !important; // 加大内边距,让气泡更"大"
display: inline-block;
word-break: break-word;
white-space: pre-wrap;
- line-height: 1.6;
+ line-height: 1.7;
text-align: left;
max-width: 100%;
+ .message-text {
+ font-size: 20rpx !important; // 还原为用户消息原字号
+ }
+
&::before {
display: none !important;
content: none !important;
@@ -3166,244 +3242,232 @@ _handleStreamError(msg) {
transition: height 0.25s ease;
}
- /* 输入区域 (WorkBuddy IDE 风格: 圆角白底容器 + 顶部输入 + 底部工具栏) */
+ /* 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标均匀分布) */
.input-area {
background: transparent;
padding: 12rpx 20rpx 16rpx;
flex-shrink: 0;
box-sizing: border-box;
- /* 圆角白底容器: 把输入框、占位提示、底部工具栏都包在一个带边框的卡片里 */
.input-card {
- background: #ffffff;
- border: 1rpx solid #e5e7eb;
- border-radius: 20rpx;
- padding: 14rpx 16rpx 12rpx;
- box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ padding: 0;
+ box-shadow: none;
box-sizing: border-box;
}
- /* 顶部输入区: textarea + 占位模式下的 @ / / 快捷提示 */
+ /* 顶部输入横条: 胶囊形,左侧输入区 + 右侧操作按钮 */
.input-row-top {
- position: relative;
display: flex;
- align-items: flex-start;
- min-height: 72rpx;
+ align-items: center;
+ justify-content: space-between;
+ background: #ffffff;
+ border-radius: 999rpx;
+ padding: 18rpx 14rpx 18rpx 32rpx;
+ min-height: 108rpx;
+ box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
- .message-input {
- flex: 1;
- min-height: 56rpx;
- max-height: 240rpx;
- background: transparent !important;
- border: none !important;
- padding: 12rpx 8rpx 12rpx 4rpx;
- font-size: 30rpx;
- line-height: 1.5;
- color: #1f1f1f;
- box-sizing: border-box;
- }
-
- /* placeholder 样式: 浅灰色 */
- .message-input-placeholder {
- color: #999 !important;
- font-size: 30rpx !important;
- }
-
- /* 占位模式下显示在输入框右侧的快捷指令链接 */
- .quick-tips {
- position: absolute;
- top: 50%;
- left: 8rpx;
- transform: translateY(-50%);
+ /* 无文字时左侧语音图标,点击打开语音面板 */
+ .input-row-icon {
+ flex-shrink: 0;
+ width: 52rpx;
+ height: 52rpx;
display: flex;
align-items: center;
- pointer-events: none; /* 让 textarea 能接收点击 */
+ justify-content: center;
+ margin-right: 12rpx;
- .quick-tip,
- .quick-tip-sep {
- font-size: 24rpx;
- color: #1f6cff;
- margin-right: 12rpx;
- pointer-events: auto;
+ .voice-icon-svg {
+ width: 36rpx;
+ height: 36rpx;
+ display: block;
+ }
+ }
- .quick-tip-icon {
- margin-right: 4rpx;
+ /* 输入区: textarea 在上,模型选择器在下 */
+ .input-left-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-height: 72rpx;
+ padding-right: 12rpx;
+
+ &.with-voice-icon {
+ margin-left: 0;
+ }
+
+ .message-input {
+ width: 100%;
+ min-height: 44rpx;
+ max-height: 240rpx;
+ background: transparent !important;
+ border: none !important;
+ padding: 0 0 6rpx 0;
+ font-size: 34rpx;
+ line-height: 1.5;
+ color: #1f1f1f;
+ box-sizing: border-box;
+ }
+
+ .message-input-placeholder {
+ color: #999 !important;
+ font-size: 32rpx !important;
+ }
+
+ .model-selector {
+ align-self: flex-start;
+ display: flex;
+ align-items: center;
+ gap: 6rpx;
+ height: 36rpx;
+ padding: 0;
+ margin-top: 4rpx;
+ background: transparent;
+
+ .model-icon-circle {
+ width: 28rpx;
+ height: 28rpx;
+ border-radius: 50%;
+ border: 2rpx solid #666666;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+
+ .model-icon-text {
+ font-size: 18rpx;
+ color: #666666;
+ line-height: 1;
+ margin-top: 2rpx;
+ }
+ }
+
+ .model-name {
+ font-size: 26rpx;
+ color: #666666;
+ font-weight: 400;
+ line-height: 1;
+ }
+
+ .model-arrow {
+ font-size: 22rpx;
+ color: #999999;
+ line-height: 1;
+ margin-top: 2rpx;
}
}
-
- .quick-tip-sep {
- color: #999;
- margin-right: 12rpx;
- }
}
- /* textarea 有内容时, 占位链接隐藏 (CSS 简易判断) */
- .quick-tips {
- opacity: 1;
- }
-
- /* 扩大按钮 */
- .expand-btn {
+ /* 右侧操作按钮: + 号 和 发送 */
+ .input-right-actions {
flex-shrink: 0;
- width: 56rpx;
- height: 56rpx;
+ display: flex;
+ align-items: center;
+ gap: 8rpx;
+ }
+
+ .top-plus-btn {
+ flex-shrink: 0;
+ width: 76rpx;
+ height: 76rpx;
border-radius: 50%;
- background-color: #f5f5f5;
+ background: transparent;
border: none;
display: flex;
align-items: center;
justify-content: center;
- margin-left: 8rpx;
+ padding: 0;
- .iconfont {
- font-size: 28rpx;
- color: #666;
+ .plus-icon {
+ font-size: 52rpx;
+ color: #1f1f1f;
+ font-weight: 300;
+ line-height: 1;
+ }
+
+ &::after {
+ border: none;
+ }
+ }
+
+ .top-send-btn {
+ flex-shrink: 0;
+ width: 76rpx;
+ height: 76rpx;
+ border-radius: 50%;
+ background: #1f1f1f;
+ border: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+
+ .send-plane-text {
+ color: #ffffff;
+ font-size: 34rpx;
+ line-height: 1;
+ transform: rotate(-90deg); // 箭头向上,呈向上纸飞机
+ display: inline-block;
+ margin-left: -2rpx; // 视觉居中
+ margin-top: -2rpx;
+ }
+
+ &::after {
+ border: none;
}
}
}
- /* 底部工具栏 */
+ /* 底部工具栏: 5 个图标按钮均匀分布 */
.input-toolbar {
display: flex;
align-items: center;
- justify-content: space-between;
- margin-top: 6rpx;
- min-height: 64rpx;
+ justify-content: space-around;
+ margin-top: 24rpx;
+ padding: 4rpx 0;
- .toolbar-left,
- .toolbar-right {
+ .toolbar-item {
+ flex: 1;
display: flex;
+ flex-direction: column;
align-items: center;
- }
+ gap: 8rpx;
+ padding: 4rpx 0;
- /* 左侧: + 圆形按钮 */
- .tool-btn-plus {
- width: 56rpx;
- height: 56rpx;
- border-radius: 50%;
- background-color: #f5f5f5;
- border: none;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: 12rpx;
+ &.toolbar-item--hover,
+ &:active {
+ opacity: 0.55;
+ }
- .plus-icon {
- font-size: 32rpx;
- color: #666;
+ .toolbar-icon {
+ font-size: 44rpx;
line-height: 1;
+ color: #1f1f1f;
}
- }
- /* 左侧: 默认权限下拉 */
- .tool-btn-with-text {
- height: 56rpx;
- padding: 0 18rpx;
- border-radius: 28rpx;
- background-color: #f5f5f5;
- border: none;
- display: flex;
- align-items: center;
- margin-right: 12rpx;
-
- .perm-icon {
- font-size: 28rpx;
- color: #1f6cff;
- margin-right: 6rpx;
+ .toolbar-icon-svg {
+ width: 44rpx;
+ height: 44rpx;
line-height: 1;
- }
+ display: flex;
+ align-items: center;
+ justify-content: center;
- .perm-text {
- font-size: 26rpx;
- color: #333;
- line-height: 1;
- }
-
- .arrow-icon {
- font-size: 22rpx;
- color: #999;
- margin-left: 6rpx;
- line-height: 1;
- }
- }
-
- /* 右侧: emoji 按钮 */
- .emoji-btn {
- width: 56rpx;
- height: 56rpx;
- border-radius: 50%;
- background-color: #f5f5f5;
- border: none;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: 12rpx;
- padding: 0;
-
- .iconfont {
- font-size: 30rpx;
- color: #666;
- line-height: 1;
- }
- }
-
- /* 右侧: 语音按钮 */
- .voice-btn {
- width: 56rpx;
- height: 56rpx;
- border-radius: 50%;
- background-color: #f5f5f5;
- border: none;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: 12rpx;
- padding: 0;
-
- .iconfont {
- font-size: 30rpx;
- color: #666;
- line-height: 1;
- }
- }
-
- /* 发送按钮: 圆形灰色, 内含向上箭头图标 (WorkBuddy 风格) */
- .send-btn {
- flex-shrink: 0;
- width: 60rpx;
- height: 60rpx;
- border-radius: 50%;
- background-color: #d1d5db;
- border: none;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- margin-left: 0;
- color: #ffffff;
- text-align: center;
- white-space: nowrap;
- line-height: 1;
- transition: background-color 0.2s ease;
-
- .send-icon {
- font-size: 28rpx;
- line-height: 1;
- color: #ffffff;
- }
-
- &.disabled {
- background-color: #d1d5db;
- opacity: 0.65;
- }
-
- &:not(.disabled) {
- background-color: #1f1f1f;
-
- .send-icon {
- color: #ffffff;
+ svg {
+ width: 100%;
+ height: 100%;
+ display: block;
}
}
+
+ .toolbar-text {
+ font-size: 22rpx;
+ color: #1f1f1f;
+ line-height: 1;
+ }
}
}
}
From 659308e116abc432674822740b516a19b22aed1f Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Fri, 11 Sep 2026 09:29:56 +0800
Subject: [PATCH 05/22] =?UTF-8?q?chore=EF=BC=9A=E4=BB=BB=E5=8A=A1=E6=A0=8F?=
=?UTF-8?q?=E7=9A=84=E5=9B=BE=E6=A0=87=E5=B0=8F=E7=A8=8B=E5=BA=8F=E6=AD=A3?=
=?UTF-8?q?=E5=B8=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pages_tool/ai-chat/ai-chat-message.vue | 78 +++++++++-----------------
1 file changed, 26 insertions(+), 52 deletions(-)
diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue
index fd9ced8..ce6bdb6 100644
--- a/pages_tool/ai-chat/ai-chat-message.vue
+++ b/pages_tool/ai-chat/ai-chat-message.vue
@@ -280,57 +280,23 @@
-
-
-
-
+
任务
-
-
-
-
+
自动化
-
-
-
-
+
资料库
-
-
-
-
+
专家
-
-
-
-
+
更多
@@ -3443,23 +3409,31 @@ _handleStreamError(msg) {
}
.toolbar-icon {
- font-size: 44rpx;
- line-height: 1;
- color: #1f1f1f;
- }
-
- .toolbar-icon-svg {
width: 44rpx;
height: 44rpx;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
line-height: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- svg {
- width: 100%;
- height: 100%;
- display: block;
+ &.icon-task {
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHJlY3QgeD0iMiIgeT0iNiIgd2lkdGg9IjI2IiBoZWlnaHQ9IjE4IiByeD0iNiIgZmlsbD0iIzFmMWYxZiIvPjxjaXJjbGUgY3g9IjEwIiBjeT0iMTUiIHI9IjEuNSIgZmlsbD0iI2ZmZiIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iMTUiIHI9IjEuNSIgZmlsbD0iI2ZmZiIvPjxjaXJjbGUgY3g9IjIyIiBjeT0iMTUiIHI9IjEuNSIgZmlsbD0iI2ZmZiIvPjwvc3ZnPg==");
+ }
+
+ &.icon-automation {
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGNpcmNsZSBjeD0iMTYiIGN5PSIxNiIgcj0iMTEiLz48cGF0aCBkPSJNMTEgMTYgbDQgNCBsNyAtOCIvPjwvc3ZnPg==");
+ }
+
+ &.icon-library {
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTMgNyBjNSAtMiAxMCAtMiAxMyAxIGMzIC0zIDggLTMgMTMgLTEgdjE4IGMtNSAtMiAtMTAgLTIgLTEzIDEgYy0zIC0zIC04IC0zIC0xMyAtMSB6Ii8+PHBhdGggZD0iTTE2IDggdjE4Ii8+PC9zdmc+");
+ }
+
+ &.icon-expert {
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGNpcmNsZSBjeD0iMTYiIGN5PSIxNiIgcj0iMTEiLz48cGF0aCBkPSJNMTYgOSB2NyBsNSAzIi8+PC9zdmc+");
+ }
+
+ &.icon-more {
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHJlY3QgeD0iNCIgeT0iNCIgd2lkdGg9IjkiIGhlaWdodD0iOSIgcng9IjEuNSIvPjxyZWN0IHg9IjE5IiB5PSI0IiB3aWR0aD0iOSIgaGVpZ2h0PSI5IiByeD0iMS41Ii8+PHJlY3QgeD0iNCIgeT0iMTkiIHdpZHRoPSI5IiBoZWlnaHQ9IjkiIHJ4PSIxLjUiLz48cmVjdCB4PSIxOSIgeT0iMTkiIHdpZHRoPSI5IiBoZWlnaHQ9IjkiIHJ4PSIxLjUiLz48L3N2Zz4=");
}
}
From 3892fb517fc342902cbee7ddfd2c5ea2c6388676 Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Fri, 11 Sep 2026 10:10:52 +0800
Subject: [PATCH 06/22] =?UTF-8?q?chore=EF=BC=9A=E9=9A=90=E8=97=8F=E6=99=BA?=
=?UTF-8?q?=E8=83=BD=E4=BD=93=E7=9A=84=E4=BB=BB=E5=8A=A1=E6=A0=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pages_tool/ai-chat/ai-chat-message.vue | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue
index ce6bdb6..906a77d 100644
--- a/pages_tool/ai-chat/ai-chat-message.vue
+++ b/pages_tool/ai-chat/ai-chat-message.vue
@@ -233,10 +233,8 @@
-
+
+
@@ -277,8 +275,8 @@
-
-
+
+
任务
@@ -3245,10 +3243,13 @@ _handleStreamError(msg) {
justify-content: center;
margin-right: 12rpx;
- .voice-icon-svg {
+ .voice-icon-bg {
width: 36rpx;
height: 36rpx;
- display: block;
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWYxZjFmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTAgMTIgaDYgbDYgLTQgdjE2IGwtNiAtNCBoLTYgYTIgMiAwIDAgMSAtMiAtMiB2LTQgYTIgMiAwIDAgMSAyIC0yIHoiLz48cGF0aCBkPSJNMjQgMTAgYzIgMyAyIDkgMCAxMiIvPjwvc3ZnPg==");
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
}
}
From 2daaa258a08a9af013f1a2bb6278141e1b6c63b2 Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Fri, 11 Sep 2026 14:43:23 +0800
Subject: [PATCH 07/22] =?UTF-8?q?chore=EF=BC=9AAI=E6=99=BA=E8=83=BD?=
=?UTF-8?q?=E5=8F=AF=E4=BB=A5=E5=A4=8D=E5=88=B6=E8=AF=AD=E5=8F=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
lang-bundle.json | 4 +
lang/en-us/ai-chat/index.js | 12 +-
lang/zh-cn/ai-chat/index.js | 4 +
pages_tool/ai-chat/ai-chat-message.vue | 395 ++++++++++++++++++++++++-
4 files changed, 402 insertions(+), 13 deletions(-)
diff --git a/lang-bundle.json b/lang-bundle.json
index 5a26840..7322e33 100644
--- a/lang-bundle.json
+++ b/lang-bundle.json
@@ -2652,6 +2652,10 @@
"noContentToCopy": "无可复制的内容",
"copySuccess": "已复制",
"copySelected": "已复制选中内容",
+ "selectTextToCopy": "选取文字复制",
+ "selectedText": "已选文字",
+ "copyAll": "复制全部",
+ "selectTextTip": "长按文字拖动两端手柄,可自由选择复制范围",
"copyFail": "复制失败",
"copyFailWithReason": "复制失败:{reason}",
"openingFile": "打开文件中...",
diff --git a/lang/en-us/ai-chat/index.js b/lang/en-us/ai-chat/index.js
index c3b6939..f41d45d 100644
--- a/lang/en-us/ai-chat/index.js
+++ b/lang/en-us/ai-chat/index.js
@@ -33,6 +33,10 @@ export const lang = {
noContentToCopy: 'No content to copy',
copySuccess: 'Copied',
copySelected: 'Copied selected content',
+ selectTextToCopy: 'Select text to copy',
+ selectedText: 'Selected',
+ copyAll: 'Copy all',
+ selectTextTip: 'Long press and drag the handles to choose any range',
copyFail: 'Copy failed',
copyFailWithReason: 'Copy failed: {reason}',
openingFile: 'Opening file...',
@@ -136,8 +140,8 @@ export const lang = {
logInitNavbarFailed: 'Failed to initialize navigation bar',
logChatComponentNotInit: 'Chat component not initialized',
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
-
- // ====== AI Agent general copy (customer service is the first agent) ======
- aiAgentDefaultTitle: 'AI Agent',
- aiAgentOpenFailed: 'Failed to open AI Agent, please try again later',
+
+ // ====== AI Agent general copy (customer service is the first agent) ======
+ aiAgentDefaultTitle: 'AI Agent',
+ aiAgentOpenFailed: 'Failed to open AI Agent, please try again later',
aiAgentNotConfigured: 'This AI Agent is not enabled'}
diff --git a/lang/zh-cn/ai-chat/index.js b/lang/zh-cn/ai-chat/index.js
index 752f15b..6cb8f12 100644
--- a/lang/zh-cn/ai-chat/index.js
+++ b/lang/zh-cn/ai-chat/index.js
@@ -33,6 +33,10 @@ export const lang = {
noContentToCopy: '无可复制的内容',
copySuccess: '已复制',
copySelected: '已复制选中内容',
+ selectTextToCopy: '选取文字复制',
+ selectedText: '已选文字',
+ copyAll: '复制全部',
+ selectTextTip: '长按文字拖动两端手柄,可自由选择复制范围',
copyFail: '复制失败',
copyFailWithReason: '复制失败:{reason}',
openingFile: '打开文件中...',
diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue
index 906a77d..c5a0337 100644
--- a/pages_tool/ai-chat/ai-chat-message.vue
+++ b/pages_tool/ai-chat/ai-chat-message.vue
@@ -44,7 +44,11 @@
@touchend="onBubbleTouchEnd"
@touchcancel="onBubbleTouchEnd">
- {{ message.content }}
+ {{ message.content }}
@@ -78,7 +82,17 @@
@touchcancel="onBubbleTouchEnd">
+
+
+ {{ _stripMarkdown(message.content) }}
+
+
+
@@ -341,6 +355,41 @@
+
+
+
@@ -457,6 +506,12 @@ export default {
localUserNickname: '', // 当前用户昵称
defaultNickname: '', // 默认昵称
showNicknameEditor: false, // 昵称编辑弹窗控制
+ // 选取文字复制弹窗
+ showSelectTextModal: false,
+ selectTextValue: '', // 弹窗内文本域当前内容
+ selectStart: 0, // 当前选中起点
+ selectEnd: 0, // 当前选中终点
+ selectedPreview: '', // 选中文字预览
// AI头像错误状态
avatarError: false,
// 当前选中的 AI 模型, 显示在输入框左侧(如 Auto)
@@ -1811,7 +1866,7 @@ _handleStreamError(msg) {
.trim()
},
- // 长按消息→直接复制
+ // 长按/右键消息 → 弹出操作菜单(复制 / 分享 / 选取文字复制)
onMessageLongPress(message) {
// loading 消息不允许操作
if (message.type === 'loading') return
@@ -1823,7 +1878,164 @@ _handleStreamError(msg) {
} catch (e) { /* 不支持震动的设备忽略 */ }
// #endif
- this.copyMessage(message)
+ this.openMessageMenu(message)
+ },
+
+ // 打开消息操作菜单:复制 / 分享 / 选取文字复制
+ openMessageMenu(message) {
+ const text = message && message.content ? message.content : ''
+ if (!text) return
+
+ // #ifdef H5
+ // H5 优先使用原生浏览器上下文菜单体验(右键已出系统菜单时避免重复弹层)
+ // 仍提供底部操作菜单,保持与小程序一致
+ // #endif
+
+ uni.showActionSheet({
+ itemList: ['复制', '分享', this.$lang('ai-chat.index.selectTextToCopy', '选取文字复制')],
+ success: (res) => {
+ if (res.tapIndex === 0) {
+ this.copyMessage(message)
+ } else if (res.tapIndex === 1) {
+ this.shareMessage(message)
+ } else if (res.tapIndex === 2) {
+ this.openSelectTextModal(message)
+ }
+ },
+ fail: () => { /* 用户取消 */ }
+ })
+ },
+
+ // 分享消息内容(兼容微信小程序与 H5)
+ shareMessage(message) {
+ const text = message && message.content ? message.content : ''
+ if (!text) return
+
+ // #ifdef MP-WEIXIN
+ // 微信小程序:调起分享功能
+ try {
+ wx.showShareMenu({
+ withShareTicket: true,
+ menus: ['shareAppMessage', 'shareTimeline']
+ })
+ } catch (e) {
+ // 若当前页面未配置分享,降级为提示复制
+ uni.showToast({ title: '请长按使用系统分享', icon: 'none' })
+ }
+ // #endif
+
+ // #ifdef H5
+ // H5:调用 Web Share API,降级为复制到剪贴板
+ if (navigator.share) {
+ navigator.share({
+ title: '聊天记录',
+ text: text
+ }).catch(() => { /* 用户取消分享 */ })
+ } else {
+ this.copyMessage(message)
+ uni.showToast({ title: '已复制,可直接粘贴分享', icon: 'none' })
+ }
+ // #endif
+ },
+
+ // 打开"选取文字复制"弹窗:长按后可自由选取任意范围(含单个字符/标点)复制
+ openSelectTextModal(message) {
+ const text = message && message.content ? message.content : ''
+ if (!text) return
+ this.selectTextValue = text
+ this.selectStart = 0
+ this.selectEnd = 0
+ this.selectedPreview = ''
+ this.showSelectTextModal = true
+
+ // 下一帧自动全选,方便用户直接拖动手柄缩小范围(H5 / 微信小程序均适用)
+ this.$nextTick(() => {
+ this.selectStart = 0
+ this.selectEnd = text.length
+ // H5:绑定 textarea DOM 选区变化事件,实时同步选中范围
+ // #ifdef H5
+ try {
+ const el = document.querySelector('.select-text-textarea textarea') || document.querySelector('.select-text-textarea')
+ if (el) {
+ const sync = () => {
+ try {
+ const s = el.selectionStart || 0
+ const e = el.selectionEnd || 0
+ this.selectStart = s
+ this.selectEnd = e
+ this.selectedPreview = (el.value || '').substring(s, e)
+ } catch (err) { /* noop */ }
+ }
+ el.addEventListener('select', sync)
+ el.addEventListener('keyup', sync)
+ el.addEventListener('mouseup', sync)
+ el.addEventListener('touchend', sync)
+ el.focus()
+ el.select && el.select()
+ el.setSelectionRange && el.setSelectionRange(0, el.value.length)
+ sync()
+ }
+ } catch (e) { /* 部分环境不支持 */ }
+ // #endif
+ })
+ },
+
+ // 文本域输入:同步内容(用户若误编辑也能拿到最新文本)
+ onTextInput(event) {
+ const val = (event && event.detail && event.detail.value) || (event && event.target && event.target.value) || ''
+ this.selectTextValue = val
+ },
+
+ // 微信小程序:文本域选区变化(拖动两端手柄选择范围)
+ onTextSelectionChange(event) {
+ try {
+ const detail = event && event.detail
+ if (detail) {
+ this.selectStart = detail.start || 0
+ this.selectEnd = detail.end || 0
+ this.selectedPreview = (this.selectTextValue || '').substring(this.selectStart, this.selectEnd)
+ }
+ } catch (e) { /* noop */ }
+ },
+
+ // 失焦时兜底同步一次选区(H5 拖选结束时)
+ onTextBlur(event) {
+ try {
+ // #ifdef H5
+ const el = document.querySelector('.select-text-textarea textarea') || document.querySelector('.select-text-textarea')
+ if (el) {
+ const s = el.selectionStart || 0
+ const e = el.selectionEnd || 0
+ this.selectStart = s
+ this.selectEnd = e
+ this.selectedPreview = (el.value || '').substring(s, e)
+ }
+ // #endif
+ } catch (e) { /* noop */ }
+ },
+
+ // 复制选中范围(未选则复制全部)
+ copySelectedText() {
+ const text = this.selectTextValue || ''
+ if (!text) {
+ uni.showToast({ title: this.$lang('ai-chat.index.noContentToCopy'), icon: 'none' })
+ return
+ }
+ const start = Math.min(this.selectStart, this.selectEnd)
+ const end = Math.max(this.selectStart, this.selectEnd)
+ const selected = start < end ? text.substring(start, end) : ''
+ const copyText = selected || text
+ this.copyMessage({ content: copyText, _selected: !!selected })
+ this.closeSelectTextModal()
+ },
+
+ // 关闭"选取文字复制"弹窗
+ closeSelectTextModal() {
+ this.showSelectTextModal = false
+ this.selectTextValue = ''
+ this.selectStart = 0
+ this.selectEnd = 0
+ this.selectedPreview = ''
},
// 手动长按检测(touch 事件方案,兼容所有平台)
@@ -1866,17 +2078,28 @@ _handleStreamError(msg) {
return
}
+ // 由"选取文字复制"弹窗传入的已选范围,直接按传入内容复制,不再读取浏览器选区
+ const isSelected = !!message._selected
+
// #ifdef H5
// H5:检测是否有局部文本被选中,有则复制选区文本,无则复制全文
- const selection = window.getSelection()
- const selectedText = selection && selection.toString().trim()
- const finalText = selectedText || copyText
+ let finalText = copyText
+ let selectedText = ''
+ if (!isSelected) {
+ try {
+ const selection = window.getSelection()
+ selectedText = selection && selection.toString().trim()
+ finalText = selectedText || copyText
+ } catch (e) { /* noop */ }
+ } else {
+ selectedText = copyText
+ }
// H5 优先使用 Clipboard API,降级使用 document.execCommand
try {
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(finalText).then(() => {
- uni.showToast({ title: selectedText ? this.$lang('ai-chat.index.copySelected') : this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
+ uni.showToast({ title: selectedText ? this.$lang('ai-chat.index.copySelected', '已复制选中内容') : this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
}).catch(() => {
this._fallbackCopyH5(finalText)
})
@@ -1895,7 +2118,7 @@ _handleStreamError(msg) {
data: copyText,
success: () => {
console.log('[AI Chat] 复制成功')
- uni.showToast({ title: this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
+ uni.showToast({ title: isSelected ? this.$lang('ai-chat.index.copySelected', '已复制选中内容') : this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
},
fail: (err) => {
console.error('[AI Chat] 复制失败, 详细错误:', JSON.stringify(err))
@@ -2536,6 +2759,15 @@ _handleStreamError(msg) {
letter-spacing: 0.3rpx;
color: #1f1f1f;
}
+
+ // 微信小程序: AI 文本消息降级为 selectable text,支持长按选择部分文字
+ .ai-message-text {
+ font-size: 30rpx;
+ line-height: 1.6;
+ color: #1f1f1f;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
}
.message-time {
@@ -3880,7 +4112,152 @@ _handleStreamError(msg) {
}
}
}
-
+
+/* 选取文字复制弹窗 */
+.select-text-popup {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1001;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+
+ .select-text-mask {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.4);
+ }
+
+ .select-text-panel {
+ position: relative;
+ width: 100%;
+ height: 70%;
+ display: flex;
+ flex-direction: column;
+ background-color: #fff;
+ border-radius: 24rpx 24rpx 0 0;
+ overflow: hidden;
+
+ .select-text-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 24rpx 32rpx;
+ border-bottom: 1rpx solid #e5e5e5;
+ background-color: #f9f9f9;
+
+ text {
+ font-size: 32rpx;
+ font-weight: 500;
+ color: #333;
+ }
+
+ .close-btn {
+ width: 64rpx;
+ height: 64rpx;
+ border-radius: 50%;
+ background-color: transparent;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ padding: 0;
+
+ text {
+ font-size: 48rpx;
+ color: #999;
+ line-height: 1;
+ }
+ }
+ }
+
+ .select-text-tip {
+ padding: 16rpx 32rpx 0;
+ font-size: 24rpx;
+ color: #999;
+ background-color: #f9f9f9;
+ }
+
+ .select-text-textarea {
+ flex: 1;
+ width: 100%;
+ padding: 32rpx;
+ font-size: 30rpx;
+ line-height: 1.6;
+ color: #333;
+ background-color: #fff;
+ border: none;
+ box-sizing: border-box;
+ }
+
+ .select-text-preview {
+ max-height: 200rpx;
+ overflow-y: auto;
+ padding: 20rpx 32rpx;
+ border-top: 1rpx dashed #e5e5e5;
+ background-color: #f6f8fa;
+ font-size: 26rpx;
+ color: #555;
+ word-break: break-all;
+
+ .select-text-preview-label {
+ color: #999;
+ margin-right: 8rpx;
+ }
+
+ .select-text-preview-content {
+ color: #07c160;
+ font-weight: 500;
+ }
+ }
+
+ .select-text-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 24rpx 32rpx;
+ padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+ border-top: 1rpx solid #e5e5e5;
+ background-color: #f9f9f9;
+
+ .select-text-copy-btn {
+ flex: 1;
+ height: 80rpx;
+ border-radius: 40rpx;
+ background-color: #07c160;
+ color: #fff;
+ font-size: 28rpx;
+ font-weight: 500;
+ border: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 20rpx;
+ }
+
+ .select-text-close-btn {
+ width: 160rpx;
+ height: 80rpx;
+ border-radius: 40rpx;
+ background-color: #f2f2f2;
+ color: #666;
+ font-size: 28rpx;
+ border: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+ }
+}
+
// H5 下允许 rich-text 内容文本选择(鼠标拖选复制)
.text-content,
.markdown-body {
From 4cb34bf28e3db1f05f4ba7de68768f8d8f110a54 Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Fri, 11 Sep 2026 17:14:42 +0800
Subject: [PATCH 08/22] =?UTF-8?q?chore=EF=BC=9A=E7=94=A8=E6=88=B7=E5=8F=AF?=
=?UTF-8?q?=E4=BB=A5=E8=87=AA=E5=AE=9A=E4=B9=89=E9=80=89=E6=8B=A9=E5=A4=8D?=
=?UTF-8?q?=E5=88=B6=E7=9A=84=E6=96=87=E5=AD=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
lang/en-us/ai-chat/index.js | 4 -
lang/zh-cn/ai-chat/index.js | 4 -
pages_tool/ai-chat/ai-chat-message.vue | 450 ++-----------------------
3 files changed, 29 insertions(+), 429 deletions(-)
diff --git a/lang/en-us/ai-chat/index.js b/lang/en-us/ai-chat/index.js
index f41d45d..59447c1 100644
--- a/lang/en-us/ai-chat/index.js
+++ b/lang/en-us/ai-chat/index.js
@@ -33,10 +33,6 @@ export const lang = {
noContentToCopy: 'No content to copy',
copySuccess: 'Copied',
copySelected: 'Copied selected content',
- selectTextToCopy: 'Select text to copy',
- selectedText: 'Selected',
- copyAll: 'Copy all',
- selectTextTip: 'Long press and drag the handles to choose any range',
copyFail: 'Copy failed',
copyFailWithReason: 'Copy failed: {reason}',
openingFile: 'Opening file...',
diff --git a/lang/zh-cn/ai-chat/index.js b/lang/zh-cn/ai-chat/index.js
index 6cb8f12..752f15b 100644
--- a/lang/zh-cn/ai-chat/index.js
+++ b/lang/zh-cn/ai-chat/index.js
@@ -33,10 +33,6 @@ export const lang = {
noContentToCopy: '无可复制的内容',
copySuccess: '已复制',
copySelected: '已复制选中内容',
- selectTextToCopy: '选取文字复制',
- selectedText: '已选文字',
- copyAll: '复制全部',
- selectTextTip: '长按文字拖动两端手柄,可自由选择复制范围',
copyFail: '复制失败',
copyFailWithReason: '复制失败:{reason}',
openingFile: '打开文件中...',
diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue
index c5a0337..659b48d 100644
--- a/pages_tool/ai-chat/ai-chat-message.vue
+++ b/pages_tool/ai-chat/ai-chat-message.vue
@@ -37,18 +37,9 @@
-
+
- {{ message.content }}
+ {{ message.content }}
@@ -74,35 +65,30 @@
{{ $lang('ai-chat.index.aiNickname') }}
{{ formatTime(message.timestamp) }}
-
-
+
+
-
- {{ _stripMarkdown(message.content) }}
+ {{ _stripMarkdown(message.content) }}
-
+
-
-
+
+
-
+
+ {{ _stripMarkdown(message.content) }}
+
+
+
+
@@ -356,41 +342,6 @@
-
-
-
@@ -506,12 +457,6 @@ export default {
localUserNickname: '', // 当前用户昵称
defaultNickname: '', // 默认昵称
showNicknameEditor: false, // 昵称编辑弹窗控制
- // 选取文字复制弹窗
- showSelectTextModal: false,
- selectTextValue: '', // 弹窗内文本域当前内容
- selectStart: 0, // 当前选中起点
- selectEnd: 0, // 当前选中终点
- selectedPreview: '', // 选中文字预览
// AI头像错误状态
avatarError: false,
// 当前选中的 AI 模型, 显示在输入框左侧(如 Auto)
@@ -530,10 +475,6 @@ export default {
isFetchingHistory: false,
keyboardHeight: 0,
- // 手动长按检测相关(touch 计时器)
- _longPressTimer: null,
- _longPressMessage: null,
-
// 聊天记录搜索状态(由 AiChatSearch 组件事件同步)
showSearchBar: false,
searchKeyword: '',
@@ -1866,207 +1807,6 @@ _handleStreamError(msg) {
.trim()
},
- // 长按/右键消息 → 弹出操作菜单(复制 / 分享 / 选取文字复制)
- onMessageLongPress(message) {
- // loading 消息不允许操作
- if (message.type === 'loading') return
-
- // #ifdef MP-WEIXIN
- // 微信小程序:震动反馈提升手感(小米等 Android 设备也可用)
- try {
- wx.vibrateShort({ type: 'medium' })
- } catch (e) { /* 不支持震动的设备忽略 */ }
- // #endif
-
- this.openMessageMenu(message)
- },
-
- // 打开消息操作菜单:复制 / 分享 / 选取文字复制
- openMessageMenu(message) {
- const text = message && message.content ? message.content : ''
- if (!text) return
-
- // #ifdef H5
- // H5 优先使用原生浏览器上下文菜单体验(右键已出系统菜单时避免重复弹层)
- // 仍提供底部操作菜单,保持与小程序一致
- // #endif
-
- uni.showActionSheet({
- itemList: ['复制', '分享', this.$lang('ai-chat.index.selectTextToCopy', '选取文字复制')],
- success: (res) => {
- if (res.tapIndex === 0) {
- this.copyMessage(message)
- } else if (res.tapIndex === 1) {
- this.shareMessage(message)
- } else if (res.tapIndex === 2) {
- this.openSelectTextModal(message)
- }
- },
- fail: () => { /* 用户取消 */ }
- })
- },
-
- // 分享消息内容(兼容微信小程序与 H5)
- shareMessage(message) {
- const text = message && message.content ? message.content : ''
- if (!text) return
-
- // #ifdef MP-WEIXIN
- // 微信小程序:调起分享功能
- try {
- wx.showShareMenu({
- withShareTicket: true,
- menus: ['shareAppMessage', 'shareTimeline']
- })
- } catch (e) {
- // 若当前页面未配置分享,降级为提示复制
- uni.showToast({ title: '请长按使用系统分享', icon: 'none' })
- }
- // #endif
-
- // #ifdef H5
- // H5:调用 Web Share API,降级为复制到剪贴板
- if (navigator.share) {
- navigator.share({
- title: '聊天记录',
- text: text
- }).catch(() => { /* 用户取消分享 */ })
- } else {
- this.copyMessage(message)
- uni.showToast({ title: '已复制,可直接粘贴分享', icon: 'none' })
- }
- // #endif
- },
-
- // 打开"选取文字复制"弹窗:长按后可自由选取任意范围(含单个字符/标点)复制
- openSelectTextModal(message) {
- const text = message && message.content ? message.content : ''
- if (!text) return
- this.selectTextValue = text
- this.selectStart = 0
- this.selectEnd = 0
- this.selectedPreview = ''
- this.showSelectTextModal = true
-
- // 下一帧自动全选,方便用户直接拖动手柄缩小范围(H5 / 微信小程序均适用)
- this.$nextTick(() => {
- this.selectStart = 0
- this.selectEnd = text.length
- // H5:绑定 textarea DOM 选区变化事件,实时同步选中范围
- // #ifdef H5
- try {
- const el = document.querySelector('.select-text-textarea textarea') || document.querySelector('.select-text-textarea')
- if (el) {
- const sync = () => {
- try {
- const s = el.selectionStart || 0
- const e = el.selectionEnd || 0
- this.selectStart = s
- this.selectEnd = e
- this.selectedPreview = (el.value || '').substring(s, e)
- } catch (err) { /* noop */ }
- }
- el.addEventListener('select', sync)
- el.addEventListener('keyup', sync)
- el.addEventListener('mouseup', sync)
- el.addEventListener('touchend', sync)
- el.focus()
- el.select && el.select()
- el.setSelectionRange && el.setSelectionRange(0, el.value.length)
- sync()
- }
- } catch (e) { /* 部分环境不支持 */ }
- // #endif
- })
- },
-
- // 文本域输入:同步内容(用户若误编辑也能拿到最新文本)
- onTextInput(event) {
- const val = (event && event.detail && event.detail.value) || (event && event.target && event.target.value) || ''
- this.selectTextValue = val
- },
-
- // 微信小程序:文本域选区变化(拖动两端手柄选择范围)
- onTextSelectionChange(event) {
- try {
- const detail = event && event.detail
- if (detail) {
- this.selectStart = detail.start || 0
- this.selectEnd = detail.end || 0
- this.selectedPreview = (this.selectTextValue || '').substring(this.selectStart, this.selectEnd)
- }
- } catch (e) { /* noop */ }
- },
-
- // 失焦时兜底同步一次选区(H5 拖选结束时)
- onTextBlur(event) {
- try {
- // #ifdef H5
- const el = document.querySelector('.select-text-textarea textarea') || document.querySelector('.select-text-textarea')
- if (el) {
- const s = el.selectionStart || 0
- const e = el.selectionEnd || 0
- this.selectStart = s
- this.selectEnd = e
- this.selectedPreview = (el.value || '').substring(s, e)
- }
- // #endif
- } catch (e) { /* noop */ }
- },
-
- // 复制选中范围(未选则复制全部)
- copySelectedText() {
- const text = this.selectTextValue || ''
- if (!text) {
- uni.showToast({ title: this.$lang('ai-chat.index.noContentToCopy'), icon: 'none' })
- return
- }
- const start = Math.min(this.selectStart, this.selectEnd)
- const end = Math.max(this.selectStart, this.selectEnd)
- const selected = start < end ? text.substring(start, end) : ''
- const copyText = selected || text
- this.copyMessage({ content: copyText, _selected: !!selected })
- this.closeSelectTextModal()
- },
-
- // 关闭"选取文字复制"弹窗
- closeSelectTextModal() {
- this.showSelectTextModal = false
- this.selectTextValue = ''
- this.selectStart = 0
- this.selectEnd = 0
- this.selectedPreview = ''
- },
-
- // 手动长按检测(touch 事件方案,兼容所有平台)
- // 在 H5 desktop 上用 @contextmenu 触发,
- // 在 H5 mobile / 小米微信小程序等场景靠 touch 计时器兜底
- onBubbleTouchStart(event, message) {
- // 单指长按才触发,忽略多点触控
- if (event.touches && event.touches.length > 1) return
- this._longPressMessage = message
- this._longPressTimer = setTimeout(() => {
- this._longPressTimer = null
- this.onMessageLongPress(message)
- }, 500) // 和系统长按接近的 500ms
- },
- onBubbleTouchMove() {
- // 手指移动(滚动列表)→ 取消长按
- if (this._longPressTimer) {
- clearTimeout(this._longPressTimer)
- this._longPressTimer = null
- }
- this._longPressMessage = null
- },
- onBubbleTouchEnd() {
- // 手指提前松开 → 取消长按
- if (this._longPressTimer) {
- clearTimeout(this._longPressTimer)
- this._longPressTimer = null
- }
- this._longPressMessage = null
- },
-
// 复制消息内容到剪贴板(兼容 H5 / 微信小程序)
copyMessage(message) {
let copyText = message.content || ''
@@ -4113,158 +3853,26 @@ _handleStreamError(msg) {
}
}
-/* 选取文字复制弹窗 */
-.select-text-popup {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 1001;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: flex-end;
-
- .select-text-mask {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(0, 0, 0, 0.4);
- }
-
- .select-text-panel {
- position: relative;
- width: 100%;
- height: 70%;
- display: flex;
- flex-direction: column;
- background-color: #fff;
- border-radius: 24rpx 24rpx 0 0;
- overflow: hidden;
-
- .select-text-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 24rpx 32rpx;
- border-bottom: 1rpx solid #e5e5e5;
- background-color: #f9f9f9;
-
- text {
- font-size: 32rpx;
- font-weight: 500;
- color: #333;
- }
-
- .close-btn {
- width: 64rpx;
- height: 64rpx;
- border-radius: 50%;
- background-color: transparent;
- display: flex;
- align-items: center;
- justify-content: center;
- border: none;
- padding: 0;
-
- text {
- font-size: 48rpx;
- color: #999;
- line-height: 1;
- }
- }
- }
-
- .select-text-tip {
- padding: 16rpx 32rpx 0;
- font-size: 24rpx;
- color: #999;
- background-color: #f9f9f9;
- }
-
- .select-text-textarea {
- flex: 1;
- width: 100%;
- padding: 32rpx;
- font-size: 30rpx;
- line-height: 1.6;
- color: #333;
- background-color: #fff;
- border: none;
- box-sizing: border-box;
- }
-
- .select-text-preview {
- max-height: 200rpx;
- overflow-y: auto;
- padding: 20rpx 32rpx;
- border-top: 1rpx dashed #e5e5e5;
- background-color: #f6f8fa;
- font-size: 26rpx;
- color: #555;
- word-break: break-all;
-
- .select-text-preview-label {
- color: #999;
- margin-right: 8rpx;
- }
-
- .select-text-preview-content {
- color: #07c160;
- font-weight: 500;
- }
- }
-
- .select-text-footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 24rpx 32rpx;
- padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
- border-top: 1rpx solid #e5e5e5;
- background-color: #f9f9f9;
-
- .select-text-copy-btn {
- flex: 1;
- height: 80rpx;
- border-radius: 40rpx;
- background-color: #07c160;
- color: #fff;
- font-size: 28rpx;
- font-weight: 500;
- border: none;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: 20rpx;
- }
-
- .select-text-close-btn {
- width: 160rpx;
- height: 80rpx;
- border-radius: 40rpx;
- background-color: #f2f2f2;
- color: #666;
- font-size: 28rpx;
- border: none;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- }
- }
-}
-
- // H5 下允许 rich-text 内容文本选择(鼠标拖选复制)
+ // H5 下允许 rich-text / v-html 内容文本选择(鼠标拖选复制)
.text-content,
- .markdown-body {
+ .markdown-body,
+ .selectable-md {
user-select: text;
-webkit-user-select: text;
}
+ .selectable-md {
+ font-size: 28rpx;
+ line-height: 1.7;
+ color: #333;
+
+ // 保证 v-html 内部段落、列表等不影响选择
+ p, ul, ol, li, h1, h2, h3, h4, h5, h6, code, pre {
+ user-select: text;
+ -webkit-user-select: text;
+ }
+ }
+
// 用户消息 rich-text 高亮(搜索时替代 )
.message-rich-text {
font-size: 28rpx;
From d15724dd2f946b652335a9b4f4cc490eb785cdbf Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Fri, 11 Sep 2026 17:34:43 +0800
Subject: [PATCH 09/22] =?UTF-8?q?chore=EF=BC=9A=E7=94=A8=E6=88=B7=E6=B0=94?=
=?UTF-8?q?=E6=B3=A1=E6=A0=B7=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.local.config.js | 6 +++++-
pages_tool/ai-chat/ai-chat-message.vue | 11 +++++++++--
2 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/.local.config.js b/.local.config.js
index bc43ffd..5be02c9 100644
--- a/.local.config.js
+++ b/.local.config.js
@@ -210,6 +210,10 @@ const localDevConfig = ({
uniacid: 2505,
domain: 'https://xcx.aigc-quickapp.com/',
},
+ '3007': { // 煤矿钻机
+ uniacid: 3007,
+ domain: 'https://xcx.aigc-quickapp.com/',
+ },
// })['5g-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
@@ -225,7 +229,7 @@ const localDevConfig = ({
// })['local-11']; // 选择要使用的环境配置
// })['2811']; // 本地
-})['2821']; // 本地
+})['1']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test
diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue
index 659b48d..b1f40d8 100644
--- a/pages_tool/ai-chat/ai-chat-message.vue
+++ b/pages_tool/ai-chat/ai-chat-message.vue
@@ -3046,12 +3046,12 @@ _handleStreamError(msg) {
}
}
- /* 气泡: 浅灰背景 + 圆角, 内容在气泡内左对齐(中文阅读体验更好) */
+ /* 气泡: 浅灰背景 + 方形圆角, 内容在气泡内左对齐(中文阅读体验更好) */
.message-bubble {
background: #f5f5f5 !important;
color: #1f1f1f !important;
border: none !important;
- border-radius: 20rpx !important;
+ border-radius: 0 !important;
box-shadow: none !important;
padding: 20rpx 30rpx !important; // 加大内边距,让气泡更"大"
display: inline-block;
@@ -3073,6 +3073,13 @@ _handleStreamError(msg) {
}
}
+ /* #ifdef H5 */
+ /* H5 用户消息气泡加大内边距,文字大小保持不变 */
+ .user-message .message-bubble {
+ padding: 24rpx 40rpx !important;
+ }
+ /* #endif */
+
/* AI消息特有样式 (微信风格: 头像 + 名称 + 时间同行, 下方内容)
与你截图样式一致 */
.ai-message {
From 429487b1d1a8415104969f89a708e90de43797cb Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Sat, 12 Sep 2026 09:20:51 +0800
Subject: [PATCH 10/22] =?UTF-8?q?chore=EF=BC=9A=E5=BE=AE=E4=BF=A1=E5=B0=8F?=
=?UTF-8?q?=E7=A8=8B=E5=BA=8F=E7=9C=9F=E6=9C=BA=E6=99=BA=E8=83=BD=E5=AE=A2?=
=?UTF-8?q?=E6=9C=8D=E6=AD=A3=E5=B8=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
common/js/ai-service.js | 59 ++++++++++++++++++++------
lang-bundle.json | 2 +-
pages_tool/ai-chat/ai-chat-message.vue | 11 +++--
3 files changed, 55 insertions(+), 17 deletions(-)
diff --git a/common/js/ai-service.js b/common/js/ai-service.js
index b31f899..c3efd29 100644
--- a/common/js/ai-service.js
+++ b/common/js/ai-service.js
@@ -367,8 +367,10 @@ export default {
try {
await ensureWebSocketConnected();
} catch (e) {
- if (onComplete) onComplete({ error: e.message });
- throw e;
+ // WebSocket 不可用(真机常见: socket 合法域名未配置 / 后端 WS 未部署)
+ // 自动降级为 HTTP 阻塞式请求, 复用与 H5 相同的后端接口
+ console.warn('[AI Service] MP WebSocket 不可用,降级为 HTTP blocking 请求:', e && e.message);
+ return this._fallbackToHttp(message, onChunk, onComplete);
}
wsMessageContent = '';
@@ -380,21 +382,52 @@ export default {
wsMessageResolve = resolve;
wsMessageReject = reject;
- wsSocketTask.send({
- data: JSON.stringify({
- action: 'chat',
- agent_id: getAgentId(),
- uniacid: this.getUniacid(),
- query: message,
- user_id: getUserId(),
- conversation_id: this.getConversationId() || '',
- ...getDomainParams()
- })
- });
+ try {
+ wsSocketTask.send({
+ data: JSON.stringify({
+ action: 'chat',
+ agent_id: getAgentId(),
+ uniacid: this.getUniacid(),
+ query: message,
+ user_id: getUserId(),
+ conversation_id: this.getConversationId() || '',
+ ...getDomainParams()
+ })
+ });
+ } catch (sendErr) {
+ console.warn('[AI Service] MP WebSocket send 失败,降级为 HTTP blocking 请求:', sendErr && sendErr.message);
+ // 用降级结果决定该 promise 的最终状态, 避免同时弹出错误气泡
+ this._fallbackToHttp(message, onChunk, onComplete)
+ .then((res) => resolve(res))
+ .catch((err) => reject(err));
+ }
});
// #endif
},
+ /**
+ * [MP 降级] WebSocket 不可用时, 改用 HTTP 阻塞式请求完成一次完整回复
+ * 只要「request 合法域名」已配置即可工作, 不依赖 socket 合法域名
+ */
+ async _fallbackToHttp(message, onChunk, onComplete) {
+ try {
+ const result = await this.sendMessage(message);
+ const content = result && result.content ? result.content : '';
+ if (content) {
+ if (onChunk) onChunk(content);
+ if (onComplete) onComplete({ content, conversation_id: result.conversationId });
+ return { content, conversation_id: result.conversationId };
+ }
+ const emptyErr = new Error(lang.lang('aiService.serviceUnavailable'));
+ if (onComplete) onComplete({ error: emptyErr.message });
+ throw emptyErr;
+ } catch (err) {
+ console.error('[AI Service] MP HTTP 降级请求也失败:', err);
+ if (onComplete) onComplete({ error: (err && err.message) || lang.lang('aiService.serviceUnavailable') });
+ throw err;
+ }
+ },
+
/**
* HTTP 流式请求(仅 H5 使用)
* PR-7: URL 按 agent_id 路由
diff --git a/lang-bundle.json b/lang-bundle.json
index 7322e33..15fa5dd 100644
--- a/lang-bundle.json
+++ b/lang-bundle.json
@@ -2625,7 +2625,7 @@
},
"ai-chat": {
"index": {
- "title": "AI智能客服",
+ "title": "AI智能",
"cancel": "取消",
"save": "保存",
"send": "发送",
diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue
index b1f40d8..3e59fc9 100644
--- a/pages_tool/ai-chat/ai-chat-message.vue
+++ b/pages_tool/ai-chat/ai-chat-message.vue
@@ -3046,12 +3046,12 @@ _handleStreamError(msg) {
}
}
- /* 气泡: 浅灰背景 + 方形圆角, 内容在气泡内左对齐(中文阅读体验更好) */
+ /* 气泡: 浅灰背景 + 轻微圆角, 内容在气泡内左对齐(中文阅读体验更好) */
.message-bubble {
background: #f5f5f5 !important;
color: #1f1f1f !important;
border: none !important;
- border-radius: 0 !important;
+ border-radius: 16rpx !important;
box-shadow: none !important;
padding: 20rpx 30rpx !important; // 加大内边距,让气泡更"大"
display: inline-block;
@@ -3076,7 +3076,7 @@ _handleStreamError(msg) {
/* #ifdef H5 */
/* H5 用户消息气泡加大内边距,文字大小保持不变 */
.user-message .message-bubble {
- padding: 24rpx 40rpx !important;
+ padding: 36rpx 60rpx !important;
}
/* #endif */
@@ -3682,6 +3682,11 @@ _handleStreamError(msg) {
}
}
}
+
+ /* 用户消息气泡: 保持浅灰气泡 + 加大内边距(文字大小不变), 覆盖上面的 padding:0 */
+ .user-message .message-content .message-bubble {
+ padding: 36rpx 60rpx !important;
+ }
}
}
}
From 69935825924674a4feb146e33de0df9fbfb0d745 Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Sat, 12 Sep 2026 09:25:30 +0800
Subject: [PATCH 11/22] =?UTF-8?q?chore=EF=BC=9AAI=E6=99=BA=E8=83=BD?=
=?UTF-8?q?=E5=AE=A2=E6=9C=8D=E6=94=B9=E6=88=90AI=E6=99=BA=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pages.json | 2 +-
scripts/check-title-keys.js | 2 +-
scripts/lang_audit_report_v2.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/pages.json b/pages.json
index 53f3c2b..d4ba971 100644
--- a/pages.json
+++ b/pages.json
@@ -1086,7 +1086,7 @@
{
"path": "ai-chat/index",
"style": {
- "navigationBarTitleText": "AI智能客服",
+ "navigationBarTitleText": "AI智能",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
diff --git a/scripts/check-title-keys.js b/scripts/check-title-keys.js
index e061d07..ffc3f7c 100644
--- a/scripts/check-title-keys.js
+++ b/scripts/check-title-keys.js
@@ -8,7 +8,7 @@ const paths = [
['pages_promotion/merch/detail','商户首页'],
['pages_promotion/fenxiao/ranking_list','排行榜'],
['pages_promotion/fenxiao/child_fenxiao','分销商'],
- ['pages_tool/ai-chat/index','AI智能客服'],
+ ['pages_tool/ai-chat/index','AI智能'],
['pages_tool/contact/contact','电子名片'],
['pages_tool/member/info','个人资料'],
['pages_tool/order/activist','退款'],
diff --git a/scripts/lang_audit_report_v2.json b/scripts/lang_audit_report_v2.json
index bf8133e..5fdcb12 100644
--- a/scripts/lang_audit_report_v2.json
+++ b/scripts/lang_audit_report_v2.json
@@ -121,7 +121,7 @@
],
[
"pages_tool/ai-chat/index",
- "AI智能客服"
+ "AI智能"
],
[
"pages_tool/contact/contact",
From 3fffab81c87d3596d05b0b6c18bb4724492e7f48 Mon Sep 17 00:00:00 2001
From: jing <1683105490@qq.com>
Date: Sat, 12 Sep 2026 11:03:34 +0800
Subject: [PATCH 12/22] =?UTF-8?q?chore:=E5=8F=AF=E4=BB=A5=E5=A4=8D?=
=?UTF-8?q?=E5=88=B6=E5=9B=9E=E7=AD=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
common/js/uniapp.utils.js | 24 ++++++++++++++++-
docs/weixin-bug.md | 36 +++++++++++++++++++++++++-
lang/en-us/ai-chat/index.js | 3 ++-
lang/en-us/common.js | 2 ++
lang/zh-cn/ai-chat/index.js | 3 ++-
lang/zh-cn/common.js | 2 ++
manifest.json | 2 +-
pages_tool/ai-chat/ai-chat-message.vue | 8 ++++--
8 files changed, 73 insertions(+), 7 deletions(-)
diff --git a/common/js/uniapp.utils.js b/common/js/uniapp.utils.js
index 045e801..5344c44 100644
--- a/common/js/uniapp.utils.js
+++ b/common/js/uniapp.utils.js
@@ -5,6 +5,24 @@
import lang from './lang.js';
+/**
+ * 判断是否为「微信剪贴板隐私声明缺失」导致的复制失败
+ *
+ * 微信自 2023-09-15 起把剪贴板接口纳入《小程序用户隐私保护指引》,
+ * 若小程序后台未声明「剪贴板」(或声明未审核通过),调用会返回:
+ * { errno: 112, errMsg: 'setClipboardData:fail api scope is not declared in the privacy agreement' }
+ * 这是平台侧配置问题,不改代码也能复现,因此不能把原始英文 errMsg 直接展示给用户。
+ *
+ * @param {*} err uni.setClipboardData 的 fail 回调参数
+ * @returns {Boolean} true=剪贴板隐私声明缺失
+ */
+export const isClipboardPrivacyError = (err) => {
+ if (!err) return false;
+ if (Number(err.errno) === 112) return true;
+ const msg = `${err.errMsg || err.message || ''}`;
+ return /privacy agreement/i.test(msg) || /api scope is not declared/i.test(msg);
+};
+
/**
* 显示错误信息
* @param {Exception} err
@@ -97,8 +115,12 @@ export const copyTextAsync = (text, { copySuccess = '', copyFailed = '' } = {})
fail: (err) => {
clearTimeout(timeoutId);
try {
+ // 剪贴板隐私声明缺失(errno 112)属平台配置问题, 用可读文案替代微信原始英文错误
+ const tip = isClipboardPrivacyError(err)
+ ? lang.lang('uniappUtils.clipboardPrivacyNotDeclared')
+ : (err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'));
uni.showToast({
- title: err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'),
+ title: tip,
icon: 'none',
duration: 2000
});
diff --git a/docs/weixin-bug.md b/docs/weixin-bug.md
index 064f809..57c1457 100644
--- a/docs/weixin-bug.md
+++ b/docs/weixin-bug.md
@@ -105,4 +105,38 @@ components/mescroll/components/mescroll-empty.json
有些"修模拟器表现"的尝试(@load 多次重试 setNavigationBarTitle、加看门狗定时器)会让真机代码变得复杂、可能和内嵌页 `document.title` 抢控制权、引入边界 case。
-**结论**:遇到 navigationBar / web-view 相关问题,先在真机上复现确认;只在真机还在问题再继续查代码。
\ No newline at end of file
+**结论**:遇到 navigationBar / web-view 相关问题,先在真机上复现确认;只在真机还在问题再继续查代码。
+
+#### 4. `setClipboardData:fail api scope is not declared in the privacy agreement`
+
+**症状**:AI 智能(`pages_tool/ai-chat`)复制消息时 Toast 提示
+`复制失败:setClipboardData:fail api scope is not declared in the privacy agreement`,
+H5 正常,**只有微信小程序失败**。
+
+**根因**:微信自 2023-09-15 起把剪贴板接口(`setClipboardData` / `getClipboardData`)纳入
+《小程序用户隐私保护指引》。小程序后台未声明「剪贴板」,或声明还在审核中时,调用直接失败,
+返回 `errno: 112`。这是**平台侧配置问题,不是代码问题**:
+`app.json` 的 `permission` / `requiredPrivateInfos` 都不涉及剪贴板,改代码无法绕过。
+
+**正确处理**:
+- 微信公众平台 → 设置 → 基本设置 → 服务内容声明 → 用户隐私保护指引 → 更新 →
+ 在「开发者收集你的……信息」中勾选「剪贴板」并填写用途 → 提交审核;
+ **审核通过后才生效**,通过后重新编译/发版即恢复。
+- 代码侧只做兜底:`common/js/uniapp.utils.js` 的 `isClipboardPrivacyError()` 识别
+ `errno === 112` / errMsg 含 `privacy agreement`,用 i18n 文案替换微信原始英文错误
+ (AI 聊天页 `copyMessage` 同样处理),不要把平台英文报错抛给用户。
+- 开发期想临时绕过校验:把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改成 `false`
+ (**仅限本地调试**,上线前必须还原,并按上面的后台配置完成声明)。
+
+**注意**:根目录的 `privacy.json`(内容为 `Scope.writeClipboard`)不是微信的隐私声明机制,
+未被任何代码或构建流程引用,不要把它当成剪贴板隐私的解决方案。
+
+**代码侧备选(无法改后台、只想先跑起来时)**:
+- 写剪贴板在微信里只有 `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)。
diff --git a/lang/en-us/ai-chat/index.js b/lang/en-us/ai-chat/index.js
index 59447c1..c10f96d 100644
--- a/lang/en-us/ai-chat/index.js
+++ b/lang/en-us/ai-chat/index.js
@@ -35,6 +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',
openingFile: 'Opening file...',
openingFileName: 'Opening file: {name}',
openingFileTitle: 'Opening: {title}',
@@ -135,7 +136,7 @@ export const lang = {
logEventHandleError: 'Event handling error',
logInitNavbarFailed: 'Failed to initialize navigation bar',
logChatComponentNotInit: 'Chat component not initialized',
- logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
+ logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...',
// ====== AI Agent general copy (customer service is the first agent) ======
aiAgentDefaultTitle: 'AI Agent',
diff --git a/lang/en-us/common.js b/lang/en-us/common.js
index c6dabe9..b7cfd32 100644
--- a/lang/en-us/common.js
+++ b/lang/en-us/common.js
@@ -126,6 +126,8 @@ export const lang = {
copyTimeoutReason: 'Reason:',
wechatClipboardReason1: '1. WeChat Platform -> User Privacy Protection Guidelines -> "Clipboard" feature not added or review not passed;',
wechatClipboardReason2: '2. WeChat platform has restrictions on clipboard API call frequency',
+ // readable tip when clipboard privacy scope is not declared (errno 112)
+ clipboardPrivacyNotDeclared: 'Clipboard privacy permission is not configured',
copyFailed: 'Copy failed',
},
diff --git a/lang/zh-cn/ai-chat/index.js b/lang/zh-cn/ai-chat/index.js
index 752f15b..6666241 100644
--- a/lang/zh-cn/ai-chat/index.js
+++ b/lang/zh-cn/ai-chat/index.js
@@ -35,6 +35,7 @@ export const lang = {
copySelected: '已复制选中内容',
copyFail: '复制失败',
copyFailWithReason: '复制失败:{reason}',
+ copyFailPrivacyReason: '小程序未声明剪贴板权限,请长按消息文字手动复制',
openingFile: '打开文件中...',
openingFileName: '正在打开文件:{name}',
openingFileTitle: '正在打开:{title}',
@@ -135,7 +136,7 @@ export const lang = {
logEventHandleError: '事件处理错误',
logInitNavbarFailed: '初始化导航栏失败',
logChatComponentNotInit: '聊天组件未初始化',
- logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
+ logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...',
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
aiAgentDefaultTitle: 'AI 智能体',
diff --git a/lang/zh-cn/common.js b/lang/zh-cn/common.js
index d4cf588..951e931 100644
--- a/lang/zh-cn/common.js
+++ b/lang/zh-cn/common.js
@@ -126,6 +126,8 @@ export const lang = {
copyTimeoutReason: '原因:',
wechatClipboardReason1: '1.微信平台->用户隐私保护指引->"剪贴板"功能未添加或审核未通过;',
wechatClipboardReason2: '2.微信平台对剪贴板API调用频率有限制',
+ // 微信剪贴板隐私声明缺失(errno 112)时的可读提示
+ clipboardPrivacyNotDeclared: '剪贴板隐私权限未配置',
copyFailed: '复制失败',
},
diff --git a/manifest.json b/manifest.json
index cf0b306..692a3cc 100644
--- a/manifest.json
+++ b/manifest.json
@@ -82,7 +82,7 @@
}
},
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
- "__usePrivacyCheck__" : true,
+ "__usePrivacyCheck__" : false,
"optimization" : {
"subPackages" : true
}
diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue
index 3e59fc9..be35b00 100644
--- a/pages_tool/ai-chat/ai-chat-message.vue
+++ b/pages_tool/ai-chat/ai-chat-message.vue
@@ -369,6 +369,7 @@
+
+
+```
+
+### 1.5 关键注意点
+
+1. **aiagent HTTP 路径下, 中途没有 `thought` event**: 流式渲染期间, 思考面板一直空; 流结束后从 `message_end.thoughts` 一次渲染完整文本。这是协议事实, 不是 bug
+2. **WS 路径下 `thought` 是增量**: 必须用 `+=` 拼接, 不要 set 覆盖; 同一段思考可能被切成多个 chunk
+3. **`tool_call` 必须 upsert**: WB 协议对同一个 `tool_call_id` 会推 10+ 次 `pending`, 必须按 id 合并, 不能 append
+4. **`tokens` 数据 (input/output/cost/model)**: 调试面板用, 普通用户不需要看到
+5. **`stop_reason` 区分**: `end_turn` = 正常结束, `pending_stale` = 用户长时间没回答 + 后端 reject, `error` = 异常
+6. **B 路 Dify 兼容 `> `> 前缀**: 不是协议标准, 是 Dify 把思考内容混进 `answer` 的习惯, 后端透传, 前端要兜底拆
+7. **思考步数限制**: 折叠面板最多展示 20 步 / 累计 4000 字符, 超出折叠 ("还有 5 步未显示")
+
+### 1.6 后端 SSE event 来源 (供调试)
+
+如果前端收不到 `thought`, 用 curl 验 WS 路径 (aikefu HTTP 路径也行):
+
+```bash
+# aikefu HTTP (B-path 或 A-path 都行, 这里是 B-path)
+curl -N 'https://your-domain.com/api/aikefu/kefu/chat' \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: text/event-stream' \
+ -d '{
+ "config_uuid":"your_uuid",
+ "agent_id":"agent_aikefu",
+ "query":"hi",
+ "user_id":"guest_test",
+ "response_mode":"streaming"
+ }'
+```
+
+应该看到 (aikefu HTTP 走 `event:` + `data:` 两行):
+
+```
+: open\n\n ← aikefu HTTP 立即推, 让响应头 flush
+
+event: message
+data: {"event":"message","answer":"您好...","message_id":"wb_xxx",...}
+
+event: thought
+data: {"event":"thought","thought":"用户打招呼...",...}
+
+event: message_end
+data: {"event":"message_end","conversation_id":"...","message_id":"wb_xxx","thoughts":"...","tokens":{...},"stop_reason":"end_turn"}
+
+```
+
+> **注意格式差异**: aikefu HTTP 写 `event: \ndata: \n\n` 两行式; aiagent HTTP 只写 `data: \n\n` 一行式. 上面的示例只适用于 aikefu. 验 aiagent HTTP 时, `event` 字段只能从 data 行的 JSON 体内取.
+
+如果只有 `message` 和 `message_end`, 看:
+- `runtime/log/app_run_info/2026-09-14-wb-rpc.log` 里 `processSseEvent` 日志
+- `runtime/log/app_run_info/2026-09-14-wb-rest.log` 看是 A-path 还是 B-path
+
+---
+
+## 2. Question 选项及反馈
+
+### 2.1 后端协议 (真实格式, 不要再用旧的 `options[]` 假设)
+
+WB Agent 用 `AskUserQuestion` 工具触发提问, 后端 `WbAgentOsClient::processSseEvent` 解析两种来源:
+
+1. **JSON-RPC 协议** `method === '_codebuddy.ai/question'`, 携带 `params.schema.questions[]`
+2. **Session update 协议** `sessionUpdate === 'tool_call_update'`, `_meta.codebuddy.ai.toolName === 'AskUserQuestion'`
+
+两种协议解析后统一抛 `pending_question`:
+
+```json
+{
+ "tool_call_id": "tc_xxx",
+ "questions": [
+ {
+ "question": "您想了解哪种商品的售后政策?",
+ "options": [
+ {"label": "退款", "description": "7 天无理由退款"},
+ {"label": "换货", "description": "30 天内可换同款"},
+ {"label": "维修", "description": "1 年保修"},
+ {"label": "仅咨询"}
+ ],
+ "header": "售后类型",
+ "multiSelect": false
+ }
+ ],
+ "source": "elicitation",
+ "asked_at": 1700000000,
+ "rpc_id": 0
+}
+```
+
+**关键字段 (跟旧版假设差别很大):**
+
+| 字段 | 含义 |
+|---|---|
+| `tool_call_id` | 工具调用 ID, 回传时必须带 |
+| `questions[]` | 数组! 不是单 question 字符串 |
+| `questions[].question` | 问题文本 |
+| `questions[].options[]` | 选项数组 |
+| `options[].label` | 显示给用户的文本, **也是回传的值** (没有 `value` 字段) |
+| `options[].description` | 鼠标 hover 提示, 可选 |
+| `questions[].header` | 短标签 (顶部 chip), 可选 |
+| `questions[].multiSelect` | 是否多选 |
+| `rpc_id` | **JSON-RPC 的请求 ID, 回传答案时必带** |
+
+### 2.2 Answer 回传机制 (3 种, 不要瞎写)
+
+| 场景 | 端点 / 动作 | 字段 |
+|---|---|---|
+| **管理员后台测试对话框** (aiagent shop controller) | `POST /aiagent/shop/agent/testChatRespond` (form, 无 `/addon/` 前缀) | `conversation_id, message_id, tool_call_id, rpc_id, answers[]` |
+| **WS 通道** (MP / H5 任意, 流还没结束) | WS `{action: 'elicit_response', ...}` | `conversation_id, message_id, tool_call_id, rpc_id, answers: {"q0":"label",...}` |
+| **HTTP SSE aiagent 通道** | **没有原生 answer 回传路径** | 必须改用 WS, 或等 `pending_stale` (≈10s) 后端 emit message_end |
+| **HTTP SSE aikefu 通道** | 同样没有原生路径 | 同上 |
+
+**`answers` 格式:**
+
+```json
+// 单选 (multiSelect=false)
+{
+ "q0": "退款"
+}
+
+// 多选 (multiSelect=true), 多个 label 用数组
+{
+ "q0": ["退款", "换货"]
+}
+```
+
+`q0` 是 question 在数组里的 index (字符串化), value 是 label 文本 (不是 id, 不是 value)。
+
+### 2.3 现状 (PR 前)
+
+`ai-service.js` SSE 解析只识别 `event === 'message'`, `pending_question` / `question` event 被丢 (`ai-service.js:233-236` 的 `else` 分支只 log)。
+
+用户看到的是 AI 一句问话后**直接卡住**, 实际是后端在等前端回传选项。
+
+### 2.4 目标行为
+
+AI 消息末尾渲染**选项卡片**:
+
+```
+┌─────────────────────────────────────┐
+│ 售后类型 │ ← header (可选)
+│ 您想了解哪种商品的售后政策? │ ← question text
+│ │
+│ [退款] [换货] [维修] [仅咨询] │ ← 选项 (label)
+│ 7天无理由 30天可换 1年保修 ← description (可选)
+└─────────────────────────────────────┘
+```
+
+要点:
+- 单选: 点击选项 → 卡片折叠 ("已选择: 退款 ✓") + 自动发送
+- 多选: 多个选项可点 → 底部出现 "确认 (N)" 按钮
+- 30 秒未选 → 倒计时 + 自动选第一个, 后端 emit `stop_reason='pending_stale'`
+
+### 2.5 代码示例
+
+**Step 1: ai-service 加 `elicitResponse` 方法**
+
+```js
+// common/js/ai-service.js 末尾追加
+
+/**
+ * 向 WorkBuddy Agent 回传 AskUserQuestion 的用户选项
+ *
+ * @param {Object} params
+ * @param {string} params.conversation_id
+ * @param {string} params.message_id
+ * @param {string} params.tool_call_id
+ * @param {number} params.rpc_id
+ * @param {Object} params.answers - {q0: 'label', q1: ['a','b']}
+ * @returns {Promise}
+ */
+async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answers }) {
+ const addon = resolveAddonName(currentAgentId);
+
+ // ★ aiagent + aikefu 都支持 WS elicit_response (WebSocket.php:146/147)
+ // 走同一段 WS 发送逻辑
+ // #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
+ await ensureWebSocketConnected();
+ wsSocketTask.send({
+ data: JSON.stringify({
+ action: 'elicit_response',
+ conversation_id,
+ message_id,
+ tool_call_id,
+ rpc_id,
+ answers,
+ uniacid: this.getUniacid(),
+ token: store.state.token || 'test_token',
+ })
+ });
+ return true;
+ // #endif
+
+ // #ifdef H5
+ // H5 端没有原生 WS 长连接, HTTP SSE 也没有 elicit_response 端点
+ // 兜底: 复用 chat 端点 + action=elicit_response body
+ // (此端点需后端补 — 当前后端 5 个 chat 入口 (aiagent Chat + aikefu Kefu B+A + 2 个 WS) 都没注册 elicit-response HTTP 路由)
+ // 临时方案: 30s 超时后端 emit pending_stale, 用户的选项卡片就当 UI 提示展示, 不真回传给 Agent
+ console.warn('[ai-service] H5 elicit_response 无可用通道, 仅展示用户选项 + 30s 后由 pending_stale 兜底');
+ return false;
+ // #endif
+},
+```
+
+**Step 2: 渲染层 — 选项卡片**
+
+```vue
+
+
+
+
+ {{ q.question }}
+ ⏱ {{ msg.questionTimeout }}s
+
+
+ {{ opt.label }}
+ {{ opt.description }}
+
+
+
+
+ 确认 ({{ (msg.selectedAnswers[`q${qi}`] || []).length }})
+
+
+
+
+
+
+
+
+```
+
+### 2.6 关键注意点
+
+1. **`label` 是回传的值, 不是 `value`**: 协议没有 `value` 字段, 显示啥就回传啥 (改文案会导致原选项答非所问)
+2. **answers 是对象 `{q0: label, q1: [label,...]}`**, 不是数组
+3. **多 question**: WB 协议允许一次推多个 question, UI 要按数组渲染, 全部答完才提交
+4. **WS 是 HTTP SSE 的兜底**: HTTP SSE aiagent 通道没有原生 answer 回传, 实际项目里建议前端始终连 WS, elicit_response 走 WS
+5. **后端等答案 ≈ 10s × 3 协议尝试** (`PENDING_STALE_AFTER`), 超时后 emit `message_end` with `stop_reason='pending_stale'`, 前端要展示 "对方没回应, 已跳过提问"
+6. **不同步 `pending_question` 到 history**: 历史消息不带 question, 用户重看历史不会触发 "重新答"
+7. **`rpc_id` 是 int**: JSON-RPC 的请求 ID, 回传时原样带回去, 后端用它绑回原始 RPC 调用
+8. **`description` 是可选 hover 提示**: WB Agent 可能不填, 前端判断 `opt.description` 再渲染
+
+### 2.7 后端兜底 (前端不用管)
+
+- 后端 `tool_call_id` 找不到 → 回 `{code: -1, msg: '找不到对应的提问'}`
+- 后端超时 (10s × 3 协议) → 走 `pending_stale` 分支, emit message_end, `stop_reason='pending_stale'`
+
+---
+
+## 3. 图片/音视频/文件上传下载
+
+### 3.1 后端协议 (单点真源, 详见 `docs/mp-media-upload-integration.md`)
+
+**端点汇总 (按 agent addon 分组):**
+
+| 用途 | aiagent addon | aikefu addon |
+|---|---|---|
+| 图片上传 (走我们服务器) | `POST /aiagent/api/upload/image` | `POST /aikefu/api/upload/image` |
+| 文件上传 (audio/video/file, 走我们服务器) | `POST /aiagent/api/upload/file` | `POST /aikefu/api/upload/file` |
+| 拿云存储直传凭证 | `POST /aiagent/api/upload/ossSts` | `POST /aikefu/api/upload/ossSts` |
+| 拿 agent 配置 (含 upload_strategy) | `POST /api/aiagent/info` | `POST /api/kefu/info` |
+
+**URL 模式说明 (2026-09-14 PR-WB-MP-MEDIA-API-PREFIX 修正):**
+
+上传端点必须用 **addon 模式 URL** (`//api/upload/`,addon 名在前),原因:
+
+- 之前曾用 `/api//upload/` (addon 名在后) — 后端 InitRoute 默认 system_array 命中分支不切 namespace,精确路由注册也实测未生效,fallback 默认解析 → `controller not exists: app\api\controller\Aikefu`
+- addon 模式 URL (`/aikefu/api/upload/image`) 经 InitRoute 的 addon 分支 (L131-162) 自动切 namespace 到 `addon\aikefu\api`,L187 简写 `Route::rule($pathinfo, 'api/upload/image')` 即可 dispatch 到正确 controller
+- 历史教训: 不要在前端用 `'/api/aikefu/upload/...'` 这种不规范路径,即使后端有显式路由注册也会被 thinkphp 路由匹配漏掉
+- nginx 已加 `merge_slashes on;` 兜底前端拼 baseURL+path 时的双斜杠 (`//aikefu/api/upload/image` → `/aikefu/api/upload/image`)
+
+**注意 info 端点路径差异 (与上传端点不一致):**
+
+- aiagent: `/api/aiagent/info` (Chat.php 的 info() 方法)
+- aikefu: **`/api/kefu/info`** (app\api\controller\Kefu.php:182, 触发 `KefuGetInfo` event), **不是** `/api/aikefu/kefu/getInfo` 也不是 `/api/aikefu/info`
+
+> **关键**: aikefu 端的 info 端点挂在**主项目** `/api/kefu/info` (不进 addon 路由), 是历史遗留. 调上传时 `Config.baseUrl + '/api/kefu/info'` 即可, 别拼 addon 名前缀. addon's `getInfo` 事件不是 REST 端点, 是 ThinkPHP 事件总线调用, 仅后端内部用.
+
+前端 `aiService.getApiPrefix()` 当前返回 `'/api/kefu'` 或 `'/api/aiagent'` (用于 chat/info/history 端点),**不**用于上传端点 — 上传端点必须直接拼 addon 模式 URL `'/aikefu/api/upload/'` 或 `'/aiagent/api/upload/'`。
+
+**关键决策点 (按 agent 配置 `upload_strategy`):**
+
+> 本表 `upload_strategy` 内部代号 vs 后台 UI 商家看到的文字:
+>
+> | 后端代号 | 后台 UI 商家看到的文字 |
+> |---|---|
+> | `backend` | 走我们服务器传 (推荐, 默认) |
+> | `oss_sts` | 走云存储传 (高级, 大视频用) |
+> | `auto` | 智能选 (小图走我们, 大文件走云存储) |
+>
+> 前端代码按后端代号 (`backend` / `oss_sts` / `auto`) 读, 不要把 UI 文字写死.
+>
+> **oss_sts 走哪家云** = `media_policy.oss_provider` 字段,可选 12 个 provider (alioss/qiniu/txcos/huaweiobs/baidubos/qingstor/jdoss/volcengine/ucloud/wyoss/awsoss/minio);空串 = 后端按 OssProviderRegistry 自动选. PR-OSS-REGISTRY (2026-09-15) 后已是 12 provider,不再仅阿里云.
+
+| 后端代号 (UI 别名) | image ≤2MB | image >2MB | audio/video/file |
+|---|---|---|---|
+| `backend` (走我们服务器) | 走 PHP 中转 (默认, 小白选) | 走 PHP 中转 (后端再转云存储) | 走 PHP 中转 |
+| `oss_sts` (走云存储) | **强制云存储直传** | **强制云存储直传** | **强制云存储直传** |
+| `auto` (智能选) | 走 PHP 中转 (省事) | 云存储直传 (性能) | 云存储直传 |
+
+**核心结论: 前端先 `POST /info` 拿 `upload_strategy`, 再决定走哪条路径。**
+
+### 3.1.5 attachments.data 字段协议 (PR-WB-MP-INLINE-MEDIA 2026-09-16 新增)
+
+> 这是**最容易被忽略但最重要的协议层细节**。背景: backend 模式上传的文件 URL 落在 `/upload/{site_id}/...`,**`/upload/` 路径 nginx 无鉴权**,把这种 URL 透传给 WB Agent 让它 fetch — 等于把"任意访客"的边界扩到 WB 服务端。base64 inline 是唯一不依赖 WB 服务端 fetch 公网 URL 的稳路径。
+
+**attachments[] 单条 dict 协议 (PR-WB-MP-INLINE-MEDIA 之后):**
+
+```jsonc
+{
+ "type": "image", // image | audio | video | file
+ "url": "https://...", // 可选 (data 存在时不强求; 否则必填)
+ "data": "", // ★ PR-WB-MP-INLINE-MEDIA 新增, JSON 上下文必为 base64
+ "encoding": "base64", // 可选, 显式标记 (默认 base64)
+ "mime": "image/png", // data 内联时必填 (ACP ContentBlock mimeType)
+ "size": 1024, // 原始字节 (WB 阈值 5MB 判断用, 后端计算 sizeof(base64_decode(data)) 兜底)
+ "name": "x.png"
+}
+```
+
+**核心规则:**
+
+| 场景 | 后端处理 (A 路 `WbAgentOsClient::buildMediaBlocks` / B 路 `WbAcpClient::buildMediaBlocks`) |
+|---|---|
+| `image` 或 `audio` + `data` + `size ≤ 5MB` (5242880 字节) | ACP ContentBlock `image`/`audio` base64 inline, **WB 完全不 fetch URL** |
+| `data` 超限 (5MB) 或 `type` 不支持 inline (video/file) | fallback 到 `resource_link` (透传 URL 给 WB, 依赖 J9 服务端 fetch) |
+| 仅 `url`,无 `data` | `WbAgentOsClient` 服务端自 curl (`fetchInline`) 转 base64 后 inline; `WbAcpClient` 直接 resource_link (J9 依赖) |
+| 无 `url` 且无 `data` | drop + error_log 留 trace (避免静默丢) |
+| URL 是 `javascript:` / 非 http(s) 协议 | drop (MediaGuard SSRF 闸门) |
+
+**storage/transport 分离 (后端保证,前端不用关心):**
+
+- `data` 字段**只在 process memory 流动**,**不会写 DB** (attachments 列存的是 url+meta, 不存 base64)
+- 后端 `MessageMedia::buildFields()` 落库前**显式 `unset(data, encoding)`**
+- 前端回显只需要 `url`,数据库无 base64 也不会被泄露
+
+**协议层 A/B 对称 (后端 2026-09-16 修复):**
+
+| 路径 | 旧行为 | 新行为 |
+|---|---|---|
+| A `WbAgentOsClient::buildMediaBlocks` (L2763) | `url` 空即**静默丢** | `data` 优先, `url` 兜底 |
+| B `WbAcpClient::buildMediaBlocks` (L349-419) | 已认 `data` 字段 | 0 改动 |
+| `MediaGuard::check` (L116-122) | `data` 字段被 SSRF 闸门拦掉 | `data` 短路放行 (无出站请求) |
+
+**前端必须做的 (本期新增):**
+
+1. `uni.uploadFile()` 上传成功后, 再调 `uni.getFileSystemManager().readFile({filePath, encoding:'base64'})` 读 base64
+2. 把 `data` (base64) + `url` + `mime` + `size` + `name` 一起塞进 chat 入参 `attachments[]`
+3. **大文件 (>5MB) 不要塞 `data`** — base64 会让 chat JSON 膨胀 ~4/3 倍, 走 URL 透传 (依赖 J9, 见下面 caveat)
+4. H5 端用 `FileReader.readAsDataURL()` 拿 base64
+
+**WB SDK 协议层约束 (详见 `wb-cloud-sdk-coverage.md`):**
+
+| 编号 | 含义 | SDK 支持 | 我们怎么走 |
+|---|---|---|---|
+| **J7** | ACP ContentBlock union (image/audio/resource) 支持 base64 inline | ✅ session.py:59-65 | ≤5MB 文件走这条 |
+| **J9** | WB 服务端是否 fetch 外部 URL | ❌ 不在 SDK (服务端策略) | >5MB / 非 image-audio 走这条 (兜底) |
+| **J10** | WB `/uploads/*` 分片上传 API | ❌ grep 无 | **目前无解**, 等 WB 出官方 API 后再优化 |
+
+**仍未覆盖的边界 (本期没办法):**
+
+- >5MB 文件仍依赖 J9, 部署到云服务器后 WB 服务端 fetch `/upload/{site_id}/...` 不一定通 — 此时只能让 Agent 在前端提示用户 "请发小图"
+- 想 100% 消除 J9 依赖, 等 WB 出 `/uploads/*` 分片上传 API (PR-J10, P2 阻塞)
+
+
+
+### 3.2 现状 (PR 前)
+
+`pages_tool/ai-chat/components/ai-chat-tools-panel.vue:76-94` 的 `selectImage()`:
+
+```js
+selectImage() {
+ uni.chooseImage({
+ count: 1,
+ sizeType: ['compressed'],
+ sourceType: ['album', 'camera'],
+ success: (res) => {
+ const imageMessage = {
+ id: ++this.messageId,
+ role: 'user',
+ type: 'image',
+ content: '',
+ imageUrl: res.tempFilePaths[0], // ★ 只存本地路径, 没真上传
+ timestamp: Date.now()
+ }
+ this.$emit('image-selected', imageMessage)
+ }
+ })
+}
+```
+
+`selectFile()` 同样: 只拿到 `file.name / file.size`, 没 `wx.uploadFile`。
+
+`ai-chat-message.vue` 渲染 `message.imageUrl` 时**直接显示本地路径**, AI 端永远看不到这张图。
+
+**结论: 上传功能完全没接通, 用户发的图/文件 AI 端收到的 attachments 是空的。**
+
+> 仓库里有 `common/js/util.js:1005-1031` 的 `upload_file_server` 是走 PHP 中转到 `/api/upload/...` (产品图/头像用), **不能直接借给 AI chat**, 因为 AI chat 需要带 `agent_id` / `config_uuid` 路由到对应 addon, 还要走 upload_strategy 决策。
+
+### 3.3 目标行为
+
+```
+用户点 [图片] 按钮
+ ↓
+uni.chooseImage → 选图完成
+ ↓
+aiService.getServiceConfig() → 拿 upload_strategy (后端代号, 参见 §3.1 表)
+ ↓
+判断:
+ - upload_strategy=backend + size ≤2MB → POST /upload/image (走我们服务器)
+ - upload_strategy=oss_sts 或 auto+size>2MB → POST /upload/ossSts → uni.uploadFile 直传云存储
+ - audio/video/file → 一律 POST /upload/ossSts
+ ↓
+拿到 { url, mime, size, name } 后, **fileToBase64 拿 data 字段** (≤5MB 时), 发聊天时塞 attachments[] (见 §3.1.5)
+```
+
+### 3.4 代码示例
+
+> **前置依赖 (PR 前必须先做)**: `common/js/ai-service.js` 当前**没有暴露**下面两个方法:
+> - `getCurrentConfigUuid()` — 全仓 grep 0 命中, 不存在
+> - `getUserId()` — `ai-service.js:83` 是 private module function, 不在 `export default {}` 里 (line 292)
+>
+> 本节示例假定已经加了 (PR 提交时一起加). 改动小, diff 如下:
+>
+> ```diff
+> // common/js/ai-service.js
+> + import Config from './config.js'
+>
+> // 已有 (line 83) — 私有的, 改成 public
+> - function getUserId() { ... }
+> + export function getUserId() { ... }
+>
+> // 新增 (插在 export default 之前)
+> + export function getCurrentConfigUuid() {
+> + return uni.getStorageSync('current_config_uuid') || '';
+> + }
+> ```
+>
+> 否则下面所有 `aiService.getCurrentConfigUuid?.()` 会拿到 `undefined` 静默走空, `aiService.getUserId?.()` 同理, 导致上传请求少字段被后端拒.
+
+> **代码块术语速查 (后续 §3.4 出现的内部代号 vs 后台 UI 别名)**:
+> - `uploadViaPhp()` / `PHP 中转` / `PHP passthrough` / `backend` = "走我们服务器" (商家看到的)
+> - `uploadViaSts()` / `STS 直传` / `STS 凭证` / `oss_sts` = "走云存储直传" (商家看到的)
+> - 端点路径 `/upload/ossSts` 后端真名是 `Upload::ossSts()`, 别名沿用 (历史叫法, 实际后端走 12 个云存储 provider 任选)
+> - HTTP header `Authorization: STS :` 是**仅 8 个 4-field STS provider** (alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) 的协议规范, 另外 4 个 single-URL provider (huaweiobs/volcengine/awsoss/minio) 走预签 URL, 不带 Authorization header — 详见 §3.4 uploadViaSts 内的 provider 分流注释
+
+**Step 1: 新建 `common/js/media-upload.js`**
+
+```js
+// common/js/media-upload.js (新增)
+import aiService from './ai-service.js'
+import Config from './config.js'
+
+const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB
+
+/**
+ * 上传图片 (type=image)
+ * @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0]
+ * @returns {Promise<{url, mime, size, name, kind, used_strategy}>}
+ */
+export async function uploadImage(filePath) {
+ const addon = aiService.getAddonName(); // 'aikefu' | 'aiagent'
+ const config = await aiService.getServiceConfig();
+ const strategy = config?.upload_strategy || 'backend';
+
+ // H5 端: >10MB 先压
+ let compressedPath = filePath;
+ // #ifdef H5
+ const fileSize = await getFileSize(filePath);
+ if (fileSize > 10 * 1024 * 1024) {
+ try {
+ const compressed = await uni.compressImage({
+ src: filePath,
+ quality: 80,
+ compressedWidth: 2048,
+ compressedHeight: 2048,
+ });
+ compressedPath = compressed.tempFilePath || filePath;
+ } catch (e) {
+ console.warn('[media-upload] compressImage failed, use original:', e);
+ }
+ }
+ // #endif
+
+ const useSts = strategy === 'oss_sts'
+ || (strategy === 'auto' && (await getFileSize(compressedPath)) > SIZE_LIMIT_PHP_PASSTHROUGH);
+
+ if (useSts) {
+ return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config);
+ } else {
+ return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId());
+ }
+}
+
+/**
+ * 上传 audio / video / file (一律 STS)
+ */
+export async function uploadMedia(filePath, kind) {
+ if (!['audio', 'video', 'file'].includes(kind)) {
+ throw new Error(`invalid kind: ${kind}`);
+ }
+ const addon = aiService.getAddonName();
+ const config = await aiService.getServiceConfig();
+ return await uploadViaSts(filePath, kind, addon, aiService.getAgentId(), config);
+}
+
+/**
+ * PHP 中转 (multipart/form-data)
+ * 端点必须用 addon 模式 URL: //api/upload/
+ * (不能用 /api//upload/<...>, InitRoute 在 system_array 命中分支不切 namespace)
+ */
+async function uploadViaPhp(filePath, kind, addon, agentId) {
+ const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/')
+ + (kind === 'image' ? 'image' : 'file');
+
+ // #ifdef MP-WEIXIN
+ return new Promise((resolve, reject) => {
+ uni.uploadFile({
+ url: Config.baseUrl + url,
+ filePath,
+ name: 'file',
+ formData: {
+ kind,
+ agent_id: addon === 'aiagent' ? agentId : undefined,
+ config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
+ uniacid: aiService.getUniacid(),
+ user_id: aiService.getUserId?.() || '',
+ },
+ success: (res) => {
+ try {
+ const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
+ if (data.code === 0 || data.success) {
+ resolve({
+ url: data.data.url,
+ mime: data.data.mime,
+ size: data.data.size,
+ name: data.data.name,
+ kind: data.data.kind || kind,
+ used_strategy: data.data.used_strategy || 'backend',
+ oss_sts_required: data.data.oss_sts_required || false,
+ });
+ } else {
+ reject(new Error(data.msg || '上传失败'));
+ }
+ } catch (e) {
+ reject(new Error('解析响应失败: ' + e.message));
+ }
+ },
+ fail: (err) => reject(new Error('网络错误: ' + (err.errMsg || err.message))),
+ });
+ });
+ // #endif
+
+ // #ifdef H5
+ const form = new FormData();
+ const file = await pathToFile(filePath, kind);
+ form.append('file', file);
+ form.append('kind', kind);
+ if (addon === 'aiagent') form.append('agent_id', agentId);
+ else form.append('config_uuid', aiService.getCurrentConfigUuid?.() || '');
+ form.append('uniacid', aiService.getUniacid());
+ const res = await fetch(Config.baseUrl + url, {
+ method: 'POST',
+ headers: { 'token': store.state.token || '' },
+ body: form,
+ });
+ const data = await res.json();
+ if (data.code === 0 || data.success) {
+ return {
+ url: data.data.url,
+ mime: data.data.mime,
+ size: data.data.size,
+ name: data.data.name,
+ kind: data.data.kind || kind,
+ used_strategy: data.data.used_strategy || 'backend',
+ oss_sts_required: data.data.oss_sts_required || false,
+ };
+ }
+ throw new Error(data.msg || '上传失败');
+ // #endif
+}
+
+/**
+ * STS 直传 (拿临时凭证 → 直传云存储)
+ *
+ * PR-OSS-REGISTRY (2026-09-15) 后端支持 12 个 provider, 协议层分两类:
+ * A) 4-field STS (8 个: alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
+ * → 前端拼 PUT URL, Authorization header = STS :
+ * B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio)
+ * → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='-SIGNED' 标记, 不带 Authorization
+ *
+ * 后端 stsResp.data.data 可能字段:
+ * { provider, bucket, region, accessKeyId, accessKeySecret, securityToken,
+ * key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) }
+ */
+async function uploadViaSts(filePath, kind, addon, agentId, config) {
+ // 1. 拿 STS 凭证 (addon 模式 URL)
+ const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts';
+ const stsResp = await uni.request({
+ url: Config.baseUrl + stsUrl,
+ method: 'POST',
+ header: { 'Content-Type': 'application/json', 'token': store.state.token || '' },
+ data: {
+ agent_id: addon === 'aiagent' ? agentId : undefined,
+ config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
+ uniacid: aiService.getUniacid(),
+ user_id: aiService.getUserId?.() || '',
+ },
+ });
+ if (stsResp.statusCode !== 200 || (stsResp.data.code !== 0 && !stsResp.data.success)) {
+ throw new Error(stsResp.data?.msg || 'STS 凭证获取失败');
+ }
+ const sts = stsResp.data.data;
+ const provider = sts.provider || 'alioss'; // 老后端默认 alioss
+
+ // 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符)
+ const ext = (filePath.split('.').pop() || 'bin').toLowerCase();
+ const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => {
+ const r = Math.random() * 16 | 0;
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
+ });
+ const now = new Date();
+ const key = sts.key_prefix_tpl
+ .replace('{site_id}', sts.site_id || '0')
+ .replace('{agent_id}', agentId || '0')
+ .replace('{yyyy}', now.getFullYear().toString())
+ .replace('{mm}', String(now.getMonth() + 1).padStart(2, '0'))
+ .replace('{dd}', String(now.getDate()).padStart(2, '0'))
+ .replace('{rand}', uuid)
+ + '.' + ext;
+
+ // ★ PR-OSS-REGISTRY (2026-09-15): provider 分流
+ // single-URL 模式: securityToken 末尾是 -SIGNED (OBS-SIGNED / TOS-SIGNED / AWS-SIGNED / MINIO-SIGNED)
+ const isSingleUrlMode = typeof sts.securityToken === 'string'
+ && /-(SIGNED)$/.test(sts.securityToken);
+
+ let ossUrl, headers;
+ if (isSingleUrlMode) {
+ // B) single-URL: sts.accessKeyId 是完整预签 PUT URL, 不需要 Authorization header
+ ossUrl = sts.accessKeyId;
+ headers = { 'Content-Type': guessMime(ext) };
+ } else if (provider === 'qiniu') {
+ // A) qiniu 兼容模式: sts.accessKeyId = uptoken, endpoint 来自 sts.endpoint 或 sts.host
+ ossUrl = (sts.endpoint || sts.host || `https://upload-${sts.region}.qiniup.com`) + '/' + key;
+ headers = {
+ 'Authorization': 'UpToken ' + sts.accessKeyId,
+ 'Content-Type': guessMime(ext),
+ };
+ } else {
+ // A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
+ // endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼
+ const tpl = (provider === 'alioss') ? `https://${sts.bucket}.${sts.region}.aliyuncs.com`
+ : (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com`
+ : (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com`
+ : (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com`
+ : (provider === 'wyoss') ? `https://${sts.bucket}.${sts.host || sts.region}`
+ : (sts.endpoint ? sts.endpoint.replace('{bucket}', sts.bucket) : `https://${sts.bucket}.${sts.region}`);
+ ossUrl = `${tpl}/${key}`;
+ headers = {
+ 'Authorization': `STS ${sts.accessKeyId}:${sts.accessKeySecret}`,
+ 'x-oss-security-token': sts.securityToken,
+ 'Content-Type': guessMime(ext),
+ };
+ }
+
+ // 3. 直传云存储
+ // #ifdef MP-WEIXIN
+ await new Promise((resolve, reject) => {
+ uni.uploadFile({
+ url: ossUrl,
+ filePath,
+ name: 'file',
+ formData: {},
+ header: headers,
+ success: (res) => {
+ if (res.statusCode >= 200 && res.statusCode < 300) resolve();
+ else reject(new Error(`${provider} PUT ${res.statusCode}`));
+ },
+ fail: reject,
+ });
+ });
+ // #endif
+ // #ifdef H5
+ const file = await pathToFile(filePath, kind);
+ const ossRes = await fetch(ossUrl, {
+ method: 'PUT',
+ headers,
+ body: file,
+ });
+ if (!ossRes.ok) throw new Error(`${provider} PUT ${ossRes.status}`);
+ // #endif
+
+ // 4. 返回公网 URL
+ return {
+ url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL
+ mime: guessMime(ext),
+ size: file.size || 0,
+ name: key.split('/').pop(),
+ kind,
+ used_strategy: 'oss_sts',
+ provider,
+ };
+}
+
+async function getFileSize(filePath) {
+ // #ifdef MP-WEIXIN
+ return new Promise((resolve) => {
+ uni.getFileInfo({
+ filePath,
+ success: (res) => resolve(res.size),
+ fail: () => resolve(0),
+ });
+ });
+ // #endif
+ // #ifdef H5
+ try {
+ const res = await fetch(filePath);
+ const blob = await res.blob();
+ return blob.size;
+ } catch (e) { return 0; }
+ // #endif
+}
+
+async function pathToFile(filePath, kind) {
+ // #ifdef H5
+ const res = await fetch(filePath);
+ const blob = await res.blob();
+ return new File([blob], filePath.split('/').pop() || `${kind}.bin`, { type: blob.type });
+ // #endif
+}
+
+function guessMime(ext) {
+ const map = {
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
+ mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
+ mp4: 'video/mp4', mov: 'video/quicktime',
+ pdf: 'application/pdf', txt: 'text/plain',
+ };
+ return map[ext] || 'application/octet-stream';
+}
+
+export default { uploadImage, uploadMedia };
+```
+
+**Step 2: 改造 `selectImage()`**
+
+```vue
+
+
+```
+
+**Step 3: 发送消息时携带 attachments (含 `data` 字段, PR-WB-MP-INLINE-MEDIA)**
+
+```js
+// ai-chat-message.vue sendMessage()
+async sendMessage() {
+ const hasContent = this.inputText?.trim() !== '';
+ const readyAttachments = this.messages
+ .filter(m => m.role === 'user' && m.uploaded && !m.sendWithBatch)
+ .map(m => {
+ m.sendWithBatch = true;
+ const a = {
+ type: m.uploaded.kind,
+ url: m.uploaded.url,
+ mime: m.uploaded.mime,
+ name: m.uploaded.name,
+ size: m.uploaded.size,
+ };
+ // ★ PR-WB-MP-INLINE-MEDIA: ≤5MB 图片/音频塞 base64, 让 WB ContentBlock 内联
+ // >5MB 不要塞 data (避免 chat JSON 膨胀 ~4/3 倍, 走 URL 透传依赖 J9)
+ if ((a.type === 'image' || a.type === 'audio')
+ && a.size > 0 && a.size <= 5 * 1024 * 1024
+ && m.uploaded.base64) {
+ a.data = m.uploaded.base64;
+ a.encoding = 'base64';
+ }
+ return a;
+ });
+
+ if (!hasContent && !readyAttachments.length) return;
+
+ const queryText = this.inputText?.trim() || (readyAttachments.length ? '[附件]' : '');
+
+ await this._doSendStream(queryText, readyAttachments);
+}
+
+async _doSendStream(queryText, attachments) {
+ // 改造后的 sendStreamMessage 接 attachments 参数
+ await this.sendStreamMessage(queryText, attachments);
+}
+```
+
+**Step 4: 上传成功后 fileToBase64 拿 data 字段**
+
+`m.uploaded.base64` 字段需要在 §3.4 Step 2 `_uploadOneFile()` 拿到上传结果后补:
+
+```js
+// ai-chat-message.vue / ai-chat-tools-panel.vue _uploadOneFile 改造
+async _uploadOneFile(file, kind) {
+ // ... 原有 pending 占位代码 ...
+
+ try {
+ const uploaded = kind === 'image'
+ ? await uploadImage(file.path || file)
+ : await uploadMedia(file.path || file, kind);
+ pending.uploading = false;
+ pending.uploaded = uploaded;
+ if (uploaded.url) pending.imageUrl = uploaded.url;
+
+ // ★ PR-WB-MP-INLINE-MEDIA: 上传成功后, ≤5MB 文件再读 base64 塞进 uploaded
+ if ((kind === 'image' || kind === 'audio')
+ && uploaded.size > 0 && uploaded.size <= 5 * 1024 * 1024) {
+ try {
+ pending.uploaded.base64 = await readFileAsBase64(file.path || file);
+ } catch (e) {
+ console.warn('[ai-chat] fileToBase64 失败, 后端会走 URL 透传 (依赖 J9):', e);
+ // 不阻断, 大不了降级到 J9
+ }
+ }
+
+ this.$emit('file-uploaded', pending);
+ } catch (e) {
+ // ... 原有错误处理 ...
+ }
+},
+
+async function readFileAsBase64(filePath) {
+ // #ifdef MP-WEIXIN
+ return new Promise((resolve, reject) => {
+ uni.getFileSystemManager().readFile({
+ filePath,
+ encoding: 'base64',
+ success: (res) => resolve(res.data),
+ fail: reject,
+ });
+ });
+ // #endif
+ // #ifdef H5
+ const res = await fetch(filePath);
+ const blob = await res.blob();
+ return await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result.split(',')[1]); // 去掉 "data:;base64," 前缀
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+ // #endif
+ // #ifdef APP-PLUS / MP-HARMONY
+ return new Promise((resolve, reject) => {
+ plus.io.resolveLocalFileSystemURL(filePath, (entry) => {
+ entry.file((f) => {
+ const reader = new plus.io.FileReader();
+ reader.onload = (e) => resolve(e.target.result.split(',')[1]);
+ reader.onerror = reject;
+ reader.readAsDataURL(f);
+ }, reject);
+ }, reject);
+ });
+ // #endif
+}
+```
+
+**为什么必须 fileToBase64**: 后端 upload 接口只返 `{url, mime, size, name}`,**不含 base64 字节**。base64 必须在上传后**前端读本地文件**重新拿, 然后跟 url 一起塞 chat attachment。
+
+`sendStreamMessage` 在 §1.4 已改造接 `attachments`, 无需再改。
+
+### 3.5 下载 / 预览
+
+OSS 直传后返回的 `url` 是公网 URL, 可直接 `` / `