Commit e165cc1e authored by nick zheng's avatar nick zheng

feat: 应用语音功能

parent a4cf1c15
......@@ -8,7 +8,7 @@
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link rel="stylesheet" href="//at.alicdn.com/t/c/font_4711453_f5muspehl1h.css" />
<link rel="stylesheet" href="//at.alicdn.com/t/c/font_4711453_04ol63hssfos.css" />
<title>Model Link</title>
</head>
......
......@@ -22,12 +22,14 @@
"@unocss/reset": "^0.61.9",
"@vueuse/core": "^10.11.1",
"axios": "^1.7.7",
"bowser": "^2.11.0",
"clipboardy": "^4.0.0",
"cropperjs": "^1.6.2",
"dayjs": "^1.11.13",
"dompurify": "^3.2.0",
"github-markdown-css": "^5.7.0",
"highlight.js": "^11.10.0",
"howler": "^2.2.4",
"lodash-es": "^4.17.21",
"marked": "^15.0.0",
"marked-highlight": "^2.2.1",
......@@ -46,6 +48,7 @@
"@commitlint/config-conventional": "^19.5.0",
"@commitlint/types": "^19.5.0",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@types/howler": "^2.2.12",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.16.5",
"@types/spark-md5": "^3.0.4",
......
This diff is collapsed.
......@@ -170,3 +170,20 @@ export function fetchRemoveSalePublishApplication<T>(agentPublishId: number) {
export function fetchGetApplicationMallInfo<T>(agentId: string) {
return request.post<T>(`/bizAgentApplicationMallRest/getMallInfoByAgentId.json?agentId=${agentId}`)
}
/**
* @query agentId 应用Id
* @returns 获取用户自动播放配置
*/
export function fetchGetAutoPlayByAgentId<T>(agentId: string) {
return request.post<T>(`/agentApplicationRest/autoPlayByAgentId.json?agentId=${agentId}`)
}
/**
* @query agentId 应用Id
* @query autoPlay 是否自动播放
* @returns 设置用户自动播放配置
*/
export function fetchUpdateAutoPlay<T>(agentId: string, autoPlay: 'Y' | 'N') {
return request.post<T>(`/agentApplicationRest/enableAutoPlay.json?agentId=${agentId}&autoPlay=${autoPlay}`)
}
import { request } from '@/utils/request'
/**
* @returns 获取音色列表
*/
export function fetchGetTimbreList<T>() {
return request.post<T>('/bizVoiceTimbreRest/getTimbreList.json')
}
/**
* @query timbreId 音色Id
* @returns 获取音色详情
*/
export function fetchGetTimbreInfoDetail<T>(timbreId: string) {
return request.post<T>(`/bizVoiceTimbreRest/getTimbreInfo.json?timbreId=${timbreId}`)
}
<?xml version="1.0" encoding="UTF-8"?>
<svg width="34px" height="34px" viewBox="0 0 34 34" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>黑色暂停</title>
<defs>
<rect id="path-1" x="0" y="0" width="34" height="34"></rect>
</defs>
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="对话-进入" transform="translate(-181.000000, -310.000000)">
<g id="编组-2" transform="translate(181.000000, 310.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="矩形"></g>
<path d="M17.0006063,1.5 C21.2734934,1.5 25.1479946,3.23913189 27.9545032,6.04584067 C30.7609677,8.85250533 32.5,12.7272396 32.5,17.0006063 C32.5,21.2738531 30.7610549,25.1482139 27.9547057,27.9545631 C25.1481717,30.7610971 21.2735836,32.5 17.0006063,32.5 C12.7272396,32.5 8.85250534,30.7609677 6.04584068,27.9545032 C3.23913189,25.1479946 1.5,21.2734933 1.5,17.0006063 C1.5,12.7275994 3.23921912,8.8527246 6.04604319,6.04590054 C8.85268248,3.23926124 12.72733,1.5 17.0006063,1.5 L17.0006063,1.5 Z" id="路径" stroke="#333333" stroke-width="3" fill-rule="nonzero" mask="url(#mask-2)"></path>
<rect id="矩形" fill="#000DFF" mask="url(#mask-2)" x="11.3333333" y="11.3333333" width="3.23809524" height="11.3333333" rx="1.61904762"></rect>
<rect id="矩形备份" fill="#000DFF" mask="url(#mask-2)" x="19.4285714" y="11.3333333" width="3.23809524" height="11.3333333" rx="1.61904762"></rect>
</g>
</g>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="34px" height="34px" viewBox="0 0 34 34" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>播放</title>
<defs>
<rect id="path-1" x="0" y="0" width="34" height="34"></rect>
</defs>
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="对话-按住语音-提示语" transform="translate(-181.000000, -310.000000)">
<g id="播放" transform="translate(181.000000, 310.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="矩形"></g>
<g mask="url(#mask-2)" fill-rule="nonzero">
<path d="M17.0005306,1.5 C21.2734482,1.5 25.1478582,3.23921344 27.9543229,6.04585326 C30.7609106,8.85261617 32.5,12.7274291 32.5,17.0005306 C32.5,21.2735273 30.7609869,25.1480133 27.9545001,27.9545001 C25.1480133,30.7609869 21.2735273,32.5 17.0005306,32.5 C12.7269453,32.5 8.85210629,30.7611966 6.04545533,27.9547208 C3.23892745,25.1483681 1.5,21.273932 1.5,17.0005306 C1.5,12.7270243 3.23900379,8.85226132 6.04563255,6.04563255 C8.85226132,3.23900379 12.7270243,1.5 17.0005306,1.5 Z" id="路径" stroke="#CCCCCC" stroke-width="3"></path>
<path d="M13.6855279,9.59377047 C14.4484879,9.59377047 15.1658188,9.88982862 15.2443432,9.92378513 L15.2443432,9.92378513 L15.3812303,9.99488155 L24.6725758,15.6783496 L24.8073406,15.9213507 C25.5108767,17.1883524 24.8275023,18.3131612 24.1950626,18.7609625 L24.1950626,18.7609625 L24.1048657,18.8193252 L14.9196343,24.02528 C14.471833,24.2544864 14.0654162,24.3606004 13.6727943,24.3606004 L13.6727943,24.3606004 L13.4721536,24.3516533 C12.36468,24.2516108 11.7569642,23.3358336 11.5685528,22.7816235 L11.5685528,22.7816235 L11.5112512,22.6150245 L11.5112512,11.827471 C11.5112512,10.4713336 12.3654693,9.59377047 13.6855279,9.59377047 Z M13.64096,11.7171125 C13.6367155,11.7447021 13.6345932,11.781842 13.6345932,11.8274711 L13.6345932,11.8274711 L13.6345932,22.1884461 C13.6515714,22.21073 13.6674885,22.2287694 13.6802222,22.2393808 C13.7046285,22.2372585 13.7799694,22.2266471 13.9136731,22.1587341 L13.9136731,22.1587341 L22.8856153,17.071627 L14.3551075,11.8539996 C14.2044256,11.7988203 13.837271,11.7065011 13.64096,11.7171125 Z" id="形状结合" stroke="#000DFF" stroke-width="0.5" fill="#000DFF"></path>
</g>
</g>
</g>
</g>
</svg>
......@@ -7,3 +7,12 @@ export const INDEX_URLS: Record<'DEV' | 'PROD', string> = {
DEV: 'https://poc-sit.gsstcloud.com/fe/',
PROD: 'https://model-link.gsstcloud.com/fe/',
}
const ENV = import.meta.env.VITE_APP_ENV
export const Domain_Name: Record<'DEV' | 'PROD', string> = {
DEV: 'poc-sit.gsstcloud.com',
PROD: 'model-link.gsstcloud.com',
}
export const TEXTTOSPEECH_WS_URL = `wss://${Domain_Name[ENV || 'DEV']}/websocket/textToSpeechTC.ws`
......@@ -91,6 +91,17 @@ common_module:
bind: 'Bind'
sms: 'Short message'
verificationCode: 'Verification code'
role: 'Role'
mandarin: 'Mandarin'
cantonese: 'Cantonese'
english: 'English'
voice: 'Voice'
sound: 'Sound'
voice_auto_play: 'Voice auto play'
start_playing: 'play'
stop_playing: 'stop'
response_error: 'Response error'
agent_exception: 'Agent exception, please try again later!'
dialogue_module:
continue_question_message: 'You can keep asking questions'
......@@ -281,6 +292,9 @@ personal_space_module:
memory_fragment_delete_row_tip_content: 'After data deletion, it cannot be revoked. Are you sure you want to delete it?'
add_knowledge_successfully: 'Data set {0} was added successfully'
remove_knowledge_successfully: 'Data set {0} was removed successfully'
setting_timbre: 'Setting timbre'
setting_timbre_message: 'You can set the language and timbre'
setting_timbre_desc: 'You can customize the voice and timbre for voice broadcast, and you can set only one at a time.'
memory_variable_modal:
edit_memory_variable: 'Edit memory variable'
......
......@@ -90,6 +90,17 @@ common_module:
bind: '绑定'
sms: '短信'
verificationCode: '验证码'
role: '角色'
mandarin: '普通话'
cantonese: '粤语'
english: '英语'
voice: '语音'
sound: '声音'
voice_auto_play: '语音自动播放'
start_playing: '开始播放'
stop_playing: '停止播放'
response_error: '响应错误'
agent_exception: '应用异常,请稍后重试!'
dialogue_module:
continue_question_message: '你可以继续提问'
......@@ -279,6 +290,9 @@ personal_space_module:
memory_fragment_delete_row_tip_content: '数据删除后不可撤销,确定要删除吗?'
add_knowledge_successfully: '数据集 {0} 添加成功'
remove_knowledge_successfully: '数据集 {0} 移除成功'
setting_timbre: '设置音色'
setting_timbre_message: '你可以设置语言和音色'
setting_timbre_desc: '您可自定义语音及音色,用于语音播报,且每次仅可设置一种。'
memory_variable_modal:
edit_memory_variable: '编辑记忆变量'
......
......@@ -90,6 +90,17 @@ common_module:
bind: '綁定'
sms: '短信'
verificationCode: '驗證碼'
role: '角色'
mandarin: '普通話'
cantonese: '粵語'
english: '英語'
voice: '語音'
sound: '聲音'
voice_auto_play: '語音自動播放'
start_playing: '開始播放'
stop_playing: '停止播放'
response_error: '響應錯誤'
agent_exception: '應用異常,請稍後重試!'
dialogue_module:
continue_question_message: '你可以繼續提問'
......@@ -279,6 +290,9 @@ personal_space_module:
memory_fragment_delete_row_tip_content: '數據删除後不可撤銷,確定要删除嗎?'
add_knowledge_successfully: '數據集 {0} 添加成功'
remove_knowledge_successfully: '數據集 {0} 移除成功'
setting_timbre: '設置音色'
setting_timbre_message: '你可以設置語言和音色'
setting_timbre_desc: '您可自定義語音及音色,用於語音播報,且每次僅可設置一種。'
memory_variable_modal:
edit_memory_variable: '編輯記憶變數'
......
......@@ -32,6 +32,10 @@ export function defaultPersonalAppConfigState(): PersonalAppConfigState {
communicationTurn: 3,
temperature: 0.5,
},
voiceConfig: {
defaultOpen: 'Y',
timbreId: '',
},
modifiedTime: new Date(),
createdTime: '',
isCollect: '',
......
......@@ -36,6 +36,10 @@ export interface PersonalAppConfigState {
communicationTurn: number //参考对话轮次 0-100
temperature: number //多样性 0-1.00
}
voiceConfig: {
defaultOpen: 'Y' | 'N' //是否默认开启 Y-开启 N-关闭
timbreId: string //音色ID
}
popularity?: number
modifiedTime: Date
createdTime: string
......
import Bowser from 'bowser'
export function validBrowser() {
const browser = Bowser.getParser(window.navigator.userAgent)
return browser.satisfies({
android: {
chrome: '> 80',
},
})
}
import i18n from '@/locales'
const { t } = i18n.global
export default class WebSocketCtr {
private socket: WebSocket | null = null
private readonly url: string
public isDisconnect: boolean = true
constructor(url: string) {
this.url = url
}
private initSocket(callBack?: () => void) {
const socket = new WebSocket(this.url)
socket.onopen = () => {
this.isDisconnect = false
this.onConnect()
callBack && callBack()
}
socket.onmessage = (event: any) => {
let data: any = { final: false }
try {
data = JSON.parse(event.data)
} catch (err) {
window.$message.error(t('common_module.response_error'))
this.onMessageError()
}
if (data.code && data.code !== '0') {
if (this.socket) {
this.socket.close()
this.socket = null
}
window.$message.error(t('common_module.agent_exception'))
this.onMessageError()
return
}
this.onMessage(data)
}
socket.onerror = (event) => {
this.onError(event)
}
socket.onclose = () => {
this.isDisconnect = true
this.onDisconnect()
}
this.socket = socket
}
connect(callBack?: () => void) {
if (this.socket && [0, 1].includes(this.socket.readyState)) {
callBack && callBack()
return
}
this.initSocket(callBack)
}
send(content: { [k: string]: any }) {
this.socket && this.socket.send(JSON.stringify(content))
}
disconnect() {
if (this.socket) {
this.socket.close()
this.socket = null
}
this.onDisconnect()
}
/* call back */
onConnect() {}
onMessage(_message: any) {}
onMessageError() {}
onError(_event?: any) {}
onDisconnect() {}
}
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { Plus, RightOne, MoreOne, Edit, ReduceOne } from '@icon-park/vue-next'
import TimbreSettingModal from './timbre-setting-modal.vue'
import { TimbreLanguageInfoItem } from '../types'
import { usePersonalAppConfigStore } from '@/store/modules/personal-app-config'
import { fetchGetTimbreInfoDetail } from '@/apis/timber'
const { t } = useI18n()
const personalAppConfigStore = usePersonalAppConfigStore()
const { voiceConfig } = storeToRefs(personalAppConfigStore)
const roleConfigExpandedNames = ref<string[]>([])
const isShowTimbreSettingModal = ref(false)
const timbreInfoDetail = ref<TimbreLanguageInfoItem>()
let timbreInfo = reactive<TimbreLanguageInfoItem>({ language: 0, matchLang: '', timbreInfo: [] })
const timberFullName = computed(() => {
const languageOptions = [
{ label: 'common_module.mandarin', value: 'zh-CN' },
{ label: 'common_module.cantonese', value: 'zh-HK' },
{ label: 'common_module.english', value: 'en' },
]
const currentLanguage = languageOptions.find((item) => item.value === timbreInfoDetail.value?.matchLang)
return `${timbreInfoDetail.value?.timbreInfo?.[0]?.timbreName || '--'}(${t(currentLanguage?.label || 'common_module.sound')})`
})
onMounted(() => {
voiceConfig.value.timbreId && handleGetTimberInfoDetail()
})
async function handleGetTimberInfoDetail() {
const res = await fetchGetTimbreInfoDetail<TimbreLanguageInfoItem>(voiceConfig.value.timbreId)
if (res.code === 0) {
timbreInfoDetail.value = res.data
roleConfigExpandedNames.value = ['timbre']
}
}
function handleUpdateRoleConfigExpandedNames(expandedNames: string[]) {
roleConfigExpandedNames.value = expandedNames
}
function handleShowAssociatedTimbreModel() {
isShowTimbreSettingModal.value = true
timbreInfo = { language: 0, matchLang: '', timbreInfo: [] }
}
function handleUpdateTimbreId(timberId: string) {
isShowTimbreSettingModal.value = false
voiceConfig.value.timbreId = timberId
voiceConfig.value.defaultOpen = 'Y'
handleGetTimberInfoDetail()
}
function handleRemoveTimbreId() {
voiceConfig.value.timbreId = ''
}
function handleEditAssociatedTimbreModel() {
isShowTimbreSettingModal.value = true
timbreInfo = timbreInfoDetail.value!
}
</script>
<template>
<section class="border-b border-[#e8e9eb] px-5">
<div class="pt-4">
<h2 class="my-3 text-[#84868c]">{{ t('common_module.role') }}</h2>
<NCollapse
:expanded-names="roleConfigExpandedNames"
:trigger-areas="['main', 'arrow']"
@update:expanded-names="handleUpdateRoleConfigExpandedNames"
>
<template #arrow>
<RightOne theme="filled" size="17" fill="#333" :stroke-width="3" />
</template>
<NCollapseItem :title="t('common_module.sound')" name="timbre" class="my-[13px]!">
<template #header-extra>
<NTooltip trigger="hover">
<template #trigger>
<Plus
theme="outline"
size="22"
:stroke-width="3"
class="text-theme-color cursor-pointer"
@click="handleShowAssociatedTimbreModel"
/>
</template>
{{ t('personal_space_module.agent_module.agent_setting_module.agent_config_module.setting_timbre') }}
</NTooltip>
</template>
<span v-show="!voiceConfig.timbreId" class="text-xs text-[#84868c]">
{{ t('personal_space_module.agent_module.agent_setting_module.agent_config_module.setting_timbre_desc') }}
</span>
<div class="flex flex-1 flex-wrap items-center gap-[12px] overflow-hidden">
<div
v-show="voiceConfig.timbreId"
class="font-400 line-height-[20px] flex cursor-pointer items-center rounded-[4px] bg-[#f2f5f9] py-[2px] pl-[8px] text-[12px] hover:bg-[#e3e8f0]"
@click="handleEditAssociatedTimbreModel"
>
<div class="max-w-[205px] truncate text-[#151b26]">{{ timberFullName }}</div>
<n-popover placement="bottom" trigger="hover" :show-arrow="false" class="p-[4px]!">
<template #trigger>
<MoreOne theme="outline" size="14" fill="#333" :stroke-width="3" class="mr-[4px] mt-[2px]" />
</template>
<div class="text-[12px]">
<div
class="flex h-[30px] w-[90px] cursor-pointer items-center justify-start px-[8px] py-[5px] hover:rounded-[4px] hover:bg-[#f2f5f9]"
@click="handleEditAssociatedTimbreModel"
>
<Edit theme="outline" size="16" fill="#333" :stroke-width="3" /><span class="ml-[4px]">
{{
t(
'personal_space_module.agent_module.agent_setting_module.agent_config_module.memory_variable_action_edit',
)
}}
</span>
</div>
<n-space>
<div
class="flex h-[30px] w-[90px] cursor-pointer items-center justify-start px-[8px] py-[5px] hover:rounded-[4px] hover:bg-[#f2f5f9]"
@click="handleRemoveTimbreId"
>
<ReduceOne theme="outline" size="16" fill="#333" :stroke-width="3" />
<span class="ml-[4px]">
{{ t('common_module.delete') }}
</span>
</div>
</n-space>
</div>
</n-popover>
</div>
</div>
</NCollapseItem>
</NCollapse>
</div>
</section>
<TimbreSettingModal
v-model:is-show-modal="isShowTimbreSettingModal"
:btn-loading="false"
:modal-title="t('personal_space_module.agent_module.agent_setting_module.agent_config_module.setting_timbre')"
:timbre-info="timbreInfo"
@confirm="handleUpdateTimbreId"
/>
</template>
<script setup lang="ts">
import { inject, onMounted, onUnmounted, ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import Preamble from './preamble.vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Emitter } from 'mitt'
import { Howl } from 'howler'
import MessageList from './message-list.vue'
import FooterInput from './footer-input.vue'
import { fetchCreateContinueQuestions } from '@/apis/agent-application'
import { usePersonalAppConfigStore } from '@/store/modules/personal-app-config'
import { useSystemLanguageStore } from '@/store/modules/system-language'
import { Brain, Down } from '@icon-park/vue-next'
import MemoryPreviewModal from './memory-preview-modal.vue'
import { validBrowser } from '@/utils/browser-detection'
const { t } = useI18n()
const router = useRouter()
const personalAppConfigStore = usePersonalAppConfigStore()
const systemLanguageStore = useSystemLanguageStore()
const emitter = inject<Emitter<MittEvents>>('emitter')
......@@ -29,9 +33,20 @@ const continuousQuestionStatus = ref<'default' | 'close'>(personalAppConfigStore
const continuousQuestionList = ref<string[]>([])
const isShowMemoryPreviewModal = ref(false)
const selectedMemoryTabName = ref('memoryVariable')
const answerAudioAutoPlaying = ref(personalAppConfigStore.voiceConfig.defaultOpen === 'Y')
const currentPlayMessageItem = ref<ConversationMessageItem | null>(null)
const currentPlayAudioFragmentSerialNo = ref(0)
const currentSoundCtl = shallowRef<Howl | null>(null)
const isSoundCtlCreated = ref(false)
const isEnLanguage = computed(() => {
return systemLanguageStore.currentLanguageInfo.key === 'en'
})
onMounted(() => {
emitter?.on('resetAgent', () => {
handleAudioPause()
footerInputRef.value?.blockMessageResponse()
messageList.value = []
})
......@@ -39,6 +54,9 @@ onMounted(() => {
onUnmounted(() => {
emitter?.off('resetAgent')
handleAudioPause()
footerInputRef.value?.blockMessageResponse()
messageList.value = []
})
function handleAddMessageItem(messageItem: ConversationMessageItem) {
......@@ -68,6 +86,7 @@ function handleClearAllMessage() {
t('common_module.dialogue_module.clear_message_dialog_title'),
)
.then(() => {
handleAudioPause()
footerInputRef.value?.blockMessageResponse()
messageList.value = []
window.$message.success(t('common_module.clear_success_message'))
......@@ -88,6 +107,10 @@ function handleUpdateContinueQuestionStatus(status: 'default' | 'close') {
continuousQuestionList.value = []
}
function handleUpdateAudioAutoPlaying(isAutoPlaying: boolean) {
personalAppConfigStore.voiceConfig.defaultOpen = isAutoPlaying ? 'Y' : 'N'
}
function handleTurnMultiModelDialogue() {
if (!personalAppConfigStore.baseInfo.agentId) return
......@@ -103,6 +126,91 @@ function handleOpenMemoryPreviewModal(MemoryTabName: string) {
selectedMemoryTabName.value = MemoryTabName
isShowMemoryPreviewModal.value = true
}
function howlSoundFactory(url: string) {
const soundCtl = new Howl({
src: [url],
format: ['mpeg'],
html5: !validBrowser(),
preload: true,
autoplay: true,
onplay: () => {
currentSoundCtl.value = soundCtl
if (currentPlayMessageItem.value) {
currentPlayMessageItem.value.isVoiceLoading = false
currentPlayMessageItem.value.isVoicePlaying = true
}
},
onend: () => {
soundCtl.unload()
currentPlayAudioFragmentSerialNo.value += 1
if (
currentPlayMessageItem.value &&
currentPlayAudioFragmentSerialNo.value > currentPlayMessageItem.value.voiceFragmentUrlList.length - 1
) {
currentPlayMessageItem.value.isVoicePlaying = false
}
let audioFragmentUrl = currentPlayMessageItem.value?.voiceFragmentUrlList[currentPlayAudioFragmentSerialNo.value]
if (audioFragmentUrl) {
howlSoundFactory(audioFragmentUrl)
} else if (
(currentPlayMessageItem.value?.voiceFragmentUrlList || []).length > currentPlayAudioFragmentSerialNo.value
) {
let timerId: NodeJS.Timeout | null = null
const audioFragmentCheck = () => {
audioFragmentUrl = currentPlayMessageItem.value?.voiceFragmentUrlList[currentPlayAudioFragmentSerialNo.value]
if (audioFragmentUrl) {
timerId && clearInterval(timerId)
howlSoundFactory(audioFragmentUrl)
}
}
timerId = setInterval(audioFragmentCheck, 600)
}
},
})
return soundCtl
}
function handleAudioPlay(currentMessageItem: ConversationMessageItem, specificPlayUrl?: string) {
handleAudioPause()
currentPlayMessageItem.value = currentMessageItem
currentPlayAudioFragmentSerialNo.value = 0
const audioUrl = specificPlayUrl || currentMessageItem.voiceFragmentUrlList[0]
howlSoundFactory(audioUrl)
isSoundCtlCreated.value = true
}
function handleAudioPause(isClearMessageList = false) {
if (currentSoundCtl.value) {
currentSoundCtl.value.pause()
currentSoundCtl.value.unload()
currentSoundCtl.value = null
isSoundCtlCreated.value = false
}
currentPlayMessageItem.value && (currentPlayMessageItem.value.isVoicePlaying = false)
if (isClearMessageList) {
messageList.value = []
footerInputRef.value?.blockMessageResponse()
}
}
</script>
<template>
......@@ -113,7 +221,27 @@ function handleOpenMemoryPreviewModal(MemoryTabName: string) {
{{ t('personal_space_module.agent_module.agent_setting_module.agent_config_module.preview') }}
</p>
<div class="flex items-center">
<div class="flex items-center" :class="isEnLanguage ? 'gap-1.5 2xl:gap-4' : 'gap-4'">
<n-popover placement="bottom" trigger="hover" :show-arrow="false">
<template #trigger>
<div v-show="personalAppConfigStore.voiceConfig.timbreId" class="text-font-color cursor-pointer">
<i class="iconfont icon-yuyin mr-1 text-sm" />
<span>{{ t('common_module.voice') }}</span>
</div>
</template>
<div class="flex items-center gap-2.5">
<span>{{ t('common_module.voice_auto_play') }}</span>
<n-switch
v-model:value="answerAudioAutoPlaying"
size="small"
@update:value="handleUpdateAudioAutoPlaying"
>
<template #checked> {{ t('common_module.open') }} </template>
<template #unchecked> {{ t('common_module.close') }} </template>
</n-switch>
</div>
</n-popover>
<div
:class="
personalAppConfigStore.baseInfo.agentId
......@@ -135,7 +263,7 @@ function handleOpenMemoryPreviewModal(MemoryTabName: string) {
>
<n-popover placement="bottom" trigger="hover" class="p-[4px]!" :show-arrow="false">
<template #trigger>
<div class="flex items-center justify-center pl-5 text-[14px]">
<div class="flex items-center justify-center text-[14px]">
<Brain theme="outline" size="15" fill="#333" />
<div class="mx-[4px]">
{{ t('personal_space_module.agent_module.agent_setting_module.agent_config_module.memory') }}
......@@ -176,6 +304,8 @@ function handleOpenMemoryPreviewModal(MemoryTabName: string) {
:message-list="messageList"
:continuous-question-status="continuousQuestionStatus"
:continuous-question-list="continuousQuestionList"
@audio-play="handleAudioPlay"
@audio-pause="handleAudioPause"
/>
</div>
</div>
......@@ -184,6 +314,7 @@ function handleOpenMemoryPreviewModal(MemoryTabName: string) {
ref="footerInputRef"
:continuous-question-status="continuousQuestionStatus"
:message-list="messageList"
:answer-audio-auto-playing="answerAudioAutoPlaying"
@add-message-item="handleAddMessageItem"
@update-specify-message-item="handleUpdateSpecifyMessageItem"
@delete-last-message-item="handleDeleteLastMessageItem"
......@@ -191,6 +322,7 @@ function handleOpenMemoryPreviewModal(MemoryTabName: string) {
@clear-all-message="handleClearAllMessage"
@create-continue-questions="handleCreateContinueQuestions"
@update-continuous-question-status="handleUpdateContinueQuestionStatus"
@audio-play="handleAudioPlay"
/>
<MemoryPreviewModal v-model="isShowMemoryPreviewModal" :data="selectedMemoryTabName" />
......
......@@ -24,6 +24,7 @@ import AgentModelSetting from './agent-model-setting.vue'
import AgentAssociatedKnowledge from './agent-associated-knowledge.vue'
import AgentMemorySetting from './agent-memory-setting.vue'
import AgentDialogueSetting from './agent-dialogue-setting.vue'
import AgentRoleSetting from './agent-role-setting.vue'
const { t } = useI18n()
......@@ -581,6 +582,8 @@ function handleStopGenerate() {
@generate-preamble="handleAIGeneratePreamble"
@generate-featured-questions="handleAIGenerateFeaturedQuestions"
/>
<AgentRoleSetting />
</div>
</div>
</div>
......
......@@ -7,10 +7,13 @@ import { fetchCustomEventSource } from '@/composables/useEventSource'
import { usePersonalAppConfigStore } from '@/store/modules/personal-app-config'
import { UploadStatus } from '@/enums/upload-status'
import { useDialogueFile } from '@/composables/useDialogueFile'
import { TEXTTOSPEECH_WS_URL } from '@/config/base-url'
import WebSocketCtr from '@/utils/web-socket-ctr'
interface Props {
messageList: ConversationMessageItem[]
continuousQuestionStatus: 'default' | 'close'
answerAudioAutoPlaying: boolean
}
const { t } = useI18n()
......@@ -25,6 +28,7 @@ const emit = defineEmits<{
clearAllMessage: []
createContinueQuestions: [value: string]
updateContinuousQuestionStatus: [value: 'default' | 'close']
audioPlay: [messageItem: ConversationMessageItem, requestId?: string]
}>()
const personalAppConfigStore = usePersonalAppConfigStore()
......@@ -37,6 +41,10 @@ const emitter = inject<Emitter<MittEvents>>('emitter')
const inputMessageContent = ref('')
const isAnswerResponseWait = ref(false)
const currentReplyContentSentenceExtractIndex = ref(0)
const sentenceFragmentSerialNo = ref(0)
const sentenceExtractCheckEnabled = ref(false)
const assistantFullAnswerContent = ref('')
let controller: AbortController | null = null
......@@ -57,7 +65,10 @@ const isSendBtnDisabled = computed(() => {
})
const isInputMessageDisabled = computed(() => {
return uploadFileList.value.some((fileItem) => fileItem.status !== UploadStatus.FINISHED)
return (
uploadFileList.value.some((fileItem) => fileItem.status !== UploadStatus.FINISHED) ||
!personalAppConfigStore.baseInfo.agentId
)
})
const isEnableDocumentParse = computed(() => {
......@@ -68,6 +79,10 @@ const isUploadFileDisabled = computed(() => {
return uploadFileList.value.length === 1
})
const isEnableVoice = computed(() => {
return !!personalAppConfigStore.voiceConfig.timbreId
})
const uploadFileIcon = (type: string) => {
return `https://gsst-poe-sit.gz.bcebos.com/icon/${type}.svg`
}
......@@ -91,7 +106,7 @@ onMounted(() => {
})
})
function messageItemFactory() {
function messageItemFactory(): ConversationMessageItem {
return {
timestamp: Date.now(),
role: 'user',
......@@ -99,7 +114,10 @@ function messageItemFactory() {
isEmptyContent: false,
isTextContentLoading: false,
isAnswerResponseLoading: false,
} as const
isVoiceLoading: false,
isVoicePlaying: false,
voiceFragmentUrlList: [],
}
}
function handleMessageSend() {
......@@ -138,11 +156,17 @@ function handleMessageSend() {
inputMessageContent.value = ''
isAnswerResponseWait.value = true
currentReplyContentSentenceExtractIndex.value = 0
sentenceFragmentSerialNo.value = 0
sentenceExtractCheckEnabled.value = false
assistantFullAnswerContent.value = ''
emit('addMessageItem', {
...messageItemFactory(),
role: 'assistant',
isTextContentLoading: true,
isAnswerResponseLoading: true,
isVoiceLoading: true,
})
emit('updatePageScroll')
......@@ -166,6 +190,12 @@ function handleMessageSend() {
isTextContentLoading: false,
isAnswerResponseLoading: false,
})
if (!props.answerAudioAutoPlaying) {
emit('updateSpecifyMessageItem', currentMessageIndex, {
isVoiceLoading: false,
})
}
isCreateContinueQuestions.value && emit('createContinueQuestions', replyTextContent)
emit('updatePageScroll')
blockMessageResponse()
......@@ -175,6 +205,13 @@ function handleMessageSend() {
if (data) {
replyTextContent += data
assistantFullAnswerContent.value = (assistantFullAnswerContent.value + data).replace(
/\^\[[\d\\[\]-]+?\]\^/g,
'',
)
!sentenceExtractCheckEnabled.value && isEnableVoice.value && sentenceExtract()
emit('updateSpecifyMessageItem', currentMessageIndex, {
textContent: replyTextContent,
isTextContentLoading: false,
......@@ -226,6 +263,95 @@ function handleSelectFile(cb: () => void) {
cb()
}
function sentenceExtract() {
sentenceExtractCheckEnabled.value = true
const symbolRegExp = /[。!?;.!?;]/g
let sentenceDraft = assistantFullAnswerContent.value
.replace(/\s/gi, '')
.slice(currentReplyContentSentenceExtractIndex.value)
let matchResult = symbolRegExp.exec(sentenceDraft)
function matchExtract() {
const article = assistantFullAnswerContent.value.replace(/\s/gi, '')
if (matchResult && matchResult.index && matchResult.index > 60) {
sentenceDraft = article.slice(
currentReplyContentSentenceExtractIndex.value,
currentReplyContentSentenceExtractIndex.value + matchResult.index + matchResult['0'].length,
)
currentReplyContentSentenceExtractIndex.value += sentenceDraft.length
ttsSocketSendText(sentenceDraft, sentenceFragmentSerialNo.value)
sentenceFragmentSerialNo.value += 1
if (article.length - currentReplyContentSentenceExtractIndex.value > 60) {
sentenceDraft = article.slice(currentReplyContentSentenceExtractIndex.value)
matchResult = symbolRegExp.exec(sentenceDraft)
matchExtract()
} else {
setTimeout(() => sentenceExtract(), 600)
}
} else if (!isAnswerResponseWait.value) {
/* 延时避免最后回复内容没有更新全 */
setTimeout(() => {
sentenceDraft = article.slice(currentReplyContentSentenceExtractIndex.value)
ttsSocketSendText(sentenceDraft, sentenceFragmentSerialNo.value)
sentenceFragmentSerialNo.value += 1
}, 700)
} else {
sentenceDraft = assistantFullAnswerContent.value
.replace(/\s/gi, '')
.slice(currentReplyContentSentenceExtractIndex.value)
matchResult = symbolRegExp.exec(sentenceDraft)
setTimeout(() => matchExtract(), 500)
}
}
if (matchResult) matchExtract()
else setTimeout(() => sentenceExtract(), 600)
}
function ttsSocketSendText(text: string, audioUrlSerialNo: number) {
const ttsSocketCtl = new WebSocketCtr(TEXTTOSPEECH_WS_URL)
ttsSocketCtl.onMessage = (data: { audio: string; replyVoiceUrl: string; final: boolean }) => {
if (data.replyVoiceUrl) {
const currentMessageIndex = props.messageList.length - 1
if (props.messageList[currentMessageIndex]?.voiceFragmentUrlList) {
const voiceFragmentUrlListDraft = [...props.messageList[currentMessageIndex].voiceFragmentUrlList]
voiceFragmentUrlListDraft[audioUrlSerialNo] = data.replyVoiceUrl
emit('updateSpecifyMessageItem', currentMessageIndex, { voiceFragmentUrlList: voiceFragmentUrlListDraft })
if (props.answerAudioAutoPlaying && audioUrlSerialNo === 0 && voiceFragmentUrlListDraft[audioUrlSerialNo]) {
emit('audioPlay', props.messageList[currentMessageIndex])
}
}
}
}
const content = (text || '').replace(/\^\[[\d\\[\]-]+?\]\^/g, '')
if (content) {
ttsSocketCtl.connect(() => {
ttsSocketCtl.send({
codec: 'wav',
sampleRate: 16000,
speed: 0,
voiceType: personalAppConfigStore.voiceConfig.timbreId,
volume: 0,
content,
})
})
}
}
defineExpose({
blockMessageResponse,
})
......
......@@ -11,12 +11,16 @@ interface Props {
messageItem: ConversationMessageItem
}
const { t } = useI18n()
const props = defineProps<Props>()
const userStore = useUserStore()
const emit = defineEmits<{
audioPlay: []
audioPause: []
}>()
defineProps<Props>()
const { t } = useI18n()
const userStore = useUserStore()
const personalAppConfigStore = usePersonalAppConfigStore()
const useAvatar = computed(() => {
......@@ -26,6 +30,20 @@ const useAvatar = computed(() => {
const assistantAvatar = computed(() => {
return personalAppConfigStore.baseInfo.agentAvatar
})
const isShowAudioControl = computed(() => {
return (
props.role === 'assistant' && !props.messageItem.isVoiceLoading && !!props.messageItem.voiceFragmentUrlList.length
)
})
function handleAudioControl() {
if (props.messageItem.isVoicePlaying) {
emit('audioPause')
} else {
emit('audioPlay')
}
}
</script>
<template>
......@@ -36,33 +54,47 @@ const assistantAvatar = computed(() => {
:width="32"
:height="32"
object-fit="cover"
class="mr-2 mt-1.5 h-8 w-8 rounded-full"
class="mr-2 mt-1.5 h-8 w-8 flex-shrink-0 rounded-full"
/>
<div
class="min-w-[80px] max-w-[calc(100%-32px-12px)] flex-wrap rounded-xl border border-[#e8e9eb] px-4 py-[11px]"
:class="role === 'user' ? 'bg-[#4b87ff] text-white' : 'bg-white text-[#333]'"
>
<div v-if="messageItem.isTextContentLoading" class="py-1.5 pl-4">
<CustomLoading />
</div>
<div class="flex flex-col items-start">
<div
class="min-w-[80px] max-w-full flex-wrap rounded-xl border border-[#e8e9eb] px-4 py-[11px]"
:class="role === 'user' ? 'bg-[#4b87ff] text-white' : 'bg-white text-[#333]'"
>
<div v-if="messageItem.isTextContentLoading" class="py-1.5 pl-4">
<CustomLoading />
</div>
<div v-else>
<p class="break-all">
<MarkdownRender
:raw-text-content="
messageItem.isEmptyContent
? t('common_module.dialogue_module.empty_message_content')
: messageItem.textContent
"
:color="role === 'user' ? '#fff' : '#192338'"
/>
</p>
<div v-else>
<p class="break-all">
<MarkdownRender
:raw-text-content="
messageItem.isEmptyContent
? t('common_module.dialogue_module.empty_message_content')
: messageItem.textContent
"
:color="role === 'user' ? '#fff' : '#192338'"
/>
</p>
<div v-show="role === 'assistant' && messageItem.isAnswerResponseLoading" class="mb-[5px] mt-4 px-4">
<CustomLoading />
<div v-show="role === 'assistant' && messageItem.isAnswerResponseLoading" class="mb-[5px] mt-4 px-4">
<CustomLoading />
</div>
</div>
</div>
<div
v-show="isShowAudioControl"
class="hover:text-theme-color text-font-color flex cursor-pointer items-center gap-0.5 hover:opacity-80"
@click="handleAudioControl"
>
<i v-if="!messageItem.isVoicePlaying" class="iconfont icon-play text-[24px]" />
<div v-else class="mx-1.5 my-3 h-[12px] w-[12px] bg-[url(@/assets/images/playing.gif)] bg-[length:100%_100%]" />
<span class="text-[12px]" :class="messageItem.isVoicePlaying ? 'text-theme-color' : ''">
{{ messageItem.isVoicePlaying ? t('common_module.stop_playing') : t('common_module.start_playing') }}
</span>
</div>
</div>
</div>
</template>
......@@ -12,6 +12,11 @@ interface Props {
const props = defineProps<Props>()
defineEmits<{
audioPlay: [messageItem: ConversationMessageItem]
audioPause: []
}>()
const { scrollRef, scrollToBottom } = useScroll()
const isShowContinueQuestion = computed(() => {
......@@ -35,6 +40,8 @@ defineExpose({
:key="messageItem.timestamp"
:role="messageItem.role"
:message-item="messageItem"
@audio-play="() => $emit('audioPlay', messageItem)"
@audio-pause="() => $emit('audioPause')"
/>
</div>
......
<script setup lang="ts">
import { computed, h, readonly, ref, shallowRef, watch } from 'vue'
import { SelectOption, SelectRenderTag } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { Howl } from 'howler'
import { useSystemLanguageStore } from '@/store/modules/system-language'
import CustomModal from '@/components/custom-modal/custom-modal.vue'
import { fetchGetTimbreList } from '@/apis/timber'
import { TimbreLanguageInfoItem, TimbreInfoItem } from '../types'
import { validBrowser } from '@/utils/browser-detection'
interface Props {
modalTitle: string
isShowModal: boolean
btnLoading: boolean
timbreInfo?: TimbreLanguageInfoItem
}
interface Emits {
(e: 'update:isShowModal', value: boolean): void
(e: 'confirm', value: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const systemLanguageStore = useSystemLanguageStore()
const languageInfoList = ref<TimbreLanguageInfoItem[]>([])
const timbreInfoList = ref<TimbreInfoItem[]>([])
const timbreOptionList = ref<SelectOption[]>([])
const selectedLanguage = ref('')
const selectedTimberId = ref('')
const timberUrl = ref('')
const isPlaySound = ref(false)
const requestDataLoading = ref(false)
const currentSoundCtl = shallowRef<Howl | null>(null)
let timbreRenderTag: SelectRenderTag
const languageOptions = readonly([
{ label: () => h('span', {}, t('common_module.mandarin')), value: 'zh-CN' },
{ label: () => h('span', {}, t('common_module.cantonese')), value: 'zh-HK' },
{ label: () => h('span', {}, t('common_module.english')), value: 'en' },
])
const showModal = computed({
get() {
return props.isShowModal
},
set(value: boolean) {
handleAudioPause()
emit('update:isShowModal', value)
},
})
const isHasTimberUrl = computed(() => {
return !!timberUrl.value
})
const confirmBtnDisabled = computed(() => {
return requestDataLoading.value || !selectedTimberId.value
})
watch(
() => showModal.value,
(value) => {
value && handleGetTimberList()
},
)
async function handleGetTimberList() {
requestDataLoading.value = true
selectedLanguage.value = props.timbreInfo?.matchLang || systemLanguageStore.currentLanguageInfo.key
const res = await fetchGetTimbreList<TimbreLanguageInfoItem[]>()
if (res.code === 0) {
requestDataLoading.value = false
languageInfoList.value = res.data
timbreInfoList.value =
languageInfoList.value.find((item) => item.matchLang === selectedLanguage.value)?.timbreInfo || []
selectedTimberId.value = props.timbreInfo?.timbreInfo?.[0]?.timbreId || timbreInfoList.value?.[0]?.timbreId || ''
timberUrl.value = props.timbreInfo?.timbreInfo?.[0]?.voiceUrl || timbreInfoList.value?.[0]?.voiceUrl || ''
handleRenderTimbreOption()
}
}
function handleRenderTimbreOption() {
timbreOptionList.value = timbreInfoList.value.map((item) => {
return {
label: () =>
h('div', { class: 'flex items-baseline justify-between w-[180px]' }, [
h('span', {}, { default: () => item.timbreName }),
h('i', {
class: {
'iconfont icon-playing ': item.isPlaying,
'iconfont icon-stop text-[14px]': !item.isPlaying,
'text-theme-color text-[14px]': true,
},
onClick: (e) => {
e.stopPropagation()
timbreInfoList.value.forEach((mockItem) => {
if (mockItem.timbreId !== item.timbreId) {
mockItem.isPlaying = false
}
})
isPlaySound.value = false
item.isPlaying = !item.isPlaying
if (item.isPlaying) {
handleAudioPlay(item.voiceUrl)
}
},
}),
]),
value: item.timbreId,
nickName: item.timbreName,
timberUrl: item.voiceUrl,
}
})
timbreRenderTag = ({ option }) => {
return h('span', {}, { default: () => option.nickName as string })
}
}
function handleUpdateTimberLanguage(value: string) {
timbreInfoList.value = languageInfoList.value.find((item) => item.matchLang === value)?.timbreInfo || []
selectedTimberId.value = timbreInfoList.value?.[0].timbreId || ''
timberUrl.value = timbreInfoList.value?.[0].voiceUrl || ''
isPlaySound.value = false
handleAudioPause()
handleRenderTimbreOption()
}
function handleUpdateTimber(_value: string, option: SelectOption) {
timberUrl.value = option.timberUrl as string
}
function handleClickTimber() {
isPlaySound.value = !isPlaySound.value
if (isPlaySound.value) {
timbreInfoList.value.forEach((item) => (item.isPlaying = false))
handleAudioPlay(timberUrl.value)
} else {
handleAudioPause()
}
}
function handleUpdateTimberId() {
selectedTimberId.value && emit('confirm', selectedTimberId.value)
}
function howlSoundFactory(url: string) {
const soundCtl = new Howl({
src: [url],
format: ['mpeg'],
html5: !validBrowser(),
preload: true,
autoplay: true,
onplay: () => {
currentSoundCtl.value = soundCtl
},
onend: () => {
soundCtl.unload()
timbreInfoList.value.forEach((timbreItem) => (timbreItem.isPlaying = false))
isPlaySound.value = false
},
})
return soundCtl
}
function handleAudioPlay(audioUrl: string) {
if (currentSoundCtl.value) {
currentSoundCtl.value.pause()
currentSoundCtl.value.unload()
currentSoundCtl.value = null
}
howlSoundFactory(audioUrl)
}
function handleAudioPause() {
if (currentSoundCtl.value) {
currentSoundCtl.value.pause()
currentSoundCtl.value.unload()
currentSoundCtl.value = null
}
timbreInfoList.value.forEach((timbreItem) => (timbreItem.isPlaying = false))
isPlaySound.value = false
}
</script>
<template>
<CustomModal
v-model:is-show="showModal"
:title="modalTitle"
:btn-loading="btnLoading"
:btn-disabled="confirmBtnDisabled"
:width="515"
:height="378"
@confirm="handleUpdateTimberId"
>
<template #content>
<div class="text-gray-font-color mb-2">
{{ t('personal_space_module.agent_module.agent_setting_module.agent_config_module.setting_timbre_message') }}
</div>
<div v-show="!requestDataLoading" class="flex items-center gap-3">
<n-select
v-model:value="selectedLanguage"
class="w-[211px]!"
:options="languageOptions"
@update:value="handleUpdateTimberLanguage"
/>
<n-select
v-model:value="selectedTimberId"
class="w-[211px]!"
:options="timbreOptionList"
:render-tag="timbreRenderTag"
:show-checkmark="false"
@update:value="handleUpdateTimber"
/>
<div
class="flex h-5 w-5 items-center justify-center rounded-full bg-[#eff0ff]"
:class="isHasTimberUrl ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
>
<i
class="iconfont text-theme-color text-[13px] leading-[13px]"
:class="isPlaySound ? 'icon-playing' : 'icon-stop'"
@click="handleClickTimber"
/>
</div>
</div>
<div v-show="requestDataLoading" class="flex items-center gap-3">
<n-skeleton height="32px" />
<n-skeleton height="32px" />
<n-skeleton height="20px" width="20px" circle class="flex-shrink-0" />
</div>
</template>
</CustomModal>
</template>
export interface TimbreInfoItem {
timbreId: string
timbreName: string
voiceUrl: string
isPlaying: boolean
}
export interface TimbreLanguageInfoItem {
language: number
matchLang: string
timbreInfo: TimbreInfoItem[]
}
......@@ -7,6 +7,8 @@ import { useUserStore } from '@/store/modules/user'
import { UploadStatus } from '@/enums/upload-status'
import { useDialogueFile } from '@/composables/useDialogueFile'
import { useLayoutConfig } from '@/composables/useLayoutConfig'
import { TEXTTOSPEECH_WS_URL } from '@/config/base-url'
import WebSocketCtr from '@/utils/web-socket-ctr'
interface Props {
agentId: string
......@@ -14,6 +16,9 @@ interface Props {
messageList: ConversationMessageItem[]
continuousQuestionStatus: 'default' | 'close'
isEnableDocumentParse: boolean
isEnableVoice: boolean
answerAudioAutoPlaying: boolean
timbreId: string
}
const { t } = useI18n()
......@@ -29,6 +34,7 @@ const emit = defineEmits<{
toLogin: []
createContinueQuestions: [value: string]
resetContinueQuestionList: []
audioPlay: [messageItem: ConversationMessageItem, requestId?: string]
}>()
const { isMobile } = useLayoutConfig()
......@@ -40,8 +46,11 @@ const { uploadFileList, handleLimitUpload, handleUpload, handleRemoveFile } = us
const emitter = inject<Emitter<MittEvents>>('emitter')
const inputMessageContent = ref('')
const isAnswerResponseWait = ref(false)
const currentReplyContentSentenceExtractIndex = ref(0)
const sentenceFragmentSerialNo = ref(0)
const sentenceExtractCheckEnabled = ref(false)
const assistantFullAnswerContent = ref('')
let controller: AbortController | null = null
......@@ -94,7 +103,7 @@ onUnmounted(() => {
emitter?.off('selectQuestion')
})
function messageItemFactory() {
function messageItemFactory(): ConversationMessageItem {
return {
timestamp: Date.now(),
role: 'user',
......@@ -102,7 +111,10 @@ function messageItemFactory() {
isEmptyContent: false,
isTextContentLoading: false,
isAnswerResponseLoading: false,
} as const
isVoiceLoading: false,
isVoicePlaying: false,
voiceFragmentUrlList: [],
}
}
function handleMessageSend() {
......@@ -122,6 +134,7 @@ function handleMessageSend() {
role: 'assistant',
isTextContentLoading: true,
isAnswerResponseLoading: true,
isVoiceLoading: true,
})
emit('updatePageScroll')
......@@ -130,6 +143,10 @@ function handleMessageSend() {
let replyTextContent = ''
isAnswerResponseWait.value = true
inputMessageContent.value = ''
currentReplyContentSentenceExtractIndex.value = 0
sentenceFragmentSerialNo.value = 0
sentenceExtractCheckEnabled.value = false
assistantFullAnswerContent.value = ''
controller = new AbortController()
......@@ -149,6 +166,13 @@ function handleMessageSend() {
isTextContentLoading: false,
isAnswerResponseLoading: false,
})
if (!props.answerAudioAutoPlaying) {
emit('updateSpecifyMessageItem', currentMessageIndex, {
isVoiceLoading: false,
})
}
isCreateContinueQuestions.value && emit('createContinueQuestions', replyTextContent)
emit('updatePageScroll')
blockMessageResponse()
......@@ -158,6 +182,13 @@ function handleMessageSend() {
if (data) {
replyTextContent += data
assistantFullAnswerContent.value = (assistantFullAnswerContent.value + data).replace(
/\^\[[\d\\[\]-]+?\]\^/g,
'',
)
!sentenceExtractCheckEnabled.value && props.isEnableVoice && sentenceExtract()
emit('updateSpecifyMessageItem', currentMessageIndex, {
textContent: replyTextContent,
isTextContentLoading: false,
......@@ -213,6 +244,95 @@ function handleSelectFile(cb: () => void) {
cb()
}
function sentenceExtract() {
sentenceExtractCheckEnabled.value = true
const symbolRegExp = /[。!?;.!?;]/g
let sentenceDraft = assistantFullAnswerContent.value
.replace(/\s/gi, '')
.slice(currentReplyContentSentenceExtractIndex.value)
let matchResult = symbolRegExp.exec(sentenceDraft)
function matchExtract() {
const article = assistantFullAnswerContent.value.replace(/\s/gi, '')
if (matchResult && matchResult.index && matchResult.index > 60) {
sentenceDraft = article.slice(
currentReplyContentSentenceExtractIndex.value,
currentReplyContentSentenceExtractIndex.value + matchResult.index + matchResult['0'].length,
)
currentReplyContentSentenceExtractIndex.value += sentenceDraft.length
ttsSocketSendText(sentenceDraft, sentenceFragmentSerialNo.value)
sentenceFragmentSerialNo.value += 1
if (article.length - currentReplyContentSentenceExtractIndex.value > 60) {
sentenceDraft = article.slice(currentReplyContentSentenceExtractIndex.value)
matchResult = symbolRegExp.exec(sentenceDraft)
matchExtract()
} else {
setTimeout(() => sentenceExtract(), 600)
}
} else if (!isAnswerResponseWait.value) {
/* 延时避免最后回复内容没有更新全 */
setTimeout(() => {
sentenceDraft = article.slice(currentReplyContentSentenceExtractIndex.value)
ttsSocketSendText(sentenceDraft, sentenceFragmentSerialNo.value)
sentenceFragmentSerialNo.value += 1
}, 700)
} else {
sentenceDraft = assistantFullAnswerContent.value
.replace(/\s/gi, '')
.slice(currentReplyContentSentenceExtractIndex.value)
matchResult = symbolRegExp.exec(sentenceDraft)
setTimeout(() => matchExtract(), 500)
}
}
if (matchResult) matchExtract()
else setTimeout(() => sentenceExtract(), 600)
}
function ttsSocketSendText(text: string, audioUrlSerialNo: number) {
const ttsSocketCtl = new WebSocketCtr(TEXTTOSPEECH_WS_URL)
ttsSocketCtl.onMessage = (data: { audio: string; replyVoiceUrl: string; final: boolean }) => {
if (data.replyVoiceUrl) {
const currentMessageIndex = props.messageList.length - 1
if (props.messageList[currentMessageIndex]?.voiceFragmentUrlList) {
const voiceFragmentUrlListDraft = [...props.messageList[currentMessageIndex].voiceFragmentUrlList]
voiceFragmentUrlListDraft[audioUrlSerialNo] = data.replyVoiceUrl
emit('updateSpecifyMessageItem', currentMessageIndex, { voiceFragmentUrlList: voiceFragmentUrlListDraft })
if (props.answerAudioAutoPlaying && audioUrlSerialNo === 0 && voiceFragmentUrlListDraft[audioUrlSerialNo]) {
emit('audioPlay', props.messageList[currentMessageIndex])
}
}
}
}
const content = (text || '').replace(/\^\[[\d\\[\]-]+?\]\^/g, '')
if (content) {
ttsSocketCtl.connect(() => {
ttsSocketCtl.send({
codec: 'wav',
sampleRate: 16000,
speed: 0,
voiceType: props.timbreId,
volume: 0,
content,
})
})
}
}
defineExpose({
blockMessageResponse,
})
......@@ -336,8 +456,9 @@ defineExpose({
? 'hover:text-theme-color text-font-color cursor-pointer'
: 'cursor-not-allowed text-[#b8babf]'
"
@click="handleClearAllMessage"
>
<i class="iconfont icon-clear text-base leading-none" @click="handleClearAllMessage" />
<i class="iconfont icon-clear text-base leading-none" />
</div>
</template>
<span class="text-xs"> {{ t('common_module.dialogue_module.clear_message_popover_message') }}</span>
......
......@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import CustomLoading from './custom-loading.vue'
import MusicWavesLoading from './music-waves-loading.vue'
import MarkdownRender from '@/components/markdown-render/markdown-render.vue'
import { PersonalAppConfigState } from '@/store/types/personal-app-config'
import { useLayoutConfig } from '@/composables/useLayoutConfig'
......@@ -17,6 +18,11 @@ const { t } = useI18n()
const props = defineProps<Props>()
const emit = defineEmits<{
audioPlay: []
audioPause: []
}>()
const userStore = useUserStore()
const { isMobile } = useLayoutConfig()
......@@ -31,6 +37,32 @@ const assistantAvatar = computed(() => {
'https://gsst-poe-sit.gz.bcebos.com/data/20240911/1726041369632.webp'
)
})
const isShowWebAudioControl = computed(() => {
return (
props.role === 'assistant' &&
!props.messageItem.isVoiceLoading &&
!isMobile.value &&
!!props.messageItem.voiceFragmentUrlList.length
)
})
const isShowMobileAudioControl = computed(() => {
return (
props.role === 'assistant' &&
!props.messageItem.isVoiceLoading &&
isMobile.value &&
!!props.messageItem.voiceFragmentUrlList.length
)
})
function handleAudioControl() {
if (props.messageItem.isVoicePlaying) {
emit('audioPause')
} else {
emit('audioPlay')
}
}
</script>
<template>
......@@ -48,36 +80,60 @@ const assistantAvatar = computed(() => {
:width="32"
:height="32"
object-fit="cover"
class="mr-2 mt-1.5 h-8 w-8 rounded-full"
class="mr-2 mt-1.5 h-8 w-8 flex-shrink-0 rounded-full"
/>
<div
class="min-w-[80px] flex-wrap rounded-xl border border-[#e8e9eb] px-4 py-[11px]"
:class="[
role === 'user' ? 'bg-theme-color text-white' : 'bg-white text-[#333]',
isMobile ? 'max-w-[calc(100%-20px)]' : 'max-w-[calc(100%-32px-12px)]',
]"
>
<div v-if="messageItem.isTextContentLoading" class="py-1.5 pl-4">
<CustomLoading />
</div>
<div class="flex w-full flex-col" :class="isMobile && role === 'user' ? 'items-end' : 'items-start'">
<div
class="min-w-[80px] flex-wrap rounded-xl border border-[#e8e9eb] px-4 py-[11px]"
:class="[
role === 'user' ? 'bg-theme-color text-white' : 'bg-white text-[#333]',
isMobile ? 'max-w-[calc(100%-20px)]' : 'max-w-full',
]"
>
<div v-if="messageItem.isTextContentLoading" class="py-1.5 pl-4">
<CustomLoading />
</div>
<div v-else>
<p class="break-all">
<MarkdownRender
:raw-text-content="
messageItem.isEmptyContent
? t('common_module.dialogue_module.empty_message_content')
: messageItem.textContent
"
:color="role === 'user' ? '#fff' : '#192338'"
/>
</p>
<div v-else>
<p class="break-all">
<MarkdownRender
:raw-text-content="
messageItem.isEmptyContent
? t('common_module.dialogue_module.empty_message_content')
: messageItem.textContent
"
:color="role === 'user' ? '#fff' : '#192338'"
<div v-show="role === 'assistant' && messageItem.isAnswerResponseLoading" class="mb-[5px] mt-4 px-4">
<CustomLoading />
</div>
</div>
<div v-show="isShowMobileAudioControl" class="mt-[13px] flex items-center gap-2">
<div
class="h-[18px] w-[18px] cursor-pointer"
:class="messageItem.isVoicePlaying ? 'bg-svg-pause' : 'bg-svg-play'"
@click="handleAudioControl"
/>
</p>
<div v-show="role === 'assistant' && messageItem.isAnswerResponseLoading" class="mb-[5px] mt-4 px-4">
<CustomLoading />
<MusicWavesLoading v-show="messageItem.isVoicePlaying" bar-bg-color="#333" />
</div>
</div>
<div
v-show="isShowWebAudioControl"
class="hover:text-theme-color text-font-color flex cursor-pointer items-center gap-0.5 hover:opacity-80"
@click="handleAudioControl"
>
<i v-if="!messageItem.isVoicePlaying" class="iconfont icon-play text-[24px]" />
<div v-else class="mx-1.5 my-3 h-[12px] w-[12px] bg-[url(@/assets/images/playing.gif)] bg-[length:100%_100%]" />
<span class="text-[12px]" :class="messageItem.isVoicePlaying ? 'text-theme-color' : ''">
{{ messageItem.isVoicePlaying ? t('common_module.stop_playing') : t('common_module.start_playing') }}
</span>
</div>
</div>
</div>
</template>
......@@ -14,6 +14,11 @@ interface Props {
const props = defineProps<Props>()
defineEmits<{
audioPlay: [messageItem: ConversationMessageItem]
audioPause: []
}>()
const { scrollRef, scrollToBottom } = useScroll()
const isShowContinueQuestion = computed(() => {
......@@ -38,6 +43,8 @@ defineExpose({
:role="messageItem.role"
:message-item="messageItem"
:agent-application-config="agentApplicationConfig"
@audio-play="() => $emit('audioPlay', messageItem)"
@audio-pause="() => $emit('audioPause')"
/>
</div>
......
<script setup lang="ts">
interface Props {
barBgColor?: string
height?: string
}
withDefaults(defineProps<Props>(), {
barBgColor: '#363636',
height: '12px',
})
</script>
<template>
<div class="music">
<div class="bar" />
<div class="bar" />
<div class="bar" />
<div class="bar" />
<div class="bar" />
<div class="bar" />
<div class="bar" />
<div class="bar" />
<div class="bar" />
<div class="bar" />
</div>
</template>
<style lang="scss" scoped>
.music {
display: flex;
align-items: center;
justify-content: space-between;
width: 42px;
height: v-bind(height);
.bar {
width: 2px;
/* stylelint-disable-next-line value-keyword-case */
background: v-bind(barBgColor);
border-radius: 50px;
animation: loader 1.5s ease-in-out infinite;
&:nth-child(1) {
/* background: purple; */
animation-delay: 1s;
}
&:nth-child(2) {
/* background: crimson; */
animation-delay: 0.8s;
}
&:nth-child(3) {
/* background: deeppink; */
animation-delay: 0.6s;
}
&:nth-child(4) {
/* background: orange; */
animation-delay: 0.4s;
}
&:nth-child(5) {
/* background: gold; */
animation-delay: 0.2s;
}
&:nth-child(6) {
/* background: gold; */
animation-delay: 0.2s;
}
&:nth-child(7) {
/* background: gold; */
animation-delay: 0.4s;
}
&:nth-child(8) {
/* background: deeppink; */
animation-delay: 0.6s;
}
&:nth-child(9) {
/* background: crimson; */
animation-delay: 0.8s;
}
&:nth-child(10) {
/* background: purple; */
animation-delay: 1s;
}
}
}
@keyframes loader {
0% {
height: 4px;
}
50% {
height: v-bind(height);
}
100% {
height: 4px;
}
}
</style>
......@@ -55,8 +55,8 @@ function handleToLogin() {
<span class="mb-1 line-clamp-1 max-w-[200px] break-all">{{ agentApplicationConfig.baseInfo.agentTitle }}</span>
<div class="flex items-center text-xs text-[#84868c]">
<img v-show="isLogin" :src="agentMemberInfo.avatarUrl" class="mr-2 h-5 w-5 rounded-full" />
<n-ellipsis v-show="isLogin" class="max-w-[120px]! mr-4">
<span class="select-none">{{ agentMemberInfo.nickName }}</span>
<n-ellipsis class="max-w-[120px]! mr-4">
<span v-show="isLogin" class="select-none">{{ agentMemberInfo.nickName }}</span>
</n-ellipsis>
<span>
{{ t('common_module.publish_time_in') }}
......
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Howl } from 'howler'
import PageHeader from './components/mobile-page-header.vue'
import Preamble from './components/preamble.vue'
import MessageList from './components/message-list.vue'
......@@ -9,8 +10,15 @@ import FooterInput from './components/footer-input.vue'
import { PersonalAppConfigState } from '@/store/types/personal-app-config'
import { useUserStore } from '@/store/modules/user'
import { defaultPersonalAppConfigState } from '@/store/modules/personal-app-config'
import { fetchCreateContinueQuestions, fetchCreateDialogues, fetchGetApplicationInfo } from '@/apis/agent-application'
import {
fetchCreateContinueQuestions,
fetchCreateDialogues,
fetchGetApplicationInfo,
fetchGetAutoPlayByAgentId,
fetchUpdateAutoPlay,
} from '@/apis/agent-application'
import { useLayoutConfig } from '@/composables/useLayoutConfig'
import { validBrowser } from '@/utils/browser-detection'
const { t } = useI18n()
......@@ -33,11 +41,20 @@ const messageList = ref<ConversationMessageItem[]>([])
const continuousQuestionStatus = ref<'default' | 'close'>('default')
const continueQuestionList = ref<string[]>([])
const answerAudioAutoPlaying = ref(false)
const currentPlayMessageItem = ref<ConversationMessageItem | null>(null)
const currentPlayAudioFragmentSerialNo = ref(0)
const currentSoundCtl = shallowRef<Howl | null>(null)
const isSoundCtlCreated = ref(false)
const isEnableDocumentParse = computed(() => {
return agentApplicationConfig.value.knowledgeConfig.isDocumentParsing === 'Y'
})
const isEnableVoice = computed(() => {
return !!agentApplicationConfig.value.voiceConfig.timbreId
})
onMounted(async () => {
if (router.currentRoute.value.params.agentId) {
agentId.value = router.currentRoute.value.params.agentId as string
......@@ -53,7 +70,10 @@ onMounted(async () => {
if (agentId.value) {
fullScreenLoading.value = true
await handleGetApplicationDetail()
userStore.isLogin && (await handleCreateDialogues())
if (userStore.isLogin) {
await handleCreateDialogues()
await handleGetAutoPlayByAgentId()
}
fullScreenLoading.value = false
return
}
......@@ -61,6 +81,11 @@ onMounted(async () => {
router.replace({ name: 'Home' })
})
onUnmounted(() => {
handleAudioPause()
footerInputRef.value?.blockMessageResponse()
})
async function handleGetApplicationDetail() {
fetchGetApplicationInfo<PersonalAppConfigState>(agentId.value)
.then((res) => {
......@@ -81,6 +106,14 @@ async function handleCreateDialogues() {
}
}
async function handleGetAutoPlayByAgentId() {
const res = await fetchGetAutoPlayByAgentId<'Y' | 'N'>(agentId.value)
if (res.code === 0) {
answerAudioAutoPlaying.value = res.data === 'Y'
}
}
function handleToLoginPage() {
router.push({
name: 'Login',
......@@ -96,6 +129,11 @@ function handleCreateApplicationPage() {
})
}
async function handleUpdateAutoPlaying(isAutoPlaying: boolean) {
const autoplay = isAutoPlaying ? 'Y' : 'N'
await fetchUpdateAutoPlay(agentId.value, autoplay)
}
function handleAddMessageItem(messageItem: ConversationMessageItem) {
messageList.value.push(messageItem)
}
......@@ -123,6 +161,7 @@ function handleClearAllMessage() {
t('common_module.dialogue_module.clear_message_dialog_title'),
)
.then(() => {
handleAudioPause()
footerInputRef.value?.blockMessageResponse()
messageList.value = []
window.$message.success(t('common_module.clear_success_message'))
......@@ -141,6 +180,91 @@ async function handleCreateContinueQuestions(replyTextContent: string) {
function handleResetContinueQuestionList() {
continueQuestionList.value = []
}
function howlSoundFactory(url: string) {
const soundCtl = new Howl({
src: [url],
format: ['mpeg'],
html5: !validBrowser(),
preload: true,
autoplay: true,
onplay: () => {
currentSoundCtl.value = soundCtl
if (currentPlayMessageItem.value) {
currentPlayMessageItem.value.isVoiceLoading = false
currentPlayMessageItem.value.isVoicePlaying = true
}
},
onend: () => {
soundCtl.unload()
currentPlayAudioFragmentSerialNo.value += 1
if (
currentPlayMessageItem.value &&
currentPlayAudioFragmentSerialNo.value > currentPlayMessageItem.value.voiceFragmentUrlList.length - 1
) {
currentPlayMessageItem.value.isVoicePlaying = false
}
let audioFragmentUrl = currentPlayMessageItem.value?.voiceFragmentUrlList[currentPlayAudioFragmentSerialNo.value]
if (audioFragmentUrl) {
howlSoundFactory(audioFragmentUrl)
} else if (
(currentPlayMessageItem.value?.voiceFragmentUrlList || []).length > currentPlayAudioFragmentSerialNo.value
) {
let timerId: NodeJS.Timeout | null = null
const audioFragmentCheck = () => {
audioFragmentUrl = currentPlayMessageItem.value?.voiceFragmentUrlList[currentPlayAudioFragmentSerialNo.value]
if (audioFragmentUrl) {
timerId && clearInterval(timerId)
howlSoundFactory(audioFragmentUrl)
}
}
timerId = setInterval(audioFragmentCheck, 600)
}
},
})
return soundCtl
}
function handleAudioPlay(currentMessageItem: ConversationMessageItem, specificPlayUrl?: string) {
handleAudioPause()
currentPlayMessageItem.value = currentMessageItem
currentPlayAudioFragmentSerialNo.value = 0
const audioUrl = specificPlayUrl || currentMessageItem.voiceFragmentUrlList[0]
howlSoundFactory(audioUrl)
isSoundCtlCreated.value = true
}
function handleAudioPause(isClearMessageList = false) {
if (currentSoundCtl.value) {
currentSoundCtl.value.pause()
currentSoundCtl.value.unload()
currentSoundCtl.value = null
isSoundCtlCreated.value = false
}
currentPlayMessageItem.value && (currentPlayMessageItem.value.isVoicePlaying = false)
if (isClearMessageList) {
messageList.value = []
footerInputRef.value?.blockMessageResponse()
}
}
</script>
<template>
......@@ -152,6 +276,16 @@ function handleResetContinueQuestionList() {
/>
<div class="flex h-[calc(100%-48px)] w-full flex-col bg-[#f2f5f9]">
<div class="mt-5 flex select-none justify-end px-4">
<div v-show="isEnableVoice" class="flex items-center gap-2">
<span>{{ t('common_module.voice_auto_play') }}</span>
<n-switch v-model:value="answerAudioAutoPlaying" size="small" @update:value="handleUpdateAutoPlaying">
<template #checked> {{ t('common_module.open') }} </template>
<template #unchecked> {{ t('common_module.close') }} </template>
</n-switch>
</div>
</div>
<div v-if="messageList.length === 0" class="w-full flex-1 overflow-auto px-4">
<Preamble :agent-application-config="agentApplicationConfig" />
</div>
......@@ -164,6 +298,8 @@ function handleResetContinueQuestionList() {
:message-list="messageList"
:continuous-question-status="continuousQuestionStatus"
:continuous-question-list="continueQuestionList"
@audio-play="handleAudioPlay"
@audio-pause="handleAudioPause"
/>
</div>
</div>
......@@ -176,6 +312,9 @@ function handleResetContinueQuestionList() {
:agent-id="agentApplicationConfig.baseInfo.agentId"
:continuous-question-status="continuousQuestionStatus"
:is-enable-document-parse="isEnableDocumentParse"
:is-enable-voice="isEnableVoice"
:answer-audio-auto-playing="answerAudioAutoPlaying"
:timbre-id="agentApplicationConfig.voiceConfig.timbreId"
@add-message-item="handleAddMessageItem"
@update-specify-message-item="handleUpdateSpecifyMessageItem"
@delete-last-message-item="handleDeleteLastMessageItem"
......@@ -184,6 +323,7 @@ function handleResetContinueQuestionList() {
@to-login="handleToLoginPage"
@create-continue-questions="handleCreateContinueQuestions"
@reset-continue-question-list="handleResetContinueQuestionList"
@audio-play="handleAudioPlay"
/>
</div>
</div>
......
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Howl } from 'howler'
import PageHeader from './components/web-page-header.vue'
import Preamble from './components/preamble.vue'
import MessageList from './components/message-list.vue'
import FooterInput from './components/footer-input.vue'
import { fetchCreateContinueQuestions, fetchCreateDialogues, fetchGetApplicationInfo } from '@/apis/agent-application'
import {
fetchCreateContinueQuestions,
fetchCreateDialogues,
fetchGetApplicationInfo,
fetchGetAutoPlayByAgentId,
fetchUpdateAutoPlay,
} from '@/apis/agent-application'
import { PersonalAppConfigState } from '@/store/types/personal-app-config'
import { defaultPersonalAppConfigState } from '@/store/modules/personal-app-config'
import { createDefaultUserInfoFactory, useUserStore } from '@/store/modules/user'
import { useLayoutConfig } from '@/composables/useLayoutConfig'
import { fetchGetMemberInfoById } from '@/apis/user'
import { UserInfo } from '@/store/types/user'
import { validBrowser } from '@/utils/browser-detection'
const { t } = useI18n()
......@@ -36,11 +44,20 @@ const messageList = ref<ConversationMessageItem[]>([])
const continuousQuestionStatus = ref<'default' | 'close'>('default')
const continueQuestionList = ref<string[]>([])
const answerAudioAutoPlaying = ref(false)
const currentPlayMessageItem = ref<ConversationMessageItem | null>(null)
const currentPlayAudioFragmentSerialNo = ref(0)
const currentSoundCtl = shallowRef<Howl | null>(null)
const isSoundCtlCreated = ref(false)
const isEnableDocumentParse = computed(() => {
return agentApplicationConfig.value.knowledgeConfig.isDocumentParsing === 'Y'
})
const isEnableVoice = computed(() => {
return !!agentApplicationConfig.value.voiceConfig.timbreId
})
onMounted(async () => {
if (router.currentRoute.value.params.agentId) {
agentId.value = router.currentRoute.value.params.agentId as string
......@@ -56,7 +73,11 @@ onMounted(async () => {
if (agentId.value) {
fullScreenLoading.value = true
await handleGetApplicationDetail()
userStore.isLogin && (await handleCreateDialogues())
if (userStore.isLogin) {
await handleCreateDialogues()
await handleGetAutoPlayByAgentId()
}
fullScreenLoading.value = false
return
}
......@@ -64,6 +85,11 @@ onMounted(async () => {
router.replace({ name: 'Home' })
})
onUnmounted(() => {
handleAudioPause()
footerInputRef.value?.blockMessageResponse()
})
async function handleGetApplicationDetail() {
fetchGetApplicationInfo<PersonalAppConfigState>(agentId.value)
.then((res) => {
......@@ -93,6 +119,14 @@ async function handleCreateDialogues() {
}
}
async function handleGetAutoPlayByAgentId() {
const res = await fetchGetAutoPlayByAgentId<'Y' | 'N'>(agentId.value)
if (res.code === 0) {
answerAudioAutoPlaying.value = res.data === 'Y'
}
}
function handleBack() {
if (!history.state.back) {
router.replace({
......@@ -116,6 +150,11 @@ function handleCreateApplicationPage() {
})
}
async function handleUpdateAutoPlaying(isAutoPlaying: boolean) {
const autoplay = isAutoPlaying ? 'Y' : 'N'
await fetchUpdateAutoPlay(agentId.value, autoplay)
}
function handleAddMessageItem(messageItem: ConversationMessageItem) {
messageList.value.push(messageItem)
}
......@@ -143,6 +182,7 @@ function handleClearAllMessage() {
t('common_module.dialogue_module.clear_message_dialog_title'),
)
.then(() => {
handleAudioPause()
footerInputRef.value?.blockMessageResponse()
messageList.value = []
window.$message.success(t('common_module.clear_success_message'))
......@@ -161,6 +201,91 @@ async function handleCreateContinueQuestions(replyTextContent: string) {
function handleResetContinueQuestionList() {
continueQuestionList.value = []
}
function howlSoundFactory(url: string) {
const soundCtl = new Howl({
src: [url],
format: ['mpeg'],
html5: !validBrowser(),
preload: true,
autoplay: true,
onplay: () => {
currentSoundCtl.value = soundCtl
if (currentPlayMessageItem.value) {
currentPlayMessageItem.value.isVoiceLoading = false
currentPlayMessageItem.value.isVoicePlaying = true
}
},
onend: () => {
soundCtl.unload()
currentPlayAudioFragmentSerialNo.value += 1
if (
currentPlayMessageItem.value &&
currentPlayAudioFragmentSerialNo.value > currentPlayMessageItem.value.voiceFragmentUrlList.length - 1
) {
currentPlayMessageItem.value.isVoicePlaying = false
}
let audioFragmentUrl = currentPlayMessageItem.value?.voiceFragmentUrlList[currentPlayAudioFragmentSerialNo.value]
if (audioFragmentUrl) {
howlSoundFactory(audioFragmentUrl)
} else if (
(currentPlayMessageItem.value?.voiceFragmentUrlList || []).length > currentPlayAudioFragmentSerialNo.value
) {
let timerId: NodeJS.Timeout | null = null
const audioFragmentCheck = () => {
audioFragmentUrl = currentPlayMessageItem.value?.voiceFragmentUrlList[currentPlayAudioFragmentSerialNo.value]
if (audioFragmentUrl) {
timerId && clearInterval(timerId)
howlSoundFactory(audioFragmentUrl)
}
}
timerId = setInterval(audioFragmentCheck, 600)
}
},
})
return soundCtl
}
function handleAudioPlay(currentMessageItem: ConversationMessageItem, specificPlayUrl?: string) {
handleAudioPause()
currentPlayMessageItem.value = currentMessageItem
currentPlayAudioFragmentSerialNo.value = 0
const audioUrl = specificPlayUrl || currentMessageItem.voiceFragmentUrlList[0]
howlSoundFactory(audioUrl)
isSoundCtlCreated.value = true
}
function handleAudioPause(isClearMessageList = false) {
if (currentSoundCtl.value) {
currentSoundCtl.value.pause()
currentSoundCtl.value.unload()
currentSoundCtl.value = null
isSoundCtlCreated.value = false
}
currentPlayMessageItem.value && (currentPlayMessageItem.value.isVoicePlaying = false)
if (isClearMessageList) {
messageList.value = []
footerInputRef.value?.blockMessageResponse()
}
}
</script>
<template>
......@@ -175,33 +300,29 @@ function handleResetContinueQuestionList() {
/>
<div class="h-[calc(100%-68px)] w-full bg-[#f2f5f9]">
<div class="mx-auto flex h-full w-[1000px] flex-col overflow-hidden">
<div class="relative mx-auto flex h-full w-[1000px] flex-col overflow-hidden">
<div v-show="isEnableVoice" class="absolute right-10 top-7 flex select-none items-center gap-2">
<span>{{ t('common_module.voice_auto_play') }}</span>
<n-switch v-model:value="answerAudioAutoPlaying" size="small" @update:value="handleUpdateAutoPlaying">
<template #checked> {{ t('common_module.open') }} </template>
<template #unchecked> {{ t('common_module.close') }} </template>
</n-switch>
</div>
<div v-if="messageList.length === 0" class="w-full flex-1 overflow-auto px-5">
<Preamble :agent-application-config="agentApplicationConfig" />
</div>
<div v-if="messageList.length > 0" class="flex w-full flex-1 flex-col overflow-hidden">
<!-- <div class="my-5 ml-16 mr-5 border-b border-[#e8e9eb] py-6">
<div class="flex items-start">
<img :src="agentApplicationConfig.baseInfo.agentAvatar" class="h-14 w-14 rounded-xl" />
<p class="font-500 ml-4 line-clamp-1 h-8 max-w-[90%] break-all text-2xl leading-8 text-[#151b26]">
{{ agentApplicationConfig.baseInfo.agentTitle }}
</p>
</div>
<div
v-show="agentApplicationConfig.commConfig.preamble"
class="mt-3 rounded-xl border border-[#e8e9eb] bg-white px-4 py-3 shadow-[0_2px_2px_#0000000a]"
>
{{ agentApplicationConfig.commConfig.preamble }}
</div>
</div> -->
<div class="mt-16 flex-1 overflow-auto">
<div class="mt-20 flex-1 overflow-auto">
<MessageList
ref="messageListRef"
:agent-application-config="agentApplicationConfig"
:message-list="messageList"
:continuous-question-status="continuousQuestionStatus"
:continuous-question-list="continueQuestionList"
@audio-play="handleAudioPlay"
@audio-pause="handleAudioPause"
/>
</div>
</div>
......@@ -214,6 +335,9 @@ function handleResetContinueQuestionList() {
:agent-id="agentApplicationConfig.baseInfo.agentId"
:continuous-question-status="continuousQuestionStatus"
:is-enable-document-parse="isEnableDocumentParse"
:is-enable-voice="isEnableVoice"
:answer-audio-auto-playing="answerAudioAutoPlaying"
:timbre-id="agentApplicationConfig.voiceConfig.timbreId"
@add-message-item="handleAddMessageItem"
@update-specify-message-item="handleUpdateSpecifyMessageItem"
@delete-last-message-item="handleDeleteLastMessageItem"
......@@ -222,6 +346,7 @@ function handleResetContinueQuestionList() {
@to-login="handleToLoginPage"
@create-continue-questions="handleCreateContinueQuestions"
@reset-continue-question-list="handleResetContinueQuestionList"
@audio-play="handleAudioPlay"
/>
</div>
</div>
......
......@@ -5,4 +5,7 @@ declare interface ConversationMessageItem {
isEmptyContent: boolean
isTextContentLoading: boolean
isAnswerResponseLoading: boolean
isVoiceLoading: boolean
isVoicePlaying: boolean
voiceFragmentUrlList: string[]
}
......@@ -91,6 +91,17 @@ declare namespace I18n {
bind: string
sms: string
verificationCode: string
role: string
mandarin: string
cantonese: string
english: string
voice: string
sound: string
voice_auto_play: string
start_playing: string
stop_playing: string
response_error: string
agent_exception: string
dialogue_module: {
continue_question_message: string
......@@ -279,6 +290,9 @@ declare namespace I18n {
memory_fragment_delete_row_tip_content: string
add_knowledge_successfully: string
remove_knowledge_successfully: string
setting_timbre: string
setting_timbre_message: string
setting_timbre_desc: string
memory_variable_modal: {
edit_memory_variable: string
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment