更新
This commit is contained in:
@@ -42,6 +42,11 @@ export interface PrivateChatMessage {
|
||||
create_time: string | number
|
||||
}
|
||||
|
||||
export interface PrivateChatVoiceToTextResult {
|
||||
text: string
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
export function getChatSessions(data?: { page_no?: number; page_size?: number }) {
|
||||
return request.get<{
|
||||
lists: PrivateChatSession[]
|
||||
@@ -76,3 +81,15 @@ export function sendChatMessage(data: {
|
||||
}) {
|
||||
return request.post<PrivateChatMessage>({ url: '/chat/send', data })
|
||||
}
|
||||
|
||||
export function voiceToText(filePath: string, token?: string) {
|
||||
return request.uploadFile({
|
||||
url: '/chat/voiceToText',
|
||||
filePath,
|
||||
name: 'file',
|
||||
header: {
|
||||
token
|
||||
},
|
||||
fileType: 'audio'
|
||||
}) as Promise<PrivateChatVoiceToTextResult>
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
@@ -49,7 +50,10 @@
|
||||
},
|
||||
/* ios打包配置 */
|
||||
"ios" : {
|
||||
"dSYMs" : false
|
||||
"dSYMs" : false,
|
||||
"privacyDescription" : {
|
||||
"NSMicrophoneUsageDescription" : "用于在私聊中录制语音并转换为文字消息"
|
||||
}
|
||||
},
|
||||
/* SDK配置 */
|
||||
"sdkConfigs" : {
|
||||
|
||||
@@ -467,7 +467,7 @@ const handleFollow = async () => {
|
||||
const relation = post.value.relationship || {}
|
||||
relation.is_followed_by_me = followed
|
||||
relation.is_mutual = followed && !!relation.is_following_me
|
||||
relation.relationship_text = relation.is_mutual ? '互相关注' : '不是好友'
|
||||
relation.relationship_text = relation.is_mutual ? '互相关注' : '对方还未添加你为好友'
|
||||
post.value.relationship = relation
|
||||
uni.showToast({ title: followed ? '已加好友' : '已取消好友', icon: 'none' })
|
||||
} catch (e) { }
|
||||
|
||||
@@ -289,7 +289,7 @@ const handleFollow = async () => {
|
||||
const relation = userInfo.value.relationship || {}
|
||||
relation.is_followed_by_me = followed
|
||||
relation.is_mutual = followed && !!relation.is_following_me
|
||||
relation.relationship_text = relation.is_mutual ? '互相关注' : '不是好友'
|
||||
relation.relationship_text = relation.is_mutual ? '互相关注' : '对方还未添加你为好友'
|
||||
userInfo.value.relationship = relation
|
||||
if (followed) {
|
||||
userInfo.value.fans_count = (userInfo.value.fans_count || 0) + 1
|
||||
|
||||
@@ -231,11 +231,12 @@ const sendMessage = async () => {
|
||||
messages.value.splice(messages.value.length - 1, 1, answer)
|
||||
await loadSessions()
|
||||
} catch (e: any) {
|
||||
console.error('[ai_assistant] chat failed:', e)
|
||||
messages.value.splice(messages.value.length - 1, 1, {
|
||||
id: Date.now() + 2,
|
||||
session_id: sessionId.value,
|
||||
role: 'assistant',
|
||||
content: typeof e === 'string' ? e : 'AI助手请求失败,请稍后重试。',
|
||||
content: getAssistantErrorMessage(e),
|
||||
sources: []
|
||||
})
|
||||
} finally {
|
||||
@@ -245,6 +246,28 @@ const sendMessage = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getRawAssistantErrorMessage = (error: any) => {
|
||||
return typeof error === 'string'
|
||||
? error
|
||||
: String(error?.msg || error?.errMsg || error?.message || error?.data?.msg || '')
|
||||
}
|
||||
|
||||
const getAssistantErrorMessage = (error: any) => {
|
||||
const rawMessage = getRawAssistantErrorMessage(error)
|
||||
|
||||
if (/timeout|超时/i.test(rawMessage)) {
|
||||
return 'AI助手响应时间较长,请稍后再试;当前请求已超时。'
|
||||
}
|
||||
if (/abort|cancel/i.test(rawMessage)) {
|
||||
return 'AI助手请求已取消,请重新发送问题。'
|
||||
}
|
||||
if (/request:fail|network|网络/i.test(rawMessage)) {
|
||||
return 'AI助手请求失败,请检查网络后重试。'
|
||||
}
|
||||
|
||||
return rawMessage || 'AI助手请求失败,请稍后重试。'
|
||||
}
|
||||
|
||||
const startAnalysisTimer = () => {
|
||||
stopAnalysisTimer()
|
||||
analysisElapsedSeconds.value = 0
|
||||
|
||||
@@ -16,11 +16,13 @@
|
||||
</view>
|
||||
|
||||
<view v-for="item in sessions" :key="item.id" class="session-item" @tap="openRoom(item)">
|
||||
<image class="session-item__avatar" :src="item.target_user?.avatar || defaultAvatar" mode="aspectFill" />
|
||||
<image class="session-item__avatar" :src="item.target_user?.avatar || defaultAvatar"
|
||||
mode="aspectFill" />
|
||||
<view class="session-item__body">
|
||||
<view class="session-item__top">
|
||||
<text class="session-item__name">{{ item.target_user?.nickname || '用户' }}</text>
|
||||
<text class="session-item__time">{{ formatTime(item.last_active_time || item.create_time) }}</text>
|
||||
<text class="session-item__time">{{ formatTime(item.last_active_time || item.create_time)
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="session-item__bottom">
|
||||
<text class="session-item__message">{{ lastMessageText(item) }}</text>
|
||||
@@ -109,7 +111,7 @@ const lastMessageText = (item: PrivateChatSession) => {
|
||||
}
|
||||
|
||||
const relationText = (item: PrivateChatSession) => {
|
||||
return item.relationship?.relationship_text || (item.relationship?.is_mutual ? '互相关注' : '不是好友')
|
||||
return item.relationship?.relationship_text || (item.relationship?.is_mutual ? '互相关注' : '对方还未添加你为好友')
|
||||
}
|
||||
|
||||
const formatTime = (value: string | number) => {
|
||||
|
||||
@@ -20,25 +20,15 @@
|
||||
</view>
|
||||
|
||||
<view v-if="session && !isMutual" class="relationship-notice">
|
||||
<text>不是好友</text>
|
||||
<text>{{ relationshipText }}</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="item in messages"
|
||||
:id="'msg-' + item.id"
|
||||
:key="item.id"
|
||||
class="message-row"
|
||||
:class="{ 'message-row--self': item.is_self }"
|
||||
>
|
||||
<view v-for="item in messages" :id="'msg-' + item.id" :key="item.id" class="message-row"
|
||||
:class="{ 'message-row--self': item.is_self }">
|
||||
<view class="message-bubble" :class="{ 'message-bubble--image': item.message_type === 'image' }">
|
||||
<text v-if="item.message_type === 'text'" class="message-bubble__text">{{ item.content }}</text>
|
||||
<image
|
||||
v-else
|
||||
class="message-bubble__image"
|
||||
:src="item.content"
|
||||
mode="widthFix"
|
||||
@tap="previewImage(item.content)"
|
||||
/>
|
||||
<image v-else class="message-bubble__image" :src="item.content" mode="widthFix"
|
||||
@tap="previewImage(item.content)" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -47,22 +37,37 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="input-bar">
|
||||
<view class="input-bar__tool" :class="{ 'input-bar__tool--disabled': sending }" @tap="chooseImage">
|
||||
<u-icon name="photo" size="34" color="#185dff" />
|
||||
<view class="input-panel">
|
||||
<view v-if="voicePanelVisible" class="voice-panel">
|
||||
<view class="voice-panel__status">
|
||||
<view class="voice-panel__dot" :class="{ 'voice-panel__dot--active': recording }" />
|
||||
<text>{{ voiceStatusText }}</text>
|
||||
</view>
|
||||
<view class="voice-panel__button"
|
||||
:class="{ 'voice-panel__button--recording': recording, 'voice-panel__button--disabled': transcribing }"
|
||||
@touchstart="startRecord" @touchend="stopRecord" @touchcancel="cancelRecord">
|
||||
<u-icon name="mic" size="42" :color="recording ? '#fff' : '#185dff'" />
|
||||
</view>
|
||||
<view class="voice-panel__meta">
|
||||
<text>{{ recordTimeText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
class="input-bar__textarea"
|
||||
auto-height
|
||||
maxlength="1000"
|
||||
:disabled="sending"
|
||||
placeholder="输入消息"
|
||||
confirm-type="send"
|
||||
@confirm="sendText"
|
||||
/>
|
||||
<view class="input-bar__send" :class="{ 'input-bar__send--disabled': !canSend }" @tap="sendText">
|
||||
<u-icon name="arrow-upward" size="30" color="#fff" />
|
||||
|
||||
<view class="input-bar">
|
||||
<view class="input-bar__tool"
|
||||
:class="{ 'input-bar__tool--disabled': toolDisabled, 'input-bar__tool--active': voicePanelVisible }"
|
||||
@tap="toggleVoicePanel">
|
||||
<u-icon name="mic" size="34" color="#185dff" />
|
||||
</view>
|
||||
<view class="input-bar__tool" :class="{ 'input-bar__tool--disabled': toolDisabled }" @tap="chooseImage">
|
||||
<u-icon name="photo" size="34" color="#185dff" />
|
||||
</view>
|
||||
<textarea v-model="inputText" class="input-bar__textarea" maxlength="1000"
|
||||
:disabled="sending || recording || transcribing" :placeholder="inputPlaceholder"
|
||||
disable-default-padding confirm-type="send" @confirm="sendText" />
|
||||
<view class="input-bar__send" :class="{ 'input-bar__send--disabled': !canSend }" @tap="sendText">
|
||||
<u-icon name="arrow-upward" size="30" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -71,11 +76,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
|
||||
import { getChatMessages, openChat, sendChatMessage, type PrivateChatMessage, type PrivateChatSession } from '@/api/chat'
|
||||
import {
|
||||
getChatMessages,
|
||||
openChat,
|
||||
sendChatMessage,
|
||||
voiceToText,
|
||||
type PrivateChatMessage,
|
||||
type PrivateChatSession
|
||||
} from '@/api/chat'
|
||||
import { uploadImage } from '@/api/app'
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
const defaultAvatar = '/static/images/avatar.png'
|
||||
const notMutualText = '对方还未添加你为好友'
|
||||
const statusBarHeight = ref(0)
|
||||
const sessionId = ref(0)
|
||||
const targetUserId = ref(0)
|
||||
@@ -85,15 +98,39 @@ const inputText = ref('')
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const polling = ref(false)
|
||||
const voicePanelVisible = ref(false)
|
||||
const voiceSupported = ref(false)
|
||||
const recording = ref(false)
|
||||
const transcribing = ref(false)
|
||||
const recordSeconds = ref(0)
|
||||
const scrollIntoView = ref('')
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let recordTimer: ReturnType<typeof setInterval> | null = null
|
||||
let recorderManager: any = null
|
||||
let recordCancelled = false
|
||||
let recordStartAt = 0
|
||||
|
||||
const targetUser = computed(() => session.value?.target_user || null)
|
||||
const isMutual = computed(() => !!session.value?.relationship?.is_mutual)
|
||||
const relationshipText = computed(() => {
|
||||
return session.value?.relationship?.relationship_text || (isMutual.value ? '互相关注' : '不是好友')
|
||||
return session.value?.relationship?.relationship_text || (isMutual.value ? '互相关注' : notMutualText)
|
||||
})
|
||||
const toolDisabled = computed(() => sending.value || recording.value || transcribing.value || !sessionId.value)
|
||||
const canSend = computed(() => inputText.value.trim().length > 0 && !toolDisabled.value)
|
||||
const inputPlaceholder = computed(() => {
|
||||
if (recording.value) return '正在录音'
|
||||
if (transcribing.value) return '语音识别中'
|
||||
return '输入消息'
|
||||
})
|
||||
const voiceStatusText = computed(() => {
|
||||
if (transcribing.value) return '正在识别语音'
|
||||
if (recording.value) return '松开发起识别'
|
||||
return '按住说话'
|
||||
})
|
||||
const recordTimeText = computed(() => {
|
||||
const seconds = Math.min(recordSeconds.value, 60)
|
||||
return `${seconds}s / 60s`
|
||||
})
|
||||
const canSend = computed(() => inputText.value.trim().length > 0 && !sending.value && sessionId.value > 0)
|
||||
|
||||
onLoad(async (options) => {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
@@ -103,6 +140,10 @@ onLoad(async (options) => {
|
||||
// #ifdef H5
|
||||
statusBarHeight.value = 0
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
voiceSupported.value = true
|
||||
initRecorder()
|
||||
// #endif
|
||||
|
||||
sessionId.value = Number(options?.session_id || 0)
|
||||
targetUserId.value = Number(options?.target_user_id || 0)
|
||||
@@ -124,10 +165,12 @@ onShow(() => {
|
||||
|
||||
onHide(() => {
|
||||
stopPolling()
|
||||
cancelRecord()
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
stopPolling()
|
||||
cancelRecord()
|
||||
})
|
||||
|
||||
const openTargetSession = async () => {
|
||||
@@ -196,7 +239,7 @@ const stopPolling = () => {
|
||||
|
||||
const sendText = async () => {
|
||||
const content = inputText.value.trim()
|
||||
if (!content || sending.value || !sessionId.value) return
|
||||
if (!content || !canSend.value) return
|
||||
if (content.length > 1000) {
|
||||
uni.showToast({ title: '文字消息最多1000字', icon: 'none' })
|
||||
return
|
||||
@@ -218,7 +261,7 @@ const sendText = async () => {
|
||||
}
|
||||
|
||||
const chooseImage = () => {
|
||||
if (sending.value || !sessionId.value) return
|
||||
if (toolDisabled.value) return
|
||||
uni.chooseImage({
|
||||
count: 9,
|
||||
sizeType: ['compressed'],
|
||||
@@ -250,6 +293,119 @@ const chooseImage = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const toggleVoicePanel = () => {
|
||||
if (recording.value || transcribing.value || sending.value || !sessionId.value) return
|
||||
if (!voiceSupported.value) {
|
||||
uni.showToast({ title: '请在App中使用语音输入', icon: 'none' })
|
||||
return
|
||||
}
|
||||
voicePanelVisible.value = !voicePanelVisible.value
|
||||
}
|
||||
|
||||
const initRecorder = () => {
|
||||
if (recorderManager) return recorderManager
|
||||
// #ifdef APP-PLUS
|
||||
recorderManager = uni.getRecorderManager()
|
||||
recorderManager.onStop(handleRecordStop)
|
||||
recorderManager.onError(() => {
|
||||
stopRecordTimer()
|
||||
recording.value = false
|
||||
transcribing.value = false
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '录音失败,请检查麦克风权限', icon: 'none' })
|
||||
})
|
||||
// #endif
|
||||
return recorderManager
|
||||
}
|
||||
|
||||
const startRecord = () => {
|
||||
if (!voiceSupported.value || recording.value || transcribing.value || sending.value) return
|
||||
const manager = initRecorder()
|
||||
if (!manager) {
|
||||
uni.showToast({ title: '请在App中使用语音输入', icon: 'none' })
|
||||
return
|
||||
}
|
||||
recordCancelled = false
|
||||
recording.value = true
|
||||
recordSeconds.value = 0
|
||||
recordStartAt = Date.now()
|
||||
startRecordTimer()
|
||||
manager.start({
|
||||
duration: 60000,
|
||||
sampleRate: 16000,
|
||||
numberOfChannels: 1,
|
||||
encodeBitRate: 48000,
|
||||
format: 'mp3'
|
||||
})
|
||||
}
|
||||
|
||||
const stopRecord = () => {
|
||||
if (!recording.value) return
|
||||
const duration = Date.now() - recordStartAt
|
||||
if (duration < 800) {
|
||||
recordCancelled = true
|
||||
uni.showToast({ title: '说话时间太短', icon: 'none' })
|
||||
}
|
||||
recording.value = false
|
||||
stopRecordTimer()
|
||||
recorderManager?.stop()
|
||||
}
|
||||
|
||||
const cancelRecord = () => {
|
||||
if (!recording.value) return
|
||||
recordCancelled = true
|
||||
recording.value = false
|
||||
stopRecordTimer()
|
||||
recorderManager?.stop()
|
||||
}
|
||||
|
||||
const handleRecordStop = async (res: any) => {
|
||||
stopRecordTimer()
|
||||
recording.value = false
|
||||
if (recordCancelled) {
|
||||
recordCancelled = false
|
||||
return
|
||||
}
|
||||
const filePath = res?.tempFilePath || ''
|
||||
if (!filePath) {
|
||||
uni.showToast({ title: '录音文件生成失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
transcribing.value = true
|
||||
uni.showLoading({ title: '识别中...' })
|
||||
try {
|
||||
const result = await voiceToText(filePath, getToken() || '')
|
||||
const text = (result?.text || '').trim()
|
||||
if (!text) {
|
||||
uni.showToast({ title: '未识别到语音内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
inputText.value = inputText.value.trim() ? `${inputText.value.trim()}\n${text}` : text
|
||||
voicePanelVisible.value = false
|
||||
} catch (e: any) {
|
||||
const title = typeof e === 'string' && e ? e : '语音识别失败'
|
||||
uni.showToast({ title, icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
transcribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startRecordTimer = () => {
|
||||
stopRecordTimer()
|
||||
recordTimer = setInterval(() => {
|
||||
recordSeconds.value = Math.min(60, Math.floor((Date.now() - recordStartAt) / 1000))
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const stopRecordTimer = () => {
|
||||
if (recordTimer) {
|
||||
clearInterval(recordTimer)
|
||||
recordTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const appendMessage = (message: PrivateChatMessage) => {
|
||||
if (!message?.id) return
|
||||
if (messages.value.some((item) => item.id === message.id)) return
|
||||
@@ -441,14 +597,73 @@ const goBack = () => {
|
||||
}
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
.input-panel {
|
||||
flex-shrink: 0;
|
||||
padding: 18rpx 22rpx calc(18rpx + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1rpx solid #e8edf3;
|
||||
padding: 16rpx 22rpx calc(18rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.voice-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 104rpx 1fr;
|
||||
align-items: center;
|
||||
min-height: 116rpx;
|
||||
padding-bottom: 16rpx;
|
||||
|
||||
&__status,
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-size: 24rpx;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
justify-content: flex-end;
|
||||
color: #8a95a6;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
margin-right: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: #b8c5d8;
|
||||
|
||||
&--active {
|
||||
background: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
border-radius: 50%;
|
||||
background: #edf3ff;
|
||||
border: 1rpx solid #c9d8f4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--recording {
|
||||
background: #185dff;
|
||||
border-color: #185dff;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
height: 72rpx;
|
||||
overflow: hidden;
|
||||
|
||||
&__tool,
|
||||
&__send {
|
||||
@@ -465,6 +680,11 @@ const goBack = () => {
|
||||
background: #edf3ff;
|
||||
border: 1rpx solid #c9d8f4;
|
||||
|
||||
&--active {
|
||||
background: #dce9ff;
|
||||
border-color: #9fbaf3;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -472,14 +692,16 @@ const goBack = () => {
|
||||
|
||||
&__textarea {
|
||||
flex: 1;
|
||||
height: 72rpx !important;
|
||||
min-height: 72rpx;
|
||||
max-height: 180rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
max-height: 72rpx;
|
||||
padding: 16rpx 22rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8rpx;
|
||||
background: #f3f6fa;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.45;
|
||||
line-height: 40rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__send {
|
||||
|
||||
@@ -1353,4 +1353,3 @@ Schema.warning = warning;
|
||||
Schema.messages = messages;
|
||||
|
||||
export default Schema;
|
||||
//# sourceMappingURL=index.js.map
|
||||
|
||||
Reference in New Issue
Block a user