From 9e405f0604a8379e8e15d7655914ab8fb2b3f83f Mon Sep 17 00:00:00 2001 From: one Date: Sat, 2 Aug 2025 23:17:14 +0800 Subject: [PATCH 001/124] perf: model select popup (#8766) - use DynamicVirtualList in SelectModelPopup - use DynamicVirtualList in QuickPanelView - remove react-window - simplify SelectModelPopup states, improve maintainability --- package.json | 2 - .../Popups/SelectModelPopup/hook.ts | 40 -- .../Popups/SelectModelPopup/popup.tsx | 441 ++++++------------ .../Popups/SelectModelPopup/reducer.ts | 102 ---- .../Popups/SelectModelPopup/searchbar.tsx | 77 +++ .../Popups/SelectModelPopup/types.ts | 21 - .../src/components/QuickPanel/view.tsx | 123 ++--- .../__tests__/QuickPanelView.test.tsx | 26 +- yarn.lock | 33 +- 9 files changed, 304 insertions(+), 561 deletions(-) delete mode 100644 src/renderer/src/components/Popups/SelectModelPopup/hook.ts delete mode 100644 src/renderer/src/components/Popups/SelectModelPopup/reducer.ts create mode 100644 src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx diff --git a/package.json b/package.json index 875515bbf9..28561231b0 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,6 @@ "@types/react": "^19.0.12", "@types/react-dom": "^19.0.4", "@types/react-infinite-scroll-component": "^5.0.0", - "@types/react-window": "^1", "@types/tinycolor2": "^1", "@types/word-extractor": "^1", "@uiw/codemirror-extensions-langs": "^4.23.14", @@ -236,7 +235,6 @@ "react-router": "6", "react-router-dom": "6", "react-spinners": "^0.14.1", - "react-window": "^1.8.11", "redux": "^5.0.1", "redux-persist": "^6.0.0", "reflect-metadata": "0.2.2", diff --git a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts deleted file mode 100644 index 4a8206df69..0000000000 --- a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 } }), - focusOnListChange: (modelItems: FlatListItem[]) => - dispatch({ type: 'FOCUS_ON_LIST_CHANGE', payload: { modelItems } }) - }), - [] - ) - - 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/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index c1b04de5df..e7557f5e5e 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -1,17 +1,16 @@ 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 { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { usePinnedModels } from '@renderer/hooks/usePinnedModels' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' -import { Model } from '@renderer/types' +import { Model, Provider } from '@renderer/types' import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils' -import { Avatar, Divider, Empty, Input, InputRef, Modal } from 'antd' +import { Avatar, Divider, Empty, Modal } from 'antd' import { first, sortBy } from 'lodash' -import { Search } from 'lucide-react' -import { +import React, { startTransition, useCallback, useDeferredValue, @@ -21,15 +20,13 @@ import { useRef, useState } from 'react' -import React from 'react' import { useTranslation } from 'react-i18next' -import { FixedSizeList } from 'react-window' import styled from 'styled-components' -import { useScrollState } from './hook' +import SelectModelSearchBar from './searchbar' import { FlatListItem } from './types' -const PAGE_SIZE = 10 +const PAGE_SIZE = 11 const ITEM_HEIGHT = 36 interface PopupParams { @@ -47,8 +44,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { const { providers } = useProviders() const { pinnedModels, togglePinnedModel, loading } = usePinnedModels() const [open, setOpen] = useState(true) - const inputRef = useRef(null) - const listRef = useRef(null) + const listRef = useRef(null) const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) @@ -56,49 +52,19 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { const currentModelId = model ? getModelUniqId(model) : '' // 管理滚动和焦点状态 - const { - focusedItemKey, - scrollTrigger, - lastScrollOffset, - stickyGroup, - isMouseOver, - setFocusedItemKey: _setFocusedItemKey, - setScrollTrigger, - setLastScrollOffset: _setLastScrollOffset, - setStickyGroup: _setStickyGroup, - setIsMouseOver, - focusNextItem, - focusPage, - searchChanged, - focusOnListChange - } = useScrollState() + const [focusedItemKey, _setFocusedItemKey] = useState('') + const [isMouseOver, setIsMouseOver] = useState(false) + const preventScrollToIndex = useRef(false) - const firstGroupRef = useRef(null) - - const setFocusedItemKey = useCallback( - (key: string) => { - startTransition(() => _setFocusedItemKey(key)) - }, - [_setFocusedItemKey] - ) - - const setLastScrollOffset = useCallback( - (offset: number) => { - startTransition(() => _setLastScrollOffset(offset)) - }, - [_setLastScrollOffset] - ) - - const setStickyGroup = useCallback( - (group: FlatListItem | null) => { - startTransition(() => _setStickyGroup(group)) - }, - [_setStickyGroup] - ) + const setFocusedItemKey = useCallback((key: string) => { + startTransition(() => { + _setFocusedItemKey(key) + }) + }, []) // 根据输入的文本筛选模型 const getFilteredModels = useCallback( - (provider) => { + (provider: Provider) => { let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) if (searchText.trim()) { @@ -112,7 +78,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 创建模型列表项 const createModelItem = useCallback( - (model: Model, provider: any, isPinned: boolean): FlatListItem => { + (model: Model, provider: Provider, isPinned: boolean): FlatListItem => { const modelId = getModelUniqId(model) const groupName = getFancyProviderName(provider) @@ -143,16 +109,18 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { [currentModelId] ) - // 构建扁平化列表数据 - const listItems = useMemo(() => { + // 构建扁平化列表数据,并派生出可选择的模型项 + const { listItems, modelItems } = useMemo(() => { const items: FlatListItem[] = [] + const pinnedModelIds = new Set(pinnedModels) + const finalModelFilter = modelFilter || (() => true) // 添加置顶模型分组(仅在无搜索文本时) - if (searchText.length === 0 && pinnedModels.length > 0) { + if (searchText.length === 0 && pinnedModelIds.size > 0) { const pinnedItems = providers.flatMap((p) => p.models - .filter((m) => pinnedModels.includes(getModelUniqId(m))) - .filter(modelFilter ? modelFilter : () => true) + .filter((m) => pinnedModelIds.has(getModelUniqId(m))) + .filter(finalModelFilter) .map((m) => createModelItem(m, p, true)) ) @@ -172,8 +140,8 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 添加常规模型分组 providers.forEach((p) => { const filteredModels = getFilteredModels(p) - .filter((m) => searchText.length > 0 || !pinnedModels.includes(getModelUniqId(m))) - .filter(modelFilter ? modelFilter : () => true) + .filter((m) => searchText.length > 0 || !pinnedModelIds.has(getModelUniqId(m))) + .filter(finalModelFilter) if (filteredModels.length === 0) return @@ -185,92 +153,52 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { isSelected: false }) - items.push(...filteredModels.map((m) => createModelItem(m, p, pinnedModels.includes(getModelUniqId(m))))) + items.push(...filteredModels.map((m) => createModelItem(m, p, pinnedModelIds.has(getModelUniqId(m))))) }) - // 移除第一个分组标题,使用 sticky group banner 替代,模拟 sticky 效果 - if (items.length > 0 && items[0].type === 'group') { - firstGroupRef.current = items[0] - items.shift() - } else { - firstGroupRef.current = null - } - return items + // 获取可选择的模型项(过滤掉分组标题) + const modelItems = items.filter((item) => item.type === 'model') as FlatListItem[] + return { listItems: items, modelItems } }, [searchText.length, pinnedModels, providers, modelFilter, createModelItem, t, getFilteredModels]) - // 获取可选择的模型项(过滤掉分组标题) - const modelItems = useMemo(() => { - return listItems.filter((item) => item.type === 'model') - }, [listItems]) + const listHeight = useMemo(() => { + return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT + }, [listItems.length]) - // 当搜索文本变化时更新滚动触发器 - useEffect(() => { - searchChanged(searchText) - }, [searchText, searchChanged]) - - // 基于滚动位置更新sticky分组标题 - const updateStickyGroup = useCallback( - (scrollOffset?: number) => { - if (listItems.length === 0) { - stickyGroup && setStickyGroup(null) - return - } - - let newStickyGroup: FlatListItem | null = null - - // 基于滚动位置计算当前可见的第一个项的索引 - const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffset) / ITEM_HEIGHT) - - // 从该索引向前查找最近的分组标题 - for (let i = estimatedIndex - 1; i >= 0; i--) { - if (i < listItems.length && listItems[i]?.type === 'group') { - newStickyGroup = listItems[i] - break - } - } - - // 找不到则使用第一个分组标题 - if (!newStickyGroup) newStickyGroup = firstGroupRef.current - - if (stickyGroup?.key !== newStickyGroup?.key) { - setStickyGroup(newStickyGroup) - } - }, - [listItems, lastScrollOffset, setStickyGroup, stickyGroup] - ) - - // 处理列表滚动事件,更新lastScrollOffset并更新sticky分组 - const handleScroll = useCallback( - ({ scrollOffset }) => { - setLastScrollOffset(scrollOffset) - }, - [setLastScrollOffset] - ) - - // 列表项更新时,更新焦点 - useEffect(() => { - if (!loading) focusOnListChange(modelItems) - }, [modelItems, focusOnListChange, loading]) - - // 列表项更新时,更新sticky分组 - useEffect(() => { - if (!loading) updateStickyGroup() - }, [modelItems, updateStickyGroup, loading]) - - // 滚动到聚焦项 + // 处理程序化滚动(加载、搜索开始、搜索清空) useLayoutEffect(() => { - if (scrollTrigger === 'none' || !focusedItemKey) return + if (loading) return - const index = listItems.findIndex((item) => item.key === focusedItemKey) - if (index < 0) return + if (preventScrollToIndex.current) { + preventScrollToIndex.current = false + return + } - // 根据触发源决定滚动对齐方式 - const alignment = scrollTrigger === 'keyboard' ? 'auto' : 'center' - listRef.current?.scrollToItem(index, alignment) + let targetItemKey: string | undefined - // 滚动后重置触发器 - setScrollTrigger('none') - }, [focusedItemKey, scrollTrigger, listItems, setScrollTrigger]) + // 启动搜索时,滚动到第一个 item + if (searchText) { + targetItemKey = modelItems[0]?.key + } + // 初始加载或清空搜索时,滚动到 selected item + else { + targetItemKey = modelItems.find((item) => item.isSelected)?.key + } + + if (targetItemKey) { + setFocusedItemKey(targetItemKey) + const index = listItems.findIndex((item) => item.key === targetItemKey) + if (index >= 0) { + // FIXME: 手动计算偏移量,给 scroller 增加了 scrollPaddingStart 之后, + // scrollToIndex 不能准确滚动到 item 中心,但是又需要 padding 来改善体验。 + const targetScrollTop = index * ITEM_HEIGHT - listHeight / 2 + listRef.current?.scrollToOffset(targetScrollTop, { + align: 'start', + behavior: 'auto' + }) + } + } + }, [searchText, listItems, modelItems, loading, setFocusedItemKey, listHeight]) const handleItemClick = useCallback( (item: FlatListItem) => { @@ -285,7 +213,9 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 处理键盘导航 const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (!open || modelItems.length === 0 || e.isComposing) return + const modelCount = modelItems.length + + if (!open || modelCount === 0 || e.isComposing) return // 键盘操作时禁用鼠标 hover if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) { @@ -294,25 +224,31 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { setIsMouseOver(false) } + // 当前聚焦的模型 index const currentIndex = modelItems.findIndex((item) => item.key === focusedItemKey) - const normalizedIndex = currentIndex < 0 ? 0 : currentIndex + + let nextIndex = -1 switch (e.key) { - case 'ArrowUp': - focusNextItem(modelItems, -1) + case 'ArrowUp': { + nextIndex = (currentIndex < 0 ? 0 : currentIndex - 1 + modelCount) % modelCount break - case 'ArrowDown': - focusNextItem(modelItems, 1) + } + case 'ArrowDown': { + nextIndex = (currentIndex < 0 ? 0 : currentIndex + 1) % modelCount break - case 'PageUp': - focusPage(modelItems, normalizedIndex, -PAGE_SIZE) + } + case 'PageUp': { + nextIndex = Math.max(0, (currentIndex < 0 ? 0 : currentIndex) - PAGE_SIZE) break - case 'PageDown': - focusPage(modelItems, normalizedIndex, PAGE_SIZE) + } + case 'PageDown': { + nextIndex = Math.min(modelCount - 1, (currentIndex < 0 ? 0 : currentIndex) + PAGE_SIZE) break + } case 'Enter': - if (focusedItemKey) { - const selectedItem = modelItems.find((item) => item.key === focusedItemKey) + if (currentIndex >= 0) { + const selectedItem = modelItems[currentIndex] if (selectedItem) { handleItemClick(selectedItem) } @@ -324,8 +260,20 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { resolve(undefined) break } + + // 没有键盘导航,直接返回 + if (nextIndex < 0) return + + const nextKey = modelItems[nextIndex]?.key || '' + if (nextKey) { + setFocusedItemKey(nextKey) + const index = listItems.findIndex((item) => item.key === nextKey) + if (index >= 0) { + listRef.current?.scrollToIndex(index, { align: 'auto' }) + } + } }, - [focusedItemKey, modelItems, handleItemClick, open, resolve, setIsMouseOver, focusNextItem, focusPage] + [modelItems, open, focusedItemKey, resolve, handleItemClick, setFocusedItemKey, listItems] ) useEffect(() => { @@ -338,40 +286,57 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { }, []) const onAfterClose = useCallback(async () => { - setScrollTrigger('initial') resolve(undefined) SelectModelPopup.hide() - }, [resolve, setScrollTrigger]) - - // 初始化焦点和滚动位置 - useEffect(() => { - if (!open) return - const timer = setTimeout(() => inputRef.current?.focus(), 0) - return () => clearTimeout(timer) - }, [open]) + }, [resolve]) const togglePin = useCallback( async (modelId: string) => { await togglePinnedModel(modelId) + preventScrollToIndex.current = true }, [togglePinnedModel] ) - const RowData = useMemo( - (): VirtualizedRowData => ({ - listItems, - focusedItemKey, - setFocusedItemKey, - stickyGroup, - handleItemClick, - togglePin - }), - [stickyGroup, focusedItemKey, handleItemClick, listItems, togglePin, setFocusedItemKey] - ) + const getItemKey = useCallback((index: number) => listItems[index].key, [listItems]) + const estimateSize = useCallback(() => ITEM_HEIGHT, []) + const isSticky = useCallback((index: number) => listItems[index].type === 'group', [listItems]) - const listHeight = useMemo(() => { - return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT - }, [listItems.length]) + const rowRenderer = useCallback( + (item: FlatListItem) => { + const isFocused = item.key === focusedItemKey + if (item.type === 'group') { + return {item.name} + } + return ( + handleItemClick(item)} + onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}> + + {item.icon} + {item.name} + {item.tags} + + { + e.stopPropagation() + if (item.model) { + togglePin(getModelUniqId(item.model)) + } + }} + data-pinned={item.isPinned} + $isPinned={item.isPinned}> + + + + ) + }, + [focusedItemKey, handleItemClick, setFocusedItemKey, togglePin] + ) return ( = ({ model, resolve, modelFilter }) => { closeIcon={null} footer={null}> {/* 搜索框 */} - - - - - } - ref={inputRef} - placeholder={t('models.search')} - value={_searchText} // 使用 _searchText,需要实时更新 - onChange={(e) => setSearchText(e.target.value)} - allowClear - autoFocus - spellCheck={false} - style={{ paddingLeft: 0 }} - variant="borderless" - size="middle" - onKeyDown={(e) => { - // 防止上下键移动光标 - if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') { - e.preventDefault() - } - }} - /> - + {listItems.length > 0 ? ( - !isMouseOver && startTransition(() => setIsMouseOver(true))}> - {/* Sticky Group Banner,它会替换第一个分组名称 */} - {stickyGroup?.name} - !isMouseOver && setIsMouseOver(true)}> + data.listItems[index].key} - overscanCount={4} - onScroll={handleScroll} - style={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}> - {VirtualizedRow} - + list={listItems} + size={listHeight} + getItemKey={getItemKey} + estimateSize={estimateSize} + isSticky={isSticky} + scrollPaddingStart={ITEM_HEIGHT} // 留出 sticky header 高度 + overscan={5} + scrollerStyle={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}> + {rowRenderer} + ) : ( @@ -450,73 +388,12 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { ) } -interface VirtualizedRowData { - listItems: FlatListItem[] - focusedItemKey: string - setFocusedItemKey: (key: string) => void - stickyGroup: FlatListItem | null - handleItemClick: (item: FlatListItem) => void - togglePin: (modelId: string) => void -} - -/** - * 虚拟化列表行组件,用于避免重新渲染 - */ -const VirtualizedRow = React.memo( - ({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => { - const { listItems, focusedItemKey, setFocusedItemKey, handleItemClick, togglePin, stickyGroup } = data - - const item = listItems[index] - - if (!item) { - return
- } - - const isFocused = item.key === focusedItemKey - - return ( -
- {item.type === 'group' ? ( - {item.name} - ) : ( - handleItemClick(item)} - onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}> - - {item.icon} - {item.name} - {item.tags} - - { - e.stopPropagation() - if (item.model) { - togglePin(getModelUniqId(item.model)) - } - }} - data-pinned={item.isPinned} - $isPinned={item.isPinned}> - - - - )} -
- ) - } -) - -VirtualizedRow.displayName = 'VirtualizedRow' - const ListContainer = styled.div` position: relative; overflow: hidden; ` -const GroupItem = styled.div<{ $isSticky?: boolean }>` +const GroupItem = styled.div` display: flex; align-items: center; position: relative; @@ -526,12 +403,6 @@ const GroupItem = styled.div<{ $isSticky?: boolean }>` padding: 5px 10px 5px 18px; color: var(--color-text-3); z-index: 1; - - visibility: ${(props) => (props.$isSticky ? 'hidden' : 'visible')}; -` - -const StickyGroupBanner = styled(GroupItem)` - position: sticky; background: var(--modal-background); ` @@ -613,18 +484,6 @@ const EmptyState = styled.div` height: 200px; ` -const SearchIcon = styled.div` - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - background-color: var(--color-background-soft); - margin-right: 2px; -` - const PinIconWrapper = styled.div.attrs({ className: 'pin-icon' })<{ $isPinned?: boolean }>` margin-left: auto; padding: 0 10px; diff --git a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts deleted file mode 100644 index 974fc5b509..0000000000 --- a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 'FOCUS_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 - } - } - - default: - return state - } -} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx b/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx new file mode 100644 index 0000000000..1b4d1dbb02 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx @@ -0,0 +1,77 @@ +import { HStack } from '@renderer/components/Layout' +import { Input, InputRef } from 'antd' +import { Search } from 'lucide-react' +import React, { memo, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface SelectModelSearchBarProps { + onSearch: (text: string) => void +} + +const SelectModelSearchBar: React.FC = ({ onSearch }) => { + const { t } = useTranslation() + const [searchText, setSearchText] = useState('') + const inputRef = useRef(null) + + const handleTextChange = useCallback( + (text: string) => { + setSearchText(text) + onSearch(text) + }, + [onSearch] + ) + + const handleClear = useCallback(() => { + setSearchText('') + onSearch('') + }, [onSearch]) + + useEffect(() => { + const timer = setTimeout(() => inputRef.current?.focus(), 0) + return () => clearTimeout(timer) + }, []) + + return ( + + + + + } + ref={inputRef} + placeholder={t('models.search')} + value={searchText} + onChange={(e) => handleTextChange(e.target.value)} + onClear={handleClear} + allowClear + autoFocus + spellCheck={false} + style={{ paddingLeft: 0 }} + variant="borderless" + size="middle" + onKeyDown={(e) => { + // 防止上下键移动光标 + if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') { + e.preventDefault() + } + }} + /> + + ) +} + +const SearchIcon = styled.div` + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + background-color: var(--color-background-soft); + margin-right: 2px; +` + +export default memo(SelectModelSearchBar) diff --git a/src/renderer/src/components/Popups/SelectModelPopup/types.ts b/src/renderer/src/components/Popups/SelectModelPopup/types.ts index 745e9688bb..954a8e0d37 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/types.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/types.ts @@ -18,24 +18,3 @@ export interface FlatListItem { 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: 'FOCUS_ON_LIST_CHANGE'; payload: { modelItems: FlatListItem[] } } diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 4f91a729ae..36957aaf63 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -1,4 +1,5 @@ import { RightOutlined } from '@ant-design/icons' +import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' import { isMac } from '@renderer/config/constant' import useUserTheme from '@renderer/hooks/useUserTheme' import { classNames } from '@renderer/utils' @@ -6,7 +7,6 @@ import { Flex } from 'antd' import { t } from 'i18next' import { Check } from 'lucide-react' import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { FixedSizeList } from 'react-window' import styled from 'styled-components' import * as tinyPinyin from 'tiny-pinyin' @@ -55,7 +55,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const [historyPanel, setHistoryPanel] = useState([]) const bodyRef = useRef(null) - const listRef = useRef(null) + const listRef = useRef(null) const footerRef = useRef(null) const [_searchText, setSearchText] = useState('') @@ -306,8 +306,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { useLayoutEffect(() => { if (!listRef.current || index < 0 || scrollTriggerRef.current === 'none') return - const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'smart' - listRef.current?.scrollToItem(index, alignment) + const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'center' + listRef.current?.scrollToIndex(index, { align: alignment }) scrollTriggerRef.current = 'none' }, [index]) @@ -470,13 +470,45 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT }, [ctx.pageSize, list.length]) - const RowData = useMemo( - (): VirtualizedRowData => ({ - list, - focusedIndex: index, - handleItemAction - }), - [list, index, handleItemAction] + const estimateSize = useCallback(() => ITEM_HEIGHT, []) + + const rowRenderer = useCallback( + (item: QuickPanelListItem, itemIndex: number) => { + if (!item) return null + + return ( + { + e.stopPropagation() + handleItemAction(item, 'click') + }}> + + {item.icon} + {item.label} + + + + {item.description && {item.description}} + + {item.suffix ? ( + item.suffix + ) : item.isSelected ? ( + + ) : ( + item.isMenu && !item.disabled && + )} + + + + ) + }, + [index, handleItemAction] ) return ( @@ -494,19 +526,17 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { return prev ? prev : true }) }> - - {VirtualizedRow} - + {rowRenderer} + {ctx.title || ''} @@ -546,57 +576,6 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { ) } -interface VirtualizedRowData { - list: QuickPanelListItem[] - focusedIndex: number - handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void -} - -/** - * 虚拟化列表行组件,用于避免重新渲染 - */ -const VirtualizedRow = React.memo( - ({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => { - const { list, focusedIndex, handleItemAction } = data - const item = list[index] - if (!item) return null - - return ( -
- { - e.stopPropagation() - handleItemAction(item, 'click') - }}> - - {item.icon} - {item.label} - - - - {item.description && {item.description}} - - {item.suffix ? ( - item.suffix - ) : item.isSelected ? ( - - ) : ( - item.isMenu && !item.disabled && - )} - - - -
- ) - } -) - const QuickPanelContainer = styled.div<{ $pageSize: number $selectedColor: string diff --git a/src/renderer/src/components/__tests__/QuickPanelView.test.tsx b/src/renderer/src/components/__tests__/QuickPanelView.test.tsx index 995c5c5d0b..4e904efeb8 100644 --- a/src/renderer/src/components/__tests__/QuickPanelView.test.tsx +++ b/src/renderer/src/components/__tests__/QuickPanelView.test.tsx @@ -1,12 +1,35 @@ import { configureStore } from '@reduxjs/toolkit' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useEffect } from 'react' +import React, { useEffect } from 'react' import { Provider } from 'react-redux' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QuickPanelListItem, QuickPanelProvider, QuickPanelView, useQuickPanel } from '../QuickPanel' +// Mock the DynamicVirtualList component +vi.mock('@renderer/components/VirtualList', async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + DynamicVirtualList: ({ ref, list, children, scrollerStyle }: any & { ref?: React.RefObject }) => { + // Expose a mock function for scrollToIndex + React.useImperativeHandle(ref, () => ({ + scrollToIndex: vi.fn() + })) + + // Render all items, not virtualized + return ( +
+ {list.map((item: any, index: number) => ( +
{children(item, index)}
+ ))} +
+ ) + } + } +}) + // Mock Redux store const mockStore = configureStore({ reducer: { @@ -16,6 +39,7 @@ const mockStore = configureStore({ function createList(length: number, prefix = 'Item', extra: Partial = {}) { return Array.from({ length }, (_, i) => ({ + id: `${prefix}-${i + 1}`, label: `${prefix} ${i + 1}`, description: `${prefix} Description ${i + 1}`, icon: `${prefix} Icon ${i + 1}`, diff --git a/yarn.lock b/yarn.lock index a1643ab873..17f2d0dfac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1461,7 +1461,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.9.2": version: 7.27.4 resolution: "@babel/runtime@npm:7.27.4" checksum: 10c0/ca99e964179c31615e1352e058cc9024df7111c829631c90eec84caba6703cc32acc81503771847c306b3c70b815609fe82dde8682936debe295b0b283b2dc6e @@ -6625,15 +6625,6 @@ __metadata: languageName: node linkType: hard -"@types/react-window@npm:^1": - version: 1.8.8 - resolution: "@types/react-window@npm:1.8.8" - dependencies: - "@types/react": "npm:*" - checksum: 10c0/2170a3957752603e8b994840c5d31b72ddf94c427c0f42b0175b343cc54f50fe66161d8871e11786ec7a59906bd33861945579a3a8f745455a3744268ec1069f - languageName: node - linkType: hard - "@types/react@npm:*, @types/react@npm:^19.0.12": version: 19.1.2 resolution: "@types/react@npm:19.1.2" @@ -7698,7 +7689,6 @@ __metadata: "@types/react": "npm:^19.0.12" "@types/react-dom": "npm:^19.0.4" "@types/react-infinite-scroll-component": "npm:^5.0.0" - "@types/react-window": "npm:^1" "@types/tinycolor2": "npm:^1" "@types/word-extractor": "npm:^1" "@uiw/codemirror-extensions-langs": "npm:^4.23.14" @@ -7790,7 +7780,6 @@ __metadata: react-router: "npm:6" react-router-dom: "npm:6" react-spinners: "npm:^0.14.1" - react-window: "npm:^1.8.11" redux: "npm:^5.0.1" redux-persist: "npm:^6.0.0" reflect-metadata: "npm:0.2.2" @@ -15223,13 +15212,6 @@ __metadata: languageName: node linkType: hard -"memoize-one@npm:>=3.1.1 <6": - version: 5.2.1 - resolution: "memoize-one@npm:5.2.1" - checksum: 10c0/fd22dbe9a978a2b4f30d6a491fc02fb90792432ad0dab840dc96c1734d2bd7c9cdeb6a26130ec60507eb43230559523615873168bcbe8fafab221c30b11d54c1 - languageName: node - linkType: hard - "memoize-one@npm:^6.0.0": version: 6.0.0 resolution: "memoize-one@npm:6.0.0" @@ -18410,19 +18392,6 @@ __metadata: languageName: node linkType: hard -"react-window@npm:^1.8.11": - version: 1.8.11 - resolution: "react-window@npm:1.8.11" - dependencies: - "@babel/runtime": "npm:^7.0.0" - memoize-one: "npm:>=3.1.1 <6" - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/5ae8da1bc5c47d8f0a428b28a600256e2db511975573e52cb65a9b27ed1a0e5b9f7b3bee5a54fb0da93956d782c24010be434be451072f46ba5a89159d2b3944 - languageName: node - linkType: hard - "react@npm:^19.0.0": version: 19.1.0 resolution: "react@npm:19.1.0" From fb2dccc7ff351444c3b86e6e826c0f6b406be4f8 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sat, 2 Aug 2025 23:23:08 +0800 Subject: [PATCH 002/124] fix(Inputbar): input bar auto focus (#8756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(Inputbar): 简化输入框自动聚焦逻辑 * fix(Inputbar): 修复依赖数组缺失导致的焦点问题 添加 assistant.mcpServers 和 mentionedModels 到依赖数组,确保 textarea 在相关数据变化时能正确获取焦点 * fix(Inputbar): 添加knowledge_bases到useEffect依赖数组以修复潜在问题 * fix(Inputbar): 添加缺失的依赖项到useEffect中 * fix(Inputbar): 清空消息时自动聚焦输入框 确保当消息列表为空时,输入框自动获得焦点,提升用户体验 * refactor(Inputbar): 提取聚焦文本域逻辑到单独的回调函数 将多处直接操作textareaRef.current?.focus()的逻辑提取到focusTextarea回调函数中,提高代码复用性和可维护性 --- .../src/pages/home/Inputbar/Inputbar.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 11775e5ad7..daab28aac3 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -189,6 +189,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = _text = text _files = files + const focusTextarea = useCallback(() => { + textareaRef.current?.focus() + }, []) + const resizeTextArea = useCallback( (force: boolean = false) => { const textArea = textareaRef.current?.resizableTextArea?.textArea @@ -470,9 +474,9 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setTimeout(() => resizeTextArea(), 0) return newText }) - textareaRef.current?.focus() + focusTextarea() }, - [resizeTextArea] + [resizeTextArea, focusTextarea] ) const onPause = async () => { @@ -485,6 +489,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = await delay(1) } EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES, topic) + focusTextarea() } const onNewContext = () => { @@ -670,7 +675,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = useShortcut('new_topic', () => { addNewTopic() EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR) - textareaRef.current?.focus() + focusTextarea() }) useShortcut('clear_topic', clearTopic) @@ -704,12 +709,17 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = useEffect(() => { if (!document.querySelector('.topview-fullscreen-container')) { - const lastFocusedComponent = PasteService.getLastFocusedComponent() - if (lastFocusedComponent === 'inputbar') { - textareaRef.current?.focus() - } + focusTextarea() } - }, [assistant, topic]) + }, [ + topic.id, + assistant.mcpServers, + assistant.knowledge_bases, + assistant.enableWebSearch, + assistant.webSearchProviderId, + mentionedModels, + focusTextarea + ]) useEffect(() => { const timerId = requestAnimationFrame(() => resizeTextArea()) @@ -734,12 +744,12 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const lastFocusedComponent = PasteService.getLastFocusedComponent() if (!lastFocusedComponent || lastFocusedComponent === 'inputbar') { - textareaRef.current?.focus() + focusTextarea() } } window.addEventListener('focus', onFocus) return () => window.removeEventListener('focus', onFocus) - }, []) + }, [focusTextarea]) useEffect(() => { // if assistant knowledge bases are undefined return [] @@ -819,7 +829,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = }) } - textareaRef.current?.focus() + focusTextarea() } const isExpended = expended || !!textareaHeight From 63198ee3d2a1b20b43ffd289ca1e346d90ec7946 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sun, 3 Aug 2025 11:00:04 +0800 Subject: [PATCH 003/124] refactor(ModelList): improve group style (#8761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ModelList): 重构模型列表组件结构,优化分组渲染逻辑 将扁平化列表结构改为嵌套结构,提升分组模型的渲染性能 移除不必要的状态依赖,简化组件逻辑 添加分组容器样式,改善视觉呈现 * Revert "refactor(ModelList): 重构模型列表组件结构,优化分组渲染逻辑" This reverts commit f60f6267e624aa91153150cabd0861fd5431be02. * refactor(ModelList): 优化模型列表的渲染和样式 - 使用startTransition优化折叠/展开性能 - 重构数据结构,将单个模型渲染改为批量渲染 - 改进组头和模型项的样式和布局 * Revert "refactor(ModelList): 优化模型列表的渲染和样式" This reverts commit e18286c70e63b7e38f5a84e7569079eec0c67aca. * feat(模型列表): 优化模型列表项的样式和分组显示 添加last属性标记列表最后一项,优化分组标题和列表项的样式 移除多余的底部间距,调整边框圆角以提升视觉一致性 * refactor: 移除调试用的console.log语句 * style(ManageModelsList): 调整分组标题的内边距以改善视觉间距 * style(ModelList): 移动按钮位置 * style(ManageModelsList): 调整列表项和分组标题的高度以优化空间使用 * style(ManageModelsList): 为滚动容器添加圆角边框样式 --- .../components/ModelList/ManageModelsList.tsx | 125 +++++++++++------- .../src/components/ModelList/ModelList.tsx | 16 +-- 2 files changed, 85 insertions(+), 56 deletions(-) diff --git a/src/renderer/src/components/ModelList/ManageModelsList.tsx b/src/renderer/src/components/ModelList/ManageModelsList.tsx index 1100f6575b..92ea801348 100644 --- a/src/renderer/src/components/ModelList/ManageModelsList.tsx +++ b/src/renderer/src/components/ModelList/ManageModelsList.tsx @@ -26,6 +26,7 @@ interface GroupRowData { interface ModelRowData { type: 'model' model: Model + last?: boolean } type RowData = GroupRowData | ModelRowData @@ -62,9 +63,16 @@ const ManageModelsList: React.FC = ({ modelGroups, provid // 只添加非空组 rows.push({ type: 'group', groupName, models }) if (!collapsedGroups.has(groupName)) { - models.forEach((model) => { - rows.push({ type: 'model', model }) - }) + rows.push( + ...models.map( + (model, index) => + ({ + type: 'model', + model, + last: index === models.length - 1 ? true : undefined + }) as const + ) + ) } } }) @@ -131,37 +139,41 @@ const ManageModelsList: React.FC = ({ modelGroups, provid isSticky={useCallback((index: number) => flatRows[index].type === 'group', [flatRows])} overscan={5} scrollerStyle={{ - paddingRight: '10px' - }} - itemContainerStyle={{ - paddingBottom: '8px' + paddingRight: '10px', + borderRadius: '8px' }}> {(row) => { if (row.type === 'group') { const isCollapsed = collapsedGroups.has(row.groupName) return ( - handleGroupToggle(row.groupName)}> - - - {row.groupName} - - {row.models.length} - - - {renderGroupTools(row.models)} - + + handleGroupToggle(row.groupName)}> + + + {row.groupName} + + {row.models.length} + + + {renderGroupTools(row.models)} + + ) } return ( - + ) }} @@ -174,41 +186,58 @@ interface ModelListItemProps { provider: Provider onAddModel: (model: Model) => void onRemoveModel: (model: Model) => void + last?: boolean } -const ModelListItem: React.FC = memo(({ model, provider, onAddModel, onRemoveModel }) => { +const ModelListItem: React.FC = memo(({ model, provider, onAddModel, onRemoveModel, last }) => { const isAdded = useMemo(() => isModelInProvider(provider, model.id), [provider, model.id]) - return ( - {model?.name?.[0]?.toUpperCase()}, - name: , - extra: model.description && , - ext: '.model', - actions: isAdded ? ( - - - + + + + ) From a4854a883b20c0d4ce551d072e8951b2da2beda0 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sun, 3 Aug 2025 12:51:37 +0800 Subject: [PATCH 004/124] Chore/issue template (#8789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ISSUE_TEMPLATE): 更新错误报告模板的标签和类型字段 将labels字段从'kind/bug'改为'bug'并添加type字段 * Revert "chore(ISSUE_TEMPLATE): 更新错误报告模板的标签和类型字段" This reverts commit f1195e210a433373bec23075beacfee0151a7fec. * docs(issue模板): 更新issue模板中的labels字段 将kind/前缀的labels更新为更简洁的格式,例如将kind/bug改为bug,kind/enhancement改为feature * docs(ISSUE_TEMPLATE): 统一错误报告模板中的标签大小写 将中文和英文错误报告模板中的标签从 'bug' 统一改为大写 'BUG',保持一致性 * docs(ISSUE_TEMPLATE): 在bug报告模板中添加版本确认选项 --- .github/ISSUE_TEMPLATE/#0_bug_report.yml | 4 +++- .github/ISSUE_TEMPLATE/#1_feature_request.yml | 2 +- .github/ISSUE_TEMPLATE/#2_question.yml | 2 +- .github/ISSUE_TEMPLATE/0_bug_report.yml | 4 +++- .github/ISSUE_TEMPLATE/1_feature_request.yml | 2 +- .github/ISSUE_TEMPLATE/2_question.yml | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/#0_bug_report.yml b/.github/ISSUE_TEMPLATE/#0_bug_report.yml index fb48c4b390..a2f71d6a7a 100644 --- a/.github/ISSUE_TEMPLATE/#0_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/#0_bug_report.yml @@ -1,7 +1,7 @@ name: 🐛 错误报告 (中文) description: 创建一个报告以帮助我们改进 title: '[错误]: ' -labels: ['kind/bug'] +labels: ['BUG'] body: - type: markdown attributes: @@ -24,6 +24,8 @@ body: required: true - label: 我填写了简短且清晰明确的标题,以便开发者在翻阅 Issue 列表时能快速确定大致问题。而不是“一个建议”、“卡住了”等。 required: true + - label: 我确认我正在使用最新版本的 Cherry Studio。 + required: true - type: dropdown id: platform diff --git a/.github/ISSUE_TEMPLATE/#1_feature_request.yml b/.github/ISSUE_TEMPLATE/#1_feature_request.yml index 0649a0ce87..15ed7df097 100644 --- a/.github/ISSUE_TEMPLATE/#1_feature_request.yml +++ b/.github/ISSUE_TEMPLATE/#1_feature_request.yml @@ -1,7 +1,7 @@ name: 💡 功能建议 (中文) description: 为项目提出新的想法 title: '[功能]: ' -labels: ['kind/enhancement'] +labels: ['feature'] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/#2_question.yml b/.github/ISSUE_TEMPLATE/#2_question.yml index 1b595883de..5b9660d921 100644 --- a/.github/ISSUE_TEMPLATE/#2_question.yml +++ b/.github/ISSUE_TEMPLATE/#2_question.yml @@ -1,7 +1,7 @@ name: ❓ 提问 & 讨论 (中文) description: 寻求帮助、讨论问题、提出疑问等... title: '[讨论]: ' -labels: ['kind/question'] +labels: ['discussion', 'help wanted'] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/0_bug_report.yml b/.github/ISSUE_TEMPLATE/0_bug_report.yml index b0af11456d..c50cdef530 100644 --- a/.github/ISSUE_TEMPLATE/0_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/0_bug_report.yml @@ -1,7 +1,7 @@ name: 🐛 Bug Report (English) description: Create a report to help us improve title: '[Bug]: ' -labels: ['kind/bug'] +labels: ['BUG'] body: - type: markdown attributes: @@ -24,6 +24,8 @@ body: required: true - label: I've filled in short, clear headings so that developers can quickly identify a rough idea of what to expect when flipping through the list of issues. And not "a suggestion", "stuck", etc. required: true + - label: I've confirmed that I am using the latest version of Cherry Studio. + required: true - type: dropdown id: platform diff --git a/.github/ISSUE_TEMPLATE/1_feature_request.yml b/.github/ISSUE_TEMPLATE/1_feature_request.yml index af95801cf6..0822742704 100644 --- a/.github/ISSUE_TEMPLATE/1_feature_request.yml +++ b/.github/ISSUE_TEMPLATE/1_feature_request.yml @@ -1,7 +1,7 @@ name: 💡 Feature Request (English) description: Suggest an idea for this project title: '[Feature]: ' -labels: ['kind/enhancement'] +labels: ['feature'] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/2_question.yml b/.github/ISSUE_TEMPLATE/2_question.yml index 789ee80318..7baa828fb4 100644 --- a/.github/ISSUE_TEMPLATE/2_question.yml +++ b/.github/ISSUE_TEMPLATE/2_question.yml @@ -1,7 +1,7 @@ name: ❓ Questions & Discussion description: Seeking help, discussing issues, asking questions, etc... title: '[Discussion]: ' -labels: ['kind/question'] +labels: ['discussion', 'help wanted'] body: - type: markdown attributes: From f9365dfa143fceb96654c6f5f48ba4c8716cdf06 Mon Sep 17 00:00:00 2001 From: one Date: Sun, 3 Aug 2025 21:40:59 +0800 Subject: [PATCH 005/124] refactor(ManageModelsPopup): better animation and feedback (#8797) * refactor(ManageModelsPopup): pass providerId, add loadModels, rename loading state * feat: add a button to reload models * refactor: better transition for ManageModelsPopup * style: fix lint --- .../aiCore/clients/openai/OpenAIApiClient.ts | 2 +- .../clients/openai/OpenAIResponseAPIClient.ts | 2 +- .../ModelList/ManageModelsPopup.tsx | 236 +++++++++--------- .../src/components/ModelList/ModelList.tsx | 4 +- src/renderer/src/config/providers.ts | 4 +- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + 10 files changed, 131 insertions(+), 122 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index d4ad386a4a..fe5d9f18ad 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -21,6 +21,7 @@ import { isSupportedThinkingTokenZhipuModel, isVisionModel } from '@renderer/config/models' +import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService' import { estimateTextTokens } from '@renderer/services/TokenService' // For Copilot token @@ -62,7 +63,6 @@ import { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatComple import { GenericChunk } from '../../middleware/schemas' import { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types' import { OpenAIBaseClient } from './OpenAIBaseClient' -import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' const logger = loggerService.withContext('OpenAIApiClient') diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index cc9dba7095..970dd1399f 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -6,6 +6,7 @@ import { isSupportedReasoningEffortOpenAIModel, isVisionModel } from '@renderer/config/models' +import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' import { estimateTextTokens } from '@renderer/services/TokenService' import { FileMetadata, @@ -44,7 +45,6 @@ import { ResponseInput } from 'openai/resources/responses/responses' import { RequestTransformer, ResponseChunkTransformer } from '../types' import { OpenAIAPIClient } from './OpenAIApiClient' import { OpenAIBaseClient } from './OpenAIBaseClient' -import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' export class OpenAIResponseAPIClient extends OpenAIBaseClient< OpenAI, diff --git a/src/renderer/src/components/ModelList/ManageModelsPopup.tsx b/src/renderer/src/components/ModelList/ManageModelsPopup.tsx index acaf0a3a00..5346f341e9 100644 --- a/src/renderer/src/components/ModelList/ManageModelsPopup.tsx +++ b/src/renderer/src/components/ModelList/ManageModelsPopup.tsx @@ -18,40 +18,35 @@ import { import { useProvider } from '@renderer/hooks/useProvider' import { fetchModels } from '@renderer/services/ApiService' import { Model, Provider } from '@renderer/types' -import { - filterModelsByKeywords, - getDefaultGroupName, - getFancyProviderName, - isFreeModel, - runAsyncFunction -} from '@renderer/utils' +import { filterModelsByKeywords, getDefaultGroupName, getFancyProviderName, isFreeModel } from '@renderer/utils' import { Button, Empty, Flex, Modal, Spin, Tabs, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { groupBy, isEmpty, uniqBy } from 'lodash' import { debounce } from 'lodash' -import { Search } from 'lucide-react' +import { RefreshCcw, Search } from 'lucide-react' import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import { HStack } from '../Layout' import ManageModelsList from './ManageModelsList' import { isModelInProvider, isValidNewApiModel } from './utils' const logger = loggerService.withContext('ManageModelsPopup') interface ShowParams { - provider: Provider + providerId: string } interface Props extends ShowParams { resolve: (data: any) => void } -const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { +const PopupContainer: React.FC = ({ providerId, resolve }) => { const [open, setOpen] = useState(true) - const { provider, models, addModel, removeModel } = useProvider(_provider.id) + const { provider, models, addModel, removeModel } = useProvider(providerId) const [listModels, setListModels] = useState([]) - const [loading, setLoading] = useState(false) + const [loadingModels, setLoadingModels] = useState(false) const [searchText, setSearchText] = useState('') const [filterSearchText, setFilterSearchText] = useState('') const debouncedSetFilterText = useMemo( @@ -78,9 +73,14 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { const { t, i18n } = useTranslation() const searchInputRef = useRef(null) - const systemModels = SYSTEM_MODELS[_provider.id] || [] + const systemModels = SYSTEM_MODELS[provider.id] || [] const allModels = uniqBy([...systemModels, ...listModels, ...models], 'id') + const isLoading = useMemo( + () => loadingModels || isFilterTypePending || isSearchPending, + [loadingModels, isFilterTypePending, isSearchPending] + ) + const list = useMemo( () => filterModelsByKeywords(filterSearchText, allModels).filter((model) => { @@ -149,48 +149,66 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { const onRemoveModel = useCallback((model: Model) => removeModel(model), [removeModel]) - useEffect(() => { - let timer: NodeJS.Timeout - let mounted = true + const onRemoveAll = useCallback(() => { + list.filter((model) => isModelInProvider(provider, model.id)).forEach(onRemoveModel) + }, [list, onRemoveModel, provider]) - runAsyncFunction(async () => { - try { - setLoading(true) - const models = await fetchModels(_provider) - setListModels( - models - .map((model) => ({ - // @ts-ignore modelId - id: model?.id || model?.name, - // @ts-ignore name - name: model?.display_name || model?.displayName || model?.name || model?.id, - provider: _provider.id, - // @ts-ignore group - group: getDefaultGroupName(model?.id || model?.name, _provider.id), - // @ts-ignore description - description: model?.description || '', - // @ts-ignore owned_by - owned_by: model?.owned_by || '', - // @ts-ignore supported_endpoint_types - supported_endpoint_types: model?.supported_endpoint_types - })) - .filter((model) => !isEmpty(model.name)) - ) - } catch (error) { - logger.error('Failed to fetch models', error as Error) - } finally { - if (mounted) { - timer = setTimeout(() => setLoading(false), 300) + const onAddAll = useCallback(() => { + const wouldAddModel = list.filter((model) => !isModelInProvider(provider, model.id)) + window.modal.confirm({ + title: t('settings.models.manage.add_listed.label'), + content: t('settings.models.manage.add_listed.confirm'), + centered: true, + onOk: () => { + if (provider.id === 'new-api') { + if (models.every(isValidNewApiModel)) { + wouldAddModel.forEach(onAddModel) + } else { + NewApiBatchAddModelPopup.show({ + title: t('settings.models.add.batch_add_models'), + batchModels: wouldAddModel, + provider + }) + } + } else { + wouldAddModel.forEach(onAddModel) } } }) + }, [list, models, onAddModel, provider, t]) - return () => { - mounted = false - if (timer) { - clearTimeout(timer) - } + const loadModels = useCallback(async (provider: Provider) => { + setLoadingModels(true) + try { + const models = await fetchModels(provider) + const filteredModels = models + .map((model) => ({ + // @ts-ignore modelId + id: model?.id || model?.name, + // @ts-ignore name + name: model?.display_name || model?.displayName || model?.name || model?.id, + provider: provider.id, + // @ts-ignore group + group: getDefaultGroupName(model?.id || model?.name, provider.id), + // @ts-ignore description + description: model?.description || '', + // @ts-ignore owned_by + owned_by: model?.owned_by || '', + // @ts-ignore supported_endpoint_types + supported_endpoint_types: model?.supported_endpoint_types + })) + .filter((model) => !isEmpty(model.name)) + + setListModels(filteredModels) + } catch (error) { + logger.error(`Failed to load models for provider ${getFancyProviderName(provider)}`, error as Error) + } finally { + setLoadingModels(false) } + }, []) + + useEffect(() => { + loadModels(provider) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -222,57 +240,39 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { const renderTopTools = useCallback(() => { const isAllFilteredInProvider = list.length > 0 && list.every((model) => isModelInProvider(provider, model.id)) - const onRemoveAll = () => { - list.filter((model) => isModelInProvider(provider, model.id)).forEach(onRemoveModel) - } - - const onAddAll = () => { - const wouldAddModel = list.filter((model) => !isModelInProvider(provider, model.id)) - window.modal.confirm({ - title: t('settings.models.manage.add_listed.label'), - content: t('settings.models.manage.add_listed.confirm'), - centered: true, - onOk: () => { - if (provider.id === 'new-api') { - if (models.every(isValidNewApiModel)) { - wouldAddModel.forEach(onAddModel) - } else { - NewApiBatchAddModelPopup.show({ - title: t('settings.models.add.batch_add_models'), - batchModels: wouldAddModel, - provider - }) - } - } else { - wouldAddModel.forEach(onAddModel) - } - } - }) - } - return ( - -
+ ), + children: ( + + {options.map((item) => ( + + + + + + + + ))} + + ) + } + ]} + ghost + expandIconPosition="end" + /> + + ) +} + +export default ApiOptionsSettings diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 93f2a87604..c1772b6e5e 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -29,6 +29,7 @@ import { SettingSubtitle, SettingTitle } from '..' +import ApiOptionsSettings from './ApiOptionsSettings' import AwsBedrockSettings from './AwsBedrockSettings' import CustomHeaderPopup from './CustomHeaderPopup' import DMXAPISettings from './DMXAPISettings' @@ -36,7 +37,6 @@ import GithubCopilotSettings from './GithubCopilotSettings' import GPUStackSettings from './GPUStackSettings' import LMStudioSettings from './LMStudioSettings' import ProviderOAuth from './ProviderOAuth' -import ProviderSettingsPopup from './ProviderSettingsPopup' import SelectProviderModelPopup from './SelectProviderModelPopup' import VertexAISettings from './VertexAISettings' @@ -236,14 +236,6 @@ const ProviderSetting: FC = ({ providerId }) => { @@ -210,7 +211,7 @@ const ModelEditContent: FC = ({ provider, model, onUpdate style={{ color: 'var(--color-text-3)' }}> {t('settings.moresetting.label')} - diff --git a/src/renderer/src/components/ModelList/ModelList.tsx b/src/renderer/src/components/ModelList/ModelList.tsx index 133c1e436c..c5e160a419 100644 --- a/src/renderer/src/components/ModelList/ModelList.tsx +++ b/src/renderer/src/components/ModelList/ModelList.tsx @@ -1,7 +1,6 @@ import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar' import CustomTag from '@renderer/components/CustomTag' -import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' -import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' +import { LoadingIcon, StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons' import { HStack } from '@renderer/components/Layout' import AddModelPopup from '@renderer/components/ModelList/AddModelPopup' import EditModelPopup from '@renderer/components/ModelList/EditModelPopup' @@ -160,7 +159,7 @@ const ModelList: React.FC = ({ providerId }) => { - }> + }> {displayedModelGroups && !isEmpty(displayedModelGroups) ? ( {Object.keys(displayedModelGroups).map((group, i) => ( diff --git a/src/renderer/src/components/ModelList/ModelListItem.tsx b/src/renderer/src/components/ModelList/ModelListItem.tsx index da0059b780..7955f8eafe 100644 --- a/src/renderer/src/components/ModelList/ModelListItem.tsx +++ b/src/renderer/src/components/ModelList/ModelListItem.tsx @@ -1,4 +1,5 @@ import { type HealthResult, HealthStatusIndicator } from '@renderer/components/HealthStatusIndicator' +import { EditIcon } from '@renderer/components/Icons' import { HStack } from '@renderer/components/Layout' import ModelIdWithTags from '@renderer/components/ModelIdWithTags' import { getModelLogo } from '@renderer/config/models' @@ -6,7 +7,7 @@ import { Model } from '@renderer/types' import { ModelWithStatus } from '@renderer/types/healthCheck' import { maskApiKey } from '@renderer/utils/api' import { Avatar, Button, Tooltip } from 'antd' -import { Minus, Pen } from 'lucide-react' +import { Minus } from 'lucide-react' import React, { memo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -51,7 +52,7 @@ const ModelListItem: React.FC = ({ ref, model, modelStatus, - + ) + + const MockTooltip: React.FC> = ({ children, title }) => ( +
+ {children} +
+ ) + + return { + Button: MockButton, + InputNumber: MockInputNumber, + Space: { Compact: MockSpaceCompact }, + Tooltip: MockTooltip + } }) // Mock dependencies @@ -46,20 +90,10 @@ vi.mock('react-i18next', () => ({ } })) -// Mock logger -vi.mock('@logger', () => ({ - loggerService: { - withContext: () => ({ - warn: vi.fn(), - error: vi.fn() - }) - } -})) - -vi.mock('lucide-react', () => ({ - RefreshCw: (props: React.SVGProps) => ( +vi.mock('@renderer/components/Icons', () => ({ + RefreshIcon: (props: React.SVGProps) => ( - RefreshCw + RefreshIcon ) })) @@ -119,13 +153,11 @@ describe('InputEmbeddingDimension', () => { describe('functionality', () => { it('should call onChange when input value changes', async () => { const handleChange = vi.fn() - const user = userEvent.setup() render() const input = screen.getByPlaceholderText('请输入维度大小') - await user.clear(input) - await user.type(input, '2048') + fireEvent.change(input, { target: { value: '2048' } }) expect(handleChange).toHaveBeenCalledWith(2048) }) @@ -182,7 +214,6 @@ describe('InputEmbeddingDimension', () => { it('should handle null value correctly', async () => { const handleChange = vi.fn() - const user = userEvent.setup() render() @@ -190,7 +221,7 @@ describe('InputEmbeddingDimension', () => { expect(input.value).toBe('') // Should allow typing new value - await user.type(input, '1024') + fireEvent.change(input, { target: { value: '1024' } }) expect(handleChange).toHaveBeenCalledWith(1024) }) }) diff --git a/src/renderer/src/components/__tests__/__snapshots__/InfoTooltip.test.tsx.snap b/src/renderer/src/components/__tests__/__snapshots__/InfoTooltip.test.tsx.snap index 68f5589cda..f6eafa2c8d 100644 --- a/src/renderer/src/components/__tests__/__snapshots__/InfoTooltip.test.tsx.snap +++ b/src/renderer/src/components/__tests__/__snapshots__/InfoTooltip.test.tsx.snap @@ -1,28 +1,18 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`InfoTooltip > should match snapshot 1`] = ` - -
- - - - + Info +
+
+ Test tooltip +
+ `; diff --git a/src/renderer/src/components/__tests__/__snapshots__/InputEmbeddingDimension.test.tsx.snap b/src/renderer/src/components/__tests__/__snapshots__/InputEmbeddingDimension.test.tsx.snap index b663328a98..9a29f4f2cf 100644 --- a/src/renderer/src/components/__tests__/__snapshots__/InputEmbeddingDimension.test.tsx.snap +++ b/src/renderer/src/components/__tests__/__snapshots__/InputEmbeddingDimension.test.tsx.snap @@ -2,95 +2,24 @@ exports[`InputEmbeddingDimension > basic rendering > should match snapshot with all props 1`] = `
-
+
-
- - - - - - - - - - -
-
- -
-
- + +
`; exports[`InputEmbeddingDimension > basic rendering > should match snapshot with loading state 1`] = `
-
+
-
- - - - - - - - - - -
-
- -
+ Loading... +
-
`; diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 74b9bc9ac4..4829ea49cf 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -596,7 +596,7 @@ "list": "Topic List", "move_to": "Move to", "new": "New Topic", - "pinned": "Pinned Topics", + "pin": "Pin Topic", "prompt": { "edit": { "title": "Edit Topic Prompts" @@ -605,7 +605,7 @@ "tips": "Topic Prompts: Additional supplementary prompts provided for the current topic" }, "title": "Topics", - "unpinned": "Unpinned Topics" + "unpin": "Unpin Topic" }, "translate": "Translate" }, @@ -1552,6 +1552,7 @@ "mode": { "edit": "Edit", "generate": "Draw", + "merge": "Merge", "remix": "Remix", "upscale": "Upscale" }, diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 41883baf48..b57fc3d260 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -596,7 +596,7 @@ "list": "トピックリスト", "move_to": "移動先", "new": "新しいトピック", - "pinned": "トピックを固定", + "pin": "トピックを固定", "prompt": { "edit": { "title": "トピック提示語を編集する" @@ -605,7 +605,7 @@ "tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供" }, "title": "トピック", - "unpinned": "固定解除" + "unpin": "固定解除" }, "translate": "翻訳" }, @@ -1552,6 +1552,7 @@ "mode": { "edit": "部分編集", "generate": "画像生成", + "merge": "マージ", "remix": "混合", "upscale": "拡大" }, diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 9b1441fe45..6d8cdfdecf 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -596,7 +596,7 @@ "list": "Список топиков", "move_to": "Переместить в", "new": "Новый топик", - "pinned": "Закрепленные темы", + "pin": "Закрепленные темы", "prompt": { "edit": { "title": "Редактировать подсказки темы" @@ -605,7 +605,7 @@ "tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы" }, "title": "Топики", - "unpinned": "Открепленные темы" + "unpin": "Открепленные темы" }, "translate": "Перевести" }, @@ -1552,6 +1552,7 @@ "mode": { "edit": "Редактирование", "generate": "Рисование", + "merge": "Слияние", "remix": "Смешивание", "upscale": "Увеличение" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 2148b168c3..8d5f7e053f 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -596,7 +596,7 @@ "list": "话题列表", "move_to": "移动到", "new": "开始新对话", - "pinned": "固定话题", + "pin": "固定话题", "prompt": { "edit": { "title": "编辑话题提示词" @@ -605,7 +605,7 @@ "tips": "话题提示词:针对当前话题提供额外的补充提示词" }, "title": "话题", - "unpinned": "取消固定" + "unpin": "取消固定" }, "translate": "翻译" }, @@ -1552,6 +1552,7 @@ "mode": { "edit": "编辑", "generate": "绘图", + "merge": "合并", "remix": "混合", "upscale": "高清增强" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 8526f3a30e..15aa9ecd8e 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -596,7 +596,7 @@ "list": "話題列表", "move_to": "移動到", "new": "開始新對話", - "pinned": "固定話題", + "pin": "固定話題", "prompt": { "edit": { "title": "編輯話題提示詞" @@ -605,7 +605,7 @@ "tips": "話題提示詞:針對目前話題提供額外的補充提示詞" }, "title": "話題", - "unpinned": "取消固定" + "unpin": "取消固定" }, "translate": "翻譯" }, @@ -1552,6 +1552,7 @@ "mode": { "edit": "編輯", "generate": "繪圖", + "merge": "合併", "remix": "混合", "upscale": "放大" }, diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index d126bc8bde..c868ab8b18 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -596,7 +596,7 @@ "list": "Λίστα θεμάτων", "move_to": "Μετακίνηση στο", "new": "Ξεκινήστε νέα συζήτηση", - "pinned": "Σταθερά θέματα", + "pin": "Σταθερά θέματα", "prompt": { "edit": { "title": "Επεξεργασία προσδοκώμενων όριων" @@ -605,7 +605,7 @@ "tips": "Προσδοκώμενα όρια: προσθέτει επιπλέον επιστημονικές προσθήκες για το παρόν θέμα" }, "title": "Θέματα", - "unpinned": "Αποστέλλω" + "unpin": "Ξεκαρφίτσωμα" }, "translate": "Μετάφραση" }, diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 76f5432a6b..b6b1a2487f 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -596,7 +596,7 @@ "list": "Lista de temas", "move_to": "Mover a", "new": "Iniciar nueva conversación", - "pinned": "Fijar tema", + "pin": "Fijar tema", "prompt": { "edit": { "title": "Editar palabras clave del tema" @@ -605,7 +605,7 @@ "tips": "Palabras clave del tema: proporcionar indicaciones adicionales para el tema actual" }, "title": "Tema", - "unpinned": "Quitar fijación" + "unpin": "Quitar fijación" }, "translate": "Traducir" }, diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index e4c9f5a940..d56d952a53 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -596,7 +596,7 @@ "list": "Liste des sujets", "move_to": "Déplacer vers", "new": "Commencer une nouvelle conversation", - "pinned": "Fixer le sujet", + "pin": "Fixer le sujet", "prompt": { "edit": { "title": "Modifier les indicateurs de sujet" @@ -605,7 +605,7 @@ "tips": "Indicateurs de sujet : fournir des indications supplémentaires pour le sujet actuel" }, "title": "Sujet", - "unpinned": "Annuler le fixage" + "unpin": "Annuler le fixage" }, "translate": "Traduire" }, diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 8761823002..59247634eb 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -596,7 +596,7 @@ "list": "Lista de tópicos", "move_to": "Mover para", "new": "Começar nova conversa", - "pinned": "Fixar tópico", + "pin": "Fixar tópico", "prompt": { "edit": { "title": "Editar prompt do tópico" @@ -605,7 +605,7 @@ "tips": "Prompt do tópico: fornecer prompts adicionais para o tópico atual" }, "title": "Tópicos", - "unpinned": "Desfixar" + "unpin": "Desfixar" }, "translate": "Traduzir" }, diff --git a/src/renderer/src/pages/agents/components/AgentCard.tsx b/src/renderer/src/pages/agents/components/AgentCard.tsx index 9e2f08eb1c..223987a06c 100644 --- a/src/renderer/src/pages/agents/components/AgentCard.tsx +++ b/src/renderer/src/pages/agents/components/AgentCard.tsx @@ -1,12 +1,5 @@ -import { - DeleteOutlined, - EditOutlined, - EllipsisOutlined, - ExportOutlined, - PlusOutlined, - SortAscendingOutlined -} from '@ant-design/icons' import CustomTag from '@renderer/components/CustomTag' +import { DeleteIcon, EditIcon } from '@renderer/components/Icons' import { useAgents } from '@renderer/hooks/useAgents' import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings' import { createAssistantFromAgent } from '@renderer/services/AssistantService' @@ -14,6 +7,7 @@ import type { Agent } from '@renderer/types' import { getLeadingEmoji } from '@renderer/utils' import { Button, Dropdown } from 'antd' import { t } from 'i18next' +import { ArrowDownAZ, Ellipsis, PlusIcon, SquareArrowOutUpRight } from 'lucide-react' import { type FC, memo, useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' @@ -66,7 +60,7 @@ const AgentCard: FC = ({ agent, onClick, activegroup, getLocalizedGroupNa { key: 'edit', label: t('agents.edit.title'), - icon: , + icon: , onClick: (e: any) => { e.domEvent.stopPropagation() AssistantSettingsPopup.show({ assistant: agent }) @@ -75,7 +69,7 @@ const AgentCard: FC = ({ agent, onClick, activegroup, getLocalizedGroupNa { key: 'create', label: t('agents.add.button'), - icon: , + icon: , onClick: (e: any) => { e.domEvent.stopPropagation() createAssistantFromAgent(agent) @@ -84,7 +78,7 @@ const AgentCard: FC = ({ agent, onClick, activegroup, getLocalizedGroupNa { key: 'sort', label: t('agents.sorting.title'), - icon: , + icon: , onClick: (e: any) => { e.domEvent.stopPropagation() ManageAgentsPopup.show() @@ -93,7 +87,7 @@ const AgentCard: FC = ({ agent, onClick, activegroup, getLocalizedGroupNa { key: 'export', label: t('agents.export.agent'), - icon: , + icon: , onClick: (e: any) => { e.domEvent.stopPropagation() exportAgent() @@ -102,7 +96,7 @@ const AgentCard: FC = ({ agent, onClick, activegroup, getLocalizedGroupNa { key: 'delete', label: t('common.delete'), - icon: , + icon: , danger: true, onClick: (e: any) => { e.domEvent.stopPropagation() @@ -173,7 +167,7 @@ const AgentCard: FC = ({ agent, onClick, activegroup, getLocalizedGroupNa color="default" variant="filled" shape="circle" - icon={} + icon={} /> diff --git a/src/renderer/src/pages/files/FileList.tsx b/src/renderer/src/pages/files/FileList.tsx index 3d55c133d1..d6ec0d850b 100644 --- a/src/renderer/src/pages/files/FileList.tsx +++ b/src/renderer/src/pages/files/FileList.tsx @@ -1,4 +1,5 @@ -import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons' +import { ExclamationCircleOutlined } from '@ant-design/icons' +import { DeleteIcon } from '@renderer/components/Icons' import { DynamicVirtualList } from '@renderer/components/VirtualList' import { handleDelete } from '@renderer/services/FileAction' import FileManager from '@renderer/services/FileManager' @@ -68,7 +69,7 @@ const FileList: React.FC = ({ id, list, files }) => { icon: }) }}> - + diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx index 3bd3be3c68..649ff6e186 100644 --- a/src/renderer/src/pages/files/FilesPage.tsx +++ b/src/renderer/src/pages/files/FilesPage.tsx @@ -1,11 +1,6 @@ -import { - DeleteOutlined, - EditOutlined, - ExclamationCircleOutlined, - SortAscendingOutlined, - SortDescendingOutlined -} from '@ant-design/icons' +import { ExclamationCircleOutlined } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' +import { DeleteIcon, EditIcon } from '@renderer/components/Icons' import ListItem from '@renderer/components/ListItem' import db from '@renderer/databases' import { getFileFieldLabel } from '@renderer/i18n/label' @@ -16,7 +11,14 @@ import { formatFileSize } from '@renderer/utils' import { Button, Empty, Flex, Popconfirm } from 'antd' import dayjs from 'dayjs' import { useLiveQuery } from 'dexie-react-hooks' -import { File as FileIcon, FileImage, FileText, FileType as FileTypeIcon } from 'lucide-react' +import { + ArrowDownNarrowWide, + ArrowUpWideNarrow, + File as FileIcon, + FileImage, + FileText, + FileType as FileTypeIcon +} from 'lucide-react' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -54,7 +56,7 @@ const FilesPage: FC = () => { created_at_unix: dayjs(file.created_at).unix(), actions: ( -
diff --git a/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx index 261ed7f140..bcc8a96859 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx @@ -1,4 +1,4 @@ -import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' +import { LoadingIcon } from '@renderer/components/Icons' import { MessageBlockStatus, MessageBlockType, type PlaceholderMessageBlock } from '@renderer/types/newMessage' import React from 'react' import styled from 'styled-components' @@ -10,7 +10,7 @@ const PlaceholderBlock: React.FC = ({ block }) => { if (block.status === MessageBlockStatus.PROCESSING && block.type === MessageBlockType.UNKNOWN) { return ( - + ) } diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 3fab692184..be637bb2ad 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -1,4 +1,5 @@ -import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons' +import { InfoCircleOutlined } from '@ant-design/icons' +import { CopyIcon, DeleteIcon, EditIcon, RefreshIcon } from '@renderer/components/Icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' @@ -32,20 +33,7 @@ import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown' import { findMainTextBlocks, findTranslationBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { Dropdown, Popconfirm, Tooltip } from 'antd' import dayjs from 'dayjs' -import { - AtSign, - Copy, - FilePenLine, - Languages, - ListChecks, - Menu, - RefreshCw, - Save, - Share, - Split, - ThumbsUp, - Trash -} from 'lucide-react' +import { AtSign, Check, FilePenLine, Languages, ListChecks, Menu, Save, Split, ThumbsUp, Upload } from 'lucide-react' import { FC, memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -223,7 +211,7 @@ const MessageMenubar: FC = (props) => { { label: t('chat.save.label'), key: 'save', - icon: , + icon: , children: [ { label: t('chat.save.file.title'), @@ -245,7 +233,7 @@ const MessageMenubar: FC = (props) => { { label: t('chat.topics.export.title'), key: 'export', - icon: , + icon: , children: [ exportMenuOptions.plain_text && { label: t('chat.topics.copy.plain_text'), @@ -440,28 +428,28 @@ const MessageMenubar: FC = (props) => { className="message-action-button" onClick={() => handleResendUserMessage()} $softHoverBg={isBubbleStyle}> - +
)} {message.role === 'user' && ( - + )} - {!copied && } - {copied && } + {!copied && } + {copied && } {isAssistantMessage && ( } + icon={} onConfirm={onRegenerate} onOpenChange={(open) => open && setShowRegenerateTooltip(false)}> = (props) => { open={showRegenerateTooltip} onOpenChange={setShowRegenerateTooltip}> - + @@ -571,7 +559,7 @@ const MessageMenubar: FC = (props) => { } + icon={} onOpenChange={(open) => open && setShowDeleteTooltip(false)} onConfirm={() => deleteMessage(message.id, message.traceId, message.model?.name)}> = (props) => { mouseEnterDelay={1} open={showDeleteTooltip} onOpenChange={setShowDeleteTooltip}> - + diff --git a/src/renderer/src/pages/home/Messages/MessageTools.tsx b/src/renderer/src/pages/home/Messages/MessageTools.tsx index 1f53e64fb8..ccccec9f5f 100644 --- a/src/renderer/src/pages/home/Messages/MessageTools.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTools.tsx @@ -1,5 +1,5 @@ -import { CheckOutlined, CloseOutlined, ExpandOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons' import { loggerService } from '@logger' +import { CopyIcon, LoadingIcon } from '@renderer/components/Icons' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useSettings } from '@renderer/hooks/useSettings' @@ -19,7 +19,18 @@ import { Tooltip } from 'antd' import { message } from 'antd' -import { ChevronDown, ChevronRight, CirclePlay, CircleX, PauseCircle, ShieldCheck } from 'lucide-react' +import { + Check, + ChevronDown, + ChevronRight, + CirclePlay, + CircleX, + Maximize, + PauseCircle, + ShieldCheck, + TriangleAlert, + X +} from 'lucide-react' import { FC, memo, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -191,23 +202,23 @@ const MessageTools: FC = ({ block }) => { switch (status) { case 'pending': label = t('message.tools.pending', 'Awaiting Approval') - icon = + icon = break case 'invoking': label = t('message.tools.invoking') - icon = + icon = break case 'cancelled': label = t('message.tools.cancelled') - icon = + icon = break case 'done': if (hasError) { label = t('message.tools.error') - icon = + icon = } else { label = t('message.tools.completed') - icon = + icon = } break default: @@ -262,7 +273,7 @@ const MessageTools: FC = ({ block }) => { }) }} aria-label={t('common.expand')}> - + {!isPending && !isInvoking && ( @@ -274,8 +285,8 @@ const MessageTools: FC = ({ block }) => { copyContent(JSON.stringify(result, null, 2), id) }} aria-label={t('common.copy')}> - {!copiedMap[id] && } - {copiedMap[id] && } + {!copiedMap[id] && } + {copiedMap[id] && } )} @@ -394,7 +405,7 @@ const MessageTools: FC = ({ block }) => { e.stopPropagation() handleAbortTool() }}> - + {t('chat.input.pause')} ) : ( @@ -572,10 +583,10 @@ const ExpandIcon = styled(ChevronRight)<{ $isActive?: boolean }>` ` const CollapseContainer = styled(Collapse)` - --status-color-warning: var(--color-warning, #faad14); + --status-color-warning: var(--color-status-warning, #faad14); --status-color-invoking: var(--color-primary); - --status-color-error: var(--color-error, #ff4d4f); - --status-color-success: var(--color-success, green); + --status-color-error: var(--color-status-error, #ff4d4f); + --status-color-success: var(--color-primary, green); border-radius: 7px; border: none; background-color: var(--color-background); diff --git a/src/renderer/src/pages/home/Messages/MessageTranslate.tsx b/src/renderer/src/pages/home/Messages/MessageTranslate.tsx index b3639c3812..91135ed737 100644 --- a/src/renderer/src/pages/home/Messages/MessageTranslate.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTranslate.tsx @@ -1,5 +1,5 @@ import { TranslationOutlined } from '@ant-design/icons' -import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' +import { LoadingIcon } from '@renderer/components/Icons' import type { TranslationMessageBlock } from '@renderer/types/newMessage' import { Divider } from 'antd' import { FC, Fragment } from 'react' @@ -20,7 +20,7 @@ const MessageTranslate: FC = ({ block }) => {
{!block.content || block.content === t('translate.processing') ? ( - + ) : ( )} diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 4402804836..9207dad35b 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -1,6 +1,6 @@ import { loggerService } from '@logger' import ContextMenu from '@renderer/components/ContextMenu' -import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' +import { LoadingIcon } from '@renderer/components/Icons' import Scrollbar from '@renderer/components/Scrollbar' import { LOAD_MORE_COUNT } from '@renderer/config/constant' import { useAssistant } from '@renderer/hooks/useAssistant' @@ -309,7 +309,7 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o ))} {isLoadingMore && ( - + )} diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index e2c7589243..5147cbb2c2 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -1,4 +1,4 @@ -import { DownOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons' +import { DownOutlined, RightOutlined } from '@ant-design/icons' import { DraggableList } from '@renderer/components/DraggableList' import Scrollbar from '@renderer/components/Scrollbar' import { useAgents } from '@renderer/hooks/useAgents' @@ -6,8 +6,9 @@ import { useAssistants } from '@renderer/hooks/useAssistant' import { useAssistantsTabSortType } from '@renderer/hooks/useStore' import { useTags } from '@renderer/hooks/useTags' import { Assistant, AssistantsSortType } from '@renderer/types' -import { Tooltip } from 'antd' -import { FC, useCallback, useRef, useState } from 'react' +import { Tooltip, Typography } from 'antd' +import { Plus } from 'lucide-react' +import { FC, useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -69,6 +70,19 @@ const Assistants: FC = ({ [assistants, t, updateAssistants] ) + const renderAddAssistantButton = useMemo(() => { + return ( + + + + + {t('chat.add.assistant.title')} + + + + ) + }, [onCreateAssistant, t]) + if (assistantsTabSortType === 'tags') { return ( @@ -117,12 +131,7 @@ const Assistants: FC = ({ ))} - - - - {t('chat.add.assistant.title')} - - + {renderAddAssistantButton} ) } @@ -149,14 +158,7 @@ const Assistants: FC = ({ /> )} - {!dragging && ( - - - - {t('chat.add.assistant.title')} - - - )} + {!dragging && renderAddAssistantButton}
) @@ -224,13 +226,13 @@ const GroupTitleDivider = styled.div` border-top: 1px solid var(--color-border); ` -const AssistantName = styled.div` - color: var(--color-text); - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; +const AddItemWrapper = styled.div` + color: var(--color-text-2); font-size: 13px; + display: flex; + align-items: center; + white-space: nowrap; + overflow: hidden; ` export default Assistants diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 670cb2638c..d5f3346a06 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -1,17 +1,5 @@ -import { - ClearOutlined, - CloseOutlined, - DeleteOutlined, - EditOutlined, - FolderOutlined, - MenuOutlined, - PlusOutlined, - PushpinOutlined, - QuestionCircleOutlined, - UploadOutlined -} from '@ant-design/icons' import { DraggableVirtualList } from '@renderer/components/DraggableList' -import CopyIcon from '@renderer/components/Icons/CopyIcon' +import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import PromptPopup from '@renderer/components/Popups/PromptPopup' import { isMac } from '@renderer/config/constant' @@ -40,6 +28,19 @@ import { Dropdown, MenuProps, Tooltip } from 'antd' import { ItemType, MenuItemType } from 'antd/es/menu/interface' import dayjs from 'dayjs' import { findIndex } from 'lodash' +import { + BrushCleaning, + FolderOpen, + HelpCircle, + MenuIcon, + PackagePlus, + PinIcon, + PinOffIcon, + PlusIcon, + Sparkles, + UploadIcon, + XIcon +} from 'lucide-react' import { FC, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' @@ -177,7 +178,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, { label: t('chat.topics.auto_rename'), key: 'auto-rename', - icon: , + icon: , disabled: isRenaming(topic.id), async onClick() { const messages = await TopicManager.getTopicMessages(topic.id) @@ -200,7 +201,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, { label: t('chat.topics.edit.title'), key: 'rename', - icon: , + icon: , disabled: isRenaming(topic.id), async onClick() { const name = await PromptPopup.show({ @@ -217,10 +218,10 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, { label: t('chat.topics.prompt.label'), key: 'topic-prompt', - icon: , + icon: , extra: ( - + ), async onClick() { @@ -243,9 +244,9 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, } }, { - label: topic.pinned ? t('chat.topics.unpinned') : t('chat.topics.pinned'), + label: topic.pinned ? t('chat.topics.unpin') : t('chat.topics.pin'), key: 'pin', - icon: , + icon: topic.pinned ? : , onClick() { onPinTopic(topic) } @@ -253,7 +254,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, { label: t('chat.topics.clear.title'), key: 'clear-messages', - icon: , + icon: , async onClick() { window.modal.confirm({ title: t('chat.input.clear.content'), @@ -265,7 +266,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, { label: t('settings.topic.position.label'), key: 'topic-position', - icon: , + icon: , children: [ { label: t('settings.topic.position.left'), @@ -282,7 +283,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, { label: t('chat.topics.copy.title'), key: 'copy', - icon: , + icon: , children: [ { label: t('chat.topics.copy.image'), @@ -304,7 +305,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, { label: t('chat.topics.export.title'), key: 'export', - icon: , + icon: , children: [ exportMenuOptions.image && { label: t('chat.topics.export.image'), @@ -375,7 +376,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, menus.push({ label: t('chat.topics.move_to'), key: 'move', - icon: , + icon: , children: assistants .filter((a) => a.id !== assistant.id) .map((a) => ({ @@ -392,7 +393,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, label: t('common.delete'), danger: true, key: 'delete', - icon: , + icon: , onClick: () => onDeleteTopic(topic) }) } @@ -446,7 +447,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, itemContainerStyle={{ paddingBottom: '8px' }} header={ EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> - + {t('chat.add.topic.title')} }> @@ -498,16 +499,16 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic, } }}> {deletingTopicId === topic.id ? ( - + ) : ( - + )} )} {topic.pinned && ( - + )} @@ -704,10 +705,5 @@ const MenuButton = styled.div` font-size: 12px; } ` -const QuestionIcon = styled(QuestionCircleOutlined)` - font-size: 14px; - cursor: pointer; - color: var(--color-text-3); -` export default Topics diff --git a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx index d33c26c613..238fe5c58c 100644 --- a/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AssistantItem.tsx @@ -1,17 +1,6 @@ -import { - CheckOutlined, - DeleteOutlined, - EditOutlined, - MinusCircleOutlined, - PlusOutlined, - SaveOutlined, - SmileOutlined, - SortAscendingOutlined, - SortDescendingOutlined -} from '@ant-design/icons' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import EmojiIcon from '@renderer/components/EmojiIcon' -import CopyIcon from '@renderer/components/Icons/CopyIcon' +import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons' import PromptPopup from '@renderer/components/Popups/PromptPopup' import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' @@ -24,7 +13,19 @@ import { getLeadingEmoji, uuid } from '@renderer/utils' import { hasTopicPendingRequests } from '@renderer/utils/queue' import { Dropdown, MenuProps } from 'antd' import { omit } from 'lodash' -import { AlignJustify, Plus, Settings2, Tag, Tags } from 'lucide-react' +import { + AlignJustify, + ArrowDownAZ, + ArrowUpAZ, + BrushCleaning, + Check, + Plus, + Save, + Settings2, + Smile, + Tag, + Tags +} from 'lucide-react' import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -198,7 +199,7 @@ const createTagMenuItems = ( const items: MenuProps['items'] = [ ...allTags.map((tag) => ({ label: tag, - icon: assistant.tags?.includes(tag) ? : , + icon: assistant.tags?.includes(tag) ? : , key: `all-tag-${tag}`, onClick: () => handleTagOperation(tag, assistant, assistants, updateAssistants) })) @@ -211,7 +212,7 @@ const createTagMenuItems = ( items.push({ label: t('assistants.tags.add'), key: 'new-tag', - icon: , + icon: , onClick: async () => { const tagName = await PromptPopup.show({ title: t('assistants.tags.add'), @@ -228,7 +229,7 @@ const createTagMenuItems = ( items.push({ label: t('assistants.tags.manage'), key: 'manage-tags', - icon: , + icon: , onClick: () => { AssistantTagsPopup.show({ title: t('assistants.tags.manage') }) } @@ -260,13 +261,13 @@ function getMenuItems({ { label: t('assistants.edit.title'), key: 'edit', - icon: , + icon: , onClick: () => AssistantSettingsPopup.show({ assistant }) }, { label: t('assistants.copy.title'), key: 'duplicate', - icon: , + icon: , onClick: async () => { const _assistant = copyAssistant(assistant) if (_assistant) { @@ -277,7 +278,7 @@ function getMenuItems({ { label: t('assistants.clear.title'), key: 'clear', - icon: , + icon: , onClick: () => { window.modal.confirm({ title: t('assistants.clear.title'), @@ -291,7 +292,7 @@ function getMenuItems({ { label: t('assistants.save.title'), key: 'save-to-agent', - icon: , + icon: , onClick: async () => { const agent = omit(assistant, ['model', 'emoji']) agent.id = uuid() @@ -306,7 +307,7 @@ function getMenuItems({ { label: t('assistants.icon.type'), key: 'icon-type', - icon: , + icon: , children: [ { label: t('settings.assistant.icon.type.model'), @@ -331,7 +332,7 @@ function getMenuItems({ { label: t('assistants.tags.manage'), key: 'all-tags', - icon: , + icon: , children: createTagMenuItems(allTags, assistant, assistants, updateAssistants, t) }, { @@ -345,13 +346,13 @@ function getMenuItems({ { label: t('common.sort.pinyin.asc'), key: 'sort-asc', - icon: , + icon: , onClick: sortByPinyinAsc }, { label: t('common.sort.pinyin.desc'), key: 'sort-desc', - icon: , + icon: , onClick: sortByPinyinDesc }, { @@ -360,7 +361,7 @@ function getMenuItems({ { label: t('common.delete'), key: 'delete', - icon: , + icon: , danger: true, onClick: () => { window.modal.confirm({ diff --git a/src/renderer/src/pages/home/Tabs/components/AssistantTagsPopup.tsx b/src/renderer/src/pages/home/Tabs/components/AssistantTagsPopup.tsx index ad0c6ba9c6..89087f5c80 100644 --- a/src/renderer/src/pages/home/Tabs/components/AssistantTagsPopup.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AssistantTagsPopup.tsx @@ -1,11 +1,11 @@ import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd' +import { DeleteIcon } from '@renderer/components/Icons' import { Box } from '@renderer/components/Layout' import { TopView } from '@renderer/components/TopView' import { useAssistants } from '@renderer/hooks/useAssistant' import { useTags } from '@renderer/hooks/useTags' import { Button, Empty, Modal } from 'antd' import { isEmpty } from 'lodash' -import { Trash } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -94,7 +94,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { {tag}