feat: AI助手交互逻辑优化

This commit is contained in:
digua
2026-04-22 23:46:31 +08:00
parent cac864be86
commit 87a631657d
17 changed files with 940 additions and 704 deletions
+5 -1
View File
@@ -67,7 +67,11 @@ export function registerApiHandlers(_ctx: IpcContext): void {
ipcMain.handle(
'api:updateDataSource',
(_event, id: string, updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'enabled'>>) => {
(
_event,
id: string,
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'enabled'>>
) => {
const ds = updateDataSource(id, updates)
if (ds) {
reloadTimer(ds.id)
+8 -5
View File
@@ -85,7 +85,12 @@ export const apiServerApi = {
return ipcRenderer.invoke('api:getDataSources')
},
addDataSource: (partial: { name?: string; baseUrl: string; token: string; intervalMinutes: number }): Promise<DataSource> => {
addDataSource: (partial: {
name?: string
baseUrl: string
token: string
intervalMinutes: number
}): Promise<DataSource> => {
return ipcRenderer.invoke('api:addDataSource', partial)
},
@@ -130,10 +135,8 @@ export const apiServerApi = {
onPullResult: (
callback: (data: { sourceId: string; sessionId?: string; status: string; detail: string }) => void
): (() => void) => {
const handler = (
_event: any,
data: { sourceId: string; sessionId?: string; status: string; detail: string }
) => callback(data)
const handler = (_event: any, data: { sourceId: string; sessionId?: string; status: string; detail: string }) =>
callback(data)
ipcRenderer.on('api:pullResult', handler)
return () => ipcRenderer.removeListener('api:pullResult', handler)
},
+6 -1
View File
@@ -1040,7 +1040,12 @@ interface ApiServerApi {
regenerateToken: () => Promise<ApiServerConfig>
onStartupError: (callback: (data: { error: string }) => void) => () => void
getDataSources: () => Promise<DataSource[]>
addDataSource: (partial: { name?: string; baseUrl: string; token: string; intervalMinutes: number }) => Promise<DataSource>
addDataSource: (partial: {
name?: string
baseUrl: string
token: string
intervalMinutes: number
}) => Promise<DataSource>
updateDataSource: (
id: string,
updates: Partial<Pick<DataSource, 'name' | 'baseUrl' | 'token' | 'intervalMinutes' | 'enabled'>>
+189 -185
View File
@@ -10,7 +10,7 @@ import AIThinkingIndicator from './chat/AIThinkingIndicator.vue'
import ChatStatusBar from './chat/ChatStatusBar.vue'
import { useAIChat } from '@/composables/useAIChat'
import CaptureButton from '@/components/common/CaptureButton.vue'
import AssistantSelector from './assistant/AssistantSelector.vue'
import AssistantInlineBar from './assistant/AssistantInlineBar.vue'
import AssistantConfigModal from './assistant/AssistantConfigModal.vue'
import AssistantMarketModal from './assistant/AssistantMarketModal.vue'
import SkillMarketModal from './skill/SkillMarketModal.vue'
@@ -46,7 +46,6 @@ const {
currentKeywords,
isLoadingSource,
isAIThinking,
showAssistantSelector,
currentConversationId,
currentToolStatus,
toolsUsedInCurrentRound,
@@ -60,14 +59,11 @@ const {
updateMaxMessages,
stopGeneration,
selectAssistantForSession,
clearAssistantForSession,
} = useAIChat(props.sessionId, props.sessionName, props.timeFilter, props.chatType ?? 'group', settingsStore.locale)
// 智能滚动
const { messagesContainer, showScrollToBottom, scrollToBottom, handleScrollToBottom } = useChatScroll(
messages,
isAIThinking
)
const chatScroll = useChatScroll(messages, isAIThinking)
const { showScrollToBottom, scrollToBottom, handleScrollToBottom } = chatScroll
// 弹窗管理
const {
@@ -171,25 +167,15 @@ function showLockedActionToast() {
toast.warn(t('ai.chat.backgroundTask.blockedAction'))
}
// 选择助手
function handleSelectAssistant(payload: { id: string; remember: boolean }) {
if (!selectAssistantForSession(payload.id)) {
showLockedActionToast()
return
}
if (payload.remember) {
assistantStore.rememberAssistantForDays(payload.id, 7)
}
startNewConversation()
}
// 返回助手选择
function handleBackToSelector() {
if (!clearAssistantForSession()) {
// 选择/切换助手(从内联栏或弹出面板中选择)
function handleSwitchAssistant(id: string) {
if (id === selectedAssistantId.value) return
if (!selectAssistantForSession(id)) {
showLockedActionToast()
return
}
skillStore.activateSkill(null)
startNewConversation()
}
async function handlePresetQuestion(question: string) {
@@ -242,28 +228,13 @@ function handleCreateConversation() {
showLockedActionToast()
return
}
if (!selectedAssistantId.value) {
const rememberedAssistantId = assistantStore.getRememberedAssistantIdForContext(
currentChatType.value,
settingsStore.locale
)
if (!rememberedAssistantId) return
if (!selectAssistantForSession(rememberedAssistantId)) {
showLockedActionToast()
return
}
}
startNewConversation()
}
// 删除对话
function handleDeleteConversation(convId: string) {
if (currentConversationId.value === convId) {
if (selectedAssistantId.value) {
startNewConversation()
} else {
clearAssistantForSession()
}
startNewConversation()
}
}
@@ -299,177 +270,210 @@ watch(
@delete="handleDeleteConversation"
/>
<!-- 右侧内容区 -->
<Transition name="fade" mode="out-in">
<!-- 助手选择页面 -->
<AssistantSelector
v-if="showAssistantSelector"
key="selector"
class="h-full flex-1"
:chat-type="currentChatType"
:locale="settingsStore.locale"
@select="handleSelectAssistant"
@configure="handleConfigureAssistant"
@market="handleOpenMarket"
/>
<!-- 对话区域 -->
<div v-else key="chat" class="flex h-full flex-1 overflow-hidden">
<div class="flex h-full flex-1">
<div class="relative flex min-w-[480px] flex-1 flex-col overflow-hidden">
<!-- 顶部返回 + 助手名称 -->
<!-- 右侧对话区域始终显示 -->
<div class="flex h-full flex-1 overflow-hidden">
<div class="flex h-full flex-1">
<div class="relative flex min-w-[480px] flex-1 flex-col overflow-hidden">
<!-- 顶部有消息时显示助手切换按钮 -->
<template v-if="messages.length > 0 || isAIThinking">
<div class="flex items-center gap-1.5 px-3 py-1.5">
<button
class="flex items-center gap-1 rounded-md px-1.5 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
:disabled="isAIThinking"
:class="{ 'cursor-not-allowed opacity-50': isAIThinking }"
@click="handleBackToSelector"
:disabled="isAIThinking || !assistantStore.selectedAssistant?.id"
:class="{ 'cursor-not-allowed opacity-50': isAIThinking || !assistantStore.selectedAssistant?.id }"
@click="handleConfigureAssistant(assistantStore.selectedAssistant!.id)"
>
<UIcon name="i-heroicons-chevron-left" class="h-3.5 w-3.5" />
<UIcon name="i-heroicons-sparkles" class="h-3.5 w-3.5" />
<span>{{ assistantStore.selectedAssistant?.name || t('ai.assistant.fallbackName') }}</span>
</button>
</div>
</template>
<!-- 消息列表 -->
<div ref="messagesContainer" class="min-h-0 flex-1 overflow-y-auto p-4">
<div ref="conversationContentRef" class="mx-auto max-w-3xl space-y-6">
<!-- 助手欢迎卡片仅在无消息时展示点击可编辑配置 -->
<div
<!-- 消息列表 -->
<div
:ref="chatScroll.messagesContainer"
class="relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto p-4"
:class="{ 'p-0!': messages.length === 0 && !isAIThinking }"
>
<!-- 空状态全局背景光 -->
<div
v-if="messages.length === 0 && !isAIThinking"
class="pointer-events-none absolute left-1/2 top-0 -z-10 h-full w-full max-w-[800px] -translate-x-1/2 overflow-hidden opacity-50"
>
<div
class="absolute -top-10 left-1/4 h-80 w-80 rounded-full bg-blue-400/20 blur-[80px] dark:bg-blue-500/20"
></div>
<div
class="absolute -top-10 right-1/4 h-80 w-80 rounded-full bg-pink-400/20 blur-[80px] dark:bg-pink-500/20"
></div>
</div>
<div
ref="conversationContentRef"
class="relative z-10 mx-auto max-w-3xl space-y-6"
:class="{
'flex min-h-full flex-col justify-center px-4 pb-32 pt-4 space-y-0!':
messages.length === 0 && !isAIThinking,
}"
>
<!-- 空状态 Hero 区域 -->
<div
v-if="messages.length === 0 && !isAIThinking"
class="flex w-full flex-col items-center justify-center animate-fade-in"
>
<!-- 主标题助手名高亮无图标 -->
<h2
v-if="welcomeInfo.name"
class="mb-3 text-center text-2xl font-semibold tracking-tight text-gray-800 dark:text-gray-100"
>
{{ t('ai.assistant.selector.heroTitlePrefix', '使用') }}
<span class="text-primary-600 dark:text-primary-400">{{ welcomeInfo.name }}</span>
{{ t('ai.assistant.selector.heroTitleSuffix', '开始对话') }}
</h2>
<!-- 系统提示词文本 -->
<p
v-if="showWelcomeCard && welcomeInfo.name"
class="cursor-pointer rounded-lg border border-gray-200 px-4 py-3 transition-colors hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800/50"
class="mb-8 max-w-lg cursor-pointer text-center text-sm leading-relaxed text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 line-clamp-2"
@click="handleConfigureAssistant(assistantStore.selectedAssistant!.id)"
>
<h4 class="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ welcomeInfo.name }}
</h4>
<p class="line-clamp-2 text-xs leading-relaxed text-gray-400 dark:text-gray-500">
<UTooltip :text="t('ai.assistant.systemPrompt', '系统设定')" :popper="{ placement: 'top' }">
{{ welcomeInfo.preview }}
</p>
</div>
</UTooltip>
</p>
<!-- 对话截屏按钮 -->
<div v-if="qaPairs.length > 0 && !isAIThinking" class="flex justify-end">
<CaptureButton
:label="t('ai.chat.capture')"
size="xs"
type="element"
:target-element="conversationContentRef"
<!-- 助手选择器 -->
<div class="flex w-full justify-center">
<AssistantInlineBar
:chat-type="currentChatType"
:locale="settingsStore.locale"
:selected-id="selectedAssistantId"
@select="handleSwitchAssistant"
@market="handleOpenMarket"
/>
</div>
</div>
<!-- QA 对渲染 -->
<template v-for="pair in qaPairs" :key="pair.id">
<div class="qa-pair space-y-6 pb-4">
<!-- 用户问题 -->
<ChatMessage
v-if="pair.user && (pair.user.role === 'user' || pair.user.content)"
:role="pair.user.role"
:content="pair.user.content"
:timestamp="pair.user.timestamp"
:is-streaming="pair.user.isStreaming"
:content-blocks="pair.user.contentBlocks"
/>
<!-- AI 回复 -->
<ChatMessage
v-if="
pair.assistant &&
(pair.assistant.content ||
(pair.assistant.contentBlocks && pair.assistant.contentBlocks.length > 0))
"
:role="pair.assistant.role"
:content="pair.assistant.content"
:timestamp="pair.assistant.timestamp"
:is-streaming="pair.assistant.isStreaming"
:content-blocks="pair.assistant.contentBlocks"
:show-capture-button="!pair.assistant.isStreaming"
/>
</div>
</template>
<!-- AI 思考中指示器(仅在没有任何内容块时显示) -->
<AIThinkingIndicator
v-if="
isAIThinking &&
!messages[messages.length - 1]?.content &&
!(messages[messages.length - 1]?.contentBlocks?.length ?? 0)
"
:current-tool-status="currentToolStatus"
:tools-used="toolsUsedInCurrentRound"
<!-- 对话截屏按钮 -->
<div v-if="qaPairs.length > 0 && !isAIThinking" class="flex justify-end">
<CaptureButton
:label="t('ai.chat.capture')"
size="xs"
type="element"
:target-element="conversationContentRef"
/>
</div>
</div>
<!-- 返回底部浮动按钮(固定在输入框上方) -->
<Transition name="fade-up">
<button
v-if="showScrollToBottom"
class="absolute bottom-20 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-gray-800/90 px-3 py-1.5 text-xs text-white shadow-lg backdrop-blur-sm transition-all hover:bg-gray-700 dark:bg-gray-700/90 dark:hover:bg-gray-600"
@click="handleScrollToBottom"
>
<UIcon name="i-heroicons-arrow-down" class="h-3.5 w-3.5" />
<span>{{ t('ai.chat.scrollToBottom') }}</span>
</button>
</Transition>
<!-- QA 对渲染 -->
<template v-for="pair in qaPairs" :key="pair.id">
<div class="qa-pair space-y-6 pb-4">
<!-- 用户问题 -->
<ChatMessage
v-if="pair.user && (pair.user.role === 'user' || pair.user.content)"
:role="pair.user.role"
:content="pair.user.content"
:timestamp="pair.user.timestamp"
:is-streaming="pair.user.isStreaming"
:content-blocks="pair.user.contentBlocks"
/>
<!-- AI 回复 -->
<ChatMessage
v-if="
pair.assistant &&
(pair.assistant.content ||
(pair.assistant.contentBlocks && pair.assistant.contentBlocks.length > 0))
"
:role="pair.assistant.role"
:content="pair.assistant.content"
:timestamp="pair.assistant.timestamp"
:is-streaming="pair.assistant.isStreaming"
:content-blocks="pair.assistant.contentBlocks"
:show-capture-button="!pair.assistant.isStreaming"
/>
</div>
</template>
<!-- 预设问题气泡(仅在对话为空时显示) -->
<div v-if="messages.length === 0 && !isAIThinking" class="px-4 pb-2">
<div class="mx-auto max-w-3xl">
<PresetQuestions
:questions="currentPresetQuestions"
:leading-action-label="t('ai.chat.input.useSkill')"
@select="handlePresetQuestion"
@leading-action="handleUseSkillEntry"
/>
</div>
</div>
<!-- 输入框区域 -->
<div class="px-4 pb-2">
<div class="mx-auto max-w-3xl">
<AIChatInput
ref="chatInputRef"
:session-id="sessionId"
:disabled="isAIThinking"
:status="isAIThinking ? 'streaming' : 'ready'"
:chat-type="currentChatType"
@send="handleSend"
@stop="handleStop"
@manage-skills="handleOpenSkillMarket"
@skill-activated="handleSkillActivated"
/>
<!-- 底部状态栏 -->
<ChatStatusBar
:session-token-usage="sessionTokenUsage"
:agent-status="agentStatus"
:current-conversation-id="currentConversationId"
/>
</div>
<!-- AI 思考中指示器(仅在没有任何内容块时显示) -->
<AIThinkingIndicator
v-if="
isAIThinking &&
!messages[messages.length - 1]?.content &&
!(messages[messages.length - 1]?.contentBlocks?.length ?? 0)
"
:current-tool-status="currentToolStatus"
:tools-used="toolsUsedInCurrentRound"
/>
</div>
</div>
<!-- 返回底部浮动按钮(固定在输入框上方) -->
<Transition name="fade-up">
<button
v-if="showScrollToBottom"
class="absolute bottom-20 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-gray-800/90 px-3 py-1.5 text-xs text-white shadow-lg backdrop-blur-sm transition-all hover:bg-gray-700 dark:bg-gray-700/90 dark:hover:bg-gray-600"
@click="handleScrollToBottom"
>
<UIcon name="i-heroicons-arrow-down" class="h-3.5 w-3.5" />
<span>{{ t('ai.chat.scrollToBottom') }}</span>
</button>
</Transition>
<!-- 预设问题气泡(仅在对话为空时显示) -->
<div v-if="messages.length === 0 && !isAIThinking" class="px-4 pb-2">
<div class="mx-auto max-w-3xl">
<PresetQuestions
:questions="currentPresetQuestions"
:leading-action-label="t('ai.chat.input.useSkill')"
@select="handlePresetQuestion"
@leading-action="handleUseSkillEntry"
/>
</div>
</div>
<!-- 输入框区域 -->
<div class="px-4 pb-2">
<div class="mx-auto max-w-3xl">
<AIChatInput
ref="chatInputRef"
:session-id="sessionId"
:disabled="isAIThinking"
:status="isAIThinking ? 'streaming' : 'ready'"
:chat-type="currentChatType"
@send="handleSend"
@stop="handleStop"
@manage-skills="handleOpenSkillMarket"
@skill-activated="handleSkillActivated"
/>
<!-- 底部状态栏 -->
<ChatStatusBar
:session-token-usage="sessionTokenUsage"
:agent-status="agentStatus"
:current-conversation-id="currentConversationId"
/>
</div>
</div>
<!-- closes relative flex min-w-[480px] -->
</div>
<!-- closes flex h-full flex-1 -->
<!-- 右侧:数据源面板 -->
<Transition name="slide-fade">
<div
v-if="sourceMessages.length > 0 && !isSourcePanelCollapsed"
class="w-80 shrink-0 border-l border-gray-200 bg-gray-50/50 p-4 dark:border-gray-800 dark:bg-gray-900/50"
>
<DataSourcePanel
:messages="sourceMessages"
:keywords="currentKeywords"
:is-loading="isLoadingSource"
:is-collapsed="isSourcePanelCollapsed"
class="h-full"
@toggle="toggleSourcePanel"
@load-more="handleLoadMore"
/>
</div>
</Transition>
</div>
</Transition>
<!-- 右侧:数据源面板 -->
<Transition name="slide-fade">
<div
v-if="sourceMessages.length > 0 && !isSourcePanelCollapsed"
class="w-80 shrink-0 border-l border-gray-200 bg-gray-50/50 p-4 dark:border-gray-800 dark:bg-gray-900/50"
>
<DataSourcePanel
:messages="sourceMessages"
:keywords="currentKeywords"
:is-loading="isLoadingSource"
:is-collapsed="isSourcePanelCollapsed"
class="h-full"
@toggle="toggleSourcePanel"
@load-more="handleLoadMore"
/>
</div>
</Transition>
</div>
<!-- 助手配置弹窗 -->
<AssistantConfigModal
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
import { storeToRefs } from 'pinia'
import { useAssistantStore, type AssistantSummary } from '@/stores/assistant'
const props = defineProps<{
chatType: 'group' | 'private'
locale: string
selectedId: string | null
}>()
const emit = defineEmits<{
select: [id: string]
market: []
}>()
// 控制溢出菜单的展开收起
const overflowPopoverOpen = ref(false)
const overflowMenuRef = ref<HTMLElement | null>(null)
// 选中溢出助手后,立即关闭菜单,让用户看到滑动环跳过去
function selectOverflow(id: string) {
emit('select', id)
overflowPopoverOpen.value = false
}
function toggleOverflowMenu() {
if (overflowAssistants.value.length === 0) return
overflowPopoverOpen.value = !overflowPopoverOpen.value
}
function handleDocumentMouseDown(event: MouseEvent) {
if (!overflowPopoverOpen.value || !overflowMenuRef.value) return
const target = event.target
if (target instanceof Node && !overflowMenuRef.value.contains(target)) {
overflowPopoverOpen.value = false
}
}
const assistantStore = useAssistantStore()
const { filteredAssistants, isLoaded } = storeToRefs(assistantStore)
function getLocaleGeneralId(locale: string): string {
if (locale.startsWith('ja')) return 'general_ja'
if (locale.startsWith('en')) return 'general_en'
return 'general_cn'
}
const sortedAssistants = computed<AssistantSummary[]>(() => {
const preferredGeneralId = getLocaleGeneralId(props.locale)
return [...filteredAssistants.value].sort((a, b) => {
if (a.id === preferredGeneralId) return -1
if (b.id === preferredGeneralId) return 1
return 0
})
})
const VISIBLE_COUNT = 4
const displayedAssistants = computed<AssistantSummary[]>(() => {
const all = sortedAssistants.value
if (all.length <= VISIBLE_COUNT) return all
const selectedIndex = all.findIndex((a) => a.id === props.selectedId)
if (selectedIndex !== -1 && selectedIndex >= VISIBLE_COUNT - 1) {
const firstFew = all.slice(0, VISIBLE_COUNT - 1)
const selected = all[selectedIndex]
return [...firstFew, selected]
}
return all.slice(0, VISIBLE_COUNT)
})
const overflowAssistants = computed<AssistantSummary[]>(() => {
const displayedIds = new Set(displayedAssistants.value.map((a) => a.id))
return sortedAssistants.value.filter((a) => !displayedIds.has(a.id))
})
watch(
() => [props.chatType, props.locale],
([chatType, locale]) => {
assistantStore.setFilterContext(chatType as 'group' | 'private', locale as string)
},
{ immediate: true }
)
// ---------- 滑动胶囊环逻辑 ----------
const buttonRefs = ref<HTMLElement[]>([])
const containerRef = ref<HTMLElement | null>(null)
let resizeObserver: ResizeObserver | null = null
const ringStyle = ref({
width: '0px',
transform: 'translateX(0px)',
opacity: 0,
})
const updateRing = async () => {
await nextTick()
const index = displayedAssistants.value.findIndex((a) => a.id === props.selectedId)
if (index !== -1 && buttonRefs.value[index]) {
const el = buttonRefs.value[index]
ringStyle.value = {
// 往外扩张3px,宽度 +6,往左偏 -3px
width: `${el.offsetWidth + 6}px`,
transform: `translateX(${el.offsetLeft - 3}px)`,
opacity: 1,
}
} else {
// 落败隐藏(极小可能)
ringStyle.value.opacity = 0
}
}
watch(
() => [props.selectedId, displayedAssistants.value],
() => {
updateRing()
},
{ deep: true }
)
onMounted(async () => {
if (!isLoaded.value) {
await assistantStore.loadAssistants()
}
updateRing()
document.addEventListener('mousedown', handleDocumentMouseDown)
// 监听容器大小变动以应对外部屏幕 Resize 等导致元素间距变动的情况
if (typeof ResizeObserver !== 'undefined' && containerRef.value) {
resizeObserver = new ResizeObserver(() => {
updateRing()
})
resizeObserver.observe(containerRef.value)
}
})
onUnmounted(() => {
document.removeEventListener('mousedown', handleDocumentMouseDown)
if (resizeObserver) {
resizeObserver.disconnect()
}
})
</script>
<template>
<div class="assistant-bar-wrapper flex justify-center">
<!-- 主胶囊容器p-[3px] 来保留上下 3px 扩张空间 gap-1.5 避免相邻背景重叠 -->
<div
ref="containerRef"
class="assistant-bar-grid relative flex items-center justify-center gap-1.5 rounded-full bg-gray-50/60 p-[3px] shadow-inner ring-1 ring-inset ring-gray-900/5 backdrop-blur-md dark:bg-gray-800/40 dark:ring-white/10 z-0 overflow-hidden sm:overflow-visible"
>
<!-- 滑动选中焦点层基于 CSS Transform 平滑移动 -->
<div
class="absolute left-0 inset-y-0 -z-10 rounded-full bg-white ring-[1.5px] ring-primary-500 shadow-sm transition-all duration-350 ease-out dark:bg-primary-500/15 dark:ring-primary-400"
:style="ringStyle"
></div>
<button
v-for="(assistant, index) in displayedAssistants"
:key="assistant.id"
:ref="
(el) => {
if (el) buttonRefs[index] = el as HTMLElement
}
"
class="relative z-10 flex h-[32px] items-center justify-center whitespace-nowrap rounded-full px-4 text-[13px] font-medium transition-colors duration-200"
:class="[
selectedId === assistant.id
? 'text-primary-600 dark:text-primary-300'
: 'text-gray-500 hover:bg-gray-200/50 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700/50 dark:hover:text-gray-200',
]"
@click="emit('select', assistant.id)"
>
{{ assistant.name }}
</button>
<!-- 溢出菜单改为组件内自管面板避免空状态 Hero 区域里 Popover 内容可见但无法稳定点击 -->
<div v-if="overflowAssistants.length > 0" ref="overflowMenuRef" class="relative z-20">
<button
type="button"
class="relative z-10 flex h-[32px] w-[32px] ml-0.5 items-center justify-center rounded-full text-gray-500 transition-colors duration-200 hover:bg-gray-200/50 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700/50 dark:hover:text-gray-200"
@click="toggleOverflowMenu"
>
<UIcon name="i-heroicons-ellipsis-horizontal" class="h-5 w-5" />
</button>
<div
v-if="overflowPopoverOpen"
class="absolute right-0 top-full z-30 mt-2 w-56 overflow-hidden rounded-xl border border-gray-200/80 bg-white/95 p-1 shadow-lg backdrop-blur-md dark:border-gray-700 dark:bg-gray-900/95"
>
<div class="custom-scrollbar max-h-60 space-y-0.5 overflow-y-auto">
<p class="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
更多助手
</p>
<button
v-for="assistant in overflowAssistants"
:key="assistant.id"
type="button"
class="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left text-sm transition-colors"
:class="[
selectedId === assistant.id
? 'bg-primary-50 text-primary-600 dark:bg-primary-500/10 dark:text-primary-400'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800',
]"
@click="selectOverflow(assistant.id)"
>
<span class="truncate font-medium">{{ assistant.name }}</span>
<UIcon v-if="selectedId === assistant.id" name="i-heroicons-check" class="h-4 w-4 shrink-0" />
</button>
</div>
</div>
</div>
<!-- 添加助手按钮 (置于胶囊内) -->
<button
type="button"
class="relative z-10 flex h-[32px] w-[32px] items-center justify-center rounded-full text-gray-500 transition-colors duration-200 hover:bg-gray-200/50 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700/50 dark:hover:text-gray-200"
@click="emit('market')"
>
<UIcon name="i-heroicons-plus" class="h-[18px] w-[18px]" />
</button>
</div>
</div>
</template>
<style scoped>
/* 隐藏下拉菜单滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: rgba(156, 163, 175, 0.3);
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.15);
}
</style>
+148 -116
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs'
@@ -13,6 +13,11 @@ interface Conversation {
updatedAt: number
}
interface ConversationGroup {
label: string
conversations: Conversation[]
}
// Props
const props = defineProps<{
sessionId: string
@@ -34,6 +39,7 @@ const isLoading = ref(false)
const editingId = ref<string | null>(null)
const editingTitle = ref('')
const isCollapsed = ref(false)
const menuOpenId = ref<string | null>(null)
// 加载对话列表
async function loadConversations() {
@@ -47,19 +53,50 @@ async function loadConversations() {
}
}
// 格式化时间(数据库存储的是秒级时间戳,需转换为毫秒级)
function formatTime(timestamp: number): string {
const now = dayjs()
const date = dayjs(timestamp * 1000)
// 按时间分组
const groupedConversations = computed<ConversationGroup[]>(() => {
if (conversations.value.length === 0) return []
if (now.diff(date, 'day') === 0) {
return date.format('HH:mm')
} else if (now.diff(date, 'day') < 7) {
return date.format('ddd HH:mm')
} else {
return date.format('MM-DD')
const now = dayjs()
const day7 = now.subtract(7, 'day').startOf('day')
const day30 = now.subtract(30, 'day').startOf('day')
const last7: Conversation[] = []
const last30: Conversation[] = []
const monthBuckets = new Map<string, Conversation[]>()
for (const conv of conversations.value) {
const date = dayjs(conv.updatedAt * 1000)
if (date.isAfter(day7)) {
last7.push(conv)
} else if (date.isAfter(day30)) {
last30.push(conv)
} else {
const key = date.format('YYYY-MM')
if (!monthBuckets.has(key)) monthBuckets.set(key, [])
monthBuckets.get(key)!.push(conv)
}
}
}
const groups: ConversationGroup[] = []
if (last7.length > 0) {
groups.push({ label: t('ai.chat.conversation.group.last7Days'), conversations: last7 })
}
if (last30.length > 0) {
groups.push({ label: t('ai.chat.conversation.group.last30Days'), conversations: last30 })
}
const sortedMonths = [...monthBuckets.keys()].sort((a, b) => b.localeCompare(a))
for (const month of sortedMonths) {
const d = dayjs(month + '-01')
groups.push({
label: d.format(d.year() === now.year() ? 'M月' : 'YYYY年M月'),
conversations: monthBuckets.get(month)!,
})
}
return groups
})
// 获取对话标题
function getTitle(conv: Conversation): string {
@@ -128,31 +165,29 @@ defineExpose({
>
<!-- 头部 -->
<div
class="flex items-center justify-between border-b border-gray-200 py-2 dark:border-gray-800"
:class="isCollapsed ? 'px-0' : 'pl-5 pr-2'"
class="flex items-center border-b border-gray-200 dark:border-gray-800"
:class="isCollapsed ? 'justify-center px-0 py-2' : 'justify-between pl-3 pr-2 py-2'"
>
<template v-if="!isCollapsed">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('ai.chat.conversation.title') }}</span>
<div class="flex items-center gap-1">
<button
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
:disabled="disabled"
:class="{ 'cursor-not-allowed opacity-50': disabled }"
@click="!disabled && emit('create')"
>
<UIcon name="i-heroicons-plus" class="h-4 w-4" />
</button>
<button
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@click="isCollapsed = !isCollapsed"
>
<UIcon name="i-heroicons-chevron-left" class="h-4 w-4" />
</button>
</div>
<button
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
:disabled="disabled"
:class="{ 'cursor-not-allowed opacity-50': disabled }"
@click="!disabled && emit('create')"
>
<UIcon name="i-heroicons-plus" class="h-3.5 w-3.5" />
<span>{{ t('ai.chat.conversation.newConversation') }}</span>
</button>
<button
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@click="isCollapsed = !isCollapsed"
>
<UIcon name="i-heroicons-chevron-left" class="h-4 w-4" />
</button>
</template>
<template v-else>
<button
class="mx-auto rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@click="isCollapsed = !isCollapsed"
>
<UIcon name="i-heroicons-chevron-right" class="h-4 w-4" />
@@ -185,100 +220,99 @@ defineExpose({
</UButton>
</div>
<!-- 对话列表 -->
<div v-else class="space-y-0.5">
<div
v-for="conv in conversations"
:key="conv.id"
class="group relative rounded-lg px-3 py-2.5 transition-all"
:class="[
'cursor-pointer',
activeId === conv.id
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white'
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800/50',
]"
@click="emit('select', conv.id)"
>
<!-- 编辑模式 -->
<template v-if="editingId === conv.id">
<input
v-model="editingTitle"
class="w-full rounded border-none bg-white px-2 py-1 text-sm shadow-sm ring-1 ring-gray-200 focus:ring-2 focus:ring-primary-500 dark:bg-gray-900 dark:ring-gray-700"
:placeholder="t('ai.chat.conversation.titlePlaceholder')"
autoFocus
@blur="saveTitle(conv.id)"
@keyup.enter="saveTitle(conv.id)"
@click.stop
/>
</template>
<!-- 分组对话列表 -->
<div v-else class="space-y-3">
<div v-for="group in groupedConversations" :key="group.label">
<!-- 分组标题 -->
<div class="px-2 pb-1 pt-1.5 text-[10px] font-medium tracking-wide text-gray-400 dark:text-gray-500">
{{ group.label }}
</div>
<!-- 正常模式 -->
<template v-else>
<div class="relative">
<!-- 标题 -->
<p class="line-clamp-1 pr-2 text-sm font-medium leading-snug">
{{ getTitle(conv) }}
</p>
<!-- 时间 -->
<p class="mt-1.5 text-[10px] text-gray-400">
{{ formatTime(conv.updatedAt) }}
</p>
<!-- 操作按钮垂直居中带渐变背景 -->
<div
class="absolute inset-y-0 right-0 flex items-center opacity-0 transition-opacity group-hover:opacity-100"
:class="{ 'opacity-100': activeId === conv.id }"
@click.stop
>
<!-- 左侧渐变过渡区域 -->
<div
class="absolute inset-y-0 -left-6 w-6 bg-linear-to-r from-transparent"
:class="[
activeId === conv.id
? 'to-gray-100 dark:to-gray-800'
: 'to-gray-50 group-hover:to-gray-100 dark:to-gray-900 dark:group-hover:to-gray-800',
]"
<!-- 对话项 -->
<div class="space-y-0.5">
<div
v-for="conv in group.conversations"
:key="conv.id"
class="group relative rounded-lg px-3 py-2 transition-all"
:class="[
'cursor-pointer',
activeId === conv.id
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white'
: 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800/50',
]"
@click="emit('select', conv.id)"
>
<!-- 编辑模式 -->
<template v-if="editingId === conv.id">
<input
v-model="editingTitle"
class="w-full rounded border-none bg-white px-2 py-1 text-sm shadow-sm outline-none ring-1 ring-gray-200 focus:ring-2 focus:ring-primary-500 dark:bg-gray-900 dark:ring-gray-700"
:placeholder="t('ai.chat.conversation.titlePlaceholder')"
autoFocus
@blur="saveTitle(conv.id)"
@keyup.enter="saveTitle(conv.id)"
@click.stop
/>
<!-- 按钮组背景 -->
<div
class="relative flex h-full items-center gap-0.5 pl-1 pr-0.5"
:class="[
activeId === conv.id
? 'bg-gray-100 dark:bg-gray-800'
: 'bg-gray-50 group-hover:bg-gray-100 dark:bg-gray-900 dark:group-hover:bg-gray-800',
]"
>
<UButton
icon="i-heroicons-pencil"
color="gray"
variant="ghost"
size="2xs"
class="text-gray-400 hover:text-primary-500 dark:hover:text-primary-400"
@click="startEditing(conv)"
/>
<UButton
icon="i-heroicons-trash"
color="gray"
variant="ghost"
size="2xs"
class="text-gray-400 hover:text-primary-500 dark:hover:text-primary-400"
@click="handleDelete(conv.id)"
/>
</template>
<!-- 正常模式 -->
<template v-else>
<div class="flex items-center">
<p class="line-clamp-1 min-w-0 flex-1 text-sm leading-snug">
{{ getTitle(conv) }}
</p>
<!-- 三点菜单 -->
<div
class="ml-1 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
:class="{ 'opacity-100': activeId === conv.id || menuOpenId === conv.id }"
@click.stop
>
<UPopover
:open="menuOpenId === conv.id"
:ui="{ content: 'z-50 p-0' }"
@update:open="(val: boolean) => (menuOpenId = val ? conv.id : null)"
>
<button
class="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
>
<UIcon name="i-heroicons-ellipsis-horizontal" class="h-4 w-4" />
</button>
<template #content>
<div class="w-28 p-1">
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
:disabled="disabled"
@click="menuOpenId = null; startEditing(conv)"
>
<UIcon name="i-heroicons-pencil" class="h-3.5 w-3.5" />
{{ t('ai.chat.conversation.rename') }}
</button>
<button
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-red-500 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
:disabled="disabled"
@click="menuOpenId = null; handleDelete(conv.id)"
>
<UIcon name="i-heroicons-trash" class="h-3.5 w-3.5" />
{{ t('ai.chat.conversation.delete') }}
</button>
</div>
</template>
</UPopover>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- 折叠状态列表 -->
<div v-else class="flex flex-1 flex-col items-center gap-2 overflow-y-auto py-2">
<!-- 新建按钮 -->
<button
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-pink-500 dark:hover:bg-gray-800"
:title="t('ai.chat.conversation.startNew')"
:title="t('ai.chat.conversation.newConversation')"
:disabled="disabled"
:class="{ 'cursor-not-allowed opacity-50': disabled }"
@click="!disabled && emit('create')"
@@ -286,10 +320,8 @@ defineExpose({
<UIcon name="i-heroicons-plus" class="h-4 w-4" />
</button>
<!-- 分隔线 -->
<div class="h-px w-6 bg-gray-200 dark:bg-gray-800"></div>
<!-- 对话列表图标 -->
<button
v-for="conv in conversations"
:key="conv.id"
@@ -36,7 +36,7 @@ const visibleCount = ref(0)
let resizeObserver: ResizeObserver | null = null
const chipClass =
'rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] leading-4 text-gray-600 transition-all hover:border-primary-300 hover:bg-primary-50 hover:text-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-primary-600 dark:hover:bg-primary-950/30 dark:hover:text-primary-400'
'rounded-full ring-1 ring-inset ring-gray-200/80 bg-white/60 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.05)] backdrop-blur-md px-3 py-1.5 text-xs text-gray-600 transition-all duration-300 hover:-translate-y-[1px] hover:ring-primary-300 hover:bg-white/90 hover:text-primary-600 hover:shadow-[0_4px_12px_-2px_rgba(0,0,0,0.08)] disabled:cursor-not-allowed disabled:opacity-50 dark:ring-gray-700/60 dark:bg-gray-800/60 dark:text-gray-300 dark:hover:ring-primary-500/50 dark:hover:bg-gray-800/90 dark:hover:text-primary-400'
const items = computed<PresetItem[]>(() => {
const result: PresetItem[] = []
+1 -3
View File
@@ -46,7 +46,7 @@ export function useAIChat(
locale,
})
// 每次进入 AI Tab 时重置到助手选择页(从浮动任务条返回时除外)
// 每次进入 AI Tab 时确保默认选中助手(从浮动任务条返回时除外)
void aiChatStore.resetToSelectorOnEnter(chatKey)
// 当前可见的 AI 页应恢复自己的助手上下文,避免不同会话之间串助手选择。
@@ -58,7 +58,6 @@ export function useAIChat(
currentKeywords: toRef(state, 'currentKeywords'),
isLoadingSource: toRef(state, 'isLoadingSource'),
isAIThinking: toRef(state, 'isAIThinking'),
showAssistantSelector: toRef(state, 'showAssistantSelector'),
currentConversationId: toRef(state, 'currentConversationId'),
currentToolStatus: toRef(state, 'currentToolStatus'),
toolsUsedInCurrentRound: toRef(state, 'toolsUsedInCurrentRound'),
@@ -73,6 +72,5 @@ export function useAIChat(
updateMaxMessages: () => aiChatStore.updateMaxMessages(),
stopGeneration: () => aiChatStore.stopGeneration(chatKey),
selectAssistantForSession: (assistantId: string) => aiChatStore.selectAssistantForSession(chatKey, assistantId),
clearAssistantForSession: () => aiChatStore.clearAssistantForSession(chatKey),
}
}
+10 -2
View File
@@ -174,9 +174,16 @@
"conversation": {
"title": "AI Conversations",
"newChat": "New Chat",
"newConversation": "New Chat",
"empty": "No history yet",
"startNew": "Start New Chat",
"titlePlaceholder": "Enter title...",
"group": {
"last7Days": "Last 7 Days",
"last30Days": "Last 30 Days"
},
"rename": "Rename",
"delete": "Delete",
"export": {
"createdAt": "Created",
"user": "User",
@@ -256,11 +263,12 @@
"assistant": {
"selector": {
"title": "Choose an assistant to start",
"heroTitlePrefix": "Chat with",
"heroTitleSuffix": "",
"subtitle": "Each assistant specializes in different types of analysis tasks. Pick the one that best fits your needs.",
"noAssistants": "No assistants available for this context",
"manage": "Manage Assistants",
"addNew": "New Assistant",
"rememberSelection": "Remember this choice for 7 days"
"addNew": "New Assistant"
},
"config": {
"viewTitle": "View Config",
+10 -2
View File
@@ -174,9 +174,16 @@
"conversation": {
"title": "AI チャット履歴",
"newChat": "新規チャット",
"newConversation": "新規チャット",
"empty": "履歴はありません",
"startNew": "新しいチャットを始める",
"titlePlaceholder": "タイトルを入力...",
"group": {
"last7Days": "過去 7 日間",
"last30Days": "過去 30 日間"
},
"rename": "名前を変更",
"delete": "削除",
"export": {
"createdAt": "作成日時",
"user": "ユーザー",
@@ -256,11 +263,12 @@
"assistant": {
"selector": {
"title": "アシスタントを選んでチャットを始めましょう",
"heroTitlePrefix": "",
"heroTitleSuffix": "と話しかける",
"subtitle": "各アシスタントは得意分野が異なります。目的に合うものを選んでください",
"noAssistants": "現在のシーンで利用可能なアシスタントはありません",
"manage": "アシスタント管理",
"addNew": "アシスタントを追加",
"rememberSelection": "この選択を7日間記憶する"
"addNew": "アシスタントを追加"
},
"config": {
"viewTitle": "設定を確認",
+10 -2
View File
@@ -174,9 +174,16 @@
"conversation": {
"title": "AI对话记录",
"newChat": "新对话",
"newConversation": "新建对话",
"empty": "暂无历史记录",
"startNew": "开始新对话",
"titlePlaceholder": "输入标题...",
"group": {
"last7Days": "最近 7 天",
"last30Days": "最近 30 天"
},
"rename": "重命名",
"delete": "删除",
"export": {
"createdAt": "创建时间",
"user": "用户",
@@ -256,11 +263,12 @@
"assistant": {
"selector": {
"title": "选择一个助手开始对话",
"heroTitlePrefix": "使用",
"heroTitleSuffix": "开始对话",
"subtitle": "每个助手擅长不同类型的分析任务,选择最适合你需求的助手",
"noAssistants": "当前场景暂无可用助手",
"manage": "管理助手",
"addNew": "新增助手",
"rememberSelection": "7天内记住此选择"
"addNew": "新增助手"
},
"config": {
"viewTitle": "查看配置",
+10 -2
View File
@@ -174,9 +174,16 @@
"conversation": {
"title": "AI 對話紀錄",
"newChat": "新對話",
"newConversation": "新建對話",
"empty": "暫無歷史紀錄",
"startNew": "開始新對話",
"titlePlaceholder": "輸入標題...",
"group": {
"last7Days": "最近 7 天",
"last30Days": "最近 30 天"
},
"rename": "重新命名",
"delete": "刪除",
"export": {
"createdAt": "建立時間",
"user": "使用者",
@@ -256,11 +263,12 @@
"assistant": {
"selector": {
"title": "選擇一個助手開始對話",
"heroTitlePrefix": "使用",
"heroTitleSuffix": "開始對話",
"subtitle": "每位助手都擅長不同分析任務,請選擇最符合需求的一位",
"noAssistants": "當前場景暫無可用助手",
"manage": "管理助手",
"addNew": "新增助手",
"rememberSelection": "7天內記住此選擇"
"addNew": "新增助手"
},
"config": {
"viewTitle": "檢視設定",
@@ -188,16 +188,18 @@ function closeModal() {
<div class="mt-2 flex items-center gap-2">
<button
v-if="currentProviderDef?.supportsCustomModels"
class="text-xs text-primary-500 hover:underline"
class="flex items-center gap-1 rounded-md border border-dashed border-gray-300 px-2 py-1 text-xs text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-500 dark:border-gray-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
@click="openAddModelDialog"
>
+ {{ t('settings.aiConfig.modal.addCustomModel') }}
<UIcon name="i-heroicons-plus" class="h-3.5 w-3.5" />
{{ t('settings.aiConfig.modal.addCustomModel') }}
</button>
<button
v-if="selectedModelIsCustom"
class="text-xs text-red-400 hover:text-red-500 hover:underline"
class="flex items-center gap-1 rounded-md border border-dashed border-red-200 px-2 py-1 text-xs text-red-400 transition-colors hover:border-red-400 hover:text-red-500 dark:border-red-800 dark:hover:border-red-500"
@click="deleteCustomModel(formData.model)"
>
<UIcon name="i-heroicons-trash" class="h-3.5 w-3.5" />
{{ t('settings.aiConfig.modal.deleteCustomModel') }}
</button>
</div>
@@ -245,16 +247,18 @@ function closeModal() {
<div class="mt-2 flex items-center gap-2">
<button
v-if="currentProviderDef?.supportsCustomModels"
class="text-xs text-primary-500 hover:underline"
class="flex items-center gap-1 rounded-md border border-dashed border-gray-300 px-2 py-1 text-xs text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-500 dark:border-gray-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
@click="openAddModelDialog"
>
+ {{ t('settings.aiConfig.modal.addCustomModel') }}
<UIcon name="i-heroicons-plus" class="h-3.5 w-3.5" />
{{ t('settings.aiConfig.modal.addCustomModel') }}
</button>
<button
v-if="selectedModelIsCustom"
class="text-xs text-red-400 hover:text-red-500 hover:underline"
class="flex items-center gap-1 rounded-md border border-dashed border-red-200 px-2 py-1 text-xs text-red-400 transition-colors hover:border-red-400 hover:text-red-500 dark:border-red-800 dark:hover:border-red-500"
@click="deleteCustomModel(formData.model)"
>
<UIcon name="i-heroicons-trash" class="h-3.5 w-3.5" />
{{ t('settings.aiConfig.modal.deleteCustomModel') }}
</button>
</div>
@@ -326,16 +330,18 @@ function closeModal() {
<div class="mt-2 flex items-center gap-2">
<button
v-if="currentProviderDef?.supportsCustomModels"
class="text-xs text-primary-500 hover:underline"
class="flex items-center gap-1 rounded-md border border-dashed border-gray-300 px-2 py-1 text-xs text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-500 dark:border-gray-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
@click="openAddModelDialog"
>
+ {{ t('settings.aiConfig.modal.addCustomModel') }}
<UIcon name="i-heroicons-plus" class="h-3.5 w-3.5" />
{{ t('settings.aiConfig.modal.addCustomModel') }}
</button>
<button
v-if="selectedModelIsCustom"
class="text-xs text-red-400 hover:text-red-500 hover:underline"
class="flex items-center gap-1 rounded-md border border-dashed border-red-200 px-2 py-1 text-xs text-red-400 transition-colors hover:border-red-400 hover:text-red-500 dark:border-red-800 dark:hover:border-red-500"
@click="deleteCustomModel(formData.model)"
>
<UIcon name="i-heroicons-trash" class="h-3.5 w-3.5" />
{{ t('settings.aiConfig.modal.deleteCustomModel') }}
</button>
</div>
@@ -56,9 +56,7 @@ watch(
{ immediate: true }
)
const availableSessions = computed(() =>
remoteSessions.value.filter((s) => !props.subscribedRemoteIds?.has(s.id))
)
const availableSessions = computed(() => remoteSessions.value.filter((s) => !props.subscribedRemoteIds?.has(s.id)))
const allSelected = computed(
() => availableSessions.value.length > 0 && selectedSessionIds.value.size === availableSessions.value.length
@@ -210,12 +210,18 @@ function subscribedRemoteIds(ds: DataSource): Set<string> {
class="rounded-lg border border-gray-200 bg-white dark:border-gray-600 dark:bg-gray-800"
>
<!-- Source header -->
<div class="flex items-center justify-between border-b border-gray-100 px-3 py-2 dark:border-gray-700">
<div
class="flex items-center justify-between border-b border-gray-100 px-3 py-2 dark:border-gray-700"
>
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-server-stack" class="h-3.5 w-3.5 text-gray-500 dark:text-gray-400" />
<span v-if="ds.name" class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ ds.name }}</span>
<span v-if="ds.name" class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ ds.name }}
</span>
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ ds.baseUrl }}</span>
<span v-if="!ds.enabled" class="text-xs text-gray-400">({{ t('settings.api.dataSources.disabled') }})</span>
<span v-if="!ds.enabled" class="text-xs text-gray-400">
({{ t('settings.api.dataSources.disabled') }})
</span>
</div>
<div class="flex items-center gap-1">
<UButton size="xs" variant="ghost" @click="openEditSource(ds)">
@@ -450,11 +456,7 @@ function subscribedRemoteIds(ds: DataSource): Set<string> {
<DataSourceAddModal v-model:open="showAddModal" @source-added="store.fetchDataSources()" />
<!-- Edit data source modal -->
<DataSourceEditModal
v-model:open="showEditModal"
:data-source="editingDataSource"
@saved="handleEditSaved"
/>
<DataSourceEditModal v-model:open="showEditModal" :data-source="editingDataSource" @saved="handleEditSaved" />
<!-- Manage import sessions modal -->
<DataSourceAddModal
+4 -24
View File
@@ -105,7 +105,6 @@ export interface AIChatSessionState {
locale: string
timeFilter?: { startTs: number; endTs: number }
selectedAssistantId: string | null
showAssistantSelector: boolean
messages: ChatMessage[]
sourceMessages: SourceMessage[]
currentKeywords: string[]
@@ -196,7 +195,6 @@ function createSessionState(params: EnsureAIChatSessionParams): AIChatSessionSta
locale: params.locale,
timeFilter: params.timeFilter,
selectedAssistantId: null,
showAssistantSelector: true,
messages: draftBuffer.messages,
sourceMessages: draftBuffer.sourceMessages,
currentKeywords: draftBuffer.currentKeywords,
@@ -291,7 +289,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
state.sourceMessages = buffer.sourceMessages
state.currentKeywords = buffer.currentKeywords
state.selectedAssistantId = buffer.assistantId
state.showAssistantSelector = bufferKey === DRAFT_CONVERSATION_KEY && !buffer.assistantId
}
function renameBufferKey(state: AIChatSessionState, fromKey: string, toKey: string): ConversationBuffer {
@@ -424,23 +421,10 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
const buffer = getOrCreateBuffer(state, getDisplayedBufferKey(state), assistantId)
buffer.assistantId = assistantId
state.selectedAssistantId = assistantId
state.showAssistantSelector = false
assistantStore.selectAssistant(assistantId)
return true
}
function clearAssistantForSession(chatKey: string): boolean {
const state = getSessionState(chatKey)
if (!state || state.isAIThinking) return false
// 返回助手选择页时切回独立草稿 buffer,不污染已有历史对话的助手绑定。
const draftBuffer = createConversationBuffer(null)
state.conversationBuffers[DRAFT_CONVERSATION_KEY] = draftBuffer
bindDisplayedBuffer(state, DRAFT_CONVERSATION_KEY)
assistantStore.clearSelection()
return true
}
async function loadConversation(chatKey: string, conversationId: string): Promise<boolean> {
const state = getSessionState(chatKey)
if (!state) return false
@@ -514,13 +498,11 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
await assistantStore.loadAssistants()
}
const rememberedAssistantId = assistantStore.getRememberedAssistantIdForContext(state.chatType, state.locale)
if (rememberedAssistantId && selectAssistantForSession(chatKey, rememberedAssistantId)) {
startNewConversation(chatKey)
return
if (!state.selectedAssistantId) {
const defaultId = getDefaultGeneralAssistantId(state.locale)
selectAssistantForSession(chatKey, defaultId)
}
clearAssistantForSession(chatKey)
startNewConversation(chatKey)
}
function startNewConversation(chatKey: string, welcomeMessage?: string): boolean {
@@ -530,7 +512,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
const draftBuffer = createConversationBuffer(state.selectedAssistantId)
state.conversationBuffers[DRAFT_CONVERSATION_KEY] = draftBuffer
bindDisplayedBuffer(state, DRAFT_CONVERSATION_KEY)
state.showAssistantSelector = false
state.currentToolStatus = null
state.toolsUsedInCurrentRound = []
state.isLoadingSource = false
@@ -1038,7 +1019,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
getActiveTaskState,
applySessionAssistantSelection,
selectAssistantForSession,
clearAssistantForSession,
loadConversation,
focusConversation,
focusActiveTaskConversation,
+269 -340
View File
@@ -48,367 +48,296 @@ export interface CloudAssistantItem {
path: string
}
interface RememberedAssistantState {
assistantId: string
expiresAt: number
}
export const useAssistantStore = defineStore('assistant', () => {
const assistants = ref<AssistantSummary[]>([])
const selectedAssistantId = ref<string | null>(null)
const isLoaded = ref(false)
const REMEMBER_ASSISTANT_DAYS = 7
const REMEMBER_ASSISTANT_MS = REMEMBER_ASSISTANT_DAYS * 24 * 60 * 60 * 1000
/** @deprecated 本地内置目录已清空,保留兼容 */
const builtinCatalog = ref<BuiltinAssistantInfo[]>([])
export const useAssistantStore = defineStore(
'assistant',
() => {
const assistants = ref<AssistantSummary[]>([])
const selectedAssistantId = ref<string | null>(null)
const rememberedAssistant = ref<RememberedAssistantState | null>(null)
const isLoaded = ref(false)
/** 内置工具目录(含分类) */
const builtinToolCatalog = ref<Array<{ name: string; category: 'core' | 'analysis' }>>([])
/** @deprecated 本地内置目录已清空,保留兼容 */
const builtinCatalog = ref<BuiltinAssistantInfo[]>([])
/** 云端市场目录 */
const cloudCatalog = ref<CloudAssistantItem[]>([])
const cloudLoading = ref(false)
const cloudError = ref<string | null>(null)
/** 内置工具目录(含分类) */
const builtinToolCatalog = ref<Array<{ name: string; category: 'core' | 'analysis' }>>([])
/** 当前过滤条件 */
const currentChatType = ref<'group' | 'private'>('group')
const currentLocale = ref<string>('zh-CN')
/** 云端市场目录 */
const cloudCatalog = ref<CloudAssistantItem[]>([])
const cloudLoading = ref(false)
const cloudError = ref<string | null>(null)
const selectedAssistant = computed(() => {
if (!selectedAssistantId.value) return null
return assistants.value.find((a) => a.id === selectedAssistantId.value) ?? null
})
/** 当前过滤条件 */
const currentChatType = ref<'group' | 'private'>('group')
const currentLocale = ref<string>('zh-CN')
const selectedAssistant = computed(() => {
if (!selectedAssistantId.value) return null
return assistants.value.find((a) => a.id === selectedAssistantId.value) ?? null
})
const filteredAssistants = computed(() => {
return assistants.value.filter((a) => {
const typeMatch = !a.applicableChatTypes?.length || a.applicableChatTypes.includes(currentChatType.value)
const localeMatch =
!a.supportedLocales?.length || a.supportedLocales.some((l) => currentLocale.value.startsWith(l))
return typeMatch && localeMatch
})
})
const defaultVisibleCount = 4
const defaultAssistants = computed(() => filteredAssistants.value.slice(0, defaultVisibleCount))
const moreAssistants = computed(() => filteredAssistants.value.slice(defaultVisibleCount))
const hasMoreAssistants = computed(() => filteredAssistants.value.length > defaultVisibleCount)
/** 云端目录中标注导入状态 */
const cloudCatalogWithStatus = computed(() => {
const localIds = new Set(assistants.value.map((a) => a.id))
return cloudCatalog.value.map((item) => ({
...item,
imported: localIds.has(item.id),
}))
})
function getValidRememberedAssistantId(): string | null {
const remembered = rememberedAssistant.value
if (!remembered) return null
if (!remembered.assistantId || typeof remembered.expiresAt !== 'number') {
rememberedAssistant.value = null
return null
}
if (remembered.expiresAt <= Date.now()) {
rememberedAssistant.value = null
return null
}
return remembered.assistantId
}
function isAssistantAvailableForContext(
assistant: AssistantSummary,
chatType: 'group' | 'private',
locale: string
): boolean {
const typeMatch = !assistant.applicableChatTypes?.length || assistant.applicableChatTypes.includes(chatType)
const filteredAssistants = computed(() => {
return assistants.value.filter((a) => {
const typeMatch = !a.applicableChatTypes?.length || a.applicableChatTypes.includes(currentChatType.value)
const localeMatch =
!assistant.supportedLocales?.length || assistant.supportedLocales.some((l) => locale.startsWith(l))
!a.supportedLocales?.length || a.supportedLocales.some((l) => currentLocale.value.startsWith(l))
return typeMatch && localeMatch
})
})
const defaultVisibleCount = 4
const defaultAssistants = computed(() => filteredAssistants.value.slice(0, defaultVisibleCount))
const moreAssistants = computed(() => filteredAssistants.value.slice(defaultVisibleCount))
const hasMoreAssistants = computed(() => filteredAssistants.value.length > defaultVisibleCount)
/** 云端目录中标注导入状态 */
const cloudCatalogWithStatus = computed(() => {
const localIds = new Set(assistants.value.map((a) => a.id))
return cloudCatalog.value.map((item) => ({
...item,
imported: localIds.has(item.id),
}))
})
function setFilterContext(chatType: 'group' | 'private', locale: string): void {
currentChatType.value = chatType
currentLocale.value = locale
}
async function loadAssistants(): Promise<void> {
try {
assistants.value = await window.assistantApi.getAll()
isLoaded.value = true
} catch (error) {
console.error('[AssistantStore] Failed to load assistants:', error)
}
}
function setFilterContext(chatType: 'group' | 'private', locale: string): void {
currentChatType.value = chatType
currentLocale.value = locale
/** @deprecated 本地内置目录已清空,保留兼容 */
async function loadBuiltinCatalog(): Promise<void> {
try {
builtinCatalog.value = await window.assistantApi.getBuiltinCatalog()
} catch (error) {
console.error('[AssistantStore] Failed to load builtin catalog:', error)
}
}
async function loadAssistants(): Promise<void> {
try {
assistants.value = await window.assistantApi.getAll()
const rememberedAssistantId = getValidRememberedAssistantId()
if (rememberedAssistantId && !assistants.value.some((assistant) => assistant.id === rememberedAssistantId)) {
rememberedAssistant.value = null
}
isLoaded.value = true
} catch (error) {
console.error('[AssistantStore] Failed to load assistants:', error)
}
async function loadBuiltinToolCatalog(): Promise<void> {
try {
builtinToolCatalog.value = await window.assistantApi.getBuiltinToolCatalog()
} catch (error) {
console.error('[AssistantStore] Failed to load builtin tool catalog:', error)
}
}
/** @deprecated 本地内置目录已清空,保留兼容 */
async function loadBuiltinCatalog(): Promise<void> {
try {
builtinCatalog.value = await window.assistantApi.getBuiltinCatalog()
} catch (error) {
console.error('[AssistantStore] Failed to load builtin catalog:', error)
}
}
// ==================== 云端市场 ====================
async function loadBuiltinToolCatalog(): Promise<void> {
try {
builtinToolCatalog.value = await window.assistantApi.getBuiltinToolCatalog()
} catch (error) {
console.error('[AssistantStore] Failed to load builtin tool catalog:', error)
}
}
async function fetchCloudCatalog(localeOverride?: string): Promise<void> {
// 助手市场请求只依赖 locale,不应该反向修改选择页的筛选上下文。
const langPath = LOCALE_PATH_MAP[localeOverride || currentLocale.value] ?? 'en'
const url = `${CLOUD_MARKET_BASE_URL}/${langPath}/assistant.json`
// ==================== 云端市场 ====================
cloudLoading.value = true
cloudError.value = null
async function fetchCloudCatalog(localeOverride?: string): Promise<void> {
// 助手市场请求只依赖 locale,不应该反向修改选择页的筛选上下文。
const langPath = LOCALE_PATH_MAP[localeOverride || currentLocale.value] ?? 'en'
const url = `${CLOUD_MARKET_BASE_URL}/${langPath}/assistant.json`
cloudLoading.value = true
cloudError.value = null
try {
const result = await window.api.app.fetchRemoteConfig(url)
if (!result.success || !result.data) {
cloudError.value = result.error || 'Failed to fetch cloud catalog'
cloudCatalog.value = []
return
}
const data = result.data as CloudAssistantItem[]
if (!Array.isArray(data)) {
cloudError.value = 'Invalid catalog format'
cloudCatalog.value = []
return
}
cloudCatalog.value = data.filter((item) => item.id && item.name && item.path)
} catch (error) {
cloudError.value = String(error)
try {
const result = await window.api.app.fetchRemoteConfig(url)
if (!result.success || !result.data) {
cloudError.value = result.error || 'Failed to fetch cloud catalog'
cloudCatalog.value = []
} finally {
cloudLoading.value = false
}
}
async function importFromCloud(item: CloudAssistantItem): Promise<{ success: boolean; error?: string }> {
const mdUrl = `${CLOUD_MARKET_BASE_URL}${item.path}`
try {
const mdResult = await window.api.app.fetchRemoteConfig(mdUrl)
if (!mdResult.success || typeof mdResult.data !== 'string') {
return { success: false, error: mdResult.error || 'Failed to fetch assistant content' }
}
const result = await window.assistantApi.importFromMd(mdResult.data)
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
function isCloudItemImported(id: string): boolean {
return assistants.value.some((a) => a.id === id)
}
// ==================== 基础 CRUD ====================
function selectAssistant(id: string): void {
selectedAssistantId.value = id
}
function clearSelection(): void {
selectedAssistantId.value = null
}
function rememberAssistantForDays(id: string | null, days = REMEMBER_ASSISTANT_DAYS): void {
if (!id) {
rememberedAssistant.value = null
return
}
rememberedAssistant.value = {
assistantId: id,
expiresAt: Date.now() + days * 24 * 60 * 60 * 1000,
const data = result.data as CloudAssistantItem[]
if (!Array.isArray(data)) {
cloudError.value = 'Invalid catalog format'
cloudCatalog.value = []
return
}
}
function getRememberedAssistantIdForContext(chatType: 'group' | 'private', locale: string): string | null {
const rememberedAssistantId = getValidRememberedAssistantId()
if (!rememberedAssistantId) return null
const remembered = assistants.value.find((assistant) => assistant.id === rememberedAssistantId)
if (!remembered) return null
return isAssistantAvailableForContext(remembered, chatType, locale) ? remembered.id : null
cloudCatalog.value = data.filter((item) => item.id && item.name && item.path)
} catch (error) {
cloudError.value = String(error)
cloudCatalog.value = []
} finally {
cloudLoading.value = false
}
async function getAssistantConfig(id: string): Promise<AssistantConfigFull | null> {
try {
return await window.assistantApi.getConfig(id)
} catch (error) {
console.error('[AssistantStore] Failed to get config:', error)
return null
}
}
async function updateAssistant(
id: string,
updates: Partial<AssistantConfigFull>
): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.update(id, updates)
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function resetAssistant(id: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.reset(id)
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function importAssistant(builtinId: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.importAssistant(builtinId)
if (result.success) {
await loadAssistants()
await loadBuiltinCatalog()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function reimportAssistant(id: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.reimportAssistant(id)
if (result.success) {
await loadAssistants()
await loadBuiltinCatalog()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function createAssistant(
config: Omit<AssistantConfigFull, 'id'>
): Promise<{ success: boolean; id?: string; error?: string }> {
try {
const result = await window.assistantApi.create(config)
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function duplicateAssistant(id: string): Promise<{ success: boolean; error?: string }> {
try {
const config = await window.assistantApi.getConfig(id)
if (!config) {
return { success: false, error: 'Assistant not found' }
}
const { id: _id, builtinId: _bid, ...rest } = config
const result = await window.assistantApi.create({
...rest,
name: `${config.name}${i18n.global.t('ai.assistant.duplicateSuffix')}`,
})
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function deleteAssistant(id: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.delete(id)
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
return {
assistants,
selectedAssistantId,
rememberedAssistant,
selectedAssistant,
isLoaded,
builtinCatalog,
builtinToolCatalog,
cloudCatalog,
cloudLoading,
cloudError,
cloudCatalogWithStatus,
currentChatType,
currentLocale,
filteredAssistants,
defaultAssistants,
moreAssistants,
hasMoreAssistants,
loadAssistants,
loadBuiltinCatalog,
loadBuiltinToolCatalog,
fetchCloudCatalog,
importFromCloud,
isCloudItemImported,
selectAssistant,
clearSelection,
rememberAssistantForDays,
getRememberedAssistantIdForContext,
setFilterContext,
getAssistantConfig,
updateAssistant,
createAssistant,
duplicateAssistant,
resetAssistant,
importAssistant,
reimportAssistant,
deleteAssistant,
}
},
{
persist: [
{
pick: ['rememberedAssistant'],
storage: localStorage,
},
],
}
)
async function importFromCloud(item: CloudAssistantItem): Promise<{ success: boolean; error?: string }> {
const mdUrl = `${CLOUD_MARKET_BASE_URL}${item.path}`
try {
const mdResult = await window.api.app.fetchRemoteConfig(mdUrl)
if (!mdResult.success || typeof mdResult.data !== 'string') {
return { success: false, error: mdResult.error || 'Failed to fetch assistant content' }
}
const result = await window.assistantApi.importFromMd(mdResult.data)
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
function isCloudItemImported(id: string): boolean {
return assistants.value.some((a) => a.id === id)
}
// ==================== 基础 CRUD ====================
function selectAssistant(id: string): void {
selectedAssistantId.value = id
}
function clearSelection(): void {
selectedAssistantId.value = null
}
async function getAssistantConfig(id: string): Promise<AssistantConfigFull | null> {
try {
return await window.assistantApi.getConfig(id)
} catch (error) {
console.error('[AssistantStore] Failed to get config:', error)
return null
}
}
async function updateAssistant(
id: string,
updates: Partial<AssistantConfigFull>
): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.update(id, updates)
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function resetAssistant(id: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.reset(id)
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function importAssistant(builtinId: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.importAssistant(builtinId)
if (result.success) {
await loadAssistants()
await loadBuiltinCatalog()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function reimportAssistant(id: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.reimportAssistant(id)
if (result.success) {
await loadAssistants()
await loadBuiltinCatalog()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function createAssistant(
config: Omit<AssistantConfigFull, 'id'>
): Promise<{ success: boolean; id?: string; error?: string }> {
try {
const result = await window.assistantApi.create(config)
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function duplicateAssistant(id: string): Promise<{ success: boolean; error?: string }> {
try {
const config = await window.assistantApi.getConfig(id)
if (!config) {
return { success: false, error: 'Assistant not found' }
}
const { id: _id, builtinId: _bid, ...rest } = config
const result = await window.assistantApi.create({
...rest,
name: `${config.name}${i18n.global.t('ai.assistant.duplicateSuffix')}`,
})
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
async function deleteAssistant(id: string): Promise<{ success: boolean; error?: string }> {
try {
const result = await window.assistantApi.delete(id)
if (result.success) {
await loadAssistants()
}
return result
} catch (error) {
return { success: false, error: String(error) }
}
}
return {
assistants,
selectedAssistantId,
selectedAssistant,
isLoaded,
builtinCatalog,
builtinToolCatalog,
cloudCatalog,
cloudLoading,
cloudError,
cloudCatalogWithStatus,
currentChatType,
currentLocale,
filteredAssistants,
defaultAssistants,
moreAssistants,
hasMoreAssistants,
loadAssistants,
loadBuiltinCatalog,
loadBuiltinToolCatalog,
fetchCloudCatalog,
importFromCloud,
isCloudItemImported,
selectAssistant,
clearSelection,
setFilterContext,
getAssistantConfig,
updateAssistant,
createAssistant,
duplicateAssistant,
resetAssistant,
importAssistant,
reimportAssistant,
deleteAssistant,
}
})