add conversation
This commit is contained in:
@@ -10,6 +10,13 @@
|
||||
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton",
|
||||
"word-dictionary": "../../components/word-dictionary/word-dictionary",
|
||||
"vx-confetti": "/components/vx-confetti/vx-confetti",
|
||||
"cloud-image": "../../components/cloud-image/cloud-image"
|
||||
"cloud-image": "../../components/cloud-image/cloud-image",
|
||||
"t-tag": "tdesign-miniprogram/tag/tag",
|
||||
"t-check-tag": "tdesign-miniprogram/check-tag/check-tag",
|
||||
"t-drawer": "tdesign-miniprogram/drawer/drawer",
|
||||
"t-chat": "tdesign-miniprogram/chat-list/chat-list",
|
||||
"t-chat-sender": "tdesign-miniprogram/chat-sender/chat-sender",
|
||||
"t-chat-loading": "tdesign-miniprogram/chat-loading/chat-loading",
|
||||
"t-chat-message": "tdesign-miniprogram/chat-message/chat-message"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import apiManager from '../../utils/api'
|
||||
import logger from '../../utils/logger'
|
||||
import { IQaExerciseItem, IQaExerciseSession, IAppOption } from '../../types/app'
|
||||
import { IQaExerciseItem, IQaExerciseSession, IAppOption, IQaConversationSettingPayload } from '../../types/app'
|
||||
|
||||
export const QUESTION_MODES = {
|
||||
CLOZE: 'cloze',
|
||||
@@ -96,7 +96,36 @@ interface IData {
|
||||
variationSubmitted?: boolean
|
||||
variationResultStatus?: 'correct' | 'incorrect'
|
||||
variationExerciseId?: string
|
||||
conversationSetting?: any
|
||||
conversationSetting?: IQaConversationSettingPayload
|
||||
conversationDifficulty?: string
|
||||
selectedRole?: { roleIndex: number, roleSide: 1 | 2 } | null
|
||||
conversationSelectedScenes?: string[]
|
||||
conversationExtraNote?: string
|
||||
conversationSceneLang?: 'zh' | 'en'
|
||||
conversationSelectedScenesMap?: Record<string, boolean>
|
||||
conversationSelectedEvents?: string[]
|
||||
conversationSelectedEventsMap?: Record<string, boolean>
|
||||
conversationCustomSceneText?: string
|
||||
conversationCustomSceneKey?: string
|
||||
conversationCustomSceneEditing?: boolean
|
||||
conversationCustomScenes?: Array<{ key: string; text: string }>
|
||||
conversationCustomSceneOverLimit?: boolean
|
||||
conversationCustomEventText?: string
|
||||
conversationCustomEventEditing?: boolean
|
||||
conversationCustomEvents?: Array<{ key: string; text: string }>
|
||||
conversationCustomEventOverLimit?: boolean
|
||||
conversationSuggestedRoles?: Array<{ key: string; role1_en: string; role1_zh: string; role2_en: string; role2_zh: string }>
|
||||
difficultyOptions: Array<{ value: string; label_zh: string; label_en: string }>
|
||||
conversationViewMode: 'setup' | 'chat'
|
||||
conversationLatestSession: any
|
||||
conversationDetail: any
|
||||
conversationMessages: any[]
|
||||
replyLoading: boolean
|
||||
chatInputValue: string
|
||||
isChatInputVisible: boolean
|
||||
scrollIntoView: string
|
||||
renderPresets: Array<{ name: string; type: string }>
|
||||
sidebar?: any[]
|
||||
}
|
||||
|
||||
interface IPageInstance {
|
||||
@@ -110,6 +139,7 @@ interface IPageInstance {
|
||||
startPolling: (taskId: string, imageId: string) => void
|
||||
startVariationPolling: (taskId: string, imageId: string) => void
|
||||
startConversationInitPolling: (taskId: string, imageId: string) => void
|
||||
startConversationPolling: (taskId: string, sessionId: string, showLoadingMask?: boolean, append?: boolean) => void
|
||||
initExerciseContent: (exercise: any, session?: IQaExerciseSession) => void
|
||||
updateActionButtonsState: () => void
|
||||
shuffleArray: <T>(arr: T[]) => T[]
|
||||
@@ -154,6 +184,34 @@ interface IPageInstance {
|
||||
loadVariationImages: (list: any[]) => void
|
||||
onVariationImageLoad: (e: any) => void
|
||||
previewVariationImage: (e: any) => void
|
||||
selectConversationDifficulty: (e: any) => void
|
||||
toggleConversationScene: (e: any) => void
|
||||
onConversationNoteInput: (e: any) => void
|
||||
onStartConversationTap: () => void
|
||||
toggleConversationSceneLang: () => void
|
||||
toggleConversationEvent: (e: any) => void
|
||||
onConversationCustomSceneAdd: () => void
|
||||
onConversationCustomSceneInput: (e: any) => void
|
||||
onConversationCustomSceneConfirm: () => void
|
||||
onConversationCustomSceneCancel: () => void
|
||||
onConversationCustomSceneBlur: () => void
|
||||
onConversationCustomSceneDelete: (e: any) => void
|
||||
onConversationCustomEventAdd: () => void
|
||||
onConversationCustomEventInput: (e: any) => void
|
||||
onConversationCustomEventConfirm: () => void
|
||||
onConversationCustomEventBlur: () => void
|
||||
onConversationCustomEventDelete: (e: any) => void
|
||||
onRoleSelect: (e: any) => void
|
||||
normalizeConversationTagLabel: (raw: string) => string
|
||||
toggleConversationView: () => void
|
||||
onSendMessage: (e: any) => void
|
||||
onChatInput: (e: any) => void
|
||||
onChatBlur: (e: any) => void
|
||||
onChatCloseTap: (e: any) => void
|
||||
updateConversationMessages: (detail: any, lang: string, append?: boolean) => void
|
||||
showChatInput: () => void
|
||||
onHistoryTap: () => void
|
||||
chatItemClick: (e: any) => void
|
||||
}
|
||||
|
||||
Page<IData, IPageInstance>({
|
||||
@@ -179,6 +237,8 @@ Page<IData, IPageInstance>({
|
||||
evalClasses: [],
|
||||
clozeSentenceTokens: [],
|
||||
modeAnim: '',
|
||||
isChatInputVisible: false,
|
||||
scrollIntoView: '',
|
||||
isModeSwitching: false,
|
||||
progressText: '',
|
||||
questionWords: [],
|
||||
@@ -224,7 +284,37 @@ Page<IData, IPageInstance>({
|
||||
variationImagesLoading: {},
|
||||
variationImageLoaded: {},
|
||||
variationSelectedIndex: -1,
|
||||
variationSubmitted: false
|
||||
variationSubmitted: false,
|
||||
conversationDifficulty: 'easy',
|
||||
sidebar: [],
|
||||
conversationSceneLang: 'zh',
|
||||
conversationSelectedScenes: [],
|
||||
conversationSelectedScenesMap: {},
|
||||
conversationSelectedEvents: [],
|
||||
conversationSelectedEventsMap: {},
|
||||
conversationCustomSceneText: '',
|
||||
conversationCustomSceneKey: '',
|
||||
conversationCustomSceneEditing: false,
|
||||
conversationCustomScenes: [],
|
||||
conversationCustomSceneOverLimit: false,
|
||||
conversationCustomEventText: '',
|
||||
conversationCustomEventEditing: false,
|
||||
conversationCustomEvents: [],
|
||||
conversationCustomEventOverLimit: false,
|
||||
conversationSuggestedRoles: [],
|
||||
conversationExtraNote: '',
|
||||
difficultyOptions: [
|
||||
{ value: 'easy', label_zh: '初级', label_en: 'Easy' },
|
||||
{ value: 'medium', label_zh: '中级', label_en: 'Medium' },
|
||||
{ value: 'hard', label_zh: '高级', label_en: 'Hard' }
|
||||
],
|
||||
conversationViewMode: 'setup', // 'setup' | 'chat'
|
||||
conversationLatestSession: null as any, // 存储 latest_session 信息
|
||||
conversationDetail: null as any, // 存储会话详情
|
||||
conversationMessages: [],
|
||||
replyLoading: false,
|
||||
chatInputValue: '',
|
||||
renderPresets: [ { name: 'send', type: 'icon'} ]
|
||||
},
|
||||
updateProcessDots() {
|
||||
const list = this.data.qaList || []
|
||||
@@ -712,19 +802,41 @@ Page<IData, IPageInstance>({
|
||||
async fetchConversationSetting(imageId: string) {
|
||||
try {
|
||||
this.setData({ loadingMaskVisible: true, statusText: '加载对话设置...' })
|
||||
const setting = await apiManager.getQaConversationSetting(imageId)
|
||||
logger.log('Conversation setting:', setting)
|
||||
if (!setting) {
|
||||
const res = await apiManager.getQaConversationSetting(imageId)
|
||||
logger.log('Conversation setting:', res)
|
||||
|
||||
let viewMode = 'setup'
|
||||
let detail = null
|
||||
|
||||
if (res && res.latest_session && res.latest_session.status === 'ongoing') {
|
||||
const sessionId = res.latest_session.id || (res.latest_session as any).session_id
|
||||
if (sessionId) {
|
||||
try {
|
||||
detail = await apiManager.getQaConversationDetail(sessionId)
|
||||
logger.info('Recovered conversation detail:', detail)
|
||||
viewMode = 'chat'
|
||||
} catch (err) {
|
||||
logger.error('Failed to recover conversation detail:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
const { task_id } = await apiManager.createQaExerciseTask(imageId, 'init_conversion')
|
||||
logger.log('Conversation init task created:', task_id)
|
||||
this.startConversationInitPolling(task_id, imageId)
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({
|
||||
conversationSetting: setting,
|
||||
conversationSetting: res.setting,
|
||||
conversationLatestSession: res.latest_session,
|
||||
conversationDetail: detail,
|
||||
conversationViewMode: viewMode as any,
|
||||
loadingMaskVisible: false,
|
||||
statusText: '加载完成'
|
||||
})
|
||||
this.updateConversationMessages(detail, this.data.conversationSceneLang || 'zh', false)
|
||||
} catch (e) {
|
||||
logger.error('获取自由对话设置失败', e)
|
||||
const msg = (e as any)?.message || ''
|
||||
@@ -732,6 +844,16 @@ Page<IData, IPageInstance>({
|
||||
this.setData({ loadingMaskVisible: false, statusText: '加载失败' })
|
||||
}
|
||||
},
|
||||
|
||||
toggleConversationView() {
|
||||
const { conversationViewMode, conversationLatestSession } = this.data
|
||||
// 如果没有 latest_session,无法切换到 chat 模式,也不应该启用按钮
|
||||
if (!conversationLatestSession) {
|
||||
return
|
||||
}
|
||||
const nextMode = conversationViewMode === 'setup' ? 'chat' : 'setup'
|
||||
this.setData({ conversationViewMode: nextMode })
|
||||
},
|
||||
startVariationPolling(taskId: string, imageId: string) {
|
||||
if (this.variationPollTimer) {
|
||||
clearInterval(this.variationPollTimer)
|
||||
@@ -780,6 +902,51 @@ Page<IData, IPageInstance>({
|
||||
}
|
||||
}, 3000) as any
|
||||
},
|
||||
startConversationPolling(taskId: string, sessionId: string, showLoadingMask: boolean = true, append?: boolean) {
|
||||
if (this.conversationPollTimer) {
|
||||
clearInterval(this.conversationPollTimer)
|
||||
this.conversationPollTimer = undefined
|
||||
}
|
||||
if (showLoadingMask) {
|
||||
this.setData({ loadingMaskVisible: true, statusText: '对话生成中...' })
|
||||
}
|
||||
this.conversationPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const res = await apiManager.getQaExerciseTaskStatus(taskId)
|
||||
if (res.status === 'completed') {
|
||||
clearInterval(this.conversationPollTimer!)
|
||||
this.conversationPollTimer = undefined
|
||||
|
||||
try {
|
||||
const detail = await apiManager.getQaConversationLatest(sessionId)
|
||||
logger.info('Started conversation detail:', detail)
|
||||
|
||||
this.setData({
|
||||
conversationDetail: detail,
|
||||
conversationViewMode: 'chat',
|
||||
loadingMaskVisible: false,
|
||||
statusText: '加载完成',
|
||||
conversationLatestSession: { id: sessionId, status: 'ongoing' },
|
||||
replyLoading: false
|
||||
})
|
||||
this.updateConversationMessages(detail, this.data.conversationSceneLang || 'zh', append)
|
||||
} catch (err) {
|
||||
logger.error('Failed to get latest conversation detail:', err)
|
||||
wx.showToast({ title: '获取对话详情失败', icon: 'none' })
|
||||
this.setData({ loadingMaskVisible: false, statusText: '获取详情失败', replyLoading: false })
|
||||
}
|
||||
|
||||
} else if (res.status === 'failed') {
|
||||
clearInterval(this.conversationPollTimer!)
|
||||
this.conversationPollTimer = undefined
|
||||
wx.showToast({ title: '任务失败', icon: 'none' })
|
||||
this.setData({ loadingMaskVisible: false, statusText: '任务失败', replyLoading: false })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('轮询对话任务状态失败', err)
|
||||
}
|
||||
}, 3000) as any
|
||||
},
|
||||
startPolling(taskId: string, imageId: string) {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
@@ -1569,6 +1736,521 @@ Page<IData, IPageInstance>({
|
||||
this.setData({ variationSelectedIndex: i, variationResultStatus: null as any })
|
||||
this.updateActionButtonsState()
|
||||
},
|
||||
selectConversationDifficulty(e: any) {
|
||||
const level = String(e?.currentTarget?.dataset?.level || '')
|
||||
if (!level) return
|
||||
this.setData({ conversationDifficulty: level })
|
||||
},
|
||||
toggleConversationScene(e: any) {
|
||||
const scene = String(e?.currentTarget?.dataset?.scene || '')
|
||||
if (!scene) return
|
||||
const list = (this.data.conversationSelectedScenes || []).slice()
|
||||
const map = { ...(this.data.conversationSelectedScenesMap || {}) }
|
||||
const idx = list.indexOf(scene)
|
||||
if (idx >= 0) {
|
||||
list.splice(idx, 1)
|
||||
delete map[scene]
|
||||
} else {
|
||||
list.push(scene)
|
||||
map[scene] = true
|
||||
}
|
||||
this.setData({ conversationSelectedScenes: list, conversationSelectedScenesMap: map })
|
||||
},
|
||||
toggleConversationEvent(e: any) {
|
||||
const ev = String(e?.currentTarget?.dataset?.event || '')
|
||||
if (!ev) return
|
||||
const list = (this.data.conversationSelectedEvents || []).slice()
|
||||
const map = { ...(this.data.conversationSelectedEventsMap || {}) }
|
||||
const idx = list.indexOf(ev)
|
||||
if (idx >= 0) {
|
||||
list.splice(idx, 1)
|
||||
delete map[ev]
|
||||
} else {
|
||||
list.push(ev)
|
||||
map[ev] = true
|
||||
}
|
||||
const setting = this.data.conversationSetting as any
|
||||
const allEvents = setting && Array.isArray((setting as any).all_possible_events) ? (setting as any).all_possible_events : []
|
||||
const selectedSet = new Set(list)
|
||||
const roles: Array<{ key: string; role1_en: string; role1_zh: string; role2_en: string; role2_zh: string }> = []
|
||||
const seen = new Set<string>()
|
||||
if (allEvents && allEvents.length && selectedSet.size) {
|
||||
allEvents.forEach((item: any) => {
|
||||
const evKey = String(item?.event_en || '')
|
||||
if (!evKey || !selectedSet.has(evKey)) return
|
||||
const sr = Array.isArray(item?.suggested_roles) ? item.suggested_roles : []
|
||||
sr.forEach((r: any) => {
|
||||
const r1en = String(r?.role1_en || '')
|
||||
const r2en = String(r?.role2_en || '')
|
||||
const r1zh = String(r?.role1_zh || '')
|
||||
const r2zh = String(r?.role2_zh || '')
|
||||
const dedupeKey = `${r1en}||${r2en}||${r1zh}||${r2zh}`
|
||||
if (!dedupeKey.trim() || seen.has(dedupeKey)) return
|
||||
seen.add(dedupeKey)
|
||||
roles.push({
|
||||
key: dedupeKey,
|
||||
role1_en: r1en,
|
||||
role1_zh: r1zh,
|
||||
role2_en: r2en,
|
||||
role2_zh: r2zh
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
this.setData({ conversationSelectedEvents: list, conversationSelectedEventsMap: map, conversationSuggestedRoles: roles })
|
||||
},
|
||||
onRoleSelect(e: WechatMiniprogram.TouchEvent) {
|
||||
const { index, side } = e.currentTarget.dataset
|
||||
const { selectedRole } = this.data
|
||||
|
||||
if (selectedRole && selectedRole.roleIndex === index && selectedRole.roleSide === side) {
|
||||
this.setData({ selectedRole: null })
|
||||
} else {
|
||||
this.setData({ selectedRole: { roleIndex: index, roleSide: side } })
|
||||
}
|
||||
},
|
||||
onConversationNoteInput(e: any) {
|
||||
const v = String(e?.detail?.value || '')
|
||||
this.setData({ conversationExtraNote: v.slice(0, 200) })
|
||||
},
|
||||
toggleConversationSceneLang() {
|
||||
const cur = this.data.conversationSceneLang || 'zh'
|
||||
const next = cur === 'zh' ? 'en' : 'zh'
|
||||
this.setData({ conversationSceneLang: next })
|
||||
this.updateConversationMessages(this.data.conversationDetail, next, false)
|
||||
},
|
||||
onConversationCustomSceneAdd() {
|
||||
this.setData({ conversationCustomSceneEditing: true, conversationCustomSceneText: '' })
|
||||
},
|
||||
onConversationCustomSceneInput(e: any) {
|
||||
const v = String(e?.detail?.value || '')
|
||||
const text = v.trim()
|
||||
let over = false
|
||||
if (text) {
|
||||
const parts = text.split(/\s+/)
|
||||
if (parts.length > 1) {
|
||||
if (parts.length > 5) {
|
||||
over = true
|
||||
}
|
||||
} else {
|
||||
const chars = Array.from(text)
|
||||
if (chars.length > 12) {
|
||||
over = true
|
||||
}
|
||||
}
|
||||
}
|
||||
this.setData({ conversationCustomSceneText: v, conversationCustomSceneOverLimit: over })
|
||||
},
|
||||
onConversationCustomSceneConfirm() {
|
||||
const raw = this.data.conversationCustomSceneText || ''
|
||||
const name = this.normalizeConversationTagLabel(raw)
|
||||
if (!name) {
|
||||
this.setData({ conversationCustomSceneEditing: false, conversationCustomSceneText: '' })
|
||||
return
|
||||
}
|
||||
const key = `custom:${Date.now()}:${Math.random().toString(36).slice(2, 6)}`
|
||||
const scenes = (this.data.conversationCustomScenes || []).slice()
|
||||
scenes.push({ key, text: name })
|
||||
const list = (this.data.conversationSelectedScenes || []).slice()
|
||||
const map = { ...(this.data.conversationSelectedScenesMap || {}) }
|
||||
if (list.indexOf(key) < 0) {
|
||||
list.push(key)
|
||||
}
|
||||
map[key] = true
|
||||
this.setData({
|
||||
conversationCustomScenes: scenes,
|
||||
conversationCustomSceneEditing: false,
|
||||
conversationCustomSceneText: '',
|
||||
conversationSelectedScenes: list,
|
||||
conversationSelectedScenesMap: map
|
||||
})
|
||||
},
|
||||
onConversationCustomSceneCancel() {
|
||||
this.setData({ conversationCustomSceneEditing: false, conversationCustomSceneText: '' })
|
||||
},
|
||||
onConversationCustomSceneBlur() {
|
||||
this.onConversationCustomSceneConfirm()
|
||||
},
|
||||
onConversationCustomSceneDelete(e: any) {
|
||||
const key = String(e?.currentTarget?.dataset?.key || '')
|
||||
if (!key) return
|
||||
const scenes = (this.data.conversationCustomScenes || []).filter((s: any) => s.key !== key)
|
||||
const list = (this.data.conversationSelectedScenes || []).slice()
|
||||
const idx = list.indexOf(key)
|
||||
if (idx >= 0) {
|
||||
list.splice(idx, 1)
|
||||
}
|
||||
const map = { ...(this.data.conversationSelectedScenesMap || {}) }
|
||||
delete map[key]
|
||||
this.setData({
|
||||
conversationCustomScenes: scenes,
|
||||
conversationSelectedScenes: list,
|
||||
conversationSelectedScenesMap: map
|
||||
})
|
||||
},
|
||||
onConversationCustomEventAdd() {
|
||||
this.setData({
|
||||
conversationCustomEventEditing: true,
|
||||
conversationCustomEventText: ''
|
||||
})
|
||||
},
|
||||
|
||||
async onSendMessage(e: any) {
|
||||
const content = e.detail?.value || this.data.chatInputValue
|
||||
if (!content || !content.trim()) return
|
||||
|
||||
const sessionId = this.data.conversationLatestSession?.session_id || this.data.conversationLatestSession?.id
|
||||
if (!sessionId) {
|
||||
wx.showToast({ title: '会话无效', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const newMsg = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', data: content }]
|
||||
}
|
||||
const messages = [...(this.data.conversationMessages || []), newMsg]
|
||||
this.setData({
|
||||
conversationMessages: messages,
|
||||
chatInputValue: '',
|
||||
replyLoading: true
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await apiManager.replyQaConversation(sessionId, content)
|
||||
if (res && res.task_id) {
|
||||
this.startConversationPolling(res.task_id, sessionId, false, true)
|
||||
} else {
|
||||
wx.showToast({ title: '回复失败', icon: 'none' })
|
||||
this.setData({ replyLoading: false })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Reply failed:', err)
|
||||
wx.showToast({ title: '发送失败', icon: 'none' })
|
||||
this.setData({ replyLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
onChatInput(e: any) {
|
||||
this.setData({ chatInputValue: e.detail?.value || '' })
|
||||
},
|
||||
|
||||
onChatCloseTap(e: any) {
|
||||
this.setData({ isChatInputVisible: false })
|
||||
},
|
||||
|
||||
onChatBlur(e: any) {
|
||||
this.setData({ isChatInputVisible: false, scrollIntoView: '' })
|
||||
setTimeout(() => {
|
||||
this.setData({ scrollIntoView: 'bottom-anchor' })
|
||||
}, 300)
|
||||
},
|
||||
|
||||
showChatInput() {
|
||||
if (!this.data.conversationLatestSession) {
|
||||
return
|
||||
}
|
||||
this.setData({ isChatInputVisible: true, scrollIntoView: '' })
|
||||
setTimeout(() => {
|
||||
this.setData({ scrollIntoView: 'bottom-anchor' })
|
||||
logger.info('Scroll to bottom anchor')
|
||||
}, 2000)
|
||||
},
|
||||
|
||||
updateConversationMessages(detail: any, lang: string, append: boolean = false) {
|
||||
if (!detail || !detail.messages) {
|
||||
if (!append) {
|
||||
this.setData({ conversationMessages: [] })
|
||||
}
|
||||
return
|
||||
}
|
||||
const messages = detail.messages.map((item: any) => {
|
||||
const contentObj = item.content || {}
|
||||
let text = ''
|
||||
if (item.role === 'user') {
|
||||
text = contentObj.text || ''
|
||||
} else {
|
||||
text = contentObj.response_en || ''
|
||||
// if (lang === 'zh') {
|
||||
// text = contentObj.response_zh || ''
|
||||
// } else {
|
||||
// text = contentObj.response_en || ''
|
||||
// }
|
||||
}
|
||||
|
||||
return {
|
||||
role: item.role,
|
||||
content: [{ type: 'text', data: text }]
|
||||
}
|
||||
})
|
||||
|
||||
if (append) {
|
||||
const current = this.data.conversationMessages || []
|
||||
this.setData({ conversationMessages: [...current, ...messages] })
|
||||
} else {
|
||||
this.setData({ conversationMessages: messages })
|
||||
}
|
||||
},
|
||||
|
||||
onConversationCustomEventInput(e: any) {
|
||||
const v = String(e?.detail?.value || '')
|
||||
const text = v.trim()
|
||||
let over = false
|
||||
if (text) {
|
||||
const parts = text.split(/\s+/)
|
||||
if (parts.length > 1) {
|
||||
if (parts.length > 5) {
|
||||
over = true
|
||||
}
|
||||
} else {
|
||||
const chars = Array.from(text)
|
||||
if (chars.length > 12) {
|
||||
over = true
|
||||
}
|
||||
}
|
||||
}
|
||||
this.setData({ conversationCustomEventText: v, conversationCustomEventOverLimit: over })
|
||||
},
|
||||
onConversationCustomEventConfirm() {
|
||||
const raw = this.data.conversationCustomEventText || ''
|
||||
const name = this.normalizeConversationTagLabel(raw)
|
||||
if (!name) {
|
||||
this.setData({ conversationCustomEventEditing: false, conversationCustomEventText: '' })
|
||||
return
|
||||
}
|
||||
const key = `custom:${Date.now()}:${Math.random().toString(36).slice(2, 6)}`
|
||||
const events = (this.data.conversationCustomEvents || []).slice()
|
||||
events.push({ key, text: name })
|
||||
const list = (this.data.conversationSelectedEvents || []).slice()
|
||||
const map = { ...(this.data.conversationSelectedEventsMap || {}) }
|
||||
if (list.indexOf(key) < 0) {
|
||||
list.push(key)
|
||||
}
|
||||
map[key] = true
|
||||
this.setData({
|
||||
conversationCustomEvents: events,
|
||||
conversationCustomEventEditing: false,
|
||||
conversationCustomEventText: '',
|
||||
conversationSelectedEvents: list,
|
||||
conversationSelectedEventsMap: map
|
||||
})
|
||||
},
|
||||
normalizeConversationTagLabel(raw: string) {
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return ''
|
||||
const parts = text.split(/\s+/)
|
||||
if (parts.length > 1) {
|
||||
const limited = parts.slice(0, 5)
|
||||
return limited.join(' ')
|
||||
}
|
||||
const chars = Array.from(text)
|
||||
if (chars.length > 12) {
|
||||
return chars.slice(0, 12).join('')
|
||||
}
|
||||
return text
|
||||
},
|
||||
onConversationCustomEventBlur() {
|
||||
this.onConversationCustomEventConfirm()
|
||||
},
|
||||
onConversationCustomEventDelete(e: any) {
|
||||
const key = String(e?.currentTarget?.dataset?.key || '')
|
||||
if (!key) return
|
||||
const events = (this.data.conversationCustomEvents || []).filter((s: any) => s.key !== key)
|
||||
const list = (this.data.conversationSelectedEvents || []).slice()
|
||||
const idx = list.indexOf(key)
|
||||
if (idx >= 0) {
|
||||
list.splice(idx, 1)
|
||||
}
|
||||
const map = { ...(this.data.conversationSelectedEventsMap || {}) }
|
||||
delete map[key]
|
||||
this.setData({
|
||||
conversationCustomEvents: events,
|
||||
conversationSelectedEvents: list,
|
||||
conversationSelectedEventsMap: map
|
||||
})
|
||||
},
|
||||
onStartConversationTap() {
|
||||
const run = async () => {
|
||||
try {
|
||||
const imageId = this.data.imageId
|
||||
if (!imageId) {
|
||||
wx.showToast({ title: '缺少图片信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const setting = this.data.conversationSetting as any
|
||||
const scenesSelected = (this.data.conversationSelectedScenes || []).slice()
|
||||
const eventsSelected = (this.data.conversationSelectedEvents || []).slice()
|
||||
const customScenes = this.data.conversationCustomScenes || []
|
||||
const customEvents = this.data.conversationCustomEvents || []
|
||||
const selectedRole = this.data.selectedRole
|
||||
const rolesList = this.data.conversationSuggestedRoles || []
|
||||
const noteRaw = this.data.conversationExtraNote || ''
|
||||
const info = noteRaw.trim()
|
||||
|
||||
let sceneList: any[] = []
|
||||
let eventList: any[] = []
|
||||
let userRole = {}
|
||||
let assistantRole = {}
|
||||
let style = {}
|
||||
|
||||
const hasScene = scenesSelected.length > 0
|
||||
const hasEvent = eventsSelected.length > 0
|
||||
const hasRole = !!selectedRole && !!rolesList[selectedRole.roleIndex]
|
||||
const hasInfo = !!info
|
||||
|
||||
if (!hasScene && !hasEvent && !hasRole && !hasInfo) {
|
||||
if (!setting) {
|
||||
wx.showToast({ title: '请先加载对话设置', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const allScenes = Array.isArray(setting.all_possible_scenes) ? setting.all_possible_scenes : []
|
||||
const allEvents = Array.isArray(setting.all_possible_events) ? setting.all_possible_events : []
|
||||
if (!allEvents.length) {
|
||||
wx.showToast({ title: '暂无可用事件', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const eventsWithRoles = allEvents.filter((ev: any) => Array.isArray(ev.suggested_roles) && ev.suggested_roles.length)
|
||||
const baseEventList = eventsWithRoles.length ? eventsWithRoles : allEvents
|
||||
const randEv = baseEventList[Math.floor(Math.random() * baseEventList.length)]
|
||||
if (randEv && randEv.event_en) {
|
||||
eventList = [String(randEv.event_en)]
|
||||
}
|
||||
if (randEv && randEv.style_en) {
|
||||
style = String(randEv.style_en)
|
||||
}
|
||||
if (randEv && typeof randEv.scene_en === 'string' && randEv.scene_en) {
|
||||
sceneList = [String(randEv.scene_en)]
|
||||
} else if (allScenes.length) {
|
||||
const randScene = allScenes[Math.floor(Math.random() * allScenes.length)]
|
||||
if (randScene && randScene.scene_en) {
|
||||
sceneList = [String(randScene.scene_en)]
|
||||
}
|
||||
}
|
||||
const sr = Array.isArray((randEv as any).suggested_roles) ? (randEv as any).suggested_roles : []
|
||||
if (sr.length) {
|
||||
const pair = sr[Math.floor(Math.random() * sr.length)] || {}
|
||||
const r1 = String(pair.role1_en || '')
|
||||
const r2 = String(pair.role2_en || '')
|
||||
if (r1 && r2) {
|
||||
userRole = r1
|
||||
assistantRole = r2
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sceneList = scenesSelected.map((s) => {
|
||||
if (s.startsWith('custom:')) {
|
||||
const found = customScenes.find((cs: any) => cs.key === s)
|
||||
if (found && found.text) {
|
||||
return { en: String(found.text), zh: String(found.text) }
|
||||
}
|
||||
} else {
|
||||
const allScenes = Array.isArray(setting.all_possible_scenes) ? setting.all_possible_scenes : []
|
||||
const found = allScenes.find((item: any) => item.scene_en === s)
|
||||
if (found) {
|
||||
return { en: String(found.scene_en), zh: String(found.scene_zh || found.scene_en) }
|
||||
}
|
||||
}
|
||||
return { en: String(s), zh: String(s) }
|
||||
}).filter((e) => !!e.en)
|
||||
|
||||
const allEvents = Array.isArray(setting.all_possible_events) ? setting.all_possible_events : []
|
||||
eventList = eventsSelected.map((evKey) => {
|
||||
if (evKey.startsWith('custom:')) {
|
||||
const found = customEvents.find((ce: any) => ce.key === evKey)
|
||||
if (found && found.text) {
|
||||
return { en: String(found.text), zh: String(found.text) }
|
||||
}
|
||||
} else {
|
||||
const matchedEv = allEvents.find((e: any) => e.event_en === evKey)
|
||||
if (matchedEv) {
|
||||
// 如果找到了事件对象,也提取 style
|
||||
if (matchedEv.style_en) {
|
||||
style = { en: String(matchedEv.style_en), zh: String(matchedEv.style_zh || matchedEv.style_en) }
|
||||
}
|
||||
return { en: String(matchedEv.event_en), zh: String(matchedEv.event_zh || matchedEv.event_en) }
|
||||
}
|
||||
}
|
||||
return { en: String(evKey), zh: String(evKey) }
|
||||
}).filter((e) => !!e.en)
|
||||
|
||||
// 如果没有选中场景,但选中了事件,且事件有默认场景,则使用默认场景
|
||||
if (sceneList.length === 0 && eventList.length > 0) {
|
||||
// 尝试从第一个事件中获取关联场景
|
||||
const firstEventKey = eventsSelected[0]
|
||||
if (!firstEventKey.startsWith('custom:')) {
|
||||
const foundEvent = allEvents.find((item: any) => item.event_en === firstEventKey)
|
||||
if (foundEvent && foundEvent.scene_en) {
|
||||
sceneList = [{ en: String(foundEvent.scene_en), zh: String(foundEvent.scene_zh || foundEvent.scene_en) }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eventList.length > 0) {
|
||||
const firstEventKey = eventsSelected[0]
|
||||
if (!firstEventKey.startsWith('custom:')) {
|
||||
const foundEvent = allEvents.find((item: any) => item.event_en === firstEventKey)
|
||||
if (foundEvent && foundEvent.style_en) {
|
||||
style = { en: String(foundEvent.style_en), zh: String(foundEvent.style_zh || foundEvent.style_en) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRole && selectedRole) {
|
||||
const idx = selectedRole.roleIndex
|
||||
const side = selectedRole.roleSide
|
||||
const pair = rolesList[idx]
|
||||
if (pair) {
|
||||
if (side === 1) {
|
||||
userRole = { en: String(pair.role1_en), zh: String(pair.role1_zh || pair.role1_en) }
|
||||
assistantRole = { en: String(pair.role2_en), zh: String(pair.role2_zh || pair.role2_en) }
|
||||
} else {
|
||||
userRole = { en: String(pair.role2_en), zh: String(pair.role2_zh || pair.role2_en) }
|
||||
assistantRole = { en: String(pair.role1_en), zh: String(pair.role1_zh || pair.role1_en) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const levelKey = this.data.conversationDifficulty || 'easy'
|
||||
let level = levelKey
|
||||
|
||||
const payload: any = {
|
||||
image_id: imageId,
|
||||
level,
|
||||
scene: sceneList,
|
||||
event: eventList,
|
||||
info: hasInfo ? info : undefined
|
||||
}
|
||||
if (userRole) payload.user_role = userRole
|
||||
if (assistantRole) payload.assistant_role = assistantRole
|
||||
if (style) payload.style = style
|
||||
|
||||
// wx.showLoading({ title: '启动对话...', mask: true })
|
||||
if (info) {
|
||||
payload.info = info
|
||||
}
|
||||
logger.info('开始对话 payload:', payload)
|
||||
|
||||
this.setData({ loadingMaskVisible: true, statusText: '正在创建对话...' })
|
||||
const res = await apiManager.startQaConversation(payload)
|
||||
|
||||
if (res && res.task_id && res.session_id) {
|
||||
this.startConversationPolling(res.task_id, res.session_id, false)
|
||||
} else {
|
||||
this.setData({ loadingMaskVisible: false })
|
||||
// wx.showToast({ title: '已发起对话', icon: 'success' })
|
||||
}
|
||||
} catch (err) {
|
||||
this.setData({ loadingMaskVisible: false })
|
||||
logger.error('开始对话失败', err)
|
||||
const msg = (err as any)?.message || '开始对话失败'
|
||||
wx.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
}
|
||||
run()
|
||||
},
|
||||
async getQuestionAudio(questionId: string) {
|
||||
try {
|
||||
const map = this.data.audioUrlMap || {}
|
||||
@@ -1674,6 +2356,67 @@ Page<IData, IPageInstance>({
|
||||
})
|
||||
}
|
||||
},
|
||||
onHistoryTap() {
|
||||
const run = async () => {
|
||||
try {
|
||||
const { imageId } = this.data
|
||||
if (!imageId) {
|
||||
wx.showToast({ title: '缺少图片信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.setData({ visible: true })
|
||||
const res = await apiManager.listQaConversations(imageId)
|
||||
logger.info('获取到的历史聊天对话记录:', res)
|
||||
|
||||
// Assuming res is { list: [...], total: ... } or just array
|
||||
const list = Array.isArray(res) ? res : (res.list || [])
|
||||
|
||||
const sidebar = list.map((item: any) => ({
|
||||
title: item.title || item.summary || (item.created_time ? `对话 ${item.created_time}` : `对话 ${item.id}`),
|
||||
...item
|
||||
}))
|
||||
|
||||
this.setData({ sidebar })
|
||||
|
||||
} catch (err) {
|
||||
logger.error('获取历史聊天对话记录失败:', err)
|
||||
wx.showToast({ title: '获取历史记录失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
run()
|
||||
},
|
||||
|
||||
chatItemClick(e: any) {
|
||||
const { item } = e.detail
|
||||
if (!item || !item.id) return
|
||||
|
||||
const sessionId = item.id
|
||||
this.setData({ visible: false })
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
this.setData({ loadingMaskVisible: true, statusText: '加载对话...' })
|
||||
const detail = await apiManager.getQaConversationLatest(sessionId)
|
||||
logger.info('Loaded conversation detail:', detail)
|
||||
|
||||
this.setData({
|
||||
conversationDetail: detail,
|
||||
conversationViewMode: 'chat',
|
||||
loadingMaskVisible: false,
|
||||
statusText: '加载完成',
|
||||
conversationLatestSession: { id: sessionId, status: 'ongoing' },
|
||||
replyLoading: false
|
||||
})
|
||||
this.updateConversationMessages(detail, this.data.conversationSceneLang || 'zh', false)
|
||||
} catch (err) {
|
||||
logger.error('Failed to load conversation:', err)
|
||||
wx.showToast({ title: '加载对话失败', icon: 'none' })
|
||||
this.setData({ loadingMaskVisible: false })
|
||||
}
|
||||
}
|
||||
run()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<view class="process-dot {{processDotClasses[index]}} {{index === currentIndex ? 'current' : ''}}"></view>
|
||||
</block>
|
||||
</view>
|
||||
<scroll-view class="question-scroll {{conversationViewMode === 'chat' && isChatInputVisible ? 'chat-input-mode-scroll' : 'chat-mode-scroll'}}" scroll-y="true" scroll-into-view="{{scrollIntoView}}" scroll-with-animation>
|
||||
<view class="image-card" wx:if="{{questionMode !== 'variation'}}">
|
||||
<image wx:if="{{imageLocalUrl}}" class="image" src="{{imageLocalUrl}}" mode="aspectFill" bindtap="previewImage" bindload="onImageLoad" binderror="onImageError"></image>
|
||||
<view class="view-full" wx:if="{{imageLocalUrl}}" bindtap="previewImage">
|
||||
@@ -34,8 +35,6 @@
|
||||
<view class="question-title" wx:if="{{questionMode !== 'conversation'}}">
|
||||
<text wx:for="{{questionWords}}" wx:key="index" class="word-item" data-word="{{item}}" bindtap="handleWordClick">{{item}}</text>
|
||||
</view>
|
||||
<!-- <view class="progress-text">{{progressText}}</view> -->
|
||||
<scroll-view class="question-scroll" scroll-y="true">
|
||||
<view class="question-content {{modeAnim}}" wx:if="{{questionMode === 'choice'}}">
|
||||
<view class="choice-title">Select the correct answer ({{selectedCount}}/{{choiceRequiredCount}})</view>
|
||||
<view class="option-list">
|
||||
@@ -61,20 +60,6 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="question-content {{modeAnim}}" wx:if="{{questionMode === 'free_text'}}">
|
||||
<view class="tip-row">
|
||||
<t-icon name="info-circle" size="32rpx" />
|
||||
<text class="tip-text">Tip: Be specific about the object type.</text>
|
||||
</view>
|
||||
<view class="answer-row">
|
||||
<text class="answer-label">Your Answer</text>
|
||||
<text class="answer-hint" bindtap="onHintTap">Need a hint?</text>
|
||||
</view>
|
||||
<view class="input-card">
|
||||
<textarea class="answer-input" placeholder="Type your answer here..." maxlength="200" bindinput="inputChange" value="{{freeTextInput}}" />
|
||||
<text class="input-type">English input</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="question-content {{modeAnim}}" wx:if="{{questionMode === 'variation'}}">
|
||||
<view class="variation-container">
|
||||
<view class="variation-grid">
|
||||
@@ -101,15 +86,194 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="conversation-content {{modeAnim}}" wx:if="{{questionMode === 'conversation' && conversationViewMode === 'setup'}}">
|
||||
<view class="conversation-section">
|
||||
<view class="conversation-label">难度选择</view>
|
||||
<view class="conversation-tags">
|
||||
<t-check-tag
|
||||
wx:for="{{difficultyOptions}}"
|
||||
wx:key="value"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
data-level="{{item.value}}"
|
||||
checked="{{conversationDifficulty === item.value}}"
|
||||
bindtap="selectConversationDifficulty"
|
||||
content="{{ [conversationSceneLang === 'zh' ? item.label_zh : item.label_en] }}"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="conversation-section">
|
||||
<view class="conversation-label">场景标签</view>
|
||||
<view class="conversation-tags" wx:if="{{conversationSetting && conversationSetting.all_possible_scenes && conversationSetting.all_possible_scenes.length}}">
|
||||
<t-check-tag
|
||||
wx:for="{{conversationSetting.all_possible_scenes}}"
|
||||
wx:key="scene_en"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
data-scene="{{item.scene_en}}"
|
||||
checked="{{conversationSelectedScenesMap && conversationSelectedScenesMap[item.scene_en]}}"
|
||||
bindtap="toggleConversationScene"
|
||||
>
|
||||
{{conversationSceneLang === 'zh' ? item.scene_zh : item.scene_en}}
|
||||
</t-check-tag>
|
||||
<t-check-tag
|
||||
wx:for="{{conversationCustomScenes}}"
|
||||
wx:key="key"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
data-scene="{{item.key}}"
|
||||
checked="{{conversationSelectedScenesMap && conversationSelectedScenesMap[item.key]}}"
|
||||
bindtap="toggleConversationScene"
|
||||
>
|
||||
<view class="custom-scene-tag">
|
||||
<text class="custom-scene-text">{{item.text}}</text>
|
||||
<t-icon name="close" size="24rpx" data-key="{{item.key}}" catchtap="onConversationCustomSceneDelete" />
|
||||
</view>
|
||||
</t-check-tag>
|
||||
<view wx:if="{{conversationCustomSceneEditing}}" class="conversation-custom-scene-input-wrapper">
|
||||
<input
|
||||
class="conversation-custom-scene-input {{conversationCustomSceneOverLimit ? 'conversation-custom-scene-input-error' : ''}}"
|
||||
value="{{conversationCustomSceneText}}"
|
||||
placeholder="自定义场景"
|
||||
maxlength="60"
|
||||
focus="{{true}}"
|
||||
bindinput="onConversationCustomSceneInput"
|
||||
bindblur="onConversationCustomSceneBlur"
|
||||
/>
|
||||
</view>
|
||||
<t-check-tag
|
||||
wx:if="{{!conversationCustomSceneEditing}}"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
bindtap="onConversationCustomSceneAdd"
|
||||
>
|
||||
+
|
||||
</t-check-tag>
|
||||
</view>
|
||||
</view>
|
||||
<view class="conversation-section">
|
||||
<view class="conversation-label">事件标签</view>
|
||||
<view class="conversation-tags" wx:if="{{conversationSetting && conversationSetting.all_possible_events && conversationSetting.all_possible_events.length}}">
|
||||
<t-check-tag
|
||||
wx:for="{{conversationSetting.all_possible_events}}"
|
||||
wx:key="event_en"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
data-event="{{item.event_en}}"
|
||||
checked="{{conversationSelectedEventsMap && conversationSelectedEventsMap[item.event_en]}}"
|
||||
bindtap="toggleConversationEvent"
|
||||
>
|
||||
{{conversationSceneLang === 'zh' ? item.event_zh : item.event_en}}
|
||||
</t-check-tag>
|
||||
<t-check-tag
|
||||
wx:for="{{conversationCustomEvents}}"
|
||||
wx:key="key"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
data-event="{{item.key}}"
|
||||
checked="{{conversationSelectedEventsMap && conversationSelectedEventsMap[item.key]}}"
|
||||
bindtap="toggleConversationEvent"
|
||||
>
|
||||
<view class="custom-scene-tag">
|
||||
<text class="custom-scene-text">{{item.text}}</text>
|
||||
<t-icon name="close" size="24rpx" data-key="{{item.key}}" catchtap="onConversationCustomEventDelete" />
|
||||
</view>
|
||||
</t-check-tag>
|
||||
<view wx:if="{{conversationCustomEventEditing}}" class="conversation-custom-scene-input-wrapper">
|
||||
<input
|
||||
class="conversation-custom-scene-input {{conversationCustomEventOverLimit ? 'conversation-custom-scene-input-error' : ''}}"
|
||||
value="{{conversationCustomEventText}}"
|
||||
placeholder="自定义事件"
|
||||
maxlength="60"
|
||||
focus="{{true}}"
|
||||
bindinput="onConversationCustomEventInput"
|
||||
bindblur="onConversationCustomEventBlur"
|
||||
/>
|
||||
</view>
|
||||
<t-check-tag
|
||||
wx:if="{{!conversationCustomEventEditing}}"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
bindtap="onConversationCustomEventAdd"
|
||||
>
|
||||
+
|
||||
</t-check-tag>
|
||||
</view>
|
||||
</view>
|
||||
<view class="conversation-section" wx:if="{{conversationSuggestedRoles && conversationSuggestedRoles.length}}">
|
||||
<view class="conversation-label">角色扮演</view>
|
||||
<view class="conversation-tags">
|
||||
<block wx:for="{{conversationSuggestedRoles}}" wx:key="key" wx:for-index="idx">
|
||||
<view style="display: flex; align-items: center;">
|
||||
<t-check-tag
|
||||
checked="{{selectedRole && selectedRole.roleIndex === idx && selectedRole.roleSide === 1}}"
|
||||
bind:change="onRoleSelect"
|
||||
data-index="{{idx}}"
|
||||
data-side="{{1}}"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
>{{conversationSceneLang === 'zh' ? item.role1_zh : item.role1_en}}</t-check-tag>
|
||||
<t-check-tag
|
||||
checked="{{selectedRole && selectedRole.roleIndex === idx && selectedRole.roleSide === 2}}"
|
||||
bind:change="onRoleSelect"
|
||||
data-index="{{idx}}"
|
||||
data-side="{{2}}"
|
||||
variant="outline"
|
||||
size="medium"
|
||||
>{{conversationSceneLang === 'zh' ? item.role2_zh : item.role2_en}}</t-check-tag>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="submit-row">
|
||||
<view class="conversation-section">
|
||||
<view class="conversation-label-row">
|
||||
<text class="conversation-label">额外说明</text>
|
||||
<text class="conversation-count">{{(conversationExtraNote && conversationExtraNote.length) || 0}}/200</text>
|
||||
</view>
|
||||
<view class="conversation-note-card">
|
||||
<textarea
|
||||
class="conversation-note-input"
|
||||
placeholder="例如:描述一下图片中的人物关系或具体发生的事件,这将帮助 AI 更好地生成对话内容..."
|
||||
maxlength="200"
|
||||
value="{{conversationExtraNote}}"
|
||||
bindinput="onConversationNoteInput"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="conversation-start-row">
|
||||
<button class="conversation-start-btn" bindtap="onStartConversationTap">
|
||||
开始对话
|
||||
<t-icon name="chat" size="32rpx" style="margin-left: 12rpx;" />
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="conversation-content {{modeAnim}}" wx:if="{{questionMode === 'conversation' && conversationViewMode === 'chat'}}">
|
||||
<block wx:if="{{conversationMessages && conversationMessages.length}}">
|
||||
<view class="conversation-block {{item.role === 'user' ? 'user' : 'default'}}" wx:for="{{conversationMessages}}" wx:key="index"
|
||||
>
|
||||
<t-chat-message
|
||||
role="{{item.role}}"
|
||||
placement="{{item.role === 'user' ? 'right' : 'left'}}"
|
||||
content="{{item.content}}"
|
||||
/>
|
||||
</view>
|
||||
</block>
|
||||
<view class="conversation-block" wx:if="{{replyLoading}}">
|
||||
<t-chat-loading animation="dots" />
|
||||
</view>
|
||||
<view id="bottom-anchor" style="height: 1rpx;"></view>
|
||||
</view>
|
||||
|
||||
<view class="submit-row" wx:if="{{questionMode !== 'conversation'}}">
|
||||
<button class="submit-btn" bindtap="onSubmitTap" disabled="{{submitDisabled}}" wx:if="{{retryDisabled}}">提交</button>
|
||||
<button class="submit-btn" bindtap="onRetryTap" disabled="{{retryDisabled}}" wx:else>重试</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-bar {{contentVisible ? 'show' : ''}}">
|
||||
<view class="bottom-bar {{contentVisible ? 'show' : ''}}" wx:if="{{questionMode !== 'conversation'}}">
|
||||
<t-icon name="chevron-left" class="bottom-btn {{currentIndex <= 0 ? 'disabled' : ''}}" size="48rpx" bind:tap="onPrevTap" />
|
||||
<t-icon name="{{isPlaying ? 'pause' : 'play'}}" class="bottom-btn" size="48rpx" bind:tap="playStandardVoice" />
|
||||
<!-- <t-icon name="swap" class="bottom-btn" size="48rpx" bind:tap="toggleMode" /> -->
|
||||
@@ -117,6 +281,43 @@
|
||||
<t-icon name="{{nextButtonIcon || 'chevron-right'}}" class="bottom-btn {{(qaList && (currentIndex >= qaList.length - 1)) ? 'disabled' : ''}}" size="48rpx" bind:tap="onNextTap" />
|
||||
</view>
|
||||
|
||||
<t-drawer
|
||||
visible="{{visible}}"
|
||||
placement="right"
|
||||
items="{{conversationList}}"
|
||||
bind:overlay-click="overlayClick"
|
||||
bind:item-click="chatItemClick"
|
||||
></t-drawer>
|
||||
|
||||
<view class="chat-sender-wrapper {{isChatInputVisible ? 'show' : ''}}" wx:if="{{questionMode === 'conversation' && conversationViewMode === 'chat'}}">
|
||||
<t-chat-sender
|
||||
placeholder="请输入..."
|
||||
loading="{{replyLoading}}"
|
||||
focus="{{isChatInputVisible}}"
|
||||
renderPresets="{{renderPresets}}"
|
||||
bind:send="onSendMessage"
|
||||
bind:input="onChatInput"
|
||||
bind:blur="onChatBlur"
|
||||
>
|
||||
<view slot="footer-prefix" class="footer-prefix">
|
||||
<!-- <view class="deep-think-block {{deepThinkActive ? 'active' : ''}}" bind:tap="onDeepThinkTap">
|
||||
<t-icon name="system-sum" size="40rpx" />
|
||||
<text class="deep-think-text">深度思考</text>
|
||||
</view> -->
|
||||
<view class="chat-icon-block" bind:tap="onChatCloseTap">
|
||||
<t-icon name="close-circle" size="64rpx" color="#dcdcdc"/>
|
||||
</view>
|
||||
</view>
|
||||
</t-chat-sender>
|
||||
</view>
|
||||
<view class="bottom-bar {{contentVisible && !isChatInputVisible ? 'show' : ''}}" wx:if="{{questionMode === 'conversation'}}">
|
||||
<t-icon name="translate" class="bottom-btn" size="48rpx" bind:tap="toggleConversationSceneLang" />
|
||||
<t-icon name="keyboard" class="bottom-btn {{conversationLatestSession ? '' : 'disabled'}}" size="48rpx" bind:tap="showChatInput" />
|
||||
<t-icon name="{{conversationLatestSession && conversationViewMode === 'chat' ? 'chat-bubble-add' : 'chat-bubble-1'}}" class="bottom-btn {{conversationLatestSession ? '' : 'disabled'}}" size="48rpx" bind:tap="toggleConversationView" />
|
||||
<t-icon name="fact-check" class="bottom-btn {{resultDisplayed ? '' : 'disabled'}}" size="48rpx" bind:tap="" />
|
||||
<t-icon name="chat-bubble-history" class="bottom-btn" size="48rpx" bind:tap="onHistoryTap" />
|
||||
</view>
|
||||
|
||||
<word-dictionary
|
||||
id="wordDict"
|
||||
visible="{{showDictPopup}}"
|
||||
|
||||
@@ -8,9 +8,61 @@
|
||||
@keyframes fadeInUp { from { opacity: 0; transform: translate3d(0, 12rpx, 0) } to { opacity: 1; transform: translate3d(0, 0, 0) } }
|
||||
@keyframes fadeOutDown { from { opacity: 1; transform: translate3d(0, 0, 0) } to { opacity: 0; transform: translate3d(0, 12rpx, 0) } }
|
||||
|
||||
.question-scroll { padding-bottom: calc(110rpx + env(safe-area-inset-bottom));}
|
||||
.question-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
/* padding-bottom: calc(110rpx + env(safe-area-inset-bottom)); */
|
||||
/* transition: padding-bottom 0.3s ease; */
|
||||
transition: margin-bottom 0.3s ease;
|
||||
}
|
||||
.chat-mode-scroll {
|
||||
/* padding-bottom: calc(110rpx + env(safe-area-inset-bottom)); */
|
||||
margin-bottom: calc(110rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.chat-input-mode-scroll {
|
||||
/* padding-bottom: calc(240rpx + env(safe-area-inset-bottom)); */
|
||||
margin-bottom: calc(240rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.loading-container { padding: 32rpx; }
|
||||
|
||||
.chat-sender-wrapper {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* bottom: calc(120rpx + env(safe-area-inset-bottom)); */
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
/* padding: 16rpx 32rpx; */
|
||||
border-top: 1rpx solid #f0f4f8;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.04);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-sender-wrapper.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.footer-prefix {
|
||||
position: absolute;
|
||||
right: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.chat-icon-block {
|
||||
color: var(--td-text-color-primary);
|
||||
border-radius: 200rpx;
|
||||
border: 2rpx solid var(--td-component-border);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.process-container { width: 100%; display: flex; gap: 16rpx; align-items: center; padding: 8rpx 0; }
|
||||
.process-dot { flex: 1; height: 10rpx; border-radius: 8rpx; background: #e6eef9; transition: all 0.3s ease; border: 3rpx solid transparent; box-sizing: border-box; }
|
||||
.process-dot.dot-0 { background: #d9e6f2; }
|
||||
@@ -46,7 +98,7 @@
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.image-card { position: relative; border-radius: 24rpx; overflow: hidden; height: 360rpx; }
|
||||
.image-card { position: relative; border-radius: 24rpx; overflow: hidden; height: 360rpx; box-shadow: none; }
|
||||
.image { width: 100%; height: 360rpx; border-radius: 24rpx; background: #f5f5f5; }
|
||||
.view-full { position: absolute; right: 16rpx; bottom: 16rpx; display: flex; align-items: center; gap: 8rpx; padding: 8rpx; border-radius: 24rpx; background: rgba(0,0,0,0.4); color: #fff; }
|
||||
.question-title { font-size: 40rpx; font-weight: 700; color: #001858; line-height: 56rpx; width: 100%; display: flex; flex-wrap: wrap; justify-content: flex-start;}
|
||||
@@ -258,6 +310,133 @@
|
||||
border-radius: 16rpx;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.conversation-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32rpx;
|
||||
padding-top: 16rpx;
|
||||
}
|
||||
.conversation-block.user {
|
||||
--td-bg-color-secondarycontainer: #f5f5f5;
|
||||
--td-spacer-2: 0rpx;
|
||||
}
|
||||
.conversation-block.default {
|
||||
--td-spacer-2: 12rpx;
|
||||
width: 85%;
|
||||
background-color: #e0e0e0;
|
||||
padding: 12rpx 20rpx 0 20rpx;
|
||||
border-radius: 0 24rpx 24rpx 24rpx;
|
||||
}
|
||||
.conversation-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.conversation-label {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #001858;
|
||||
}
|
||||
.conversation-segment {
|
||||
display: flex;
|
||||
background: #f5f7fb;
|
||||
border-radius: 999rpx;
|
||||
padding: 6rpx;
|
||||
}
|
||||
.segment-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20rpx 0;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
.segment-item.active {
|
||||
background: #ffffff;
|
||||
color: #00b578;
|
||||
font-weight: 700;
|
||||
}
|
||||
.conversation-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.conversation-custom-scene-input-wrapper {
|
||||
min-width: 160rpx;
|
||||
}
|
||||
.conversation-custom-scene-input {
|
||||
padding: 0rpx 15rpx;
|
||||
border-radius: 8rpx;
|
||||
border: 2rpx dashed #00b578;
|
||||
background: #f5f7fb;
|
||||
font-size: 24rpx;
|
||||
color: #001858;
|
||||
}
|
||||
.conversation-custom-scene-input-error {
|
||||
border-color: #ff4d4f;
|
||||
}
|
||||
.custom-scene-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.custom-scene-text {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.tag-item {
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
.tag-item.active {
|
||||
background: #e6fff4;
|
||||
color: #00b578;
|
||||
}
|
||||
.conversation-label-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.conversation-count {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
.conversation-note-card {
|
||||
border-radius: 24rpx;
|
||||
background: #f5f7fb;
|
||||
padding: 20rpx;
|
||||
}
|
||||
.conversation-note-input {
|
||||
width: 100%;
|
||||
min-height: 200rpx;
|
||||
font-size: 28rpx;
|
||||
color: #001858;
|
||||
}
|
||||
.conversation-start-row {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.conversation-start-btn {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #00b578;
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.conversation-start-btn::after {
|
||||
border: none;
|
||||
}
|
||||
.detail-row.correct { background: #eafaf2; }
|
||||
.detail-row.incorrect { background: #fff7f6; }
|
||||
.detail-row.missing { background: #eaf2ff; }
|
||||
|
||||
@@ -143,6 +143,54 @@ export interface IQaResult {
|
||||
updated_time?: string
|
||||
}
|
||||
|
||||
export interface IQaConversationCoreObject {
|
||||
object_en: string
|
||||
object_zh: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface IQaConversationEvent {
|
||||
event_en: string
|
||||
event_zh: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface IQaConversationScene {
|
||||
scene_en: string
|
||||
scene_zh: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface IQaConversationSettingPayload {
|
||||
all_possible_events: IQaConversationEvent[]
|
||||
all_possible_scenes: IQaConversationScene[]
|
||||
core_objects: IQaConversationCoreObject[]
|
||||
}
|
||||
|
||||
export interface IQaConversationSession {
|
||||
id: string | number
|
||||
status: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
ext?: any
|
||||
}
|
||||
|
||||
export interface IQaConversationSettingResponse {
|
||||
image_id: string
|
||||
latest_session: IQaConversationSession | null
|
||||
setting: IQaConversationSettingPayload
|
||||
}
|
||||
|
||||
export interface IQaConversationDetail {
|
||||
id: string | number
|
||||
status: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
ext?: any
|
||||
messages?: any[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 单词详情接口
|
||||
export interface ExtendedWordDetail {
|
||||
word: string
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
IQaExerciseQueryResponse,
|
||||
IQaQuestionAttemptAccepted,
|
||||
IQaQuestionTaskStatus,
|
||||
IQaResult
|
||||
IQaResult,
|
||||
IQaConversationSettingResponse,
|
||||
IQaConversationDetail
|
||||
} from '../types/app';
|
||||
import { BASE_URL, USE_CLOUD } from './config';
|
||||
import { cloudConfig } from './cloud.config';
|
||||
@@ -2163,13 +2165,49 @@ class ApiManager {
|
||||
return resp.data
|
||||
}
|
||||
|
||||
async getQaConversationSetting(imageId: string): Promise<any> {
|
||||
const resp = await this.request<any>('/api/v1/qa/conversations/setting', 'POST', {
|
||||
async getQaConversationSetting(imageId: string): Promise<IQaConversationSettingResponse> {
|
||||
const resp = await this.request<IQaConversationSettingResponse>('/api/v1/qa/conversations/setting', 'POST', {
|
||||
image_id: imageId
|
||||
})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
async startQaConversation(payload: {
|
||||
image_id: string | number
|
||||
scene?: Array<{ en: string; zh: string }>
|
||||
event?: Array<{ en: string; zh: string }>
|
||||
user_role?: { en: string; zh: string }
|
||||
assistant_role?: { en: string; zh: string }
|
||||
level?: string
|
||||
info?: string
|
||||
style?: { en: string; zh: string }
|
||||
}): Promise<any> {
|
||||
const resp = await this.request<any>('/api/v1/qa/conversations/start', 'POST', payload)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
async getQaConversationDetail(sessionId: string | number): Promise<IQaConversationDetail> {
|
||||
const resp = await this.request<IQaConversationDetail>(`/api/v1/qa/conversations/${sessionId}`, 'GET')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
async getQaConversationLatest(sessionId: string | number): Promise<IQaConversationDetail> {
|
||||
const resp = await this.request<IQaConversationDetail>(`/api/v1/qa/conversations/${sessionId}/latest`, 'GET')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
async listQaConversations(imageId: string | number, page: number = 1, pageSize: number = 10): Promise<any> {
|
||||
const resp = await this.request<any>(`/api/v1/qa/conversations/${imageId}/list?page=${page}&page_size=${pageSize}`, 'GET')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
async replyQaConversation(sessionId: string | number, content: string): Promise<any> {
|
||||
const resp = await this.request<any>(`/api/v1/qa/conversations/${sessionId}/reply`, 'POST', {
|
||||
content
|
||||
})
|
||||
return resp.data
|
||||
}
|
||||
|
||||
async createQaQuestionAttempt(
|
||||
questionId: string,
|
||||
payload: {
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "25.9.10",
|
||||
"dependencies": {
|
||||
"cos-wx-sdk-v5": "^1.8.0",
|
||||
"tdesign-miniprogram": "^1.11.2"
|
||||
"tdesign-miniprogram": "^1.12.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"miniprogram-api-typings": "^2.8.3-1"
|
||||
@@ -80,9 +80,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tdesign-miniprogram": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://mirrors.huaweicloud.com/repository/npm/tdesign-miniprogram/-/tdesign-miniprogram-1.11.2.tgz",
|
||||
"integrity": "sha512-lXcry3vRa9jHzjpOdIxuIAh7F85kImym82VwLbCqr/TkMhycOsOepx+r1S9fum7u2nsWiYRTV+HuvDHN3KlIuA==",
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmjs.org/tdesign-miniprogram/-/tdesign-miniprogram-1.12.1.tgz",
|
||||
"integrity": "sha512-L2w/6lqVXSCtptvlPGlY91dutMb3dJlx9rFpmpLRtG7c7v1l89rIhvgLRgsaBcwTA5btdB8VXGXpTVj621bv5w==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"cos-wx-sdk-v5": "^1.8.0",
|
||||
"tdesign-miniprogram": "^1.11.2"
|
||||
"tdesign-miniprogram": "^1.12.1"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user