diff --git a/README.md b/README.md index b5d7c2528d..36678cd62c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai # 📖 Guide -https://docs.cherry-ai.com + # 🌠 Screenshot @@ -82,17 +82,18 @@ https://docs.cherry-ai.com # 🌈 Theme -- Theme Gallery: https://cherrycss.com -- Aero Theme: https://github.com/hakadao/CherryStudio-Aero -- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial -- Claude dynamic-style: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic -- Maple Neon Theme: https://github.com/BoningtonChen/CherryStudio_themes +- Theme Gallery: +- Aero Theme: +- PaperMaterial Theme: +- Claude dynamic-style: +- Maple Neon Theme: Welcome PR for more themes # 🖥️ Develop Refer to the [development documentation](docs/dev.md) +Refer to the [Architecture overview documentation](https://deepwiki.com/CherryHQ/cherry-studio) # 🤝 Contributing @@ -144,7 +145,7 @@ Thank you for your support and contributions! # ✉️ Contact -yinsenho@cherry-ai.com + # ⭐️ Star History diff --git a/src/main/reranker/BaseReranker.ts b/src/main/reranker/BaseReranker.ts index 4109f53986..5a8bd6ee2a 100644 --- a/src/main/reranker/BaseReranker.ts +++ b/src/main/reranker/BaseReranker.ts @@ -17,6 +17,10 @@ export default abstract class BaseReranker { * Get Rerank Request Url */ protected getRerankUrl() { + if (this.base.rerankModelProvider === 'dashscope') { + return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank' + } + let baseURL = this.base?.rerankBaseURL?.endsWith('/') ? this.base.rerankBaseURL.slice(0, -1) : this.base.rerankBaseURL @@ -28,6 +32,56 @@ export default abstract class BaseReranker { return `${baseURL}/rerank` } + /** + * Get Rerank Request Body + */ + protected getRerankRequestBody(query: string, searchResults: ExtractChunkData[]) { + const provider = this.base.rerankModelProvider + const documents = searchResults.map((doc) => doc.pageContent) + const topN = this.base.topN || 5 + + if (provider === 'voyageai') { + return { + model: this.base.rerankModel, + query, + documents, + top_k: topN + } + } else if (provider === 'dashscope') { + return { + model: this.base.rerankModel, + input: { + query, + documents + }, + parameters: { + top_n: topN + } + } + } else { + return { + model: this.base.rerankModel, + query, + documents, + top_n: topN + } + } + } + + /** + * Extract Rerank Result + */ + protected extractRerankResult(data: any) { + const provider = this.base.rerankModelProvider + if (provider === 'dashscope') { + return data.output.results + } else if (provider === 'voyageai') { + return data.data + } else { + return data.results + } + } + /** * Get Rerank Result * @param searchResults diff --git a/src/main/reranker/DashscopeReranker.ts b/src/main/reranker/DashscopeReranker.ts deleted file mode 100644 index ac96092a1e..0000000000 --- a/src/main/reranker/DashscopeReranker.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' -import axiosProxy from '@main/services/AxiosProxy' -import { KnowledgeBaseParams } from '@types' - -import BaseReranker from './BaseReranker' - -interface DashscopeRerankResultItem { - document: { - text: string - } - index: number - relevance_score: number -} - -interface DashscopeRerankResponse { - output: { - results: DashscopeRerankResultItem[] - } - usage: { - total_tokens: number - } - request_id: string -} - -export default class DashscopeReranker extends BaseReranker { - constructor(base: KnowledgeBaseParams) { - super(base) - } - - public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { - const url = 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank' - - const requestBody = { - model: this.base.rerankModel, - input: { - query, - documents: searchResults.map((doc) => doc.pageContent) - }, - parameters: { - return_documents: true, // Recommended to be true to get document details if needed, though scores are primary - top_n: this.base.topN || 5 // Default to 5 if topN is not specified, as per API example - } - } - - try { - const { data } = await axiosProxy.axios.post(url, requestBody, { - headers: this.defaultHeaders() - }) - - const rerankResults = data.output.results - return this.getRerankResult(searchResults, rerankResults) - } catch (error: any) { - const errorDetails = this.formatErrorMessage(url, error, requestBody) - console.error('Dashscope Reranker API 错误:', errorDetails) - throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`) - } - } -} diff --git a/src/main/reranker/DefaultReranker.ts b/src/main/reranker/DefaultReranker.ts deleted file mode 100644 index 70a4d05ac5..0000000000 --- a/src/main/reranker/DefaultReranker.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' -import { KnowledgeBaseParams } from '@types' - -import BaseReranker from './BaseReranker' - -export default class DefaultReranker extends BaseReranker { - constructor(base: KnowledgeBaseParams) { - super(base) - } - - async rerank(): Promise { - throw new Error('Method not implemented.') - } -} diff --git a/src/main/reranker/JinaReranker.ts b/src/main/reranker/GeneralReranker.ts similarity index 70% rename from src/main/reranker/JinaReranker.ts rename to src/main/reranker/GeneralReranker.ts index 88350a5e61..185e2132c7 100644 --- a/src/main/reranker/JinaReranker.ts +++ b/src/main/reranker/GeneralReranker.ts @@ -4,7 +4,7 @@ import { KnowledgeBaseParams } from '@types' import BaseReranker from './BaseReranker' -export default class JinaReranker extends BaseReranker { +export default class GeneralReranker extends BaseReranker { constructor(base: KnowledgeBaseParams) { super(base) } @@ -12,21 +12,15 @@ export default class JinaReranker extends BaseReranker { public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { const url = this.getRerankUrl() - const requestBody = { - model: this.base.rerankModel, - query, - documents: searchResults.map((doc) => doc.pageContent), - top_n: this.base.topN - } + const requestBody = this.getRerankRequestBody(query, searchResults) try { const { data } = await AxiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() }) - const rerankResults = data.results + const rerankResults = this.extractRerankResult(data) return this.getRerankResult(searchResults, rerankResults) } catch (error: any) { const errorDetails = this.formatErrorMessage(url, error, requestBody) - console.error('Jina Reranker API Error:', errorDetails) throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`) } } diff --git a/src/main/reranker/Reranker.ts b/src/main/reranker/Reranker.ts index f9f37cfca6..d42376ea20 100644 --- a/src/main/reranker/Reranker.ts +++ b/src/main/reranker/Reranker.ts @@ -1,13 +1,12 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { KnowledgeBaseParams } from '@types' -import BaseReranker from './BaseReranker' -import RerankerFactory from './RerankerFactory' +import GeneralReranker from './GeneralReranker' export default class Reranker { - private sdk: BaseReranker + private sdk: GeneralReranker constructor(base: KnowledgeBaseParams) { - this.sdk = RerankerFactory.create(base) + this.sdk = new GeneralReranker(base) } public async rerank(query: string, searchResults: ExtractChunkData[]): Promise { return this.sdk.rerank(query, searchResults) diff --git a/src/main/reranker/RerankerFactory.ts b/src/main/reranker/RerankerFactory.ts deleted file mode 100644 index d1ae18d788..0000000000 --- a/src/main/reranker/RerankerFactory.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { KnowledgeBaseParams } from '@types' - -import BaseReranker from './BaseReranker' -import DashscopeReranker from './DashscopeReranker' -import DefaultReranker from './DefaultReranker' -import JinaReranker from './JinaReranker' -import SiliconFlowReranker from './SiliconFlowReranker' -import VoyageReranker from './VoyageReranker' - -export default class RerankerFactory { - static create(base: KnowledgeBaseParams): BaseReranker { - if (base.rerankModelProvider === 'silicon') { - return new SiliconFlowReranker(base) - } else if (base.rerankModelProvider === 'jina') { - return new JinaReranker(base) - } else if (base.rerankModelProvider === 'voyageai') { - return new VoyageReranker(base) - } else if (base.rerankModelProvider === 'dashscope') { - return new DashscopeReranker(base) - } - return new DefaultReranker(base) - } -} diff --git a/src/main/reranker/SiliconFlowReranker.ts b/src/main/reranker/SiliconFlowReranker.ts deleted file mode 100644 index 78a213561a..0000000000 --- a/src/main/reranker/SiliconFlowReranker.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' -import axiosProxy from '@main/services/AxiosProxy' -import { KnowledgeBaseParams } from '@types' - -import BaseReranker from './BaseReranker' - -export default class SiliconFlowReranker extends BaseReranker { - constructor(base: KnowledgeBaseParams) { - super(base) - } - - public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { - const url = this.getRerankUrl() - - const requestBody = { - model: this.base.rerankModel, - query, - documents: searchResults.map((doc) => doc.pageContent), - top_n: this.base.topN, - max_chunks_per_doc: this.base.chunkSize, - overlap_tokens: this.base.chunkOverlap - } - - try { - const { data } = await axiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() }) - - const rerankResults = data.results - return this.getRerankResult(searchResults, rerankResults) - } catch (error: any) { - const errorDetails = this.formatErrorMessage(url, error, requestBody) - - console.error('SiliconFlow Reranker API 错误:', errorDetails) - throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`) - } - } -} diff --git a/src/main/reranker/VoyageReranker.ts b/src/main/reranker/VoyageReranker.ts deleted file mode 100644 index 44c800b6d5..0000000000 --- a/src/main/reranker/VoyageReranker.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' -import axiosProxy from '@main/services/AxiosProxy' -import { KnowledgeBaseParams } from '@types' - -import BaseReranker from './BaseReranker' - -export default class VoyageReranker extends BaseReranker { - constructor(base: KnowledgeBaseParams) { - super(base) - } - - public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { - const url = this.getRerankUrl() - - const requestBody = { - model: this.base.rerankModel, - query, - documents: searchResults.map((doc) => doc.pageContent), - top_k: this.base.topN, - return_documents: false, - truncation: true - } - - try { - const { data } = await axiosProxy.axios.post(url, requestBody, { - headers: { - ...this.defaultHeaders() - } - }) - - const rerankResults = data.data - return this.getRerankResult(searchResults, rerankResults) - } catch (error: any) { - const errorDetails = this.formatErrorMessage(url, error, requestBody) - - console.error('Voyage Reranker API Error:', errorDetails) - throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`) - } - } -} diff --git a/src/renderer/src/assets/images/search/bocha.webp b/src/renderer/src/assets/images/search/bocha.webp new file mode 100644 index 0000000000..ee21dc16e9 Binary files /dev/null and b/src/renderer/src/assets/images/search/bocha.webp differ diff --git a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts new file mode 100644 index 0000000000..93441acb21 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts @@ -0,0 +1,41 @@ +import { useMemo, useReducer } from 'react' + +import { initialScrollState, scrollReducer } from './reducer' +import { FlatListItem, ScrollTrigger } from './types' + +/** + * 管理滚动和焦点状态的 hook + */ +export function useScrollState() { + const [state, dispatch] = useReducer(scrollReducer, initialScrollState) + + const actions = useMemo( + () => ({ + setFocusedItemKey: (key: string) => dispatch({ type: 'SET_FOCUSED_ITEM_KEY', payload: key }), + setScrollTrigger: (trigger: ScrollTrigger) => dispatch({ type: 'SET_SCROLL_TRIGGER', payload: trigger }), + setLastScrollOffset: (offset: number) => dispatch({ type: 'SET_LAST_SCROLL_OFFSET', payload: offset }), + setStickyGroup: (group: FlatListItem | null) => dispatch({ type: 'SET_STICKY_GROUP', payload: group }), + setIsMouseOver: (isMouseOver: boolean) => dispatch({ type: 'SET_IS_MOUSE_OVER', payload: isMouseOver }), + focusNextItem: (modelItems: FlatListItem[], step: number) => + dispatch({ type: 'FOCUS_NEXT_ITEM', payload: { modelItems, step } }), + focusPage: (modelItems: FlatListItem[], currentIndex: number, step: number) => + dispatch({ type: 'FOCUS_PAGE', payload: { modelItems, currentIndex, step } }), + searchChanged: (searchText: string) => dispatch({ type: 'SEARCH_CHANGED', payload: { searchText } }), + updateOnListChange: (modelItems: FlatListItem[]) => + dispatch({ type: 'UPDATE_ON_LIST_CHANGE', payload: { modelItems } }), + initScroll: () => dispatch({ type: 'INIT_SCROLL' }) + }), + [] + ) + + return { + // 状态 + focusedItemKey: state.focusedItemKey, + scrollTrigger: state.scrollTrigger, + lastScrollOffset: state.lastScrollOffset, + stickyGroup: state.stickyGroup, + isMouseOver: state.isMouseOver, + // 操作 + ...actions + } +} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/index.ts b/src/renderer/src/components/Popups/SelectModelPopup/index.ts new file mode 100644 index 0000000000..7a0b7fa862 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/index.ts @@ -0,0 +1,3 @@ +import { SelectModelPopup } from './popup' + +export default SelectModelPopup diff --git a/src/renderer/src/components/Popups/SelectModelPopup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx similarity index 72% rename from src/renderer/src/components/Popups/SelectModelPopup.tsx rename to src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index c19c5def9c..ffaac877ea 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -1,7 +1,9 @@ import { PushpinOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' +import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' import { TopView } from '@renderer/components/TopView' import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models' -import db from '@renderer/databases' +import { usePinnedModels } from '@renderer/hooks/usePinnedModels' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' import { Model } from '@renderer/types' @@ -15,101 +17,61 @@ import { useTranslation } from 'react-i18next' import { FixedSizeList } from 'react-window' import styled from 'styled-components' -import { HStack } from '../Layout' -import ModelTagsWithLabel from '../ModelTagsWithLabel' +import { useScrollState } from './hook' +import { FlatListItem } from './types' const PAGE_SIZE = 9 const ITEM_HEIGHT = 36 -// 列表项类型,组名也作为列表项 -type ListItemType = 'group' | 'model' - -// 滚动触发来源类型 -type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none' - -// 扁平化列表项接口 -interface FlatListItem { - key: string - type: ListItemType - icon?: React.ReactNode - name: React.ReactNode - tags?: React.ReactNode - model?: Model - isPinned?: boolean - isSelected?: boolean -} - -interface Props { +interface PopupParams { model?: Model } -interface PopupContainerProps extends Props { +interface Props extends PopupParams { resolve: (value: Model | undefined) => void } -const PopupContainer: React.FC = ({ model, resolve }) => { +const PopupContainer: React.FC = ({ model, resolve }) => { const { t } = useTranslation() const { providers } = useProviders() + const { pinnedModels, togglePinnedModel, loading: loadingPinnedModels } = usePinnedModels() const [open, setOpen] = useState(true) const inputRef = useRef(null) const listRef = useRef(null) const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) - const [isMouseOver, setIsMouseOver] = useState(false) - const [pinnedModels, setPinnedModels] = useState([]) - const [_focusedItemKey, setFocusedItemKey] = useState('') - const focusedItemKey = useDeferredValue(_focusedItemKey) - const [_stickyGroup, setStickyGroup] = useState(null) - const stickyGroup = useDeferredValue(_stickyGroup) - const firstGroupRef = useRef(null) - const scrollTriggerRef = useRef('initial') - const lastScrollOffsetRef = useRef(0) // 当前选中的模型ID const currentModelId = model ? getModelUniqId(model) : '' - // 加载置顶模型列表 - useEffect(() => { - const loadPinnedModels = async () => { - const setting = await db.settings.get('pinned:models') - const savedPinnedModels = setting?.value || [] + // 管理滚动和焦点状态 + const { + focusedItemKey, + scrollTrigger, + lastScrollOffset, + stickyGroup: _stickyGroup, + isMouseOver, + setFocusedItemKey, + setScrollTrigger, + setLastScrollOffset, + setStickyGroup, + setIsMouseOver, + focusNextItem, + focusPage, + searchChanged, + updateOnListChange, + initScroll + } = useScrollState() - // Filter out invalid pinned models - const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m)) - const validPinnedModels = savedPinnedModels.filter((id) => allModelIds.includes(id)) - - // Update storage if there were invalid models - if (validPinnedModels.length !== savedPinnedModels.length) { - await db.settings.put({ id: 'pinned:models', value: validPinnedModels }) - } - - setPinnedModels(sortBy(validPinnedModels)) - } - - try { - loadPinnedModels() - } catch (error) { - console.error('Failed to load pinned models', error) - setPinnedModels([]) - } - }, [providers]) + const stickyGroup = useDeferredValue(_stickyGroup) + const firstGroupRef = useRef(null) const togglePin = useCallback( async (modelId: string) => { - const newPinnedModels = pinnedModels.includes(modelId) - ? pinnedModels.filter((id) => id !== modelId) - : [...pinnedModels, modelId] - - try { - await db.settings.put({ id: 'pinned:models', value: newPinnedModels }) - setPinnedModels(sortBy(newPinnedModels)) - // Pin操作不触发滚动 - scrollTriggerRef.current = 'none' - } catch (error) { - console.error('Failed to update pinned models', error) - } + await togglePinnedModel(modelId) + setScrollTrigger('none') // pin操作不触发滚动 }, - [pinnedModels] + [togglePinnedModel, setScrollTrigger] ) // 根据输入的文本筛选模型 @@ -222,6 +184,16 @@ const PopupContainer: React.FC = ({ model, resolve }) => { return items }, [providers, getFilteredModels, pinnedModels, searchText, t, createModelItem]) + // 获取可选择的模型项(过滤掉分组标题) + const modelItems = useMemo(() => { + return listItems.filter((item) => item.type === 'model') + }, [listItems]) + + // 当搜索文本变化时更新滚动触发器 + useEffect(() => { + searchChanged(searchText) + }, [searchText, searchChanged]) + // 基于滚动位置更新sticky分组标题 const updateStickyGroup = useCallback( (scrollOffset?: number) => { @@ -231,7 +203,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { } // 基于滚动位置计算当前可见的第一个项的索引 - const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffsetRef.current) / ITEM_HEIGHT) + const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffset) / ITEM_HEIGHT) // 从该索引向前查找最近的分组标题 for (let i = estimatedIndex - 1; i >= 0; i--) { @@ -242,9 +214,9 @@ const PopupContainer: React.FC = ({ model, resolve }) => { } // 找不到则使用第一个分组标题 - setStickyGroup(firstGroupRef.current ?? null) + setStickyGroup(firstGroupRef.current) }, - [listItems] + [listItems, lastScrollOffset, setStickyGroup] ) // 在listItems变化时更新sticky group @@ -255,67 +227,46 @@ const PopupContainer: React.FC = ({ model, resolve }) => { // 处理列表滚动事件,更新lastScrollOffset并更新sticky分组 const handleScroll = useCallback( ({ scrollOffset }) => { - lastScrollOffsetRef.current = scrollOffset + setLastScrollOffset(scrollOffset) updateStickyGroup(scrollOffset) }, - [updateStickyGroup] + [updateStickyGroup, setLastScrollOffset] ) - // 获取可选择的模型项(过滤掉分组标题) - const modelItems = useMemo(() => { - return listItems.filter((item) => item.type === 'model') - }, [listItems]) - - // 搜索文本变化时设置滚动来源 + // 在列表项更新时,更新焦点项 useEffect(() => { - if (searchText.trim() !== '') { - scrollTriggerRef.current = 'search' - setFocusedItemKey('') - } - }, [searchText]) - - // 设置初始聚焦项以触发滚动 - useEffect(() => { - if (scrollTriggerRef.current === 'initial' || scrollTriggerRef.current === 'search') { - const selectedItem = modelItems.find((item) => item.isSelected) - if (selectedItem) { - setFocusedItemKey(selectedItem.key) - } else if (scrollTriggerRef.current === 'initial' && modelItems.length > 0) { - setFocusedItemKey(modelItems[0].key) - } - // 其余情况不设置focusedItemKey - } - }, [modelItems]) + updateOnListChange(modelItems) + }, [modelItems, updateOnListChange]) // 滚动到聚焦项 useEffect(() => { - if (scrollTriggerRef.current === 'none' || !focusedItemKey) return + if (scrollTrigger === 'none' || !focusedItemKey) return const index = listItems.findIndex((item) => item.key === focusedItemKey) if (index < 0) return // 根据触发源决定滚动对齐方式 - const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'center' + const alignment = scrollTrigger === 'keyboard' ? 'auto' : 'center' listRef.current?.scrollToItem(index, alignment) // 滚动后重置触发器 - scrollTriggerRef.current = 'none' - }, [focusedItemKey, listItems]) + setScrollTrigger('none') + }, [focusedItemKey, scrollTrigger, listItems, setScrollTrigger]) const handleItemClick = useCallback( (item: FlatListItem) => { if (item.type === 'model') { - scrollTriggerRef.current = 'none' + setScrollTrigger('initial') resolve(item.model) setOpen(false) } }, - [resolve] + [resolve, setScrollTrigger] ) // 处理键盘导航 - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { if (!open) return if (modelItems.length === 0) { @@ -329,43 +280,21 @@ const PopupContainer: React.FC = ({ model, resolve }) => { setIsMouseOver(false) } - const getCurrentIndex = (currentKey: string) => { - const currentIndex = modelItems.findIndex((item) => item.key === currentKey) - return currentIndex < 0 ? 0 : currentIndex - } + const currentIndex = modelItems.findIndex((item) => item.key === focusedItemKey) + const normalizedIndex = currentIndex < 0 ? 0 : currentIndex switch (e.key) { case 'ArrowUp': - scrollTriggerRef.current = 'keyboard' - setFocusedItemKey((prev) => { - const currentIndex = getCurrentIndex(prev) - const nextIndex = (currentIndex - 1 + modelItems.length) % modelItems.length - return modelItems[nextIndex].key - }) + focusNextItem(modelItems, -1) break case 'ArrowDown': - scrollTriggerRef.current = 'keyboard' - setFocusedItemKey((prev) => { - const currentIndex = getCurrentIndex(prev) - const nextIndex = (currentIndex + 1) % modelItems.length - return modelItems[nextIndex].key - }) + focusNextItem(modelItems, 1) break case 'PageUp': - scrollTriggerRef.current = 'keyboard' - setFocusedItemKey((prev) => { - const currentIndex = getCurrentIndex(prev) - const nextIndex = Math.max(currentIndex - PAGE_SIZE, 0) - return modelItems[nextIndex].key - }) + focusPage(modelItems, normalizedIndex, -PAGE_SIZE) break case 'PageDown': - scrollTriggerRef.current = 'keyboard' - setFocusedItemKey((prev) => { - const currentIndex = getCurrentIndex(prev) - const nextIndex = Math.min(currentIndex + PAGE_SIZE, modelItems.length - 1) - return modelItems[nextIndex].key - }) + focusPage(modelItems, normalizedIndex, PAGE_SIZE) break case 'Enter': if (focusedItemKey) { @@ -377,34 +306,47 @@ const PopupContainer: React.FC = ({ model, resolve }) => { break case 'Escape': e.preventDefault() - scrollTriggerRef.current = 'none' + setScrollTrigger('none') setOpen(false) resolve(undefined) break } - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [focusedItemKey, modelItems, handleItemClick, open, resolve]) - - const onCancel = useCallback(() => { - scrollTriggerRef.current = 'none' - setOpen(false) - }, []) - - const onClose = useCallback(async () => { - scrollTriggerRef.current = 'none' - resolve(undefined) - SelectModelPopup.hide() - }, [resolve]) + }, + [ + focusedItemKey, + modelItems, + handleItemClick, + open, + resolve, + setIsMouseOver, + focusNextItem, + focusPage, + setScrollTrigger + ] + ) useEffect(() => { - if (!open) return + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleKeyDown]) + + const onCancel = useCallback(() => { + setScrollTrigger('initial') + setOpen(false) + }, [setScrollTrigger]) + + const onClose = useCallback(async () => { + setScrollTrigger('initial') + resolve(undefined) + SelectModelPopup.hide() + }, [resolve, setScrollTrigger]) + + // 初始化焦点和滚动位置 + useEffect(() => { + if (!open || loadingPinnedModels) return setTimeout(() => inputRef.current?.focus(), 0) - scrollTriggerRef.current = 'initial' - lastScrollOffsetRef.current = 0 - }, [open]) + initScroll() + }, [open, initScroll, loadingPinnedModels]) const RowData = useMemo( (): VirtualizedRowData => ({ @@ -415,7 +357,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { handleItemClick, togglePin }), - [stickyGroup, focusedItemKey, handleItemClick, listItems, togglePin] + [stickyGroup, focusedItemKey, handleItemClick, listItems, togglePin, setFocusedItemKey] ) const listHeight = useMemo(() => { @@ -470,7 +412,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { {listItems.length > 0 ? ( - setIsMouseOver(true)}> + !isMouseOver && setIsMouseOver(true)}> {/* Sticky Group Banner,它会替换第一个分组名称 */} {stickyGroup?.name} ((resolve) => { - TopView.show(, 'SelectModelPopup') + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) }) } } diff --git a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts new file mode 100644 index 0000000000..45e3390ea8 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts @@ -0,0 +1,109 @@ +import { ScrollAction, ScrollState } from './types' + +/** + * 初始状态 + */ +export const initialScrollState: ScrollState = { + focusedItemKey: '', + scrollTrigger: 'initial', + lastScrollOffset: 0, + stickyGroup: null, + isMouseOver: false +} + +/** + * 滚动状态的 reducer,用于避免复杂依赖可能带来的状态更新问题 + * @param state 当前状态 + * @param action 动作 + * @returns 新的状态 + */ +export const scrollReducer = (state: ScrollState, action: ScrollAction): ScrollState => { + switch (action.type) { + case 'SET_FOCUSED_ITEM_KEY': + return { ...state, focusedItemKey: action.payload } + + case 'SET_SCROLL_TRIGGER': + return { ...state, scrollTrigger: action.payload } + + case 'SET_LAST_SCROLL_OFFSET': + return { ...state, lastScrollOffset: action.payload } + + case 'SET_STICKY_GROUP': + return { ...state, stickyGroup: action.payload } + + case 'SET_IS_MOUSE_OVER': + return { ...state, isMouseOver: action.payload } + + case 'FOCUS_NEXT_ITEM': { + const { modelItems, step } = action.payload + + if (modelItems.length === 0) { + return { + ...state, + focusedItemKey: '', + scrollTrigger: 'keyboard' + } + } + + const currentIndex = modelItems.findIndex((item) => item.key === state.focusedItemKey) + const nextIndex = (currentIndex < 0 ? 0 : currentIndex + step + modelItems.length) % modelItems.length + + return { + ...state, + focusedItemKey: modelItems[nextIndex].key, + scrollTrigger: 'keyboard' + } + } + + case 'FOCUS_PAGE': { + const { modelItems, currentIndex, step } = action.payload + const nextIndex = Math.max(0, Math.min(currentIndex + step, modelItems.length - 1)) + + return { + ...state, + focusedItemKey: modelItems.length > 0 ? modelItems[nextIndex].key : '', + scrollTrigger: 'keyboard' + } + } + + case 'SEARCH_CHANGED': + return { + ...state, + scrollTrigger: action.payload.searchText ? 'search' : 'initial' + } + + case 'UPDATE_ON_LIST_CHANGE': { + const { modelItems } = action.payload + + // 在列表变化时尝试聚焦一个模型: + // - 如果是 initial 状态,先尝试聚焦当前选中的模型 + // - 如果是 search 状态,尝试聚焦第一个模型 + let newFocusedKey = '' + if (state.scrollTrigger === 'initial' || state.scrollTrigger === 'search') { + const selectedItem = modelItems.find((item) => item.isSelected) + if (selectedItem && state.scrollTrigger === 'initial') { + newFocusedKey = selectedItem.key + } else if (modelItems.length > 0) { + newFocusedKey = modelItems[0].key + } + } else { + newFocusedKey = state.focusedItemKey + } + + return { + ...state, + focusedItemKey: newFocusedKey + } + } + + case 'INIT_SCROLL': + return { + ...state, + scrollTrigger: 'initial', + lastScrollOffset: 0 + } + + default: + return state + } +} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/types.ts b/src/renderer/src/components/Popups/SelectModelPopup/types.ts new file mode 100644 index 0000000000..41ec04c583 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/types.ts @@ -0,0 +1,42 @@ +import { Model } from '@renderer/types' +import { ReactNode } from 'react' + +// 列表项类型,组名也作为列表项 +export type ListItemType = 'group' | 'model' + +// 滚动触发来源类型 +export type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none' + +// 扁平化列表项接口 +export interface FlatListItem { + key: string + type: ListItemType + icon?: ReactNode + name: ReactNode + tags?: ReactNode + model?: Model + isPinned?: boolean + isSelected?: boolean +} + +// 滚动和焦点相关的状态类型 +export interface ScrollState { + focusedItemKey: string + scrollTrigger: ScrollTrigger + lastScrollOffset: number + stickyGroup: FlatListItem | null + isMouseOver: boolean +} + +// 滚动和焦点相关的 action 类型 +export type ScrollAction = + | { type: 'SET_FOCUSED_ITEM_KEY'; payload: string } + | { type: 'SET_SCROLL_TRIGGER'; payload: ScrollTrigger } + | { type: 'SET_LAST_SCROLL_OFFSET'; payload: number } + | { type: 'SET_STICKY_GROUP'; payload: FlatListItem | null } + | { type: 'SET_IS_MOUSE_OVER'; payload: boolean } + | { type: 'FOCUS_NEXT_ITEM'; payload: { modelItems: FlatListItem[]; step: number } } + | { type: 'FOCUS_PAGE'; payload: { modelItems: FlatListItem[]; currentIndex: number; step: number } } + | { type: 'SEARCH_CHANGED'; payload: { searchText: string } } + | { type: 'UPDATE_ON_LIST_CHANGE'; payload: { modelItems: FlatListItem[] } } + | { type: 'INIT_SCROLL'; payload?: void } diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 65ed25a04f..d86f1430af 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -237,23 +237,24 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp( ) export function isFunctionCallingModel(model: Model): boolean { - if (model.type?.includes('function_calling')) { - return true - } + if (!model) return false + if (model.type) { + return model.type.includes('function_calling') + } else { + if (isEmbeddingModel(model)) { + return false + } - if (isEmbeddingModel(model)) { - return false - } + if (model.provider === 'qiniu') { + return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id) + } - if (model.provider === 'qiniu') { - return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(model.id) - } + if (['deepseek', 'anthropic'].includes(model.provider)) { + return true + } - if (['deepseek', 'anthropic'].includes(model.provider)) { - return true + return FUNCTION_CALLING_REGEX.test(model.id) } - - return FUNCTION_CALLING_REGEX.test(model.id) } export function getModelLogo(modelId: string) { @@ -2188,20 +2189,23 @@ export function isEmbeddingModel(model: Model): boolean { if (!model) { return false } + if (model.type) { + return model.type.includes('embedding') + } else { + if (['anthropic'].includes(model?.provider)) { + return false + } - if (['anthropic'].includes(model?.provider)) { - return false + if (model.provider === 'doubao') { + return EMBEDDING_REGEX.test(model.name) + } + + if (isRerankModel(model)) { + return false + } + + return EMBEDDING_REGEX.test(model.id) } - - if (model.provider === 'doubao') { - return EMBEDDING_REGEX.test(model.name) - } - - if (isRerankModel(model)) { - return false - } - - return EMBEDDING_REGEX.test(model.id) || model.type?.includes('embedding') || false } export function isRerankModel(model: Model): boolean { @@ -2212,16 +2216,20 @@ export function isVisionModel(model: Model): boolean { if (!model) { return false } - // 新添字段 copilot-vision-request 后可使用 vision - // if (model.provider === 'copilot') { - // return false - // } + if (model.type) { + return model.type.includes('vision') + } else { + // 新添字段 copilot-vision-request 后可使用 vision + // if (model.provider === 'copilot') { + // return false + // } - if (model.provider === 'doubao') { - return VISION_REGEX.test(model.name) || model.type?.includes('vision') || false + if (model.provider === 'doubao') { + return VISION_REGEX.test(model.name) + } + + return VISION_REGEX.test(model.id) } - - return VISION_REGEX.test(model.id) || model.type?.includes('vision') || false } export function isOpenAIReasoningModel(model: Model): boolean { @@ -2355,23 +2363,26 @@ export function isReasoningModel(model?: Model): boolean { if (!model) { return false } + if (model.type) { + return model.type.includes('reasoning') + } else { + if (model.provider === 'doubao') { + return REASONING_REGEX.test(model.name) + } - if (model.provider === 'doubao') { - return REASONING_REGEX.test(model.name) || model.type?.includes('reasoning') || false + if ( + isClaudeReasoningModel(model) || + isOpenAIReasoningModel(model) || + isGeminiReasoningModel(model) || + isQwenReasoningModel(model) || + isGrokReasoningModel(model) || + model.id.includes('glm-z1') + ) { + return true + } + + return REASONING_REGEX.test(model.id) } - - if ( - isClaudeReasoningModel(model) || - isOpenAIReasoningModel(model) || - isGeminiReasoningModel(model) || - isQwenReasoningModel(model) || - isGrokReasoningModel(model) || - model.id.includes('glm-z1') - ) { - return true - } - - return REASONING_REGEX.test(model.id) || model.type?.includes('reasoning') || false } export function isSupportedModel(model: OpenAI.Models.Model): boolean { @@ -2386,89 +2397,86 @@ export function isWebSearchModel(model: Model): boolean { if (!model) { return false } - if (model.type) { - if (model.type.includes('web_search')) { - return true + return model.type.includes('web_search') + } else { + const provider = getProviderByModel(model) + + if (!provider) { + return false } - } - const provider = getProviderByModel(model) + const isEmbedding = isEmbeddingModel(model) - if (!provider) { - return false - } + if (isEmbedding) { + return false + } - const isEmbedding = isEmbeddingModel(model) + if (model.id.includes('claude')) { + return CLAUDE_SUPPORTED_WEBSEARCH_REGEX.test(model.id) + } - if (isEmbedding) { - return false - } + if (provider.type === 'openai') { + if ( + isOpenAILLMModel(model) && + !isTextToImageModel(model) && + !isOpenAIReasoningModel(model) && + !GENERATE_IMAGE_MODELS.includes(model.id) + ) { + return true + } - if (model.id.includes('claude')) { - return CLAUDE_SUPPORTED_WEBSEARCH_REGEX.test(model.id) - } + return false + } - if (provider.type === 'openai') { - if ( - isOpenAILLMModel(model) && - !isTextToImageModel(model) && - !isOpenAIReasoningModel(model) && - !GENERATE_IMAGE_MODELS.includes(model.id) - ) { + if (provider.id === 'perplexity') { + return PERPLEXITY_SEARCH_MODELS.includes(model?.id) + } + + if (provider.id === 'aihubmix') { + if ( + isOpenAILLMModel(model) && + !isTextToImageModel(model) && + !isOpenAIReasoningModel(model) && + !GENERATE_IMAGE_MODELS.includes(model.id) + ) { + return true + } + + const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search'] + return models.includes(model?.id) + } + + if (provider?.type === 'openai-compatible') { + if (GEMINI_SEARCH_MODELS.includes(model?.id) || isOpenAIWebSearch(model)) { + return true + } + } + + if (provider.id === 'gemini' || provider?.type === 'gemini') { + return GEMINI_SEARCH_MODELS.includes(model?.id) + } + + if (provider.id === 'hunyuan') { + return model?.id !== 'hunyuan-lite' + } + + if (provider.id === 'zhipu') { + return model?.id?.startsWith('glm-4-') + } + + if (provider.id === 'dashscope') { + const models = ['qwen-turbo', 'qwen-max', 'qwen-plus', 'qwq'] + // matches id like qwen-max-0919, qwen-max-latest + return models.some((i) => model.id.startsWith(i)) + } + + if (provider.id === 'openrouter') { return true } return false } - - if (provider.id === 'perplexity') { - return PERPLEXITY_SEARCH_MODELS.includes(model?.id) - } - - if (provider.id === 'aihubmix') { - if ( - isOpenAILLMModel(model) && - !isTextToImageModel(model) && - !isOpenAIReasoningModel(model) && - !GENERATE_IMAGE_MODELS.includes(model.id) - ) { - return true - } - - const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search'] - return models.includes(model?.id) - } - - if (provider?.type === 'openai-compatible') { - if (GEMINI_SEARCH_MODELS.includes(model?.id) || isOpenAIWebSearch(model)) { - return true - } - } - - if (provider.id === 'gemini' || provider?.type === 'gemini') { - return GEMINI_SEARCH_MODELS.includes(model?.id) - } - - if (provider.id === 'hunyuan') { - return model?.id !== 'hunyuan-lite' - } - - if (provider.id === 'zhipu') { - return model?.id?.startsWith('glm-4-') - } - - if (provider.id === 'dashscope') { - const models = ['qwen-turbo', 'qwen-max', 'qwen-plus', 'qwq'] - // matches id like qwen-max-0919, qwen-max-latest - return models.some((i) => model.id.startsWith(i)) - } - - if (provider.id === 'openrouter') { - return true - } - - return false } export function isGenerateImageModel(model: Model): boolean { diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 81407a8ae2..6ffbb4a550 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -95,7 +95,8 @@ export function getProviderLogo(providerId: string) { return PROVIDER_LOGO_MAP[providerId as keyof typeof PROVIDER_LOGO_MAP] } -export const SUPPORTED_REANK_PROVIDERS = ['silicon', 'jina', 'voyageai', 'dashscope', 'aihubmix'] +// export const SUPPORTED_REANK_PROVIDERS = ['silicon', 'jina', 'voyageai', 'dashscope', 'aihubmix'] +export const NOT_SUPPORTED_REANK_PROVIDERS = ['ollama'] export const PROVIDER_CONFIG = { openai: { diff --git a/src/renderer/src/config/webSearchProviders.ts b/src/renderer/src/config/webSearchProviders.ts index 1fc9638169..d33e9cba35 100644 --- a/src/renderer/src/config/webSearchProviders.ts +++ b/src/renderer/src/config/webSearchProviders.ts @@ -1,6 +1,8 @@ +import BochaLogo from '@renderer/assets/images/search/bocha.webp' import ExaLogo from '@renderer/assets/images/search/exa.png' import SearxngLogo from '@renderer/assets/images/search/searxng.svg' import TavilyLogo from '@renderer/assets/images/search/tavily.png' + export function getWebSearchProviderLogo(providerId: string) { switch (providerId) { case 'tavily': @@ -9,6 +11,8 @@ export function getWebSearchProviderLogo(providerId: string) { return SearxngLogo case 'exa': return ExaLogo + case 'bocha': + return BochaLogo default: return undefined } @@ -32,6 +36,12 @@ export const WEB_SEARCH_PROVIDER_CONFIG = { apiKey: 'https://dashboard.exa.ai/api-keys' } }, + bocha: { + websites: { + official: 'https://bochaai.com', + apiKey: 'https://open.bochaai.com/overview' + } + }, 'local-google': { websites: { official: 'https://www.google.com' diff --git a/src/renderer/src/hooks/usePinnedModels.ts b/src/renderer/src/hooks/usePinnedModels.ts new file mode 100644 index 0000000000..74337872a4 --- /dev/null +++ b/src/renderer/src/hooks/usePinnedModels.ts @@ -0,0 +1,63 @@ +import db from '@renderer/databases' +import { getModelUniqId } from '@renderer/services/ModelService' +import { sortBy } from 'lodash' +import { useCallback, useEffect, useState } from 'react' + +import { useProviders } from './useProvider' + +export const usePinnedModels = () => { + const [pinnedModels, setPinnedModels] = useState([]) + const [loading, setLoading] = useState(true) + const { providers } = useProviders() + + useEffect(() => { + const loadPinnedModels = async () => { + setLoading(true) + const setting = await db.settings.get('pinned:models') + const savedPinnedModels = setting?.value || [] + + // Filter out invalid pinned models + const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m)) + const validPinnedModels = savedPinnedModels.filter((id) => allModelIds.includes(id)) + + // Update storage if there were invalid models + if (validPinnedModels.length !== savedPinnedModels.length) { + await db.settings.put({ id: 'pinned:models', value: validPinnedModels }) + } + + setPinnedModels(sortBy(validPinnedModels)) + setLoading(false) + } + + loadPinnedModels().catch((error) => { + console.error('Failed to load pinned models', error) + setPinnedModels([]) + setLoading(false) + }) + }, [providers]) + + const updatePinnedModels = useCallback(async (models: string[]) => { + await db.settings.put({ id: 'pinned:models', value: models }) + setPinnedModels(sortBy(models)) + }, []) + + /** + * Toggle a single pinned model + * @param modelId - The ID string of the model to toggle + */ + const togglePinnedModel = useCallback( + async (modelId: string) => { + try { + const newPinnedModels = pinnedModels.includes(modelId) + ? pinnedModels.filter((id) => id !== modelId) + : [...pinnedModels, modelId] + await updatePinnedModels(newPinnedModels) + } catch (error) { + console.error('Failed to toggle pinned model', error) + } + }, + [pinnedModels, updatePinnedModels] + ) + + return { pinnedModels, updatePinnedModels, togglePinnedModel, loading } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index ac6ce14c03..e70133d759 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -705,6 +705,7 @@ "pinned": "Pinned", "rerank_model": "Reordering Model", "rerank_model_support_provider": "Currently, the reordering model only supports some providers ({{provider}})", + "rerank_model_not_support_provider": "Currently, the reordering model does not support this provider ({{provider}})", "rerank_model_tooltip": "Click the Manage button in Settings -> Model Services to add.", "search": "Search models...", "stream_output": "Stream output", @@ -1343,6 +1344,7 @@ "providerPlaceholder": "Provider name", "advancedSettings": "Advanced Settings" }, + "messages.prompt": "Show prompt", "messages.divider": "Show divider between messages", "messages.grid_columns": "Message grid display columns", "messages.grid_popover_trigger": "Grid detail trigger", @@ -1641,4 +1643,4 @@ "visualization": "Visualization" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 1fbe2249b7..7b06e8559c 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -719,7 +719,8 @@ "text": "テキスト", "vision": "画像", "websearch": "ウェブ検索" - } + }, + "rerank_model_not_support_provider": "現在、並べ替えモデルはこのプロバイダー ({{provider}}) をサポートしていません。" }, "navbar": { "expand": "ダイアログを展開", @@ -1341,6 +1342,7 @@ "providerPlaceholder": "プロバイダー名", "advancedSettings": "詳細設定" }, + "messages.prompt": "プロンプト表示", "messages.divider": "メッセージ間に区切り線を表示", "messages.grid_columns": "メッセージグリッドの表示列数", "messages.grid_popover_trigger": "グリッド詳細トリガー", @@ -1641,4 +1643,4 @@ "visualization": "可視化" } } -} +} \ 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 2d23ea3991..3f69284988 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -719,7 +719,8 @@ "text": "Текст", "vision": "Визуальные", "websearch": "Веб-поисковые" - } + }, + "rerank_model_not_support_provider": "В настоящее время модель переупорядочивания не поддерживает этого провайдера ({{provider}})" }, "navbar": { "expand": "Развернуть диалоговое окно", @@ -1341,6 +1342,7 @@ "providerPlaceholder": "Имя провайдера", "advancedSettings": "Расширенные настройки" }, + "messages.prompt": "Показывать подсказки", "messages.divider": "Показывать разделитель между сообщениями", "messages.grid_columns": "Количество столбцов сетки сообщений", "messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке", @@ -1641,4 +1643,4 @@ "visualization": "Визуализация" } } -} +} \ 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 8d8b23fb80..e777f533d9 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -705,6 +705,7 @@ "pinned": "已固定", "rerank_model": "重排模型", "rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})", + "rerank_model_not_support_provider": "目前重排序模型不支持该服务商 ({{provider}})", "rerank_model_tooltip": "在设置->模型服务中点击管理按钮添加", "search": "搜索模型...", "stream_output": "流式输出", @@ -1343,6 +1344,7 @@ "providerPlaceholder": "提供者名称", "advancedSettings": "高级设置" }, + "messages.prompt": "提示词显示", "messages.divider": "消息分割线", "messages.grid_columns": "消息网格展示列数", "messages.grid_popover_trigger": "网格详情触发", @@ -1641,4 +1643,4 @@ "visualization": "可视化" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 514491c046..cc8d898fa4 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -719,7 +719,8 @@ "text": "文字", "vision": "視覺", "websearch": "網路搜尋" - } + }, + "rerank_model_not_support_provider": "目前,重新排序模型不支援此提供者({{provider}})" }, "navbar": { "expand": "伸縮對話框", @@ -1342,6 +1343,7 @@ "providerPlaceholder": "提供者名稱", "advancedSettings": "高級設定" }, + "messages.prompt": "提示詞顯示", "messages.divider": "訊息間顯示分隔線", "messages.grid_columns": "訊息網格展示列數", "messages.grid_popover_trigger": "網格詳細資訊觸發", @@ -1641,4 +1643,4 @@ "visualization": "視覺化" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 307eebdb1c..6da7985fd4 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -42,7 +42,7 @@ interface MessagesProps { const Messages: React.FC = ({ assistant, topic, setActiveTopic }) => { const { t } = useTranslation() - const { showTopics, topicPosition, showAssistants, messageNavigation } = useSettings() + const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings() const { updateTopic, addTopic } = useAssistant(assistant.id) const dispatch = useAppDispatch() const containerRef = useRef(null) @@ -254,7 +254,7 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) )} - + {showPrompt && } {messageNavigation === 'anchor' && } {messageNavigation === 'buttons' && } diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 0c8972db6d..09fd3eb397 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -37,6 +37,7 @@ import { setPasteLongTextThreshold, setRenderInputMessageAsMarkdown, setShowInputEstimatedTokens, + setShowPrompt, setShowMessageDivider, setShowTranslateConfirm, setThoughtAutoCollapse @@ -76,6 +77,7 @@ const SettingsTab: FC = (props) => { const dispatch = useAppDispatch() const { + showPrompt, showMessageDivider, messageFont, showInputEstimatedTokens, @@ -282,6 +284,11 @@ const SettingsTab: FC = (props) => { {t('settings.messages.title')} + + {t('settings.messages.prompt')} + dispatch(setShowPrompt(checked))} /> + + {t('settings.messages.divider')} = ({ title, resolve }) => { const rerankSelectOptions = providers .filter((p) => p.models.length > 0) - .filter((p) => SUPPORTED_REANK_PROVIDERS.includes(p.id)) + .filter((p) => !NOT_SUPPORTED_REANK_PROVIDERS.includes(p.id)) .map((p) => ({ label: p.isSystem ? t(`provider.${p.id}`) : p.name, title: p.name, @@ -176,8 +177,8 @@ const PopupContainer: React.FC = ({ title, resolve }) => {