diff --git a/src/renderer/src/assets/images/providers/302ai.webp b/src/renderer/src/assets/images/providers/302ai.webp new file mode 100644 index 0000000000..ba7bcc80ae Binary files /dev/null and b/src/renderer/src/assets/images/providers/302ai.webp differ diff --git a/src/renderer/src/assets/images/providers/cephalon.jpeg b/src/renderer/src/assets/images/providers/cephalon.jpeg new file mode 100644 index 0000000000..8615e1c80d Binary files /dev/null and b/src/renderer/src/assets/images/providers/cephalon.jpeg differ diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index 5b4269e2fa..033de18ab4 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -308,13 +308,16 @@ emoji-picker { --border-size: 0; } -.katex-display { +.katex, +mjx-container { + display: inline-block; overflow-x: auto; overflow-y: hidden; -} - -mjx-container { - overflow-x: auto; + overflow-wrap: break-word; + vertical-align: middle; + max-width: 100%; + padding: 1px 2px; + margin-top: -2px; } /* CodeMirror 相关样式 */ diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 07581eadb3..acebf6171c 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -429,7 +429,86 @@ export const SYSTEM_MODELS: Record = { group: 'deepseek-ai' } ], - + '302ai': [ + { + id: 'deepseek-chat', + name: 'deepseek-chat', + provider: '302ai', + group: 'DeepSeek' + }, + { + id: 'deepseek-reasoner', + name: 'deepseek-reasoner', + provider: '302ai', + group: 'DeepSeek' + }, + { + id: 'chatgpt-4o-latest', + name: 'chatgpt-4o-latest', + provider: '302ai', + group: 'OpenAI' + }, + { + id: 'gpt-4.1', + name: 'gpt-4.1', + provider: '302ai', + group: 'OpenAI' + }, + { + id: 'o3', + name: 'o3', + provider: '302ai', + group: 'OpenAI' + }, + { + id: 'o4-mini', + name: 'o4-mini', + provider: '302ai', + group: 'OpenAI' + }, + { + id: 'qwen3-235b-a22b', + name: 'qwen3-235b-a22b', + provider: '302ai', + group: 'Qwen' + }, + { + id: 'gemini-2.5-flash-preview-05-20', + name: 'gemini-2.5-flash-preview-05-20', + provider: '302ai', + group: 'Gemini' + }, + { + id: 'gemini-2.5-pro-preview-06-05', + name: 'gemini-2.5-pro-preview-06-05', + provider: '302ai', + group: 'Gemini' + }, + { + id: 'claude-sonnet-4-20250514', + provider: '302ai', + name: 'claude-sonnet-4-20250514', + group: 'Anthropic' + }, + { + id: 'claude-opus-4-20250514', + provider: '302ai', + name: 'claude-opus-4-20250514', + group: 'Anthropic' + }, + { + id: 'jina-clip-v2', + name: 'jina-clip-v2', + provider: '302ai', + group: 'Jina AI' + }, + { + id: 'jina-reranker-m0', + name: 'jina-reranker-m0', + provider: '302ai', + group: 'Jina AI' + } + ], aihubmix: [ { id: 'gpt-4o', @@ -2082,6 +2161,14 @@ export const SYSTEM_MODELS: Record = { name: 'Qwen Plus', group: 'Qwen' } + ], + cephalon: [ + { + id: 'DeepSeek-R1', + provider: 'cephalon', + name: 'DeepSeek-R1满血版', + group: 'DeepSeek' + } ] } diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index c83522f358..629e6827fd 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -1,6 +1,7 @@ import ZhinaoProviderLogo from '@renderer/assets/images/models/360.png' import HunyuanProviderLogo from '@renderer/assets/images/models/hunyuan.png' import AzureProviderLogo from '@renderer/assets/images/models/microsoft.png' +import Ai302ProviderLogo from '@renderer/assets/images/providers/302ai.webp' import AiHubMixProviderLogo from '@renderer/assets/images/providers/aihubmix.webp' import AlayaNewProviderLogo from '@renderer/assets/images/providers/alayanew.webp' import AnthropicProviderLogo from '@renderer/assets/images/providers/anthropic.png' @@ -8,6 +9,7 @@ import BaichuanProviderLogo from '@renderer/assets/images/providers/baichuan.png import BaiduCloudProviderLogo from '@renderer/assets/images/providers/baidu-cloud.svg' import BailianProviderLogo from '@renderer/assets/images/providers/bailian.png' import BurnCloudProviderLogo from '@renderer/assets/images/providers/burncloud.png' +import CephalonProviderLogo from '@renderer/assets/images/providers/cephalon.jpeg' import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png' import DmxapiProviderLogo from '@renderer/assets/images/providers/DMXAPI.png' import FireworksProviderLogo from '@renderer/assets/images/providers/fireworks.png' @@ -48,6 +50,7 @@ import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png' import { TOKENFLUX_HOST } from './constant' const PROVIDER_LOGO_MAP = { + '302ai': Ai302ProviderLogo, openai: OpenAiProviderLogo, silicon: SiliconFlowProviderLogo, deepseek: DeepSeekProviderLogo, @@ -94,7 +97,8 @@ const PROVIDER_LOGO_MAP = { alayanew: AlayaNewProviderLogo, voyageai: VoyageAIProviderLogo, qiniu: QiniuProviderLogo, - tokenflux: TokenFluxProviderLogo + tokenflux: TokenFluxProviderLogo, + cephalon: CephalonProviderLogo } as const export function getProviderLogo(providerId: string) { @@ -106,6 +110,17 @@ export const NOT_SUPPORTED_REANK_PROVIDERS = ['ollama'] export const ONLY_SUPPORTED_DIMENSION_PROVIDERS = ['ollama', 'infini'] export const PROVIDER_CONFIG = { + '302ai': { + api: { + url: 'https://api.302.ai' + }, + websites: { + official: 'https://302.ai', + apiKey: 'https://dash.302.ai/apis/list', + docs: 'https://302ai.apifox.cn/api-147522039', + models: 'https://302.ai/pricing/' + } + }, openai: { api: { url: 'https://api.openai.com' @@ -612,5 +627,16 @@ export const PROVIDER_CONFIG = { docs: `${TOKENFLUX_HOST}/docs`, models: `${TOKENFLUX_HOST}/models` } + }, + cephalon: { + api: { + url: 'https://cephalon.cloud/user-center/v1/model' + }, + websites: { + official: 'https://cephalon.cloud/share/register-landing?invite_id=jSdOYA', + apiKey: 'https://cephalon.cloud/api', + docs: 'https://cephalon.cloud/apitoken/1864244127731589124', + models: 'https://cephalon.cloud/model' + } } } diff --git a/src/renderer/src/hooks/useAssistant.ts b/src/renderer/src/hooks/useAssistant.ts index dd2d47b89f..bc9a55fbd2 100644 --- a/src/renderer/src/hooks/useAssistant.ts +++ b/src/renderer/src/hooks/useAssistant.ts @@ -3,24 +3,18 @@ import { getDefaultTopic } from '@renderer/services/AssistantService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { addAssistant, - addTopic, - removeAllTopics, removeAssistant, - removeTopic, setModel, updateAssistant, updateAssistants, updateAssistantSettings, - updateDefaultAssistant, - updateTopic, - updateTopics + updateDefaultAssistant } from '@renderer/store/assistants' import { setDefaultModel, setQuickAssistantModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm' +import { selectTopicsForAssistant, topicsActions } from '@renderer/store/topics' import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' import { useCallback, useMemo } from 'react' -import { TopicManager } from './useTopic' - export function useAssistants() { const { assistants } = useAppSelector((state) => state.assistants) const dispatch = useAppDispatch() @@ -31,15 +25,15 @@ export function useAssistants() { addAssistant: (assistant: Assistant) => dispatch(addAssistant(assistant)), removeAssistant: (id: string) => { dispatch(removeAssistant({ id })) - const assistant = assistants.find((a) => a.id === id) - const topics = assistant?.topics || [] - topics.forEach(({ id }) => TopicManager.removeTopic(id)) + // Remove all topics for this assistant + dispatch(topicsActions.removeAllTopics({ assistantId: id })) } } } export function useAssistant(id: string) { const assistant = useAppSelector((state) => state.assistants.assistants.find((a) => a.id === id) as Assistant) + const topics = useTopicsForAssistant(id) const dispatch = useAppDispatch() const { defaultModel } = useDefaultModel() @@ -48,19 +42,18 @@ export function useAssistant(id: string) { throw new Error(`Assistant model is not set for assistant with name: ${assistant?.name ?? 'unknown'}`) } - const assistantWithModel = useMemo(() => ({ ...assistant, model }), [assistant, model]) + const assistantWithModel = useMemo(() => ({ ...assistant, model, topics }), [assistant, model, topics]) return { assistant: assistantWithModel, model, - addTopic: (topic: Topic) => dispatch(addTopic({ assistantId: assistant.id, topic })), + topics, + addTopic: (topic: Topic) => dispatch(topicsActions.addTopic({ assistantId: id, topic })), removeTopic: (topic: Topic) => { - TopicManager.removeTopic(topic.id) - dispatch(removeTopic({ assistantId: assistant.id, topic })) + dispatch(topicsActions.removeTopic({ assistantId: id, topicId: topic.id })) }, moveTopic: (topic: Topic, toAssistant: Assistant) => { - dispatch(addTopic({ assistantId: toAssistant.id, topic: { ...topic, assistantId: toAssistant.id } })) - dispatch(removeTopic({ assistantId: assistant.id, topic })) + dispatch(topicsActions.moveTopic({ fromAssistantId: id, toAssistantId: toAssistant.id, topicId: topic.id })) // update topic messages in database db.topics .where('id') @@ -74,9 +67,9 @@ export function useAssistant(id: string) { } }) }, - updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })), - updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })), - removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })), + updateTopic: (topic: Topic) => dispatch(topicsActions.updateTopic({ assistantId: id, topic })), + updateTopics: (topics: Topic[]) => dispatch(topicsActions.updateTopics({ assistantId: id, topics })), + removeAllTopics: () => dispatch(topicsActions.removeAllTopics({ assistantId: id })), setModel: useCallback( (model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })), [assistant, dispatch] @@ -88,15 +81,27 @@ export function useAssistant(id: string) { } } +export function useTopicsForAssistant(assistantId: string) { + return useAppSelector((state) => selectTopicsForAssistant(state, assistantId)) +} + export function useDefaultAssistant() { const defaultAssistant = useAppSelector((state) => state.assistants.defaultAssistant) + const topics = useTopicsForAssistant(defaultAssistant.id) const dispatch = useAppDispatch() - const memoizedTopics = useMemo(() => [getDefaultTopic(defaultAssistant.id)], [defaultAssistant.id]) + + // Ensure default assistant has at least one topic + const finalTopics = useMemo(() => { + if (topics.length > 0) { + return topics + } + return [getDefaultTopic(defaultAssistant.id)] + }, [topics, defaultAssistant.id]) return { defaultAssistant: { ...defaultAssistant, - topics: memoizedTopics + topics: finalTopics }, updateDefaultAssistant: (assistant: Assistant) => dispatch(updateDefaultAssistant({ assistant })) } diff --git a/src/renderer/src/hooks/useChat.tsx b/src/renderer/src/hooks/useChat.tsx index 64ac8f2f35..cb69eac541 100644 --- a/src/renderer/src/hooks/useChat.tsx +++ b/src/renderer/src/hooks/useChat.tsx @@ -6,13 +6,14 @@ import { Assistant } from '@renderer/types' import { Topic } from '@renderer/types' import { useEffect } from 'react' -import { useAssistants } from './useAssistant' +import { useAssistants, useTopicsForAssistant } from './useAssistant' import { useSettings } from './useSettings' export const useChat = () => { const { assistants } = useAssistants() const activeAssistant = useAppSelector((state) => state.runtime.chat.activeAssistant) || assistants[0] - const activeTopic = useAppSelector((state) => state.runtime.chat.activeTopic) || activeAssistant?.topics[0]! + const topics = useTopicsForAssistant(activeAssistant.id) + const activeTopic = useAppSelector((state) => state.runtime.chat.activeTopic) || topics[0] const { clickAssistantToShowTopic } = useSettings() const dispatch = useAppDispatch() @@ -24,12 +25,12 @@ export const useChat = () => { }, [activeTopic, dispatch]) useEffect(() => { - if (activeAssistant?.topics?.find((topic) => topic.id === activeTopic?.id)) { + if (topics.find((topic) => topic.id === activeTopic?.id)) { return } - const firstTopic = activeAssistant.topics[0] + const firstTopic = topics[0] firstTopic && dispatch(setActiveTopic(firstTopic)) - }, [activeAssistant, activeTopic?.id, dispatch]) + }, [activeAssistant, activeTopic?.id, dispatch, topics]) useEffect(() => { if (clickAssistantToShowTopic) { diff --git a/src/renderer/src/hooks/useTopic.ts b/src/renderer/src/hooks/useTopic.ts index d0d177d763..bd2f0cb7c0 100644 --- a/src/renderer/src/hooks/useTopic.ts +++ b/src/renderer/src/hooks/useTopic.ts @@ -2,7 +2,7 @@ import db from '@renderer/databases' import i18n from '@renderer/i18n' import { deleteMessageFiles } from '@renderer/services/MessagesService' import store from '@renderer/store' -import { updateTopic } from '@renderer/store/assistants' +import { selectTopicById, topicsActions } from '@renderer/store/topics' import { Assistant, Topic } from '@renderer/types' import { findMainTextBlocks } from '@renderer/utils/messageUtils/find' import { isEmpty } from 'lodash' @@ -11,18 +11,17 @@ import { getStoreSetting } from './useSettings' const renamingTopics = new Set() -export function useTopic(assistant: Assistant, topicId?: string) { - return assistant?.topics.find((topic) => topic.id === topicId) +export function useTopic(topicId?: string) { + if (!topicId) return undefined + return selectTopicById(store.getState(), topicId) } -export function getTopic(assistant: Assistant, topicId: string) { - return assistant?.topics.find((topic) => topic.id === topicId) +export function getTopic(topicId: string) { + return selectTopicById(store.getState(), topicId) } export async function getTopicById(topicId: string) { - const assistants = store.getState().assistants.assistants - const topics = assistants.map((assistant) => assistant.topics).flat() - const topic = topics.find((topic) => topic.id === topicId) + const topic = selectTopicById(store.getState(), topicId) const messages = await TopicManager.getTopicMessages(topicId) return { ...topic, messages } as Topic } @@ -55,7 +54,7 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) => .substring(0, 50) if (topicName) { const data = { ...topic, name: topicName } as Topic - store.dispatch(updateTopic({ assistantId: assistant.id, topic: data })) + store.dispatch(topicsActions.updateTopic({ assistantId: assistant.id, topic: data })) } return } @@ -65,7 +64,7 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) => const summaryText = await fetchMessagesSummary({ messages: topic.messages, assistant }) if (summaryText) { const data = { ...topic, name: summaryText } - store.dispatch(updateTopic({ assistantId: assistant.id, topic: data })) + store.dispatch(topicsActions.updateTopic({ assistantId: assistant.id, topic: data })) } } } finally { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 73d55ea3cd..4a246547ad 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -980,6 +980,7 @@ "azure-openai": "Azure OpenAI", "baichuan": "Baichuan", "baidu-cloud": "Baidu Cloud", + "cephalon": "Cephalon", "copilot": "GitHub Copilot", "dashscope": "Alibaba Cloud", "deepseek": "DeepSeek", @@ -1020,7 +1021,8 @@ "zhipu": "ZHIPU AI", "voyageai": "Voyage AI", "qiniu": "Qiniu AI", - "tokenflux": "TokenFlux" + "tokenflux": "TokenFlux", + "302ai": "302.AI" }, "restore": { "confirm": "Are you sure you want to restore data?", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 1330363aee..45bae14e71 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -706,7 +706,7 @@ "error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません", "download.success": "ダウンロードに成功しました", "download.failed": "ダウンロードに失敗しました", - "error.fetchTopicName": "[to be translated]:Failed to name the topic" + "error.fetchTopicName": "トピック名の取得に失敗しました" }, "minapp": { "popup": { @@ -1020,7 +1020,9 @@ "zhipu": "智譜AI", "voyageai": "Voyage AI", "qiniu": "七牛云 AI 推理", - "tokenflux": "TokenFlux" + "tokenflux": "TokenFlux", + "302ai": "302.AI", + "cephalon": "Cephalon" }, "restore": { "confirm": "データを復元しますか?", @@ -2045,4 +2047,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index ead1d32056..19caafa5b8 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -706,7 +706,7 @@ "warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!", "download.success": "Скачано успешно", "download.failed": "Скачивание не удалось", - "error.fetchTopicName": "[to be translated]:Failed to name the topic" + "error.fetchTopicName": "Не удалось назвать топик" }, "minapp": { "popup": { @@ -980,6 +980,7 @@ "azure-openai": "Azure OpenAI", "baichuan": "Baichuan", "baidu-cloud": "Baidu Cloud", + "cephalon": "Cephalon", "copilot": "GitHub Copilot", "dashscope": "Alibaba Cloud", "deepseek": "DeepSeek", @@ -1020,7 +1021,8 @@ "zhipu": "ZHIPU AI", "voyageai": "Voyage AI", "qiniu": "Qiniu AI", - "tokenflux": "TokenFlux" + "tokenflux": "TokenFlux", + "302ai": "302.AI" }, "restore": { "confirm": "Вы уверены, что хотите восстановить данные?", @@ -2045,4 +2047,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 179575a736..64dd191d04 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -980,6 +980,7 @@ "azure-openai": "Azure OpenAI", "baichuan": "百川", "baidu-cloud": "百度云千帆", + "cephalon": "Cephalon", "copilot": "GitHub Copilot", "dashscope": "阿里云百炼", "deepseek": "深度求索", @@ -1020,7 +1021,8 @@ "zhipu": "智谱AI", "voyageai": "Voyage AI", "qiniu": "七牛云 AI 推理", - "tokenflux": "TokenFlux" + "tokenflux": "TokenFlux", + "302ai": "302.AI" }, "restore": { "confirm": "确定要恢复数据吗?", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index bca0952797..42cb03ebb4 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -980,6 +980,7 @@ "azure-openai": "Azure OpenAI", "baichuan": "百川", "baidu-cloud": "百度雲千帆", + "cephalon": "Cephalon", "copilot": "GitHub Copilot", "dashscope": "阿里雲百鍊", "deepseek": "深度求索", @@ -1020,7 +1021,8 @@ "zhipu": "智譜 AI", "voyageai": "Voyage AI", "qiniu": "七牛雲 AI 推理", - "tokenflux": "TokenFlux" + "tokenflux": "TokenFlux", + "302ai": "302.AI" }, "restore": { "confirm": "確定要復原資料嗎?", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 81ff1ab7f0..4362777ee7 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -840,6 +840,7 @@ "azure-openai": "Azure OpenAI", "baichuan": "Παράκειμαι", "baidu-cloud": "Baidu Cloud Qianfan", + "cephalon": "Cephalon", "copilot": "GitHub Copilot", "dashscope": "AliCloud Bailian", "deepseek": "Βαθιά Αναζήτηση", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 679dc678a6..f6323c7490 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -841,6 +841,7 @@ "azure-openai": "Azure OpenAI", "baichuan": "BaiChuan", "baidu-cloud": "Baidu Nube Qiánfān", + "cephalon": "Cephalon", "copilot": "GitHub Copiloto", "dashscope": "Álibaba Nube BaiLiàn", "deepseek": "Profundo Buscar", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index b7db9ff5fb..0289043f5c 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -840,6 +840,7 @@ "azure-openai": "Azure OpenAI", "baichuan": "BaiChuan", "baidu-cloud": "Baidu Cloud Qianfan", + "cephalon": "Cephalon", "copilot": "GitHub Copilote", "dashscope": "AliCloud BaiLian", "deepseek": "DeepSeek", diff --git a/src/renderer/src/pages/history/components/TopicsHistory.tsx b/src/renderer/src/pages/history/components/TopicsHistory.tsx index d95a3f7ae6..13738567f9 100644 --- a/src/renderer/src/pages/history/components/TopicsHistory.tsx +++ b/src/renderer/src/pages/history/components/TopicsHistory.tsx @@ -1,8 +1,9 @@ import { SearchOutlined } from '@ant-design/icons' import { VStack } from '@renderer/components/Layout' -import { useAssistants } from '@renderer/hooks/useAssistant' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { getTopicById } from '@renderer/hooks/useTopic' +import { useAppSelector } from '@renderer/store' +import { selectAllTopics } from '@renderer/store/topics' import { Topic } from '@renderer/types' import { Button, Divider, Empty } from 'antd' import dayjs from 'dayjs' @@ -17,13 +18,13 @@ type Props = { } & React.HTMLAttributes const TopicsHistory: React.FC = ({ keywords, onClick, onSearch, ...props }) => { - const { assistants } = useAssistants() + const topics = useAppSelector(selectAllTopics) const { t } = useTranslation() const { handleScroll, containerRef } = useScrollPosition('TopicsHistory') - const topics = orderBy(assistants.map((assistant) => assistant.topics).flat(), 'createdAt', 'desc') + const orderedTopics = orderBy(topics, 'createdAt', 'desc') - const filteredTopics = topics.filter((topic) => { + const filteredTopics = orderedTopics.filter((topic) => { return topic.name.toLowerCase().includes(keywords.toLowerCase()) }) diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index b161cdca53..f3382312ad 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -15,6 +15,7 @@ import type { Model } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL } from '@renderer/utils' +import { copyMessageAsPlainText } from '@renderer/utils/copy' import { exportMarkdownToJoplin, exportMarkdownToSiyuan, @@ -23,7 +24,6 @@ import { exportMessageToNotion, messageToMarkdown } from '@renderer/utils/export' -import { copyMessageAsPlainText } from '@renderer/utils/copy' // import { withMessageThought } from '@renderer/utils/formats' import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown' import { findMainTextBlocks, findTranslationBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 8241742106..20683d2bbf 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -111,7 +111,7 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o setDisplayMessages([]) - const _topic = getTopic(assistant, topic.id) + const _topic = getTopic(topic.id) _topic && updateTopic({ ..._topic, name: defaultTopic.name } as Topic) }, [assistant, clearTopicMessages, topic.id, updateTopic] diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index f58be564ba..1929eba045 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -14,7 +14,7 @@ import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup import PromptPopup from '@renderer/components/Popups/PromptPopup' import Scrollbar from '@renderer/components/Scrollbar' import { isMac } from '@renderer/config/constant' -import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' +import { useAssistant, useAssistants, useTopicsForAssistant } from '@renderer/hooks/useAssistant' import { modelGenerating } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' import { TopicManager } from '@renderer/hooks/useTopic' @@ -56,6 +56,8 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic const { t } = useTranslation() const { showTopicTime, pinTopicsToTop } = useSettings() + const topics = useTopicsForAssistant(_assistant.id) + const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)' const [deletingTopicId, setDeletingTopicId] = useState(null) @@ -104,16 +106,16 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic const handleConfirmDelete = useCallback( async (topic: Topic, e: React.MouseEvent) => { e.stopPropagation() - if (assistant.topics.length === 1) { + if (topics.length === 1) { return onClearMessages(topic) } await modelGenerating() - const index = findIndex(assistant.topics, (t) => t.id === topic.id) - setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1]) + const index = findIndex(topics, (t) => t.id === topic.id) + setActiveTopic(topics[index + 1 === topics.length ? index - 1 : index + 1]) removeTopic(topic) setDeletingTopicId(null) }, - [assistant.topics, onClearMessages, removeTopic, setActiveTopic] + [topics, onClearMessages, removeTopic, setActiveTopic] ) const onPinTopic = useCallback( @@ -128,22 +130,22 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic async (topic: Topic) => { await modelGenerating() if (topic.id === activeTopic?.id) { - const index = findIndex(assistant.topics, (t) => t.id === topic.id) - setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1]) + const index = findIndex(topics, (t) => t.id === topic.id) + setActiveTopic(topics[index + 1 === topics.length ? index - 1 : index + 1]) } removeTopic(topic) }, - [assistant.topics, removeTopic, setActiveTopic, activeTopic] + [topics, removeTopic, setActiveTopic, activeTopic] ) const onMoveTopic = useCallback( async (topic: Topic, toAssistant: Assistant) => { await modelGenerating() - const index = findIndex(assistant.topics, (t) => t.id === topic.id) - setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? 0 : index + 1]) + const index = findIndex(topics, (t) => t.id === topic.id) + setActiveTopic(topics[index + 1 === topics.length ? 0 : index + 1]) moveTopic(topic, toAssistant) }, - [assistant.topics, moveTopic, setActiveTopic] + [topics, moveTopic, setActiveTopic] ) const onSwitchTopic = useCallback( @@ -340,7 +342,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic } ] - if (assistants.length > 1 && assistant.topics.length > 1) { + if (assistants.length > 1 && topics.length > 1) { menus.push({ label: t('chat.topics.move_to'), key: 'move', @@ -355,7 +357,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic }) } - if (assistant.topics.length > 1 && !topic.pinned) { + if (topics.length > 1 && !topic.pinned) { menus.push({ type: 'divider' }) menus.push({ label: t('common.delete'), @@ -380,6 +382,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic exportMenuOptions.joplin, exportMenuOptions.siyuan, assistants, + topics.length, assistant, updateTopic, activeTopic.id, @@ -393,14 +396,14 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic // Sort topics based on pinned status if pinTopicsToTop is enabled const sortedTopics = useMemo(() => { if (pinTopicsToTop) { - return [...assistant.topics].sort((a, b) => { + return [...topics].sort((a, b) => { if (a.pinned && !b.pinned) return -1 if (!a.pinned && b.pinned) return 1 return 0 }) } - return assistant.topics - }, [assistant.topics, pinTopicsToTop]) + return topics + }, [topics, pinTopicsToTop]) return ( diff --git a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx index fc1f0a6014..348e68876c 100644 --- a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx @@ -13,7 +13,7 @@ import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import EmojiIcon from '@renderer/components/EmojiIcon' import CopyIcon from '@renderer/components/Icons/CopyIcon' import PromptPopup from '@renderer/components/Popups/PromptPopup' -import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' +import { useAssistant, useAssistants, useTopicsForAssistant } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { useTags } from '@renderer/hooks/useTags' import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings' @@ -64,6 +64,8 @@ const AssistantItem: FC = ({ const defaultModel = getDefaultModel() const { assistants, updateAssistants } = useAssistants() + const topics = useTopicsForAssistant(assistant.id) + const [isPending, setIsPending] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false) @@ -73,9 +75,9 @@ const AssistantItem: FC = ({ return } - const hasPending = assistant.topics.some((topic) => hasTopicPendingRequests(topic.id)) + const hasPending = topics.some((topic) => hasTopicPendingRequests(topic.id)) setIsPending(hasPending) - }, [isActive, assistant.topics]) + }, [isActive, topics]) const sortByPinyinAsc = useCallback(() => { updateAssistants(sortAssistantsByPinyin(assistants, true)) diff --git a/src/renderer/src/services/MessagesService.ts b/src/renderer/src/services/MessagesService.ts index 178089b25b..ebb8f2df82 100644 --- a/src/renderer/src/services/MessagesService.ts +++ b/src/renderer/src/services/MessagesService.ts @@ -6,6 +6,7 @@ import { fetchMessagesSummary } from '@renderer/services/ApiService' import store from '@renderer/store' import { messageBlocksSelectors, removeManyBlocks } from '@renderer/store/messageBlock' import { selectMessagesForTopic } from '@renderer/store/newMessage' +import { selectTopicsForAssistant } from '@renderer/store/topics' import type { Assistant, FileType, MCPServer, Model, Topic, Usage } from '@renderer/types' import { FileTypes } from '@renderer/types' import type { Message, MessageBlock } from '@renderer/types/newMessage' @@ -265,7 +266,14 @@ export function checkRateLimit(assistant: Assistant): boolean { return false } - const topicId = assistant.topics[0].id + const topics = selectTopicsForAssistant(store.getState(), assistant.id) + const firstTopic = topics[0] + + if (!firstTopic) { + return false + } + + const topicId = firstTopic.id const messages = selectMessagesForTopic(store.getState(), topicId) if (!messages || messages.length <= 1) { diff --git a/src/renderer/src/store/assistants.ts b/src/renderer/src/store/assistants.ts index 14213006d3..855008b4cd 100644 --- a/src/renderer/src/store/assistants.ts +++ b/src/renderer/src/store/assistants.ts @@ -1,9 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant' -import { TopicManager } from '@renderer/hooks/useTopic' -import { getDefaultAssistant, getDefaultTopic } from '@renderer/services/AssistantService' -import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' -import { isEmpty, uniqBy } from 'lodash' +import { getDefaultAssistant } from '@renderer/services/AssistantService' +import { Assistant, AssistantSettings, Model } from '@renderer/types' export interface AssistantsState { defaultAssistant: Assistant @@ -11,9 +9,14 @@ export interface AssistantsState { tagsOrder: string[] } +// 之前的两个实例会导致两个助手不一致的问题 +// FIXME: 更彻底的办法在这次重构就直接把二者合并了 +// Create a single default assistant instance to ensure consistency +const defaultAssistant = getDefaultAssistant() + const initialState: AssistantsState = { - defaultAssistant: getDefaultAssistant(), - assistants: [getDefaultAssistant()], + defaultAssistant: defaultAssistant, + assistants: [defaultAssistant], // Share the same reference tagsOrder: [] } @@ -22,10 +25,23 @@ const assistantsSlice = createSlice({ initialState, reducers: { updateDefaultAssistant: (state, action: PayloadAction<{ assistant: Assistant }>) => { - state.defaultAssistant = action.payload.assistant + const assistant = action.payload.assistant + state.defaultAssistant = assistant + + // Also update the corresponding assistant in the array + const index = state.assistants.findIndex((a) => a.id === assistant.id) + if (index !== -1) { + state.assistants[index] = assistant + } }, updateAssistants: (state, action: PayloadAction) => { state.assistants = action.payload + + // Update defaultAssistant if it exists in the new array + const defaultInArray = action.payload.find((a) => a.id === state.defaultAssistant.id) + if (defaultInArray) { + state.defaultAssistant = defaultInArray + } }, addAssistant: (state, action: PayloadAction) => { state.assistants.push(action.payload) @@ -34,7 +50,13 @@ const assistantsSlice = createSlice({ state.assistants = state.assistants.filter((c) => c.id !== action.payload.id) }, updateAssistant: (state, action: PayloadAction) => { - state.assistants = state.assistants.map((c) => (c.id === action.payload.id ? action.payload : c)) + const assistant = action.payload + state.assistants = state.assistants.map((c) => (c.id === assistant.id ? assistant : c)) + + // Also update defaultAssistant if it's the same assistant + if (state.defaultAssistant.id === assistant.id) { + state.defaultAssistant = assistant + } }, updateAssistantSettings: ( state, @@ -58,78 +80,25 @@ const assistantsSlice = createSlice({ } } }, - addTopic: (state, action: PayloadAction<{ assistantId: string; topic: Topic }>) => { - const topic = action.payload.topic - topic.createdAt = topic.createdAt || new Date().toISOString() - topic.updatedAt = topic.updatedAt || new Date().toISOString() - state.assistants = state.assistants.map((assistant) => - assistant.id === action.payload.assistantId - ? { - ...assistant, - topics: uniqBy([topic, ...assistant.topics], 'id') - } - : assistant - ) - }, - removeTopic: (state, action: PayloadAction<{ assistantId: string; topic: Topic }>) => { - state.assistants = state.assistants.map((assistant) => - assistant.id === action.payload.assistantId - ? { - ...assistant, - topics: assistant.topics.filter(({ id }) => id !== action.payload.topic.id) - } - : assistant - ) - }, - updateTopic: (state, action: PayloadAction<{ assistantId: string; topic: Topic }>) => { - const newTopic = action.payload.topic - newTopic.updatedAt = new Date().toISOString() - state.assistants = state.assistants.map((assistant) => - assistant.id === action.payload.assistantId - ? { - ...assistant, - topics: assistant.topics.map((topic) => { - const _topic = topic.id === newTopic.id ? newTopic : topic - _topic.messages = [] - return _topic - }) - } - : assistant - ) - }, - updateTopics: (state, action: PayloadAction<{ assistantId: string; topics: Topic[] }>) => { - state.assistants = state.assistants.map((assistant) => - assistant.id === action.payload.assistantId - ? { - ...assistant, - topics: action.payload.topics.map((topic) => - isEmpty(topic.messages) ? topic : { ...topic, messages: [] } - ) - } - : assistant - ) - }, - removeAllTopics: (state, action: PayloadAction<{ assistantId: string }>) => { - state.assistants = state.assistants.map((assistant) => { - if (assistant.id === action.payload.assistantId) { - assistant.topics.forEach((topic) => TopicManager.removeTopic(topic.id)) - return { - ...assistant, - topics: [getDefaultTopic(assistant.id)] - } - } - return assistant - }) - }, + setModel: (state, action: PayloadAction<{ assistantId: string; model: Model }>) => { + const { assistantId, model } = action.payload state.assistants = state.assistants.map((assistant) => - assistant.id === action.payload.assistantId + assistant.id === assistantId ? { ...assistant, - model: action.payload.model + model: model } : assistant ) + + // Also update defaultAssistant if it's the same assistant + if (state.defaultAssistant.id === assistantId) { + state.defaultAssistant = { + ...state.defaultAssistant, + model: model + } + } }, setTagsOrder: (state, action: PayloadAction) => { state.tagsOrder = action.payload @@ -143,11 +112,6 @@ export const { addAssistant, removeAssistant, updateAssistant, - addTopic, - removeTopic, - updateTopic, - updateTopics, - removeAllTopics, setModel, setTagsOrder, updateAssistantSettings diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index d0ded856a0..f5c0a444ee 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -22,6 +22,7 @@ import runtime from './runtime' import selectionStore from './selectionStore' import settings from './settings' import shortcuts from './shortcuts' +import topics from './topics' import websearch from './websearch' const rootReducer = combineReducers({ @@ -40,6 +41,7 @@ const rootReducer = combineReducers({ mcp, copilot, selectionStore, + topics, // messages: messagesReducer, messages: newMessagesReducer, messageBlocks: messageBlocksReducer, @@ -50,7 +52,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 111, + version: 113, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, @@ -69,7 +71,7 @@ const persistedReducer = persistReducer( * Call storeSyncService.subscribe() in the window's entryPoint.tsx */ storeSyncService.setOptions({ - syncList: ['assistants/', 'settings/', 'llm/', 'selectionStore/'] + syncList: ['assistants/', 'settings/', 'llm/', 'selectionStore/', 'topics/'] }) const store = configureStore({ diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index d5c8fd566d..1ca1dad6ae 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -127,12 +127,22 @@ export const INITIAL_PROVIDERS: Provider[] = [ enabled: false }, { - id: 'o3', - name: 'O3', + id: '302ai', + name: '302.AI', type: 'openai', apiKey: '', - apiHost: 'https://api.o3.fan', - models: SYSTEM_MODELS.o3, + apiHost: 'https://api.302.ai', + models: SYSTEM_MODELS['302ai'], + isSystem: true, + enabled: false + }, + { + id: 'cephalon', + name: 'Cephalon', + type: 'openai', + apiKey: '', + apiHost: 'https://cephalon.cloud/user-center/v1/model', + models: SYSTEM_MODELS.cephalon, isSystem: true, enabled: false }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 3f25e375d8..21dc198c35 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -5,7 +5,8 @@ import { SYSTEM_MODELS } from '@renderer/config/models' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import db from '@renderer/databases' import i18n from '@renderer/i18n' -import { Assistant, WebSearchProvider } from '@renderer/types' +import { getDefaultTopic } from '@renderer/services/AssistantService' +import { Assistant, Topic, WebSearchProvider } from '@renderer/types' import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils' import { isEmpty } from 'lodash' import { createMigrate } from 'redux-persist' @@ -1555,6 +1556,132 @@ const migrateConfig = { } catch (error) { return state } + }, + '112': (state: RootState) => { + try { + addProvider(state, 'cephalon') + addProvider(state, '302ai') + state.llm.providers = moveProvider(state.llm.providers, 'cephalon', 13) + state.llm.providers = moveProvider(state.llm.providers, '302ai', 14) + return state + } catch (error) { + return state + } + }, + '113': (state: RootState) => { + try { + // Step 1: Merge defaultAssistant and assistants[0] topics to ensure consistency + // This fixes any inconsistencies from backup restores or previous versions + + if (state.assistants?.defaultAssistant && state.assistants?.assistants?.length > 0) { + const defaultAssistantId = state.assistants.defaultAssistant.id + const defaultAssistantInArray = state.assistants.assistants.find((a) => a.id === defaultAssistantId) + + if (defaultAssistantInArray) { + // Merge topics from both defaultAssistant and assistants[0] + const defaultTopics = state.assistants.defaultAssistant.topics || [] + const arrayTopics = defaultAssistantInArray.topics || [] + + // Create a map to avoid duplicates (by topic id) + const topicsMap = new Map() + + // Add topics from both sources + const allTopics = [...defaultTopics, ...arrayTopics] + allTopics.forEach((topic) => { + if (topic && topic.id) { + // Keep the one with more recent updatedAt, or prefer the one from assistants array + const existing = topicsMap.get(topic.id) + if ( + !existing || + (topic.updatedAt && existing.updatedAt && topic.updatedAt > existing.updatedAt) || + arrayTopics.includes(topic) + ) { + topicsMap.set(topic.id, topic) + } + } + }) + + const mergedTopics = Array.from(topicsMap.values()) + + // Update both defaultAssistant and the assistant in array + state.assistants.defaultAssistant.topics = mergedTopics + defaultAssistantInArray.topics = mergedTopics + } else { + // defaultAssistant not found in array, add it + state.assistants.assistants.unshift(state.assistants.defaultAssistant) + } + } + + // Step 2: Migrate from nested topic structure to flattened topic structure + // This should run after v112 which ensures defaultAssistant and assistants[0] consistency + + // Initialize the new topics slice if it doesn't exist + if (!state.topics) { + state.topics = { + ids: [], + entities: {}, + topicIdsByAssistant: {} + } + } + + // Type for legacy assistant with topics + type LegacyAssistant = Assistant + + // Extract all topics from assistants and flatten them + const allTopics: Topic[] = [] + const topicIdsByAssistant: Record = {} + + // Process regular assistants + if (state.assistants?.assistants) { + state.assistants.assistants.forEach((assistant) => { + const legacyAssistant = assistant as LegacyAssistant + if (legacyAssistant.topics && Array.isArray(legacyAssistant.topics) && legacyAssistant.topics.length > 0) { + allTopics.push(...legacyAssistant.topics) + topicIdsByAssistant[assistant.id] = legacyAssistant.topics.map((t: Topic) => t.id) + + // Clear deprecated field + legacyAssistant.topics = [] + } else { + // Create default topic for assistant with no topics + const defaultTopic = getDefaultTopic(assistant.id) + allTopics.push(defaultTopic) + topicIdsByAssistant[assistant.id] = [defaultTopic.id] + + // Set deprecated field + legacyAssistant.topics = [] + } + }) + } + + // Process default assistant - should already be consistent after v112 + if (state.assistants?.defaultAssistant) { + const legacyDefaultAssistant = state.assistants.defaultAssistant as LegacyAssistant + + // Since v112 already ensured consistency, just clear the deprecated field + legacyDefaultAssistant.topics = [] + } + + // Populate the new topics slice + const topicEntities: Record = {} + const topicIds: string[] = [] + + allTopics.forEach((topic) => { + topicEntities[topic.id] = topic + topicIds.push(topic.id) + }) + + // Update topics slice + state.topics = { + ids: topicIds, + entities: topicEntities, + topicIdsByAssistant + } + + return state + } catch (error) { + console.error('Migration 112 failed:', error) + return state + } } } diff --git a/src/renderer/src/store/topics.ts b/src/renderer/src/store/topics.ts new file mode 100644 index 0000000000..3a623315e0 --- /dev/null +++ b/src/renderer/src/store/topics.ts @@ -0,0 +1,185 @@ +import { createEntityAdapter, createSlice, EntityState, PayloadAction } from '@reduxjs/toolkit' +// --- Selectors --- +import { createSelector } from '@reduxjs/toolkit' +import { TopicManager } from '@renderer/hooks/useTopic' +import { getDefaultTopic } from '@renderer/services/AssistantService' +import { Topic } from '@renderer/types' + +import type { RootState } from './index' + +// 1. Create the Adapter +const topicsAdapter = createEntityAdapter() + +// 2. Define the State Interface +export interface TopicsState extends EntityState { + topicIdsByAssistant: Record // Map: assistantId -> ordered topic IDs +} + +// Create default topic for default assistant +const defaultTopic = getDefaultTopic('default') + +// 3. Define the Initial State with default topic +const initialState: TopicsState = topicsAdapter.getInitialState( + { + topicIdsByAssistant: { + default: [defaultTopic.id] // Default assistant has default topic + } + }, + { + [defaultTopic.id]: defaultTopic // Add default topic to entities + } +) + +// Payload types +export interface TopicsReceivedPayload { + assistantId: string + topics: Topic[] +} + +export interface AddTopicPayload { + assistantId: string + topic: Topic +} + +export interface RemoveTopicPayload { + assistantId: string + topicId: string +} + +export interface UpdateTopicPayload { + assistantId: string + topic: Topic +} + +export interface MoveTopicPayload { + fromAssistantId: string + toAssistantId: string + topicId: string +} + +// 4. Create the Slice +const topicsSlice = createSlice({ + name: 'topics', + initialState, + reducers: { + topicsReceived(state, action: PayloadAction) { + const { assistantId, topics } = action.payload + topicsAdapter.upsertMany(state, topics) + state.topicIdsByAssistant[assistantId] = topics.map((t) => t.id) + }, + addTopic(state, action: PayloadAction) { + const { assistantId, topic } = action.payload + const topicWithTimestamp = { + ...topic, + createdAt: topic.createdAt || new Date().toISOString(), + updatedAt: topic.updatedAt || new Date().toISOString() + } + + topicsAdapter.addOne(state, topicWithTimestamp) + + if (!state.topicIdsByAssistant[assistantId]) { + state.topicIdsByAssistant[assistantId] = [] + } + // Add to the beginning to match original behavior + state.topicIdsByAssistant[assistantId].unshift(topic.id) + }, + removeTopic(state, action: PayloadAction) { + const { assistantId, topicId } = action.payload + + topicsAdapter.removeOne(state, topicId) + + const currentTopicIds = state.topicIdsByAssistant[assistantId] + if (currentTopicIds) { + state.topicIdsByAssistant[assistantId] = currentTopicIds.filter((id) => id !== topicId) + } + + // Remove topic from database + TopicManager.removeTopic(topicId) + }, + updateTopic(state, action: PayloadAction) { + const { topic } = action.payload + const updatedTopic = { + ...topic, + updatedAt: new Date().toISOString() + } + + topicsAdapter.updateOne(state, { + id: topic.id, + changes: { ...updatedTopic, messages: [] } // Clear messages in redux to match original behavior + }) + }, + updateTopics(state, action: PayloadAction) { + const { assistantId, topics } = action.payload + + const topicsWithoutMessages = topics.map((topic) => ({ + ...topic, + messages: [] // Clear messages in redux + })) + + topicsAdapter.upsertMany(state, topicsWithoutMessages) + state.topicIdsByAssistant[assistantId] = topics.map((t) => t.id) + }, + removeAllTopics(state, action: PayloadAction<{ assistantId: string }>) { + const { assistantId } = action.payload + const topicIds = state.topicIdsByAssistant[assistantId] || [] + + // Remove topics from database + topicIds.forEach((topicId) => TopicManager.removeTopic(topicId)) + + // Remove topics from redux + topicsAdapter.removeMany(state, topicIds) + + // Create default topic + const defaultTopic = getDefaultTopic(assistantId) + topicsAdapter.addOne(state, defaultTopic) + state.topicIdsByAssistant[assistantId] = [defaultTopic.id] + }, + moveTopic(state, action: PayloadAction) { + const { fromAssistantId, toAssistantId, topicId } = action.payload + + // Update topic's assistantId + topicsAdapter.updateOne(state, { + id: topicId, + changes: { assistantId: toAssistantId } + }) + + // Remove from source assistant's topic list + const fromTopicIds = state.topicIdsByAssistant[fromAssistantId] + if (fromTopicIds) { + state.topicIdsByAssistant[fromAssistantId] = fromTopicIds.filter((id) => id !== topicId) + } + + // Add to target assistant's topic list + if (!state.topicIdsByAssistant[toAssistantId]) { + state.topicIdsByAssistant[toAssistantId] = [] + } + state.topicIdsByAssistant[toAssistantId].unshift(topicId) + } + } +}) + +// 5. Export Actions and Reducer +export const topicsActions = topicsSlice.actions +export default topicsSlice.reducer + +// Base selector for the topics slice state +export const selectTopicsState = (state: RootState) => state.topics + +// Selectors generated by createEntityAdapter +export const { + selectAll: selectAllTopics, + selectById: selectTopicById, + selectIds: selectAllTopicIds, + selectEntities: selectTopicEntities +} = topicsAdapter.getSelectors(selectTopicsState) + +// Custom Selector: Select topics for a specific assistant in order +export const selectTopicsForAssistant = createSelector( + [selectTopicEntities, (state: RootState, assistantId: string) => state.topics.topicIdsByAssistant[assistantId]], + (topicEntities, assistantTopicIds) => { + if (!assistantTopicIds) { + return [] + } + return assistantTopicIds.map((id) => topicEntities[id]).filter((t): t is Topic => !!t) + } +) diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 6c7ef0576f..fe5af00aa4 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -10,6 +10,7 @@ export type Assistant = { name: string prompt: string knowledge_bases?: KnowledgeBase[] + /** @deprecated 话题现在通过独立的 topics slice 管理,请使用 selectTopicsForAssistant selector */ topics: Topic[] type: string emoji?: string @@ -69,6 +70,9 @@ export type Agent = Omit & { group?: string[] } +/** + * @deprecated 旧版消息类型,已废弃 + */ export type LegacyMessage = { id: string assistantId: string diff --git a/src/renderer/src/windows/mini/chat/components/Messages.tsx b/src/renderer/src/windows/mini/chat/components/Messages.tsx index 932446312f..92878e9d06 100644 --- a/src/renderer/src/windows/mini/chat/components/Messages.tsx +++ b/src/renderer/src/windows/mini/chat/components/Messages.tsx @@ -1,9 +1,10 @@ import Scrollbar from '@renderer/components/Scrollbar' +import { useTopicsForAssistant } from '@renderer/hooks/useAssistant' import { useTopicMessages } from '@renderer/hooks/useMessageOperations' import { Assistant } from '@renderer/types' import { getMainTextContent } from '@renderer/utils/messageUtils/find' import { last } from 'lodash' -import { FC, useRef } from 'react' +import { FC, useMemo, useRef } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -21,7 +22,10 @@ interface ContainerProps { const Messages: FC = ({ assistant, route }) => { // const [messages, setMessages] = useState([]) - const messages = useTopicMessages(assistant.topics[0].id) + const topics = useTopicsForAssistant(assistant.id) + const firstTopic = useMemo(() => topics[0], [topics]) + + const messages = useTopicMessages(firstTopic?.id || '') const containerRef = useRef(null) const messagesRef = useRef(messages) diff --git a/src/renderer/src/windows/mini/home/HomeWindow.tsx b/src/renderer/src/windows/mini/home/HomeWindow.tsx index f5b39fc381..4b9d345e61 100644 --- a/src/renderer/src/windows/mini/home/HomeWindow.tsx +++ b/src/renderer/src/windows/mini/home/HomeWindow.tsx @@ -1,6 +1,6 @@ import { isMac } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' -import { useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant' +import { useDefaultAssistant, useDefaultModel, useTopicsForAssistant } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' import { fetchChatCompletion } from '@renderer/services/ApiService' @@ -20,7 +20,7 @@ import { IpcChannel } from '@shared/IpcChannel' import { Divider } from 'antd' import dayjs from 'dayjs' import { isEmpty } from 'lodash' -import React, { FC, useCallback, useEffect, useRef, useState } from 'react' +import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -41,7 +41,10 @@ const HomeWindow: FC = () => { const [lastClipboardText, setLastClipboardText] = useState(null) const textChange = useState(() => {})[1] const { defaultAssistant } = useDefaultAssistant() - const topic = defaultAssistant.topics[0] + + const topics = useTopicsForAssistant(defaultAssistant.id) + const topic = useMemo(() => topics[0], [topics]) + const { defaultModel, quickAssistantModel } = useDefaultModel() // 如果 quickAssistantModel 未設定,則使用 defaultModel const model = quickAssistantModel || defaultModel @@ -182,7 +185,7 @@ const HomeWindow: FC = () => { let blockId: string | null = null let blockContent: string = '' - const assistantMessage = getAssistantMessage({ assistant, topic: assistant.topics[0] }) + const assistantMessage = getAssistantMessage({ assistant, topic }) store.dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) fetchChatCompletion({