This commit is contained in:
Felix
2026-01-23 12:02:07 +08:00
parent 9d8f6d73ef
commit 0a36bebde5
4 changed files with 462 additions and 45 deletions

View File

@@ -8,6 +8,7 @@
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton",
"t-cell": "tdesign-miniprogram/cell/cell",
"word-dictionary": "../../components/word-dictionary/word-dictionary",
"vx-confetti": "/components/vx-confetti/vx-confetti",
"cloud-image": "../../components/cloud-image/cloud-image",

View File

@@ -126,13 +126,31 @@ interface IData {
scrollIntoView: string
renderPresets: Array<{ name: string; type: string }>
sidebar?: any[]
}
historyList: any[]
historyPage: number
historyHasMore: boolean
historyLoading: boolean
chatPlaceholder: string
showChatSuggestions: boolean
chatSuggestions: Array<{ label: string; text: string }>
inputBottom: number
}
interface IPageInstance {
pollTimer?: number
variationPollTimer?: number
conversationPollTimer?: number
placeholderTimer?: number
suggestionTimer?: number
isSuggestionTouching?: boolean
audioCtx?: WechatMiniprogram.InnerAudioContext
onKeyboardHeightChange: (res: any) => void
startPlaceholderTimer: () => void
startSuggestionTimer: () => void
stopSuggestionTimer: () => void
handleSuggestionTap: (e: any) => void
handleSuggestionTouchStart: () => void
handleSuggestionTouchEnd: () => void
fetchQaExercises: (imageId: string, referrerId?: string) => Promise<void>
fetchVariationExercises: (imageId: string) => Promise<void>
fetchConversationSetting: (imageId: string) => Promise<void>
@@ -140,6 +158,8 @@ interface IPageInstance {
startVariationPolling: (taskId: string, imageId: string) => void
startConversationInitPolling: (taskId: string, imageId: string) => void
startConversationPolling: (taskId: string, sessionId: string, showLoadingMask?: boolean, append?: boolean) => void
loadHistoryData: () => Promise<void>
onHistoryScrollBottom: () => void
initExerciseContent: (exercise: any, session?: IQaExerciseSession) => void
updateActionButtonsState: () => void
shuffleArray: <T>(arr: T[]) => T[]
@@ -314,8 +334,28 @@ Page<IData, IPageInstance>({
conversationMessages: [],
replyLoading: false,
chatInputValue: '',
renderPresets: [ { name: 'send', type: 'icon'} ]
renderPresets: [ { name: 'send', type: 'icon'} ],
historyList: [],
historyPage: 1,
historyHasMore: true,
historyLoading: false,
chatPlaceholder: '请输入...',
showChatSuggestions: false,
chatSuggestions: [],
inputBottom: 0
},
onKeyboardHeightChange(res: any) {
this.setData({ inputBottom: res.height })
},
pollTimer: undefined,
variationPollTimer: undefined,
conversationPollTimer: undefined,
placeholderTimer: undefined,
suggestionTimer: undefined,
audioCtx: undefined,
updateProcessDots() {
const list = this.data.qaList || []
const cache = this.data.qaResultCache || {}
@@ -519,6 +559,7 @@ Page<IData, IPageInstance>({
},
async onLoad(options: Record<string, string>) {
wx.onKeyboardHeightChange(this.onKeyboardHeightChange)
try {
const app = getApp<IAppOption>()
@@ -926,7 +967,7 @@ Page<IData, IPageInstance>({
conversationViewMode: 'chat',
loadingMaskVisible: false,
statusText: '加载完成',
conversationLatestSession: { id: sessionId, status: 'ongoing' },
conversationLatestSession: { id: sessionId, session_id: sessionId, status: 'ongoing' },
replyLoading: false
})
this.updateConversationMessages(detail, this.data.conversationSceneLang || 'zh', append)
@@ -1896,6 +1937,7 @@ Page<IData, IPageInstance>({
},
async onSendMessage(e: any) {
this.stopSuggestionTimer()
const content = e.detail?.value || this.data.chatInputValue
if (!content || !content.trim()) return
@@ -1933,31 +1975,71 @@ Page<IData, IPageInstance>({
},
onChatInput(e: any) {
this.setData({ chatInputValue: e.detail?.value || '' })
const val = e.detail?.value || ''
this.setData({ chatInputValue: val })
if (val) {
// 有内容,清除计时器,保持默认 placeholder
if (this.placeholderTimer) {
clearTimeout(this.placeholderTimer)
this.placeholderTimer = undefined
}
this.setData({ chatPlaceholder: '请输入...' })
} else {
// 内容清空,重新开始计时
this.startPlaceholderTimer()
}
},
onChatCloseTap(e: any) {
this.setData({ isChatInputVisible: false })
this.stopSuggestionTimer()
this.setData({ isChatInputVisible: false, chatPlaceholder: '请输入...' })
if (this.placeholderTimer) {
clearTimeout(this.placeholderTimer)
this.placeholderTimer = undefined
}
},
onChatBlur(e: any) {
this.setData({ isChatInputVisible: false, scrollIntoView: '' })
if (this.isSuggestionTouching) return
this.stopSuggestionTimer()
this.setData({ isChatInputVisible: false, scrollIntoView: '', chatPlaceholder: '请输入...' })
if (this.placeholderTimer) {
clearTimeout(this.placeholderTimer)
this.placeholderTimer = undefined
}
setTimeout(() => {
this.setData({ scrollIntoView: 'bottom-anchor' })
}, 300)
},
showChatInput() {
if (!this.data.conversationLatestSession) {
if (!this.data.conversationLatestSession || this.data.conversationViewMode === 'setup') {
return
}
this.setData({ isChatInputVisible: true, scrollIntoView: '' })
// 开启 placeholder 计时
this.startPlaceholderTimer()
// 开启建议计时
this.startSuggestionTimer()
setTimeout(() => {
this.setData({ scrollIntoView: 'bottom-anchor' })
logger.info('Scroll to bottom anchor')
}, 2000)
},
handleSuggestionTouchStart() {
this.isSuggestionTouching = true
},
handleSuggestionTouchEnd() {
setTimeout(() => {
this.isSuggestionTouching = false
}, 200)
},
updateConversationMessages(detail: any, lang: string, append: boolean = false) {
if (!detail || !detail.messages) {
if (!append) {
@@ -2357,46 +2439,69 @@ 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' })
}
this.setData({
visible: true,
historyList: [],
historyPage: 1,
historyHasMore: true,
historyLoading: false
}, () => {
this.loadHistoryData()
})
},
async loadHistoryData() {
if (this.data.historyLoading || !this.data.historyHasMore) return
this.setData({ historyLoading: true })
try {
const { imageId, historyPage } = this.data
if (!imageId) return
const res = await apiManager.listQaConversations(imageId, historyPage, 20)
logger.info('获取到的历史聊天对话记录:', res)
// Ensure we handle both structure { items: [] } and direct array if api changes
const items = res.items || (Array.isArray(res) ? res : [])
const newHistoryList = historyPage === 1 ? items : this.data.historyList.concat(items)
this.setData({
historyList: newHistoryList,
historyPage: historyPage + 1,
historyHasMore: items.length >= 20,
historyLoading: false
})
} catch (err) {
logger.error('获取历史聊天对话记录失败:', err)
this.setData({ historyLoading: false })
wx.showToast({ title: '加载失败', icon: 'none' })
}
run()
},
onHistoryScrollBottom() {
this.loadHistoryData()
},
chatItemClick(e: any) {
const { item } = e.detail
if (!item || !item.id) return
// Handle both t-drawer item-click (detail.item) and tap on custom view (currentTarget.dataset.item)
const item = e.detail?.item || e.currentTarget?.dataset?.item
if (!item || !item.session_id) return
const sessionId = item.id
const sessionId = item.session_id
this.setData({ visible: false })
// User requirement: "如果点击的 id 和目前正在对话的 id 一致,则什么都不用做"
if (this.data.conversationLatestSession && this.data.conversationLatestSession.id === sessionId) {
return
}
const run = async () => {
try {
this.setData({ loadingMaskVisible: true, statusText: '加载对话...' })
const detail = await apiManager.getQaConversationLatest(sessionId)
const detail = await apiManager.getQaConversationDetail(sessionId)
logger.info('Loaded conversation detail:', detail)
this.setData({
@@ -2404,7 +2509,7 @@ Page<IData, IPageInstance>({
conversationViewMode: 'chat',
loadingMaskVisible: false,
statusText: '加载完成',
conversationLatestSession: { id: sessionId, status: 'ongoing' },
conversationLatestSession: { id: sessionId, session_id: sessionId, status: 'ongoing' },
replyLoading: false
})
this.updateConversationMessages(detail, this.data.conversationSceneLang || 'zh', false)
@@ -2417,11 +2522,139 @@ Page<IData, IPageInstance>({
run()
},
startPlaceholderTimer() {
if (this.placeholderTimer) {
clearTimeout(this.placeholderTimer)
this.placeholderTimer = undefined
}
// 重置为默认提示
this.setData({ chatPlaceholder: '请输入...' })
// 3秒后尝试显示 prompt
this.placeholderTimer = setTimeout(() => {
this.placeholderTimer = undefined
// 获取最后一条 assistant 消息
const messages = this.data.conversationMessages || []
let lastAssistantMsg = null
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') {
lastAssistantMsg = messages[i]
break
}
}
if (lastAssistantMsg) {
// 根据当前语言设置 placeholder
// 注意messages 里的 content 结构可能已经被 updateConversationMessages 处理过,或者保留了原始结构
// 原始结构在 updateConversationMessages 中被 map 成了 { role, content: [{type:'text', data: text}] }
// 但是这里我们需要原始的 prompt_en/zh 字段。
// 查看 updateConversationMessages它似乎没有把原始 prompt 存下来?
// 让我们检查一下 updateConversationMessages 的实现。
// 确实,它只提取了 text。
// 所以我们需要从 conversationDetail.messages 里找,而不是 this.data.conversationMessages
const detailMessages = this.data.conversationDetail?.messages || []
let originalMsg = null
for (let i = detailMessages.length - 1; i >= 0; i--) {
if (detailMessages[i].role === 'assistant') {
originalMsg = detailMessages[i]
break
}
}
if (originalMsg && originalMsg.content) {
const lang = this.data.conversationSceneLang || 'zh'
const prompt = lang === 'zh' ? originalMsg.content.prompt_zh : originalMsg.content.prompt_en
if (prompt) {
this.setData({ chatPlaceholder: prompt })
}
}
}
}, 3000) as any
},
startSuggestionTimer() {
if (this.suggestionTimer) {
clearTimeout(this.suggestionTimer)
this.suggestionTimer = undefined
}
this.setData({ showChatSuggestions: false })
this.suggestionTimer = setTimeout(() => {
this.suggestionTimer = undefined
const detailMessages = this.data.conversationDetail?.messages || []
let lastAssistantMsg = null
for (let i = detailMessages.length - 1; i >= 0; i--) {
if (detailMessages[i].role === 'assistant') {
lastAssistantMsg = detailMessages[i]
break
}
}
if (lastAssistantMsg && lastAssistantMsg.content && lastAssistantMsg.content.alternative_responses) {
const alts = lastAssistantMsg.content.alternative_responses
const lang = this.data.conversationSceneLang || 'zh'
const suggestions: Array<{ label: string; text: string }> = []
// Order: positive, negative, neutral or just iterate keys?
// Prompt example shows: positive, negative, neutral.
// Let's use a specific order if possible, or just keys.
// Prompt example keys: positive, negative, neutral.
const keys = ['positive', 'negative', 'neutral']
keys.forEach(key => {
if (alts[key]) {
const item = alts[key]
suggestions.push({
label: lang === 'zh' ? item.alt_zh : item.alt_en,
text: item.alt_en // Always copy en content
})
}
})
if (suggestions.length > 0) {
this.setData({
chatSuggestions: suggestions,
showChatSuggestions: true
})
}
}
}, 15000) as any
},
stopSuggestionTimer() {
if (this.suggestionTimer) {
clearTimeout(this.suggestionTimer)
this.suggestionTimer = undefined
}
this.setData({ showChatSuggestions: false })
},
handleSuggestionTap(e: any) {
const text = e.currentTarget.dataset.text
if (text) {
this.setData({
chatInputValue: text,
showChatSuggestions: false
})
// Should we focus the input? Usually yes.
// But simply setting value works.
}
},
onUnload() {
wx.offKeyboardHeightChange(this.onKeyboardHeightChange)
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = undefined
}
if (this.placeholderTimer) {
clearTimeout(this.placeholderTimer)
this.placeholderTimer = undefined
}
if (this.suggestionTimer) {
clearTimeout(this.suggestionTimer)
this.suggestionTimer = undefined
}
if (this.audioCtx) {
this.audioCtx.destroy()
this.audioCtx = undefined

View File

@@ -284,14 +284,55 @@
<t-drawer
visible="{{visible}}"
placement="right"
items="{{conversationList}}"
bind:overlay-click="overlayClick"
bind:item-click="chatItemClick"
></t-drawer>
>
<scroll-view scroll-y class="history-drawer-scroll" bindscrolltolower="onHistoryScrollBottom">
<view class="history-list">
<t-cell
wx:for="{{historyList}}"
wx:key="session_id"
title="{{item.created_at}}"
hover
class="history-item {{conversationLatestSession && conversationLatestSession.session_id === item.session_id ? 'active' : ''}}"
bind:click="chatItemClick"
data-item="{{item}}"
>
<view slot="description" class="history-tags">
<block wx:for="{{item.scene}}" wx:for-item="scene" wx:key="index">
<view class="history-tag" wx:if="{{scene.en || scene.zh}}">
{{conversationSceneLang === 'zh' ? (scene.zh || scene.en) : (scene.en || scene.zh)}}
</view>
</block>
<block wx:for="{{item.event}}" wx:for-item="ev" wx:key="index">
<view class="history-tag" wx:if="{{ev.en || ev.zh}}">
{{conversationSceneLang === 'zh' ? (ev.zh || ev.en) : (ev.en || ev.zh)}}
</view>
</block>
</view>
</t-cell>
<view class="history-loading" wx:if="{{historyLoading}}">
<t-loading theme="spinner" size="40rpx" text="加载中..." />
</view>
<view class="history-no-more" wx:if="{{!historyHasMore && historyList.length > 0}}">没有更多内容</view>
<view class="history-no-more" wx:if="{{!historyHasMore && historyList.length === 0}}">暂无历史记录</view>
</view>
</scroll-view>
</t-drawer>
<view class="chat-sender-wrapper {{isChatInputVisible ? 'show' : ''}}" wx:if="{{questionMode === 'conversation' && conversationViewMode === 'chat'}}">
<view class="chat-sender-wrapper {{isChatInputVisible ? 'show' : ''}}" style="bottom: {{inputBottom}}px" wx:if="{{questionMode === 'conversation' && conversationViewMode === 'chat'}}">
<view class="suggestion-bar {{showChatSuggestions ? 'show' : ''}}"
wx:if="{{chatSuggestions.length > 0}}"
bind:touchstart="handleSuggestionTouchStart"
bind:touchend="handleSuggestionTouchEnd">
<scroll-view scroll-x class="suggestion-scroll" enable-flex>
<view class="suggestion-item" wx:for="{{chatSuggestions}}" wx:key="index" bindtap="handleSuggestionTap" data-text="{{item.text}}">
{{item.label}}
</view>
</scroll-view>
</view>
<t-chat-sender
placeholder="请输入..."
value="{{chatInputValue}}"
placeholder="{{chatPlaceholder}}"
loading="{{replyLoading}}"
focus="{{isChatInputVisible}}"
renderPresets="{{renderPresets}}"
@@ -311,8 +352,11 @@
</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" />
<view class="bottom-btn bottom-button-img-wrap" bind:tap="toggleConversationSceneLang">
<t-icon name="translate" class="trans-button left-half {{conversationSceneLang === 'en' ? 'trans-active' : 'trans-deactive'}}" size="48rpx" />
<t-icon name="translate" class="trans-button right-half {{conversationSceneLang === 'zh' ? 'trans-active' : 'trans-deactive'}}" size="48rpx" />
</view>
<t-icon name="keyboard" class="bottom-btn {{conversationLatestSession && conversationViewMode !== 'setup' ? '' : '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 File

@@ -698,3 +698,142 @@
opacity: 0.7;
background-color: rgba(0,0,0,0.05);
}
.history-drawer-scroll {
height: 100vh;
width: 100%;
box-sizing: border-box;
}
.history-list {
--td-cell-vertical-padding: 12rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
}
.history-item {
background: #f9f9f9;
}
.history-item:active {
background: #f0f0f0;
}
.history-item.active {
background: #eafaf2;
border-color: #21cc80;
}
.history-title {
font-size: 28rpx;
color: #333;
margin-bottom: 16rpx;
font-weight: 600;
}
.history-item.active .history-title {
color: #0052d9; /* Highlight active session title in blue */
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.history-tag {
font-size: 24rpx;
color: #666;
background: #fff;
padding: 4rpx 12rpx;
border-radius: 8rpx;
border: 1rpx solid #e0e0e0;
}
.history-loading {
display: flex;
justify-content: center;
padding: 24rpx 0;
}
.history-no-more {
text-align: center;
font-size: 24rpx;
color: #999;
padding: 24rpx 0;
}
.suggestion-bar {
height: 0;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
overflow: hidden;
background: #f9f9f9;
}
.suggestion-bar.show {
height: 60rpx;
opacity: 1;
visibility: visible;
padding: 12rpx 0;
}
.suggestion-scroll {
display: flex;
white-space: nowrap;
padding: 0 24rpx;
box-sizing: border-box;
width: 100%;
align-items: center;
}
.suggestion-item {
display: block;
background: #fff;
border: 1rpx solid #e7e7e7;
border-radius: 32rpx;
padding: 0 24rpx;
font-size: 26rpx;
color: #333;
margin-right: 16rpx;
max-width: 600rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
height: 56rpx;
line-height: 54rpx;
flex-shrink: 0;
box-sizing: border-box;
}
.bottom-button-img-wrap {
width: 46rpx;
height: 46rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.trans-button {
position: absolute;
top: 20rpx;
width: 50%;
height: 100%;
}
.trans-button.left-half {
left: 20rpx;
clip-path: inset(0 50% 0 0);
}
.trans-button.right-half {
right: 20rpx;
clip-path: inset(0 0 0 50%);
}
.trans-button.trans-active {
color: #0096fa;
z-index: 1;
}
.trans-button.trans-deactive {
color: #666;
}