mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-29 04:33:56 +08:00
fix(manager): align frontend and backend field contracts
This commit is contained in:
@@ -69,7 +69,7 @@
|
||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs",
|
||||
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs src/pages/agent/components/voicePreviewUtils.test.mjs src/pages/device/deviceTimeUtils.test.mjs",
|
||||
"openapi-ts-request": "openapi-ts",
|
||||
"prepare": "git init && husky",
|
||||
"lint": "eslint",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ModelOption,
|
||||
PageData,
|
||||
RoleTemplate,
|
||||
TtsVoice,
|
||||
} from './types'
|
||||
import { http } from '@/http/request/alova'
|
||||
|
||||
@@ -89,7 +90,7 @@ export function deleteAgent(id: string) {
|
||||
|
||||
// 获取TTS音色列表
|
||||
export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
|
||||
return http.Get<{ id: string, name: string }[]>(`/models/${ttsModelId}/voices`, {
|
||||
return http.Get<TtsVoice[]>(`/models/${ttsModelId}/voices`, {
|
||||
params: {
|
||||
voiceName,
|
||||
},
|
||||
@@ -214,7 +215,7 @@ export function updateAgentTags(agentId: string, data) {
|
||||
|
||||
// 获取所有语言
|
||||
export function getAllLanguage(modelId: string) {
|
||||
return http.Get<{ id: string, name: string, languages: string }[]>(`/models/${modelId}/voices`, {
|
||||
return http.Get<TtsVoice[]>(`/models/${modelId}/voices`, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
@@ -225,6 +226,19 @@ export function getAllLanguage(modelId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取克隆音色的临时播放ID
|
||||
* @param cloneId 克隆音色记录ID
|
||||
*/
|
||||
export function getVoiceCloneAudioId(cloneId: string) {
|
||||
return http.Post<string>(`/voiceClone/audio/${cloneId}`, {}, {
|
||||
meta: {
|
||||
ignoreAuth: false,
|
||||
toast: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取智能体历史版本列表
|
||||
export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) {
|
||||
return http.Get<PageData<AgentSnapshot>>(`/agent/${agentId}/snapshots`, {
|
||||
|
||||
@@ -114,6 +114,14 @@ export interface CorrectWordFile {
|
||||
wordCount?: number
|
||||
}
|
||||
|
||||
export interface TtsVoice {
|
||||
id: string
|
||||
name: string
|
||||
voiceDemo?: string | null
|
||||
languages?: string | null
|
||||
isClone?: boolean | null
|
||||
}
|
||||
|
||||
// 角色模板数据类型
|
||||
export interface RoleTemplate {
|
||||
id: string
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface Device {
|
||||
id: string
|
||||
userId: string
|
||||
macAddress: string
|
||||
lastConnectedAt: string
|
||||
lastConnectedAtTimestamp: string | null
|
||||
autoUpdate: number
|
||||
board: string
|
||||
alias?: string
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/** @param {Record<string, any>} voice */
|
||||
export function hasVoicePreview(voice) {
|
||||
return Boolean(voice?.isClone || voice?.voiceDemo || voice?.voice_demo)
|
||||
}
|
||||
|
||||
export function createVoicePreviewRequestGate() {
|
||||
let sequence = 0
|
||||
|
||||
return {
|
||||
begin() {
|
||||
sequence += 1
|
||||
return sequence
|
||||
},
|
||||
invalidate() {
|
||||
sequence += 1
|
||||
},
|
||||
isCurrent(requestId) {
|
||||
return requestId === sequence
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ id: string, isClone?: boolean, voiceDemo?: string | null }} voice
|
||||
* @param {(cloneId: string) => Promise<string>} getCloneAudioId
|
||||
* @param {string} baseUrl
|
||||
*/
|
||||
export async function resolveVoicePreviewUrl(voice, getCloneAudioId, baseUrl) {
|
||||
if (!voice?.isClone) {
|
||||
return typeof voice?.voiceDemo === 'string' ? voice.voiceDemo : ''
|
||||
}
|
||||
if (!voice.id) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const uuid = await getCloneAudioId(voice.id)
|
||||
if (!uuid) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `${baseUrl.replace(/\/+$/, '')}/voiceClone/play/${encodeURIComponent(uuid)}`
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './voicePreviewUtils.mjs'
|
||||
|
||||
test('keeps normal voice previews on their direct URL', async () => {
|
||||
let cloneRequestCount = 0
|
||||
const url = await resolveVoicePreviewUrl({
|
||||
id: 'normal-voice',
|
||||
isClone: false,
|
||||
voiceDemo: 'https://cdn.example.test/normal.wav',
|
||||
}, async () => {
|
||||
cloneRequestCount += 1
|
||||
return 'unused'
|
||||
}, 'https://api.example.test')
|
||||
|
||||
assert.equal(url, 'https://cdn.example.test/normal.wav')
|
||||
assert.equal(cloneRequestCount, 0)
|
||||
})
|
||||
|
||||
test('uses the clone record id to obtain and construct a temporary play URL', async () => {
|
||||
let requestedCloneId = ''
|
||||
const url = await resolveVoicePreviewUrl({
|
||||
id: 'clone-record-id',
|
||||
isClone: true,
|
||||
voiceDemo: 'provider-speaker-id-must-not-be-played',
|
||||
}, async (cloneId) => {
|
||||
requestedCloneId = cloneId
|
||||
return 'temporary uuid'
|
||||
}, 'https://api.example.test/')
|
||||
|
||||
assert.equal(requestedCloneId, 'clone-record-id')
|
||||
assert.equal(url, 'https://api.example.test/voiceClone/play/temporary%20uuid')
|
||||
})
|
||||
|
||||
test('shows a preview control for cloned voices even without voiceDemo', () => {
|
||||
assert.equal(hasVoicePreview({ id: 'clone-record-id', isClone: true }), true)
|
||||
assert.equal(hasVoicePreview({ id: 'normal-voice', isClone: false, voiceDemo: '' }), false)
|
||||
})
|
||||
|
||||
test('invalidates an older request when the same voice is cancelled and retried', () => {
|
||||
const gate = createVoicePreviewRequestGate()
|
||||
const firstRequest = gate.begin()
|
||||
|
||||
gate.invalidate()
|
||||
const retryRequest = gate.begin()
|
||||
|
||||
assert.equal(gate.isCurrent(firstRequest), false)
|
||||
assert.equal(gate.isCurrent(retryRequest), true)
|
||||
})
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
|
||||
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate, TtsVoice } from '@/api/agent/types'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, updateAgent } from '@/api/agent/agent'
|
||||
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, getVoiceCloneAudioId, updateAgent } from '@/api/agent/agent'
|
||||
import { t } from '@/i18n'
|
||||
import { usePluginStore, useProvider, useSpeedPitch } from '@/store'
|
||||
import { getEnvBaseUrl } from '@/utils'
|
||||
import { toast } from '@/utils/toast'
|
||||
import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue'
|
||||
import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs'
|
||||
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './components/voicePreviewUtils.mjs'
|
||||
|
||||
defineOptions({
|
||||
name: 'AgentEdit',
|
||||
@@ -84,10 +86,20 @@ const modelOptions = ref<{
|
||||
TTS: [],
|
||||
})
|
||||
|
||||
interface VoiceOption {
|
||||
id?: string
|
||||
value: string
|
||||
name: string
|
||||
voiceDemo?: string | null
|
||||
voice_demo?: string | null
|
||||
isClone: boolean
|
||||
train_status?: number
|
||||
}
|
||||
|
||||
// 音色选项数据
|
||||
const voiceOptions = ref<any[]>([])
|
||||
const voiceOptions = ref<VoiceOption[]>([])
|
||||
// 保存完整的音色信息
|
||||
const voiceDetails = ref<Record<string, any>>({})
|
||||
const voiceDetails = ref<Record<string, TtsVoice>>({})
|
||||
|
||||
// 上报模式选项数据
|
||||
const reportOptions = [
|
||||
@@ -139,6 +151,7 @@ interface SnapshotRestoreContext {
|
||||
// 音频播放相关
|
||||
const audioRef = ref<UniApp.InnerAudioContext | null>(null)
|
||||
const playingVoiceId = ref<string>('')
|
||||
const voicePreviewRequestGate = createVoicePreviewRequestGate()
|
||||
|
||||
// 使用插件store
|
||||
const pluginStore = usePluginStore()
|
||||
@@ -513,8 +526,8 @@ interface TtsSelectionState {
|
||||
languageTouched: boolean
|
||||
voiceTouched: boolean
|
||||
optionsModelId: string
|
||||
voiceOptions: any[]
|
||||
voiceDetails: Record<string, any>
|
||||
voiceOptions: VoiceOption[]
|
||||
voiceDetails: Record<string, TtsVoice>
|
||||
languageOptions: any[]
|
||||
displayNames: {
|
||||
tts: string
|
||||
@@ -565,7 +578,7 @@ function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
const allVoices = Object.values(voiceDetails.value) as any[]
|
||||
const allVoices = Object.values(voiceDetails.value)
|
||||
|
||||
// 根据选中的语言筛选音色
|
||||
const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
|
||||
@@ -624,7 +637,7 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption
|
||||
throw new Error('No TTS voice metadata is available')
|
||||
}
|
||||
// 保存完整的音色信息
|
||||
voiceDetails.value = res.reduce((acc, voice) => {
|
||||
voiceDetails.value = res.reduce<Record<string, TtsVoice>>((acc, voice) => {
|
||||
acc[voice.id] = voice
|
||||
return acc
|
||||
}, {})
|
||||
@@ -863,44 +876,85 @@ function onPickerCancel(type: string) {
|
||||
}
|
||||
|
||||
// 播放音频
|
||||
function playAudio(voiceDemo: string, voiceId: string, event: Event) {
|
||||
async function playAudio(voice: VoiceOption, event: Event) {
|
||||
event.stopPropagation() // 阻止事件冒泡,防止关闭下拉框
|
||||
|
||||
if (!voiceDemo) {
|
||||
if (!hasVoicePreview(voice)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在播放同一个音频,则停止
|
||||
if (playingVoiceId.value === voiceId) {
|
||||
if (playingVoiceId.value === voice.value) {
|
||||
stopAudio()
|
||||
return
|
||||
}
|
||||
|
||||
// 停止之前的音频
|
||||
stopAudio()
|
||||
const requestId = voicePreviewRequestGate.begin()
|
||||
playingVoiceId.value = voice.value
|
||||
|
||||
// 创建新的音频实例
|
||||
audioRef.value = uni.createInnerAudioContext()
|
||||
audioRef.value.src = voiceDemo
|
||||
playingVoiceId.value = voiceId
|
||||
try {
|
||||
const audioUrl = await resolveVoicePreviewUrl({
|
||||
id: voice.value,
|
||||
isClone: voice.isClone,
|
||||
voiceDemo: voice.voiceDemo || voice.voice_demo,
|
||||
}, getVoiceCloneAudioId, getEnvBaseUrl())
|
||||
|
||||
// 监听播放结束
|
||||
audioRef.value.onEnded(() => {
|
||||
// 用户可能在等待克隆音色临时地址时取消或切换了音色。
|
||||
if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
|
||||
return
|
||||
}
|
||||
if (!audioUrl) {
|
||||
toast.error(t('voiceprint.getAudioFailed'))
|
||||
playingVoiceId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新的音频实例
|
||||
const audio = uni.createInnerAudioContext()
|
||||
audioRef.value = audio
|
||||
audio.src = audioUrl
|
||||
|
||||
// 监听播放结束
|
||||
audio.onEnded(() => {
|
||||
if (
|
||||
voicePreviewRequestGate.isCurrent(requestId)
|
||||
&& audioRef.value === audio
|
||||
&& playingVoiceId.value === voice.value
|
||||
) {
|
||||
playingVoiceId.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 监听播放错误
|
||||
audio.onError(() => {
|
||||
if (
|
||||
voicePreviewRequestGate.isCurrent(requestId)
|
||||
&& audioRef.value === audio
|
||||
&& playingVoiceId.value === voice.value
|
||||
) {
|
||||
toast.error(t('voiceprint.audioPlayFailed'))
|
||||
playingVoiceId.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 播放音频
|
||||
audio.play()
|
||||
}
|
||||
catch (error) {
|
||||
if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
|
||||
return
|
||||
}
|
||||
console.error('获取克隆音色试听地址失败:', error)
|
||||
toast.error(t('voiceprint.getAudioFailed'))
|
||||
playingVoiceId.value = ''
|
||||
})
|
||||
|
||||
// 监听播放错误
|
||||
audioRef.value.onError(() => {
|
||||
toast.error('音频播放失败')
|
||||
playingVoiceId.value = ''
|
||||
})
|
||||
|
||||
// 播放音频
|
||||
audioRef.value.play()
|
||||
}
|
||||
}
|
||||
|
||||
// 停止音频
|
||||
function stopAudio() {
|
||||
voicePreviewRequestGate.invalidate()
|
||||
if (audioRef.value) {
|
||||
audioRef.value.stop()
|
||||
audioRef.value.destroy()
|
||||
@@ -1592,10 +1646,10 @@ onMounted(async () => {
|
||||
class="flex items-center justify-between border-b border-[#f5f5f5] p-[32rpx] transition-all active:bg-[#f5f7fb]"
|
||||
@click="onPickerConfirm('voiceprint', voice.value, voice.name)"
|
||||
>
|
||||
<text :class="`flex-1 text-[28rpx] text-[#232338] ${(voice.voiceDemo || voice.voice_demo) ? '' : 'text-center'}`">
|
||||
<text :class="`flex-1 text-[28rpx] text-[#232338] ${hasVoicePreview(voice) ? '' : 'text-center'}`">
|
||||
{{ voice.name }}
|
||||
</text>
|
||||
<view v-if="voice.voiceDemo || voice.voice_demo" class="ml-[20rpx]" @click.stop="playAudio(voice.voiceDemo || voice.voice_demo, voice.value, $event)">
|
||||
<view v-if="hasVoicePreview(voice)" class="ml-[20rpx]" @click.stop="playAudio(voice, $event)">
|
||||
<wd-icon
|
||||
:name="playingVoiceId === voice.value ? 'pause-circle' : 'play-circle'"
|
||||
size="24px"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** @param {unknown} timestamp */
|
||||
export function parseDeviceLastConnectedAtTimestamp(timestamp) {
|
||||
if (typeof timestamp !== 'string' || !timestamp.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const milliseconds = Number(timestamp)
|
||||
if (!Number.isFinite(milliseconds)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = new Date(milliseconds)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
|
||||
|
||||
test('parses the backend Long timestamp serialized as a string', () => {
|
||||
const timestamp = '1783689702000'
|
||||
assert.equal(parseDeviceLastConnectedAtTimestamp(timestamp)?.getTime(), Number(timestamp))
|
||||
})
|
||||
|
||||
test('rejects missing and malformed device timestamps', () => {
|
||||
assert.equal(parseDeviceLastConnectedAtTimestamp(null), null)
|
||||
assert.equal(parseDeviceLastConnectedAtTimestamp(''), null)
|
||||
assert.equal(parseDeviceLastConnectedAtTimestamp('not-a-timestamp'), null)
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { useMessage } from 'wot-design-uni/components/wd-message-box'
|
||||
import { bindDevice, bindDeviceManual, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
|
||||
import { t } from '@/i18n'
|
||||
import { toast } from '@/utils/toast'
|
||||
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
|
||||
|
||||
defineOptions({
|
||||
name: 'DeviceManage',
|
||||
@@ -131,10 +132,10 @@ function getDeviceTypeName(boardKey: string): string {
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timeStr: string) {
|
||||
if (!timeStr)
|
||||
function formatTime(timestamp: string | null) {
|
||||
const date = parseDeviceLastConnectedAtTimestamp(timestamp)
|
||||
if (!date)
|
||||
return t('device.neverConnected')
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
@@ -410,7 +411,7 @@ defineExpose({
|
||||
{{ t('device.firmwareVersion') }}:{{ device.appVersion }}
|
||||
</text>
|
||||
<text class="block text-[28rpx] text-[#65686f] leading-[1.4]">
|
||||
{{ t('device.lastConnection') }}:{{ formatTime(device.lastConnectedAt) }}
|
||||
{{ t('device.lastConnection') }}:{{ formatTime(device.lastConnectedAtTimestamp) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user