mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-21 22:00:39 +08:00
feat: AI助手交互逻辑优化
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
Vendored
+6
-1
@@ -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'>>
|
||||
|
||||
@@ -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>
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "設定を確認",
|
||||
|
||||
@@ -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": "查看配置",
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user