mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-19 21:00:25 +08:00
refactor: AI对话代码整理
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user