|
|
@@ -14,6 +14,7 @@ const props = defineProps({
|
|
|
|
|
|
const DEFAULT_AI_QA_CONFIG = Object.freeze({
|
|
|
roleId: null,
|
|
|
+ entries: [],
|
|
|
maxFileSizeMB: 20,
|
|
|
maxFiles: Infinity,
|
|
|
maxTotalFileSizeMB: Infinity,
|
|
|
@@ -51,8 +52,14 @@ const AI_QA_CONFIG = Object.freeze({ ...DEFAULT_AI_QA_CONFIG, ...props.config })
|
|
|
const uploadEnabled = computed(() => AI_QA_CONFIG.enableFileUpload === true)
|
|
|
const hasInputLengthLimit = computed(() => Number.isFinite(AI_QA_CONFIG.maxInputLength) && AI_QA_CONFIG.maxInputLength >= 0)
|
|
|
const referenceScriptEnabled = computed(() => AI_QA_CONFIG.enableReferenceScript === true)
|
|
|
-const conversationStorageKey = `ai-qa-conversation-id:${AI_QA_CONFIG.roleId}`
|
|
|
-const conversationHistoryStorageKey = `ai-qa-conversation-history:${AI_QA_CONFIG.roleId}`
|
|
|
+const roleEntries = computed(() => {
|
|
|
+ const entries = Array.isArray(AI_QA_CONFIG.entries)
|
|
|
+ ? AI_QA_CONFIG.entries.filter((entry) => entry?.key && entry?.label && entry?.roleId)
|
|
|
+ : []
|
|
|
+ return entries.length || !AI_QA_CONFIG.roleId
|
|
|
+ ? entries
|
|
|
+ : [{ key: 'qa', label: '智能问答', roleId: AI_QA_CONFIG.roleId }]
|
|
|
+})
|
|
|
|
|
|
const input = ref('')
|
|
|
const referenceScript = ref('')
|
|
|
@@ -73,7 +80,7 @@ const conversationId = ref(null)
|
|
|
const conversations = ref([])
|
|
|
const historyDrawerOpen = ref(true)
|
|
|
const isCreatingConversation = ref(false)
|
|
|
-const initialConversationType = ref('qa')
|
|
|
+const activeEntry = ref(roleEntries.value.length === 1 ? roleEntries.value[0] : null)
|
|
|
const isSending = ref(false)
|
|
|
const isVoiceRecording = ref(false)
|
|
|
const isTyping = ref(false)
|
|
|
@@ -94,28 +101,34 @@ let persistTimer = null
|
|
|
const hasConversation = computed(() => Boolean(conversationId.value))
|
|
|
const hasMessages = computed(() => messages.value.length > 0)
|
|
|
const canSend = computed(() => hasConversation.value && (input.value.trim() || (uploadEnabled.value && attachments.value.length)) && !isSending.value && !isTyping.value)
|
|
|
-const welcomeTitle = computed(() => initialConversationType.value === 'practice' ? '您要去拜访谁呢?' : '有什么可以帮您?')
|
|
|
+const activeRoleId = computed(() => activeEntry.value?.roleId || AI_QA_CONFIG.roleId || null)
|
|
|
+const welcomeTitle = computed(() => activeEntry.value?.key === 'practice' ? '您要去拜访谁呢?' : '有什么可以帮您?')
|
|
|
+const getConversationStorageKey = (roleId) => `ai-qa-conversation-id:${roleId}`
|
|
|
+const getConversationHistoryStorageKey = (roleId) => `ai-qa-conversation-history:${roleId}`
|
|
|
|
|
|
-const getCachedConversationId = () => {
|
|
|
+const getCachedConversationId = (roleId = activeRoleId.value) => {
|
|
|
+ if (!roleId) return null
|
|
|
try {
|
|
|
- return localStorage.getItem(conversationStorageKey)
|
|
|
+ return localStorage.getItem(getConversationStorageKey(roleId))
|
|
|
} catch (error) {
|
|
|
console.warn('读取 AI 会话缓存失败', error)
|
|
|
return null
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-const cacheConversationId = (id) => {
|
|
|
+const cacheConversationId = (id, roleId = activeRoleId.value) => {
|
|
|
+ if (!roleId) return
|
|
|
try {
|
|
|
- localStorage.setItem(conversationStorageKey, id)
|
|
|
+ localStorage.setItem(getConversationStorageKey(roleId), id)
|
|
|
} catch (error) {
|
|
|
console.warn('保存 AI 会话缓存失败', error)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-const clearCachedConversationId = () => {
|
|
|
+const clearCachedConversationId = (roleId = activeRoleId.value) => {
|
|
|
+ if (!roleId) return
|
|
|
try {
|
|
|
- localStorage.removeItem(conversationStorageKey)
|
|
|
+ localStorage.removeItem(getConversationStorageKey(roleId))
|
|
|
} catch (error) {
|
|
|
console.warn('清除 AI 会话缓存失败', error)
|
|
|
}
|
|
|
@@ -135,17 +148,22 @@ const serializeMessages = () => messages.value.map((message) => ({
|
|
|
attachments: message.attachments?.map(({ id, name, size, type, url }) => ({ id, name, size, type, url })) || []
|
|
|
}))
|
|
|
|
|
|
-const saveConversationList = () => {
|
|
|
+const saveConversationList = (roleId = activeRoleId.value) => {
|
|
|
+ if (!roleId) return
|
|
|
try {
|
|
|
- localStorage.setItem(conversationHistoryStorageKey, JSON.stringify(conversations.value))
|
|
|
+ localStorage.setItem(getConversationHistoryStorageKey(roleId), JSON.stringify(conversations.value))
|
|
|
} catch (error) {
|
|
|
console.warn('保存 AI 历史记录失败', error)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-const loadConversationList = () => {
|
|
|
+const loadConversationList = (roleId = activeRoleId.value) => {
|
|
|
+ if (!roleId) {
|
|
|
+ conversations.value = []
|
|
|
+ return
|
|
|
+ }
|
|
|
try {
|
|
|
- const stored = JSON.parse(localStorage.getItem(conversationHistoryStorageKey) || '[]')
|
|
|
+ const stored = JSON.parse(localStorage.getItem(getConversationHistoryStorageKey(roleId)) || '[]')
|
|
|
conversations.value = Array.isArray(stored)
|
|
|
? stored.filter((item) => item?.id).map((item) => ({
|
|
|
id: item.id,
|
|
|
@@ -199,18 +217,18 @@ const formatHistoryTime = (value) => {
|
|
|
return `${date.getMonth() + 1}/${date.getDate()}`
|
|
|
}
|
|
|
|
|
|
-const makeConversation = async () => {
|
|
|
+const makeConversation = async (roleId = activeRoleId.value) => {
|
|
|
if (conversationId.value || isCreatingConversation.value) return conversationId.value
|
|
|
isCreatingConversation.value = true
|
|
|
try {
|
|
|
- if (!AI_QA_CONFIG.roleId) {
|
|
|
+ if (!roleId) {
|
|
|
Message().warning('请先选择数字人角色!', true)
|
|
|
return null
|
|
|
}
|
|
|
- const result = await props.createDialogueApi({ roleId: AI_QA_CONFIG.roleId })
|
|
|
+ const result = await props.createDialogueApi({ roleId })
|
|
|
conversationId.value = result?.data
|
|
|
if (!conversationId.value) throw new Error('未获取到会话标识')
|
|
|
- cacheConversationId(conversationId.value)
|
|
|
+ cacheConversationId(conversationId.value, roleId)
|
|
|
return conversationId.value
|
|
|
} catch (error) {
|
|
|
console.error('创建 AI 会话失败', error)
|
|
|
@@ -221,9 +239,11 @@ const makeConversation = async () => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-const createInitialConversation = (type) => {
|
|
|
- initialConversationType.value = type
|
|
|
- return makeConversation()
|
|
|
+const createInitialConversation = (entry) => {
|
|
|
+ if (!entry?.roleId || isCreatingConversation.value) return null
|
|
|
+ activeEntry.value = entry
|
|
|
+ loadConversationList(entry.roleId)
|
|
|
+ return makeConversation(entry.roleId)
|
|
|
}
|
|
|
|
|
|
const restoreActiveConversation = () => {
|
|
|
@@ -427,18 +447,36 @@ setOnPlaybackComplete(() => {
|
|
|
const resetDigitalHumanVideo = () => {
|
|
|
const video = digitalHumanVideoRef.value
|
|
|
if (!video) return
|
|
|
+ if (isAudioSpeaking.value) return
|
|
|
video.pause()
|
|
|
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) video.currentTime = 0
|
|
|
}
|
|
|
|
|
|
-watch(isAudioSpeaking, async (speaking) => {
|
|
|
- await nextTick()
|
|
|
+const playDigitalHumanVideo = async () => {
|
|
|
const video = digitalHumanVideoRef.value
|
|
|
if (!video) return
|
|
|
+ if (video.readyState < HTMLMediaElement.HAVE_METADATA) return
|
|
|
+ video.currentTime = 0
|
|
|
+ video.playbackRate = 1.5
|
|
|
+ try {
|
|
|
+ await video.play()
|
|
|
+ } catch (error) {
|
|
|
+ console.warn('数字人视频播放失败', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleDigitalHumanVideoLoaded = () => {
|
|
|
+ if (isAudioSpeaking.value) {
|
|
|
+ playDigitalHumanVideo()
|
|
|
+ } else {
|
|
|
+ resetDigitalHumanVideo()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+watch(isAudioSpeaking, async (speaking) => {
|
|
|
+ await nextTick()
|
|
|
if (speaking) {
|
|
|
- video.currentTime = 0
|
|
|
- video.playbackRate = 1.5
|
|
|
- video.play().catch((error) => console.warn('数字人视频播放失败', error))
|
|
|
+ playDigitalHumanVideo()
|
|
|
} else {
|
|
|
resetDigitalHumanVideo()
|
|
|
}
|
|
|
@@ -646,6 +684,7 @@ const toggleVoicePlayback = () => {
|
|
|
}
|
|
|
|
|
|
const startNewConversation = () => {
|
|
|
+ const previousRoleId = activeRoleId.value
|
|
|
persistConversation()
|
|
|
stopResponse()
|
|
|
messages.value = []
|
|
|
@@ -653,9 +692,10 @@ const startNewConversation = () => {
|
|
|
input.value = ''
|
|
|
referenceScript.value = ''
|
|
|
referenceScriptVisible.value = false
|
|
|
- initialConversationType.value = 'qa'
|
|
|
conversationId.value = null
|
|
|
- clearCachedConversationId()
|
|
|
+ clearCachedConversationId(previousRoleId)
|
|
|
+ activeEntry.value = roleEntries.value.length === 1 ? roleEntries.value[0] : null
|
|
|
+ if (!activeEntry.value) conversations.value = []
|
|
|
userHasScrolled.value = false
|
|
|
nextTick(() => textareaRef.value?.focus())
|
|
|
}
|
|
|
@@ -667,8 +707,10 @@ watch(messages, () => {
|
|
|
watch(input, resizeTextarea)
|
|
|
|
|
|
onMounted(() => {
|
|
|
- loadConversationList()
|
|
|
- restoreActiveConversation()
|
|
|
+ if (activeEntry.value) {
|
|
|
+ loadConversationList(activeEntry.value.roleId)
|
|
|
+ restoreActiveConversation()
|
|
|
+ }
|
|
|
resizeTextarea()
|
|
|
})
|
|
|
|
|
|
@@ -727,7 +769,7 @@ onUnmounted(() => {
|
|
|
playsinline
|
|
|
preload="auto"
|
|
|
:loop="isAudioSpeaking"
|
|
|
- @loadedmetadata="resetDigitalHumanVideo"
|
|
|
+ @loadedmetadata="handleDigitalHumanVideoLoaded"
|
|
|
></video>
|
|
|
</div>
|
|
|
<div class="digital-human-status"><i></i>{{ isAudioSpeaking ? '正在为您播报' : '在线为您服务' }}</div>
|
|
|
@@ -738,8 +780,7 @@ onUnmounted(() => {
|
|
|
<p class="eyebrow">AI ASSISTANT</p><h1>开始一次新的问答</h1>
|
|
|
<p class="welcome-copy">向非临 AI 助教描述您的问题,获得及时的智能解答。</p>
|
|
|
<div class="initial-conversation-actions">
|
|
|
- <button class="initial-conversation-button" type="button" :disabled="isCreatingConversation" @click="createInitialConversation('qa')"><el-icon><Plus /></el-icon>智能问答</button>
|
|
|
-<!-- <button class="initial-conversation-button" type="button" :disabled="isCreatingConversation" @click="createInitialConversation('practice')"><el-icon><Plus /></el-icon>智能陪练</button>-->
|
|
|
+ <button v-for="entry in roleEntries" :key="entry.key" class="initial-conversation-button" type="button" :disabled="isCreatingConversation" @click="createInitialConversation(entry)"><el-icon><Plus /></el-icon>{{ entry.label }}</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|