This commit is contained in:
hajimi
2026-06-11 16:22:12 +08:00
parent 96efa1d905
commit 21aa11bf17
368 changed files with 2741 additions and 554 deletions
+17
View File
@@ -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>
}
+5 -1
View File
@@ -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
+24 -1
View File
@@ -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
+5 -3
View File
@@ -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) => {
+263 -41
View File
@@ -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