refactor: AI对话代码整理

This commit is contained in:
digua
2026-04-06 19:51:10 +08:00
committed by digua
parent a8c3b032a7
commit d641772d79
5 changed files with 360 additions and 208 deletions
+39 -208
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from '@nuxt/ui/runtime/composables/useToast.js'
import ConversationList from './chat/ConversationList.vue'
@@ -20,6 +20,9 @@ import { usePromptStore } from '@/stores/prompt'
import { useSettingsStore } from '@/stores/settings'
import { useAssistantStore } from '@/stores/assistant'
import { useSkillStore } from '@/stores/skill'
import { useChatScroll } from './composables/useChatScroll'
import { useChatModals } from './composables/useChatModals'
import { groupMessagesToQAPairs } from './utils/chatMessages'
import type { MentionedMemberContext } from '@/composables/useAIChat'
const { t } = useI18n()
@@ -60,19 +63,38 @@ const {
clearAssistantForSession,
} = useAIChat(props.sessionId, props.sessionName, props.timeFilter, props.chatType ?? 'group', settingsStore.locale)
// 智能滚动
const { messagesContainer, showScrollToBottom, scrollToBottom, handleScrollToBottom } = useChatScroll(
messages,
isAIThinking
)
// 弹窗管理
const {
configModalVisible,
configModalAssistantId,
configModalReadonly,
marketModalVisible,
skillMarketModalVisible,
skillConfigModalVisible,
skillConfigModalSkillId,
handleConfigureAssistant,
handleOpenMarket,
handleMarketConfigure,
handleMarketViewConfig,
handleCreateAssistant,
handleAssistantCreated,
handleAssistantConfigSaved,
handleOpenSkillMarket,
handleSkillMarketConfigure,
handleCreateSkill,
handleSkillConfigSaved,
handleSkillCreated,
} = useChatModals()
// Store
const promptStore = usePromptStore()
const configModalVisible = ref(false)
const configModalAssistantId = ref<string | null>(null)
const configModalReadonly = ref(false)
const marketModalVisible = ref(false)
// 技能相关状态
const skillMarketModalVisible = ref(false)
const skillConfigModalVisible = ref(false)
const skillConfigModalSkillId = ref<string | null>(null)
// 当前选中助手的预设问题
const currentPresetQuestions = computed(() => {
return assistantStore.selectedAssistant?.presetQuestions ?? []
@@ -85,51 +107,18 @@ const currentChatType = computed(() => props.chatType ?? 'group')
const isSourcePanelCollapsed = ref(false)
const hasLLMConfig = ref(false)
const isCheckingConfig = ref(true)
const messagesContainer = ref<HTMLElement | null>(null)
const conversationListRef = ref<InstanceType<typeof ConversationList> | null>(null)
const chatInputRef = ref<{
fillInput: (content: string) => void
openSkillSelector: () => void
} | null>(null)
// 智能滚动状态
const isStickToBottom = ref(true) // 是否粘在底部(自动滚动)
const showScrollToBottom = ref(false) // 是否显示"返回底部"按钮
const RESTICK_THRESHOLD = 30 // 距离底部此距离内时重新粘住
// QA 对
const qaPairs = computed(() => groupMessagesToQAPairs(messages.value))
// 截屏功能
const conversationContentRef = ref<HTMLElement | null>(null)
// 将消息分组为 QA 对(用户问题 + AI 回复)
const qaPairs = computed(() => {
const pairs: Array<{
user: (typeof messages.value)[0] | null
assistant: (typeof messages.value)[0] | null
id: string
}> = []
let currentUser: (typeof messages.value)[0] | null = null
for (const msg of messages.value) {
if (msg.role === 'user') {
// 如果已有用户消息但没有对应的 AI 回复,先保存
if (currentUser) {
pairs.push({ user: currentUser, assistant: null, id: currentUser.id })
}
currentUser = msg
} else if (msg.role === 'assistant') {
pairs.push({ user: currentUser, assistant: msg, id: currentUser?.id || msg.id })
currentUser = null
}
}
// 处理最后一个未配对的用户消息
if (currentUser) {
pairs.push({ user: currentUser, assistant: null, id: currentUser.id })
}
return pairs
})
// 检查 LLM 配置
async function checkLLMConfig() {
isCheckingConfig.value = true
@@ -198,45 +187,6 @@ function handleSelectAssistant(id: string) {
startNewConversation()
}
// 打开助手配置弹窗(可编辑)
function handleConfigureAssistant(id: string) {
configModalAssistantId.value = id
configModalReadonly.value = false
configModalVisible.value = true
}
// 打开助手市场
function handleOpenMarket() {
marketModalVisible.value = true
}
// 从市场中打开配置弹窗(可编辑)
function handleMarketConfigure(id: string) {
configModalAssistantId.value = id
configModalReadonly.value = false
configModalVisible.value = true
}
// 从市场中查看配置(只读)
function handleMarketViewConfig(id: string) {
configModalAssistantId.value = id
configModalReadonly.value = true
configModalVisible.value = true
}
// 新建助手(从管理弹窗触发)
function handleCreateAssistant() {
configModalAssistantId.value = null
configModalReadonly.value = false
configModalVisible.value = true
}
// 助手创建完成后刷新列表
async function handleAssistantCreated(_id: string) {
await assistantStore.loadAssistants()
await assistantStore.loadBuiltinCatalog()
}
// 返回助手选择
function handleBackToSelector() {
if (!clearAssistantForSession()) {
@@ -246,34 +196,6 @@ function handleBackToSelector() {
skillStore.activateSkill(null)
}
function handleOpenSkillMarket() {
skillMarketModalVisible.value = true
}
function handleSkillMarketConfigure(id: string) {
skillConfigModalSkillId.value = id
skillConfigModalVisible.value = true
}
function handleCreateSkill() {
skillConfigModalSkillId.value = null
skillConfigModalVisible.value = true
}
async function handleSkillConfigSaved() {
await skillStore.loadSkills()
}
async function handleSkillCreated(_id: string) {
await skillStore.loadSkills()
await skillStore.loadBuiltinCatalog()
}
// 助手配置保存后刷新列表
async function handleAssistantConfigSaved() {
await assistantStore.loadAssistants()
}
async function handlePresetQuestion(question: string) {
const result = await sendMessage(question)
if (!result.success && result.reason === 'busy') {
@@ -285,7 +207,6 @@ function handleUseSkillEntry() {
chatInputRef.value?.openSkillSelector()
}
// slash 技能选择在输入框内完成,这里保留事件钩子便于后续扩展联动。
function handleSkillActivated() {
scrollToBottom(true)
}
@@ -299,55 +220,10 @@ async function handleSend(payload: { content: string; mentionedMembers: Mentione
}
return
}
// 强制滚动到底部(用户发送消息后应该看到响应)
scrollToBottom(true)
// 刷新对话列表
conversationListRef.value?.refresh()
}
// 滚动到底部(强制滚动,用于发送消息等场景)
function scrollToBottom(force = false) {
setTimeout(() => {
if (messagesContainer.value) {
// 如果强制滚动,或者处于粘性模式,才执行滚动
if (force || isStickToBottom.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
isStickToBottom.value = true
showScrollToBottom.value = false
}
}
}, 100)
}
// 处理用户滚轮/触控板事件(可靠地检测用户主动滚动)
function handleWheel(event: WheelEvent) {
// deltaY < 0 表示向上滚动
if (event.deltaY < 0 && isAIThinking.value) {
// 用户在 AI 生成时主动向上滚动,解除粘性
isStickToBottom.value = false
showScrollToBottom.value = true
}
}
// 检测滚动位置(仅用于检测是否滚动到底部以重新粘住)
function checkScrollPosition() {
if (!messagesContainer.value) return
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// 如果用户手动滚动到接近底部,重新启用粘性
if (distanceFromBottom < RESTICK_THRESHOLD) {
isStickToBottom.value = true
showScrollToBottom.value = false
}
}
// 点击"返回底部"按钮
function handleScrollToBottom() {
scrollToBottom(true)
}
// 切换数据源面板
function toggleSourcePanel() {
isSourcePanelCollapsed.value = !isSourcePanelCollapsed.value
@@ -358,7 +234,7 @@ async function handleLoadMore() {
await loadMoreSourceMessages()
}
// 选择对话(切换到已有对话时恢复其绑定的助手)
// 选择对话
async function handleSelectConversation(convId: string) {
await loadConversation(convId)
scrollToBottom(true)
@@ -376,7 +252,6 @@ function handleCreateConversation() {
// 删除对话
function handleDeleteConversation(convId: string) {
// 如果删除的是当前对话,创建新对话
if (currentConversationId.value === convId) {
if (selectedAssistantId.value) {
startNewConversation()
@@ -386,58 +261,14 @@ function handleDeleteConversation(convId: string) {
}
}
// 初始化
onMounted(async () => {
await checkLLMConfig()
await updateMaxMessages()
// 添加事件监听
if (messagesContainer.value) {
messagesContainer.value.addEventListener('scroll', checkScrollPosition)
messagesContainer.value.addEventListener('wheel', handleWheel, { passive: true })
}
if (messages.value.length > 0) {
scrollToBottom(true)
}
})
// 组件卸载时清理
onBeforeUnmount(() => {
if (messagesContainer.value) {
messagesContainer.value.removeEventListener('scroll', checkScrollPosition)
messagesContainer.value.removeEventListener('wheel', handleWheel)
}
})
// 处理停止按钮
function handleStop() {
stopGeneration()
}
// 监听消息变化,自动滚动
watch(
() => messages.value.length,
() => {
scrollToBottom()
}
)
// 监听 AI 响应流式更新
watch(
() => messages.value[messages.value.length - 1]?.content,
() => {
scrollToBottom()
}
)
// 监听 AI 响应 contentBlocks 更新(工具调用状态变化)
watch(
() => messages.value[messages.value.length - 1]?.contentBlocks?.length,
() => {
scrollToBottom()
}
)
// 初始化
checkLLMConfig()
updateMaxMessages()
// 监听全局 AI 配置变化(从设置弹窗保存时触发)
watch(
@@ -0,0 +1,111 @@
/**
* 助手 & 技能弹窗状态管理:统一管理所有弹窗的可见性和对应的打开/刷新操作。
*/
import { ref } from 'vue'
import { useAssistantStore } from '@/stores/assistant'
import { useSkillStore } from '@/stores/skill'
export function useChatModals() {
const assistantStore = useAssistantStore()
const skillStore = useSkillStore()
// ── 助手弹窗状态 ──
const configModalVisible = ref(false)
const configModalAssistantId = ref<string | null>(null)
const configModalReadonly = ref(false)
const marketModalVisible = ref(false)
// ── 技能弹窗状态 ──
const skillMarketModalVisible = ref(false)
const skillConfigModalVisible = ref(false)
const skillConfigModalSkillId = ref<string | null>(null)
// ── 助手弹窗 handlers ──
function handleConfigureAssistant(id: string) {
configModalAssistantId.value = id
configModalReadonly.value = false
configModalVisible.value = true
}
function handleOpenMarket() {
marketModalVisible.value = true
}
function handleMarketConfigure(id: string) {
configModalAssistantId.value = id
configModalReadonly.value = false
configModalVisible.value = true
}
function handleMarketViewConfig(id: string) {
configModalAssistantId.value = id
configModalReadonly.value = true
configModalVisible.value = true
}
function handleCreateAssistant() {
configModalAssistantId.value = null
configModalReadonly.value = false
configModalVisible.value = true
}
async function handleAssistantCreated(_id: string) {
await assistantStore.loadAssistants()
await assistantStore.loadBuiltinCatalog()
}
async function handleAssistantConfigSaved() {
await assistantStore.loadAssistants()
}
// ── 技能弹窗 handlers ──
function handleOpenSkillMarket() {
skillMarketModalVisible.value = true
}
function handleSkillMarketConfigure(id: string) {
skillConfigModalSkillId.value = id
skillConfigModalVisible.value = true
}
function handleCreateSkill() {
skillConfigModalSkillId.value = null
skillConfigModalVisible.value = true
}
async function handleSkillConfigSaved() {
await skillStore.loadSkills()
}
async function handleSkillCreated(_id: string) {
await skillStore.loadSkills()
await skillStore.loadBuiltinCatalog()
}
return {
// 助手弹窗
configModalVisible,
configModalAssistantId,
configModalReadonly,
marketModalVisible,
handleConfigureAssistant,
handleOpenMarket,
handleMarketConfigure,
handleMarketViewConfig,
handleCreateAssistant,
handleAssistantCreated,
handleAssistantConfigSaved,
// 技能弹窗
skillMarketModalVisible,
skillConfigModalVisible,
skillConfigModalSkillId,
handleOpenSkillMarket,
handleSkillMarketConfigure,
handleCreateSkill,
handleSkillConfigSaved,
handleSkillCreated,
}
}
@@ -0,0 +1,82 @@
/**
* 聊天消息区的智能滚动行为:
* - 粘性底部(AI 流式输出时自动跟随)
* - 用户主动上滚时解除粘性并显示"回到底部"
* - 滚动到底部附近时重新粘住
*/
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import type { Ref } from 'vue'
import type { ChatMessage } from '@/stores/aiChat'
const RESTICK_THRESHOLD = 30
export function useChatScroll(messages: Ref<ChatMessage[]>, isAIThinking: Ref<boolean>) {
const messagesContainer = ref<HTMLElement | null>(null)
const isStickToBottom = ref(true)
const showScrollToBottom = ref(false)
function scrollToBottom(force = false) {
setTimeout(() => {
if (!messagesContainer.value) return
if (force || isStickToBottom.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
isStickToBottom.value = true
showScrollToBottom.value = false
}
}, 100)
}
function handleWheel(event: WheelEvent) {
if (event.deltaY < 0 && isAIThinking.value) {
isStickToBottom.value = false
showScrollToBottom.value = true
}
}
function checkScrollPosition() {
if (!messagesContainer.value) return
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value
if (scrollHeight - scrollTop - clientHeight < RESTICK_THRESHOLD) {
isStickToBottom.value = true
showScrollToBottom.value = false
}
}
function handleScrollToBottom() {
scrollToBottom(true)
}
// 合并三个触发源为单一 watcher:消息数量、最后一条内容、最后一条 contentBlocks 长度
watch(
() => {
const last = messages.value[messages.value.length - 1]
return [messages.value.length, last?.content, last?.contentBlocks?.length] as const
},
() => scrollToBottom()
)
onMounted(() => {
if (messagesContainer.value) {
messagesContainer.value.addEventListener('scroll', checkScrollPosition)
messagesContainer.value.addEventListener('wheel', handleWheel, { passive: true })
}
if (messages.value.length > 0) {
scrollToBottom(true)
}
})
onBeforeUnmount(() => {
if (messagesContainer.value) {
messagesContainer.value.removeEventListener('scroll', checkScrollPosition)
messagesContainer.value.removeEventListener('wheel', handleWheel)
}
})
return {
messagesContainer,
showScrollToBottom,
scrollToBottom,
handleScrollToBottom,
}
}
@@ -0,0 +1,97 @@
/**
* /技能 slash 命令菜单:输入框开头键入 "/" 时弹出技能列表,支持筛选和键盘导航。
*/
import { ref, computed } from 'vue'
import type { Ref } from 'vue'
import type { SkillSummary } from '@/stores/skill'
export function useSlashCommand(compatibleSkills: Ref<SkillSummary[]>) {
const showSlashMenu = ref(false)
const slashFilter = ref('')
const slashHighlightIndex = ref(0)
const dismissedSlashValue = ref<string | null>(null)
const filteredSkills = computed(() => {
const keyword = slashFilter.value.trim().toLocaleLowerCase()
if (!keyword) return compatibleSkills.value
return compatibleSkills.value.filter((skill) => {
const haystack = [skill.name, skill.description, skill.tags.join(' ')].join(' ').toLocaleLowerCase()
return haystack.includes(keyword)
})
})
function resetSlashState() {
showSlashMenu.value = false
slashFilter.value = ''
slashHighlightIndex.value = 0
}
function dismissSlashMenu(currentValue?: string) {
if (currentValue !== undefined && /^\s*\/([^\n]*)$/.test(currentValue)) {
dismissedSlashValue.value = currentValue
}
resetSlashState()
}
/** 根据当前输入值决定是否显示 slash 菜单 */
function updateSlashState(value: string, disabled: boolean) {
if (disabled) {
resetSlashState()
return
}
if (dismissedSlashValue.value && dismissedSlashValue.value !== value) {
dismissedSlashValue.value = null
}
const match = value.match(/^\s*\/([^\n]*)$/)
if (!match) {
resetSlashState()
return
}
const shouldResetHighlight = !showSlashMenu.value || slashFilter.value !== match[1]
slashFilter.value = match[1]
if (dismissedSlashValue.value === value) {
showSlashMenu.value = false
return
}
showSlashMenu.value = true
if (shouldResetHighlight) {
slashHighlightIndex.value = 0
}
}
function moveSlashHighlight(step: 1 | -1) {
if (!filteredSkills.value.length) return
const total = filteredSkills.value.length
slashHighlightIndex.value = (slashHighlightIndex.value + step + total) % total
}
/** 筛选列表变化时修正高亮索引 */
function clampSlashHighlight() {
const len = filteredSkills.value.length
if (len === 0) {
slashHighlightIndex.value = 0
} else if (slashHighlightIndex.value >= len) {
slashHighlightIndex.value = len - 1
}
}
return {
showSlashMenu,
slashFilter,
slashHighlightIndex,
dismissedSlashValue,
filteredSkills,
resetSlashState,
dismissSlashMenu,
updateSlashState,
moveSlashHighlight,
clampSlashHighlight,
}
}
@@ -0,0 +1,31 @@
import type { ChatMessage } from '@/stores/aiChat'
export interface QAPair {
user: ChatMessage | null
assistant: ChatMessage | null
id: string
}
/** 将消息列表分组为 QA 对(用户问题 + AI 回复) */
export function groupMessagesToQAPairs(messages: ChatMessage[]): QAPair[] {
const pairs: QAPair[] = []
let currentUser: ChatMessage | null = null
for (const msg of messages) {
if (msg.role === 'user') {
if (currentUser) {
pairs.push({ user: currentUser, assistant: null, id: currentUser.id })
}
currentUser = msg
} else if (msg.role === 'assistant') {
pairs.push({ user: currentUser, assistant: msg, id: currentUser?.id || msg.id })
currentUser = null
}
}
if (currentUser) {
pairs.push({ user: currentUser, assistant: null, id: currentUser.id })
}
return pairs
}