Merge remote-tracking branch 'origin/main' into feat/cherry-store

This commit is contained in:
MyPrototypeWhat
2025-08-01 15:54:12 +08:00
105 changed files with 3974 additions and 15668 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -35,6 +35,7 @@ interface DraggableVirtualListProps<T> {
ref?: React.Ref<HTMLDivElement>
className?: string
style?: React.CSSProperties
scrollerStyle?: React.CSSProperties
itemStyle?: React.CSSProperties
itemContainerStyle?: React.CSSProperties
droppableProps?: Partial<DroppableProps>
@@ -43,6 +44,7 @@ interface DraggableVirtualListProps<T> {
onDragEnd?: OnDragEndResponder
list: T[]
itemKey?: (index: number) => Key
estimateSize?: (index: number) => number
overscan?: number
header?: React.ReactNode
children: (item: T, index: number) => React.ReactNode
@@ -59,6 +61,7 @@ function DraggableVirtualList<T>({
ref,
className,
style,
scrollerStyle,
itemStyle,
itemContainerStyle,
droppableProps,
@@ -67,6 +70,7 @@ function DraggableVirtualList<T>({
onDragEnd,
list,
itemKey,
estimateSize: _estimateSize,
overscan = 5,
header,
children
@@ -88,12 +92,15 @@ function DraggableVirtualList<T>({
count: list?.length ?? 0,
getScrollElement: useCallback(() => parentRef.current, []),
getItemKey: itemKey,
estimateSize: useCallback(() => 50, []),
estimateSize: useCallback((index) => _estimateSize?.(index) ?? 50, [_estimateSize]),
overscan
})
return (
<div ref={ref} className={`${className} draggable-virtual-list`} style={{ height: '100%', ...style }}>
<div
ref={ref}
className={`${className} draggable-virtual-list`}
style={{ height: '100%', display: 'flex', flexDirection: 'column', ...style }}>
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
{header}
<Droppable
@@ -128,6 +135,7 @@ function DraggableVirtualList<T>({
{...provided.droppableProps}
className="virtual-scroller"
style={{
...scrollerStyle,
height: '100%',
width: '100%',
overflowY: 'auto',
@@ -1,20 +1,41 @@
import { SVGProps } from 'react'
export function SvgSpinners180Ring(props: SVGProps<SVGSVGElement>) {
// 避免与全局样式冲突
const animationClassName = 'svg-spinner-anim-180-ring'
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
{/* Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE */}
<path
fill="currentColor"
d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z">
<animateTransform
attributeName="transform"
dur="0.75s"
repeatCount="indefinite"
type="rotate"
values="0 12 12;360 12 12"></animateTransform>
</path>
</svg>
<>
{/* CSS transform 硬件加速 */}
<style>
{`
@keyframes svg-spinner-rotate-180-ring {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.${animationClassName} {
transform-origin: center;
animation: svg-spinner-rotate-180-ring 0.75s linear infinite;
}
`}
</style>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
className={`${animationClassName} ${props.className || ''}`.trim()}>
{/* Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE */}
<path
fill="currentColor"
d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z"></path>
</svg>
</>
)
}
export default SvgSpinners180Ring
@@ -143,7 +143,7 @@ const MinappPopupContainer: React.FC = () => {
const { pinned, updatePinnedMinapps } = useMinapps()
const { t } = useTranslation()
const backgroundColor = useNavBackgroundColor()
const { isLeftNavbar, isTopNavbar } = useNavbarPosition()
const { isTopNavbar } = useNavbarPosition()
const dispatch = useAppDispatch()
/** control the drawer open or close */
@@ -165,6 +165,8 @@ const MinappPopupContainer: React.FC = () => {
/** whether the minapps open link external is enabled */
const { minappsOpenLinkExternal } = useSettings()
const { isLeftNavbar } = useNavbarPosition()
const isInDevelopment = process.env.NODE_ENV === 'development'
useBridge()
@@ -403,7 +405,7 @@ const MinappPopupContainer: React.FC = () => {
</Tooltip>
)}
<Spacer />
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''} isTopNavBar={isTopNavbar}>
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
<TitleButton onClick={() => handleGoBack(appInfo.id)}>
<ArrowLeftOutlined />
@@ -505,7 +507,6 @@ const MinappPopupContainer: React.FC = () => {
closeIcon={null}
style={{
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
marginTop: isTopNavbar ? 'var(--navbar-height)' : 0,
backgroundColor: window.root.style.background
}}>
{/* 在所有小程序中显示GoogleLoginTip */}
@@ -540,7 +541,7 @@ const TitleContainer = styled.div`
padding-left: ${isMac ? '20px' : '10px'};
}
[navbar-position='top'] & {
padding-left: ${isMac ? '20px' : '10px'};
padding-left: ${isMac ? '80px' : '10px'};
border-bottom: 0.5px solid var(--color-border);
}
`
@@ -562,14 +563,14 @@ const TitleTextTooltip = styled.span`
}
`
const ButtonsGroup = styled.div<{ isTopNavBar: boolean }>`
const ButtonsGroup = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
-webkit-app-region: no-drag;
&.windows {
margin-right: ${(props) => (props.isTopNavBar ? 0 : isWin ? '130px' : isLinux ? '100px' : 0)};
margin-right: ${isWin ? '130px' : isLinux ? '100px' : 0};
background-color: var(--color-background-mute);
border-radius: 50px;
padding: 0 3px;
@@ -23,7 +23,7 @@ const WebviewContainer = memo(
}) => {
const webviewRef = useRef<WebviewTag | null>(null)
const { enableSpellCheck } = useSettings()
const { isLeftNavbar, isTopNavbar } = useNavbarPosition()
const { isLeftNavbar } = useNavbarPosition()
const setRef = (appid: string) => {
onSetRefCallback(appid, null)
@@ -74,7 +74,7 @@ const WebviewContainer = memo(
const WebviewStyle: React.CSSProperties = {
width: isLeftNavbar ? 'calc(100vw - var(--sidebar-width))' : '100vw',
height: isTopNavbar ? 'calc(100vh - var(--navbar-height) - var(--navbar-height))' : '100vh',
height: 'calc(100vh - var(--navbar-height))',
backgroundColor: 'var(--color-background)',
display: 'inline-flex'
}
@@ -3,14 +3,14 @@ import CustomTag from '@renderer/components/CustomTag'
import ExpandableText from '@renderer/components/ExpandableText'
import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
import NewApiBatchAddModelPopup from '@renderer/components/ModelList/NewApiBatchAddModelPopup'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { getModelLogo } from '@renderer/config/models'
import FileItem from '@renderer/pages/files/FileItem'
import { Model, Provider } from '@renderer/types'
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
import { Button, Flex, Tooltip } from 'antd'
import { Avatar } from 'antd'
import { ChevronRight } from 'lucide-react'
import React, { memo, useCallback, useMemo, useRef, useState } from 'react'
import React, { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -39,8 +39,6 @@ interface ManageModelsListProps {
const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provider, onAddModel, onRemoveModel }) => {
const { t } = useTranslation()
const scrollerRef = useRef<HTMLDivElement>(null)
const activeStickyIndexRef = useRef(0)
const [collapsedGroups, setCollapsedGroups] = useState(new Set<string>())
const handleGroupToggle = useCallback((groupName: string) => {
@@ -74,33 +72,6 @@ const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provid
return rows
}, [modelGroups, collapsedGroups])
// 找到所有组 header 的索引
const stickyIndexes = useMemo(() => {
return flatRows.map((row, index) => (row.type === 'group' ? index : -1)).filter((index) => index !== -1)
}, [flatRows])
const isSticky = useCallback((index: number) => stickyIndexes.includes(index), [stickyIndexes])
const isActiveSticky = useCallback((index: number) => activeStickyIndexRef.current === index, [])
// 自定义 range extractor 用于 sticky header
const rangeExtractor = useCallback(
(range: any) => {
activeStickyIndexRef.current = [...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? 0
const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)])
return [...next].sort((a, b) => a - b)
},
[stickyIndexes]
)
const virtualizer = useVirtualizer({
count: flatRows.length,
getScrollElement: () => scrollerRef.current,
estimateSize: () => 42,
rangeExtractor,
overscan: 5
})
const renderGroupTools = useCallback(
(models: Model[]) => {
const isAllInProvider = models.every((model) => isModelInProvider(provider, model.id))
@@ -153,79 +124,47 @@ const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provid
[provider, onRemoveModel, onAddModel, t]
)
const virtualItems = virtualizer.getVirtualItems()
return (
<ListContainer ref={scrollerRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualItems.map((virtualItem) => {
const row = flatRows[virtualItem.index]
const isRowSticky = isSticky(virtualItem.index)
const isRowActiveSticky = isActiveSticky(virtualItem.index)
const isCollapsed = row.type === 'group' && collapsedGroups.has(row.groupName)
if (!row) return null
<DynamicVirtualList
list={flatRows}
estimateSize={useCallback(() => 60, [])}
isSticky={useCallback((index: number) => flatRows[index].type === 'group', [flatRows])}
overscan={5}
scrollerStyle={{
paddingRight: '10px'
}}
itemContainerStyle={{
paddingBottom: '8px'
}}>
{(row) => {
if (row.type === 'group') {
const isCollapsed = collapsedGroups.has(row.groupName)
return (
<div
key={virtualItem.index}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
...(isRowSticky
? {
background: 'var(--color-background)',
zIndex: 1
}
: {}),
...(isRowActiveSticky
? {
position: 'sticky'
}
: {
position: 'absolute',
transform: `translateY(${virtualItem.start}px)`
}),
top: 0,
left: 0,
width: '100%'
}}>
{row.type === 'group' ? (
<GroupHeader onClick={() => handleGroupToggle(row.groupName)}>
<Flex align="center" gap={10} style={{ flex: 1 }}>
<ChevronRight
size={16}
color="var(--color-text-3)"
strokeWidth={1.5}
style={{ transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)' }}
/>
<span style={{ fontWeight: 'bold', fontSize: '14px' }}>{row.groupName}</span>
<CustomTag color="#02B96B" size={10}>
{row.models.length}
</CustomTag>
</Flex>
{renderGroupTools(row.models)}
</GroupHeader>
) : (
<div style={{ padding: '4px 0' }}>
<ModelListItem
model={row.model}
provider={provider}
onAddModel={onAddModel}
onRemoveModel={onRemoveModel}
/>
</div>
)}
</div>
<GroupHeader
style={{ background: 'var(--color-background)' }}
onClick={() => handleGroupToggle(row.groupName)}>
<Flex align="center" gap={10} style={{ flex: 1 }}>
<ChevronRight
size={16}
color="var(--color-text-3)"
strokeWidth={1.5}
style={{ transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)' }}
/>
<span style={{ fontWeight: 'bold', fontSize: '14px' }}>{row.groupName}</span>
<CustomTag color="#02B96B" size={10}>
{row.models.length}
</CustomTag>
</Flex>
{renderGroupTools(row.models)}
</GroupHeader>
)
})}
</div>
</ListContainer>
}
return (
<ModelListItem model={row.model} provider={provider} onAddModel={onAddModel} onRemoveModel={onRemoveModel} />
)
}}
</DynamicVirtualList>
)
}
@@ -262,18 +201,12 @@ const ModelListItem: React.FC<ModelListItemProps> = memo(({ model, provider, onA
)
})
const ListContainer = styled.div`
height: calc(100vh - 300px);
overflow: auto;
padding-right: 10px;
`
const GroupHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
min-height: 48px;
min-height: 50px;
color: var(--color-text);
cursor: pointer;
`
@@ -16,8 +16,8 @@ import { useAppDispatch } from '@renderer/store'
import { setModel } from '@renderer/store/assistants'
import { Model } from '@renderer/types'
import { filterModelsByKeywords } from '@renderer/utils'
import { Button, Flex, Spin, Tooltip } from 'antd'
import { groupBy, sortBy, toPairs } from 'lodash'
import { Button, Empty, Flex, Spin, Tooltip } from 'antd'
import { groupBy, isEmpty, sortBy, toPairs } from 'lodash'
import { ListCheck, Plus } from 'lucide-react'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -134,6 +134,8 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
[provider, onUpdateModel]
)
const isLoading = useMemo(() => displayedModelGroups === null, [displayedModelGroups])
return (
<>
<SettingSubtitle style={{ marginBottom: 5 }}>
@@ -158,54 +160,60 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
</HStack>
</HStack>
</SettingSubtitle>
{displayedModelGroups === null ? (
<Flex align="center" justify="center" style={{ minHeight: '8rem' }}>
<Spin indicator={<SvgSpinners180Ring color="var(--color-text-2)" />} />
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
{displayedModelGroups && !isEmpty(displayedModelGroups) ? (
<Flex gap={12} vertical>
{Object.keys(displayedModelGroups).map((group, i) => (
<ModelListGroup
key={group}
groupName={group}
models={displayedModelGroups[group]}
modelStatuses={modelStatuses}
defaultOpen={i <= 5}
disabled={isHealthChecking}
onEditModel={onEditModel}
onRemoveModel={removeModel}
onRemoveGroup={() => displayedModelGroups[group].forEach((model) => removeModel(model))}
/>
))}
</Flex>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('settings.models.empty')}
style={{ visibility: isLoading ? 'hidden' : 'visible' }}
/>
)}
</Spin>
<Flex justify="space-between" align="center">
{docsWebsite || modelsWebsite ? (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.docs_check')} </SettingHelpText>
{docsWebsite && (
<SettingHelpLink target="_blank" href={docsWebsite}>
{getProviderLabel(provider.id) + ' '}
{t('common.docs')}
</SettingHelpLink>
)}
{docsWebsite && modelsWebsite && <SettingHelpText>{t('common.and')}</SettingHelpText>}
{modelsWebsite && (
<SettingHelpLink target="_blank" href={modelsWebsite}>
{t('common.models')}
</SettingHelpLink>
)}
<SettingHelpText>{t('settings.provider.docs_more_details')}</SettingHelpText>
</SettingHelpTextRow>
) : (
<div style={{ height: 5 }} />
)}
<Flex gap={10} style={{ marginTop: 12 }}>
<Button type="primary" onClick={onManageModel} icon={<ListCheck size={16} />} disabled={isHealthChecking}>
{t('button.manage')}
</Button>
<Button type="default" onClick={onAddModel} icon={<Plus size={16} />} disabled={isHealthChecking}>
{t('button.add')}
</Button>
</Flex>
) : (
<Flex gap={12} vertical>
{Object.keys(displayedModelGroups).map((group, i) => (
<ModelListGroup
key={group}
groupName={group}
models={displayedModelGroups[group]}
modelStatuses={modelStatuses}
defaultOpen={i <= 5}
disabled={isHealthChecking}
onEditModel={onEditModel}
onRemoveModel={removeModel}
onRemoveGroup={() => displayedModelGroups[group].forEach((model) => removeModel(model))}
/>
))}
{docsWebsite || modelsWebsite ? (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.docs_check')} </SettingHelpText>
{docsWebsite && (
<SettingHelpLink target="_blank" href={docsWebsite}>
{getProviderLabel(provider.id) + ' '}
{t('common.docs')}
</SettingHelpLink>
)}
{docsWebsite && modelsWebsite && <SettingHelpText>{t('common.and')}</SettingHelpText>}
{modelsWebsite && (
<SettingHelpLink target="_blank" href={modelsWebsite}>
{t('common.models')}
</SettingHelpLink>
)}
<SettingHelpText>{t('settings.provider.docs_more_details')}</SettingHelpText>
</SettingHelpTextRow>
) : (
<div style={{ height: 5 }} />
)}
</Flex>
)}
<Flex gap={10} style={{ marginTop: 10 }}>
<Button type="primary" onClick={onManageModel} icon={<ListCheck size={16} />} disabled={isHealthChecking}>
{t('button.manage')}
</Button>
<Button type="default" onClick={onAddModel} icon={<Plus size={16} />} disabled={isHealthChecking}>
{t('button.add')}
</Button>
</Flex>
</>
)
@@ -1,10 +1,10 @@
import { MinusOutlined } from '@ant-design/icons'
import CustomCollapse from '@renderer/components/CustomCollapse'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { Model } from '@renderer/types'
import { ModelWithStatus } from '@renderer/types/healthCheck'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Button, Flex, Tooltip } from 'antd'
import React, { memo, useEffect, useRef, useState } from 'react'
import React, { memo, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -32,29 +32,15 @@ const ModelListGroup: React.FC<ModelListGroupProps> = ({
onRemoveGroup
}) => {
const { t } = useTranslation()
const scrollerRef = useRef<HTMLDivElement>(null)
const [isExpanded, setIsExpanded] = useState(defaultOpen)
const listRef = useRef<DynamicVirtualListRef>(null)
const virtualizer = useVirtualizer({
count: models.length,
getScrollElement: () => scrollerRef.current,
estimateSize: () => 52,
overscan: 5
})
const virtualItems = virtualizer.getVirtualItems()
// 监听折叠面板状态变化,确保虚拟列表在展开时正确渲染
useEffect(() => {
if (isExpanded && scrollerRef.current) {
requestAnimationFrame(() => virtualizer.measure())
}
}, [isExpanded, virtualizer])
const handleCollapseChange = (activeKeys: string[] | string) => {
const handleCollapseChange = useCallback((activeKeys: string[] | string) => {
const isNowExpanded = Array.isArray(activeKeys) ? activeKeys.length > 0 : !!activeKeys
setIsExpanded(isNowExpanded)
}
if (isNowExpanded) {
// 延迟到 DOM 可见后测量
requestAnimationFrame(() => listRef.current?.measure())
}
}, [])
return (
<CustomCollapseWrapper>
@@ -80,45 +66,28 @@ const ModelListGroup: React.FC<ModelListGroupProps> = ({
/>
</Tooltip>
}>
<ScrollContainer ref={scrollerRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`
}}>
{virtualItems.map((virtualItem) => {
const model = models[virtualItem.index]
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
/* 在这里调整 item 间距 */
padding: '4px 0'
}}>
<ModelListItem
model={model}
modelStatus={modelStatuses.find((status) => status.model.id === model.id)}
onEdit={onEditModel}
onRemove={onRemoveModel}
disabled={disabled}
/>
</div>
)
})}
</div>
</div>
</ScrollContainer>
<DynamicVirtualList
ref={listRef}
list={models}
estimateSize={useCallback(() => 52, [])} // 44px item + 8px padding
overscan={5}
scrollerStyle={{
maxHeight: '390px',
padding: '4px 16px'
}}
itemContainerStyle={{
padding: '4px 0'
}}>
{(model) => (
<ModelListItem
model={model}
modelStatus={modelStatuses.find((status) => status.model.id === model.id)}
onEdit={onEditModel}
onRemove={onRemoveModel}
disabled={disabled}
/>
)}
</DynamicVirtualList>
</CustomCollapse>
</CustomCollapseWrapper>
)
@@ -141,10 +110,4 @@ const CustomCollapseWrapper = styled.div`
}
`
const ScrollContainer = styled.div`
overflow-y: auto;
max-height: 390px;
padding: 4px 16px;
`
export default memo(ModelListGroup)
@@ -0,0 +1,372 @@
import { act, render, screen } from '@testing-library/react'
import React, { useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DynamicVirtualList, type DynamicVirtualListRef } from '..'
// Mock management
const mocks = vi.hoisted(() => ({
virtualizer: {
getVirtualItems: vi.fn(() => [
{ index: 0, key: 'item-0', start: 0, size: 50 },
{ index: 1, key: 'item-1', start: 50, size: 50 },
{ index: 2, key: 'item-2', start: 100, size: 50 }
]),
getTotalSize: vi.fn(() => 150),
getVirtualIndexes: vi.fn(() => [0, 1, 2]),
measure: vi.fn(),
scrollToOffset: vi.fn(),
scrollToIndex: vi.fn(),
resizeItem: vi.fn(),
measureElement: vi.fn(),
scrollElement: null as HTMLDivElement | null
},
useVirtualizer: vi.fn()
}))
// Set up the mock to return our mock virtualizer
mocks.useVirtualizer.mockImplementation(() => mocks.virtualizer)
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: mocks.useVirtualizer,
defaultRangeExtractor: vi.fn((range) =>
Array.from({ length: range.endIndex - range.startIndex + 1 }, (_, i) => range.startIndex + i)
)
}))
// Test data factory
interface TestItem {
id: string
content: string
}
function createTestItems(count = 5): TestItem[] {
return Array.from({ length: count }, (_, i) => ({
id: `${i + 1}`,
content: `Item ${i + 1}`
}))
}
describe('DynamicVirtualList', () => {
const defaultItems = createTestItems()
const defaultProps = {
list: defaultItems,
estimateSize: () => 50,
children: (item: TestItem, index: number) => <div data-testid={`item-${index}`}>{item.content}</div>
}
// Test component for ref testing
const TestComponentWithRef: React.FC<{
onRefReady?: (ref: DynamicVirtualListRef | null) => void
listProps?: any
}> = ({ onRefReady, listProps = {} }) => {
const ref = useRef<DynamicVirtualListRef>(null)
React.useEffect(() => {
onRefReady?.(ref.current)
}, [onRefReady])
return <DynamicVirtualList ref={ref} {...defaultProps} {...listProps} />
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('basic rendering', () => {
it('snapshot test', () => {
const { container } = render(<DynamicVirtualList {...defaultProps} />)
expect(container).toMatchSnapshot()
})
it('should apply custom scroller styles', () => {
const customStyle = { backgroundColor: 'red', height: '400px' }
render(<DynamicVirtualList {...defaultProps} scrollerStyle={customStyle} />)
const scrollContainer = document.querySelector('.dynamic-virtual-list')
expect(scrollContainer).toBeInTheDocument()
expect(scrollContainer).toHaveStyle('background-color: rgb(255, 0, 0)')
expect(scrollContainer).toHaveStyle('height: 400px')
})
it('should apply custom item container styles', () => {
const itemStyle = { padding: '10px', margin: '5px' }
render(<DynamicVirtualList {...defaultProps} itemContainerStyle={itemStyle} />)
const items = document.querySelectorAll('[data-index]')
expect(items.length).toBeGreaterThan(0)
// Check first item styles
const firstItem = items[0] as HTMLElement
expect(firstItem).toHaveStyle('padding: 10px')
expect(firstItem).toHaveStyle('margin: 5px')
})
})
describe('props integration', () => {
it('should render correctly with different item counts', () => {
const { rerender } = render(<DynamicVirtualList {...defaultProps} list={createTestItems(3)} />)
// Should render without errors
expect(screen.getByTestId('item-0')).toBeInTheDocument()
// Should handle dynamic item count changes
rerender(<DynamicVirtualList {...defaultProps} list={createTestItems(10)} />)
expect(document.querySelector('.dynamic-virtual-list')).toBeInTheDocument()
})
it('should work with custom estimateSize function', () => {
const customEstimateSize = vi.fn(() => 80)
// Should render without errors when using custom estimateSize
expect(() => {
render(<DynamicVirtualList {...defaultProps} estimateSize={customEstimateSize} />)
}).not.toThrow()
expect(screen.getByTestId('item-0')).toBeInTheDocument()
})
})
describe('sticky feature', () => {
it('should apply sticky positioning to specified items', () => {
const isSticky = vi.fn((index: number) => index === 0) // First item is sticky
render(<DynamicVirtualList {...defaultProps} isSticky={isSticky} />)
// Should call isSticky function during rendering
expect(isSticky).toHaveBeenCalled()
// Should apply sticky styles to sticky items
const stickyItem = document.querySelector('[data-index="0"]') as HTMLElement
expect(stickyItem).toBeInTheDocument()
expect(stickyItem).toHaveStyle('position: sticky')
expect(stickyItem).toHaveStyle('z-index: 1')
})
it('should apply absolute positioning to non-sticky items', () => {
const isSticky = vi.fn((index: number) => index === 0)
render(<DynamicVirtualList {...defaultProps} isSticky={isSticky} />)
// Non-sticky items should have absolute positioning
const regularItem = document.querySelector('[data-index="1"]') as HTMLElement
expect(regularItem).toBeInTheDocument()
expect(regularItem).toHaveStyle('position: absolute')
})
it('should apply absolute positioning to all items when no sticky function provided', () => {
render(<DynamicVirtualList {...defaultProps} />)
// All items should have absolute positioning
const items = document.querySelectorAll('[data-index]')
items.forEach((item) => {
const htmlItem = item as HTMLElement
expect(htmlItem).toHaveStyle('position: absolute')
})
})
})
describe('custom range extractor', () => {
it('should work with custom rangeExtractor', () => {
const customRangeExtractor = vi.fn(() => [0, 1, 2])
// Should render without errors when using custom rangeExtractor
expect(() => {
render(<DynamicVirtualList {...defaultProps} rangeExtractor={customRangeExtractor} />)
}).not.toThrow()
expect(screen.getByTestId('item-0')).toBeInTheDocument()
})
it('should handle both rangeExtractor and sticky props gracefully', () => {
const customRangeExtractor = vi.fn(() => [0, 1, 2])
const isSticky = vi.fn((index: number) => index === 0)
// Should render without conflicts when both props are provided
expect(() => {
render(<DynamicVirtualList {...defaultProps} rangeExtractor={customRangeExtractor} isSticky={isSticky} />)
}).not.toThrow()
expect(screen.getByTestId('item-0')).toBeInTheDocument()
})
})
describe('ref api', () => {
let refInstance: DynamicVirtualListRef | null = null
beforeEach(async () => {
render(
<TestComponentWithRef
onRefReady={(ref) => {
refInstance = ref
}}
/>
)
// Wait for ref to be ready
await new Promise((resolve) => setTimeout(resolve, 0))
})
it('should expose all required ref methods', () => {
expect(refInstance).toBeTruthy()
expect(refInstance).not.toBeNull()
// Type assertion to help TypeScript understand the type
const ref = refInstance as unknown as DynamicVirtualListRef
expect(typeof ref.measure).toBe('function')
expect(typeof ref.scrollElement).toBe('function')
expect(typeof ref.scrollToOffset).toBe('function')
expect(typeof ref.scrollToIndex).toBe('function')
expect(typeof ref.resizeItem).toBe('function')
expect(typeof ref.getTotalSize).toBe('function')
expect(typeof ref.getVirtualItems).toBe('function')
expect(typeof ref.getVirtualIndexes).toBe('function')
})
it('should allow calling all ref methods without throwing', () => {
const ref = refInstance as unknown as DynamicVirtualListRef
// Test that all methods can be called without errors
expect(() => ref.measure()).not.toThrow()
expect(() => ref.scrollToOffset(100, { align: 'start' })).not.toThrow()
expect(() => ref.scrollToIndex(2, { align: 'center' })).not.toThrow()
expect(() => ref.resizeItem(1, 80)).not.toThrow()
// Test that data methods return expected types
expect(typeof ref.getTotalSize()).toBe('number')
expect(Array.isArray(ref.getVirtualItems())).toBe(true)
expect(Array.isArray(ref.getVirtualIndexes())).toBe(true)
})
})
describe('orientation support', () => {
beforeEach(() => {
// Reset mocks for orientation tests
mocks.virtualizer.getVirtualItems.mockReturnValue([
{ index: 0, key: 'item-0', start: 0, size: 100 },
{ index: 1, key: 'item-1', start: 100, size: 100 }
])
mocks.virtualizer.getTotalSize.mockReturnValue(200)
})
it('should apply horizontal layout styles correctly', () => {
render(<DynamicVirtualList {...defaultProps} horizontal={true} />)
// Verify container styles for horizontal layout
const container = document.querySelector('div[style*="position: relative"]') as HTMLElement
expect(container).toHaveStyle('width: 200px') // totalSize
expect(container).toHaveStyle('height: 100%')
// Verify item transform for horizontal layout
const items = document.querySelectorAll('[data-index]')
const firstItem = items[0] as HTMLElement
expect(firstItem.style.transform).toContain('translateX(0px)')
expect(firstItem).toHaveStyle('height: 100%')
})
it('should apply vertical layout styles correctly', () => {
// Reset to default vertical mock values
mocks.virtualizer.getTotalSize.mockReturnValue(150)
render(<DynamicVirtualList {...defaultProps} horizontal={false} />)
// Verify container styles for vertical layout
const container = document.querySelector('div[style*="position: relative"]') as HTMLElement
expect(container).toHaveStyle('width: 100%')
expect(container).toHaveStyle('height: 150px') // totalSize from mock
// Verify item transform for vertical layout
const items = document.querySelectorAll('[data-index]')
const firstItem = items[0] as HTMLElement
expect(firstItem.style.transform).toContain('translateY(0px)')
expect(firstItem).toHaveStyle('width: 100%')
})
})
describe('edge cases', () => {
it('should handle edge cases gracefully', () => {
// Empty items list
mocks.virtualizer.getVirtualItems.mockReturnValueOnce([])
expect(() => {
render(<DynamicVirtualList {...defaultProps} list={[]} />)
}).not.toThrow()
// Null ref
expect(() => {
render(<DynamicVirtualList {...defaultProps} ref={null} />)
}).not.toThrow()
// Zero estimate size
expect(() => {
render(<DynamicVirtualList {...defaultProps} estimateSize={() => 0} />)
}).not.toThrow()
// Items without expected properties
const itemsWithoutContent = [{ id: '1' }, { id: '2' }] as any[]
expect(() => {
render(
<DynamicVirtualList
{...defaultProps}
list={itemsWithoutContent}
children={(_item, index) => <div data-testid={`item-${index}`}>No content</div>}
/>
)
}).not.toThrow()
})
})
describe('auto hide scrollbar', () => {
it('should always show scrollbar when autoHideScrollbar is false', () => {
render(<DynamicVirtualList {...defaultProps} autoHideScrollbar={false} />)
const scrollContainer = document.querySelector('.dynamic-virtual-list') as HTMLElement
expect(scrollContainer).toBeInTheDocument()
// When autoHideScrollbar is false, scrollbar should always be visible
expect(scrollContainer).not.toHaveAttribute('aria-hidden', 'true')
})
it('should hide scrollbar initially and show during scrolling when autoHideScrollbar is true', async () => {
vi.useFakeTimers()
render(<DynamicVirtualList {...defaultProps} autoHideScrollbar={true} />)
const scrollContainer = document.querySelector('.dynamic-virtual-list') as HTMLElement
expect(scrollContainer).toBeInTheDocument()
// Initially hidden
expect(scrollContainer).toHaveAttribute('aria-hidden', 'true')
// We can't easily simulate real scroll events in JSDOM, so we'll test the internal logic directly
// by calling the onChange handler which should update the state
const onChangeCallback = mocks.useVirtualizer.mock.calls[0][0].onChange
// Simulate scroll start
act(() => {
onChangeCallback({ isScrolling: true }, true)
})
// After scrolling starts, scrollbar should be visible
expect(scrollContainer).toHaveAttribute('aria-hidden', 'false')
// Simulate scroll end
act(() => {
onChangeCallback({ isScrolling: false }, true)
})
// Advance timers to trigger the hide timeout
act(() => {
vi.advanceTimersByTime(10000)
})
// After timeout, scrollbar should be hidden again
expect(scrollContainer).toHaveAttribute('aria-hidden', 'true')
vi.useRealTimers()
})
})
})
@@ -0,0 +1,58 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = `
.c0::-webkit-scrollbar-thumb {
transition: background 0.3s ease-in-out;
will-change: background;
background: var(--color-scrollbar-thumb);
}
.c0::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-thumb-hover);
}
<div>
<div
aria-hidden="false"
aria-label="Dynamic Virtual List"
class="c0 dynamic-virtual-list"
role="region"
style="overflow: auto; height: 100%;"
>
<div
style="position: relative; width: 100%; height: 150px;"
>
<div
data-index="0"
style="position: absolute; top: 0px; left: 0px; transform: translateY(0px); width: 100%;"
>
<div
data-testid="item-0"
>
Item 1
</div>
</div>
<div
data-index="1"
style="position: absolute; top: 0px; left: 0px; transform: translateY(50px); width: 100%;"
>
<div
data-testid="item-1"
>
Item 2
</div>
</div>
<div
data-index="2"
style="position: absolute; top: 0px; left: 0px; transform: translateY(100px); width: 100%;"
>
<div
data-testid="item-2"
>
Item 3
</div>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,257 @@
import type { Range, ScrollToOptions, VirtualItem, VirtualizerOptions } from '@tanstack/react-virtual'
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
import React, { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
const SCROLLBAR_AUTO_HIDE_DELAY = 2000
type InheritedVirtualizerOptions = Partial<
Omit<
VirtualizerOptions<HTMLDivElement, Element>,
| 'count' // determined by items.length
| 'getScrollElement' // determined by internal scrollerRef
| 'estimateSize' // promoted to a required prop
| 'rangeExtractor' // isSticky provides a simpler abstraction
>
>
export interface DynamicVirtualListRef {
/** Resets any prev item measurements. */
measure: () => void
/** Returns the scroll element for the virtualizer. */
scrollElement: () => HTMLDivElement | null
/** Scrolls the virtualizer to the pixel offset provided. */
scrollToOffset: (offset: number, options?: ScrollToOptions) => void
/** Scrolls the virtualizer to the items of the index provided. */
scrollToIndex: (index: number, options?: ScrollToOptions) => void
/** Resizes an item. */
resizeItem: (index: number, size: number) => void
/** Returns the total size in pixels for the virtualized items. */
getTotalSize: () => number
/** Returns the virtual items for the current state of the virtualizer. */
getVirtualItems: () => VirtualItem[]
/** Returns the virtual row indexes for the current state of the virtualizer. */
getVirtualIndexes: () => number[]
}
export interface DynamicVirtualListProps<T> extends InheritedVirtualizerOptions {
ref?: React.Ref<DynamicVirtualListRef>
/**
* List data
*/
list: T[]
/**
* List item renderer function
*/
children: (item: T, index: number) => React.ReactNode
/**
* List size (height or width, default is 100%)
*/
size?: string | number
/**
* List item size estimator function (initial estimation)
*/
estimateSize: (index: number) => number
/**
* Sticky item predicate, cannot be used with rangeExtractor
*/
isSticky?: (index: number) => boolean
/**
* Range extractor function, cannot be used with isSticky
*/
rangeExtractor?: (range: Range) => number[]
/**
* List item container style
*/
itemContainerStyle?: React.CSSProperties
/**
* Scroll container style
*/
scrollerStyle?: React.CSSProperties
/**
* Hide the scrollbar automatically when scrolling is stopped
*/
autoHideScrollbar?: boolean
}
function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
const {
ref,
list,
children,
size,
estimateSize,
isSticky,
rangeExtractor: customRangeExtractor,
itemContainerStyle,
scrollerStyle,
autoHideScrollbar = false,
...restOptions
} = props
const [showScrollbar, setShowScrollbar] = useState(!autoHideScrollbar)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const internalScrollerRef = useRef<HTMLDivElement>(null)
const scrollerRef = internalScrollerRef
const activeStickyIndexRef = useRef(0)
const stickyIndexes = useMemo(() => {
if (!isSticky) return []
return list.map((_, index) => (isSticky(index) ? index : -1)).filter((index) => index !== -1)
}, [list, isSticky])
const internalStickyRangeExtractor = useCallback(
(range: Range) => {
// The active sticky index is the last one that is before or at the start of the visible range
const newActiveStickyIndex =
[...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? stickyIndexes[0] ?? 0
if (newActiveStickyIndex !== activeStickyIndexRef.current) {
activeStickyIndexRef.current = newActiveStickyIndex
}
// Merge the active sticky index and the default range extractor
const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)])
// Sort the set to maintain proper order
return [...next].sort((a, b) => a - b)
},
[stickyIndexes]
)
const rangeExtractor = customRangeExtractor ?? (isSticky ? internalStickyRangeExtractor : undefined)
const handleScrollbarHide = useCallback(
(isScrolling: boolean) => {
if (!autoHideScrollbar) return
if (timeoutRef.current) clearTimeout(timeoutRef.current)
if (isScrolling) {
setShowScrollbar(true)
} else {
timeoutRef.current = setTimeout(() => {
setShowScrollbar(false)
}, SCROLLBAR_AUTO_HIDE_DELAY)
}
},
[autoHideScrollbar]
)
const virtualizer = useVirtualizer({
...restOptions,
count: list.length,
getScrollElement: () => scrollerRef.current,
estimateSize,
rangeExtractor,
onChange: (instance, sync) => {
restOptions.onChange?.(instance, sync)
handleScrollbarHide(instance.isScrolling)
}
})
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [autoHideScrollbar])
useImperativeHandle(
ref,
() => ({
measure: () => virtualizer.measure(),
scrollElement: () => virtualizer.scrollElement,
scrollToOffset: (offset, options) => virtualizer.scrollToOffset(offset, options),
scrollToIndex: (index, options) => virtualizer.scrollToIndex(index, options),
resizeItem: (index, size) => virtualizer.resizeItem(index, size),
getTotalSize: () => virtualizer.getTotalSize(),
getVirtualItems: () => virtualizer.getVirtualItems(),
getVirtualIndexes: () => virtualizer.getVirtualIndexes()
}),
[virtualizer]
)
const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
const { horizontal } = restOptions
return (
<ScrollContainer
ref={scrollerRef}
className="dynamic-virtual-list"
role="region"
aria-label="Dynamic Virtual List"
aria-hidden={!showScrollbar}
$autoHide={autoHideScrollbar}
$show={showScrollbar}
style={{
overflow: 'auto',
...(horizontal ? { width: size ?? '100%' } : { height: size ?? '100%' }),
...scrollerStyle
}}>
<div
style={{
position: 'relative',
width: horizontal ? `${totalSize}px` : '100%',
height: !horizontal ? `${totalSize}px` : '100%'
}}>
{virtualItems.map((virtualItem) => {
const isItemSticky = stickyIndexes.includes(virtualItem.index)
const isItemActiveSticky = isItemSticky && activeStickyIndexRef.current === virtualItem.index
const style: React.CSSProperties = {
...itemContainerStyle,
position: isItemActiveSticky ? 'sticky' : 'absolute',
top: 0,
left: 0,
zIndex: isItemSticky ? 1 : undefined,
...(horizontal
? {
transform: isItemActiveSticky ? undefined : `translateX(${virtualItem.start}px)`,
height: '100%'
}
: {
transform: isItemActiveSticky ? undefined : `translateY(${virtualItem.start}px)`,
width: '100%'
})
}
return (
<div key={virtualItem.key} data-index={virtualItem.index} ref={virtualizer.measureElement} style={style}>
{children(list[virtualItem.index], virtualItem.index)}
</div>
)
})}
</div>
</ScrollContainer>
)
}
const ScrollContainer = styled.div<{ $autoHide: boolean; $show: boolean }>`
&::-webkit-scrollbar-thumb {
transition: background 0.3s ease-in-out;
will-change: background;
background: ${(props) => (props.$autoHide && !props.$show ? 'transparent' : 'var(--color-scrollbar-thumb)')};
&:hover {
background: var(--color-scrollbar-thumb-hover);
}
}
`
const MemoizedDynamicVirtualList = memo(DynamicVirtualList) as <T>(
props: DynamicVirtualListProps<T>
) => React.ReactElement
export default MemoizedDynamicVirtualList
@@ -0,0 +1 @@
export { default as DynamicVirtualList, type DynamicVirtualListProps, type DynamicVirtualListRef } from './dynamic'
@@ -4,7 +4,7 @@ exports[`DraggableVirtualList > snapshot > should match snapshot with custom sty
<div>
<div
class="custom-class draggable-virtual-list"
style="height: 100%; border: 1px solid red;"
style="height: 100%; display: flex; flex-direction: column; border: 1px solid red;"
>
<div
data-testid="drag-drop-context"
+41 -21
View File
@@ -2663,6 +2663,17 @@ export function isQwenReasoningModel(model?: Model): boolean {
return false
}
const baseName = getLowerBaseModelName(model.id, '/')
if (baseName.startsWith('qwen3')) {
if (baseName.includes('thinking')) {
return true
} else if (baseName.includes('instruct')) {
return false
}
return true
}
if (isSupportedThinkingTokenQwenModel(model)) {
return true
}
@@ -2681,27 +2692,34 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
const baseName = getLowerBaseModelName(model.id, '/')
if (baseName.includes('coder') || baseName.includes('qwen3-235b-a22b-instruct')) {
if (baseName.includes('coder')) {
return false
}
return (
baseName.startsWith('qwen3') ||
[
'qwen-plus',
'qwen-plus-latest',
'qwen-plus-0428',
'qwen-plus-2025-04-28',
'qwen-plus-0714',
'qwen-plus-2025-07-14',
'qwen-turbo',
'qwen-turbo-latest',
'qwen-turbo-0428',
'qwen-turbo-2025-04-28',
'qwen-turbo-0715',
'qwen-turbo-2025-07-15'
].includes(baseName)
)
if (baseName.startsWith('qwen3')) {
if (baseName.includes('instruct')) {
return false
}
if (baseName.includes('thinking')) {
return true
}
return true
}
return [
'qwen-plus',
'qwen-plus-latest',
'qwen-plus-0428',
'qwen-plus-2025-04-28',
'qwen-plus-0714',
'qwen-plus-2025-07-14',
'qwen-turbo',
'qwen-turbo-latest',
'qwen-turbo-0428',
'qwen-turbo-2025-04-28',
'qwen-turbo-0715',
'qwen-turbo-2025-07-15'
].includes(baseName)
}
export function isQwen3235BA22BThinkingModel(model?: Model): boolean {
@@ -3070,11 +3088,13 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
'gemini-.*-pro.*$': { min: 128, max: 32768 },
// Qwen models
'qwen3-235b-a22b-thinking(?:-[\\w-]+)$': { min: 0, max: 81_920 },
'qwen3-235b-a22b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-30b-a3b-thinking-2507$': { min: 0, max: 81_920 },
'qwen-plus-2025-07-28$': { min: 0, max: 81_920 },
'qwen3-1\\.7b$': { min: 0, max: 30_720 },
'qwen3-0\\.6b$': { min: 0, max: 30_720 },
'qwen-plus-.*$': { min: 0, max: 38912 },
'qwen-turbo-.*$': { min: 0, max: 38912 },
'qwen3-0\\.6b$': { min: 0, max: 30720 },
'qwen3-1\\.7b$': { min: 0, max: 30720 },
'qwen3-.*$': { min: 1024, max: 38912 },
// Claude models
+2 -2
View File
@@ -5,7 +5,7 @@ import Ai302ProviderLogo from '@renderer/assets/images/providers/302ai.webp'
import AiHubMixProviderLogo from '@renderer/assets/images/providers/aihubmix.webp'
import AlayaNewProviderLogo from '@renderer/assets/images/providers/alayanew.webp'
import AnthropicProviderLogo from '@renderer/assets/images/providers/anthropic.png'
import AwsProviderLogo from '@renderer/assets/images/providers/aws-bedrock.png'
import AwsProviderLogo from '@renderer/assets/images/providers/aws-bedrock.webp'
import BaichuanProviderLogo from '@renderer/assets/images/providers/baichuan.png'
import BaiduCloudProviderLogo from '@renderer/assets/images/providers/baidu-cloud.svg'
import BailianProviderLogo from '@renderer/assets/images/providers/bailian.png'
@@ -320,7 +320,7 @@ export const PROVIDER_CONFIG = {
websites: {
official: 'https://open.bigmodel.cn/',
apiKey: 'https://open.bigmodel.cn/usercenter/apikeys',
docs: 'https://open.bigmodel.cn/dev/howuse/introduction',
docs: 'https://docs.bigmodel.cn/',
models: 'https://open.bigmodel.cn/modelcenter/square'
}
},
+3 -1
View File
@@ -6,7 +6,9 @@ import { Dexie, type EntityTable } from 'dexie'
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
// Database declaration (move this to its own module also)
export const db = new Dexie('CherryStudio') as Dexie & {
export const db = new Dexie('CherryStudio', {
chromeTransactionDurability: 'strict'
}) as Dexie & {
files: EntityTable<FileMetadata, 'id'>
topics: EntityTable<{ id: string; messages: NewMessage[] }, 'id'> // Correct type for topics
settings: EntityTable<{ id: string; value: any }, 'id'>
+12 -9
View File
@@ -8,17 +8,19 @@ import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
import MemoryService from '@renderer/services/MemoryService'
import { useAppDispatch } from '@renderer/store'
import { useAppSelector } from '@renderer/store'
import { handleSaveData } from '@renderer/store'
import { selectMemoryConfig } from '@renderer/store/memory'
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
import { delay, runAsyncFunction } from '@renderer/utils'
import { defaultLanguage } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react'
import { useDefaultModel } from './useAssistant'
import useFullScreenNotice from './useFullScreenNotice'
import { useRuntime } from './useRuntime'
import { useNavbarPosition, useSettings } from './useSettings'
import { useSettings } from './useSettings'
import useUpdateHandler from './useUpdateHandler'
const logger = loggerService.withContext('useAppInit')
@@ -31,7 +33,6 @@ export function useAppInit() {
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
const { theme } = useTheme()
const memoryConfig = useAppSelector(selectMemoryConfig)
const { isTopNavbar } = useNavbarPosition()
useEffect(() => {
document.getElementById('spinner')?.remove()
@@ -50,6 +51,12 @@ export function useAppInit() {
})
}, [])
useEffect(() => {
window.electron.ipcRenderer.on(IpcChannel.App_SaveData, async () => {
await handleSaveData()
})
}, [])
useUpdateHandler()
useFullScreenNotice()
@@ -86,17 +93,13 @@ export function useAppInit() {
const transparentWindow = windowStyle === 'transparent' && isMac && !minappShow
if (minappShow) {
if (isTopNavbar) {
window.root.style.background = 'var(--navbar-background)'
} else {
window.root.style.background =
windowStyle === 'transparent' && isMac ? 'var(--color-background)' : 'var(--navbar-background)'
}
window.root.style.background =
windowStyle === 'transparent' && isMac ? 'var(--color-background)' : 'var(--navbar-background)'
return
}
window.root.style.background = transparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
}, [windowStyle, minappShow, theme, isTopNavbar])
}, [windowStyle, minappShow, theme])
useEffect(() => {
if (isLocalAi) {
+9 -20
View File
@@ -1,6 +1,4 @@
import { isMac } from '@renderer/config/constant'
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
import { useOcrProviders } from '@renderer/hooks/useOcr'
import { usePreprocessProviders } from '@renderer/hooks/usePreprocess'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
@@ -42,11 +40,10 @@ export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
const [newBase, setNewBase] = useState<KnowledgeBase>(base || createInitialKnowledgeBase())
const { providers } = useProviders()
const { preprocessProviders } = usePreprocessProviders()
const { ocrProviders } = useOcrProviders()
const selectedDocPreprocessProvider = useMemo(
() => newBase.preprocessOrOcrProvider?.provider,
[newBase.preprocessOrOcrProvider]
() => newBase.preprocessProvider?.provider,
[newBase.preprocessProvider]
)
const docPreprocessSelectOptions = useMemo(() => {
@@ -57,14 +54,8 @@ export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
.filter((p) => p.apiKey !== '' || p.id === 'mineru')
.map((p) => ({ value: p.id, label: p.name }))
}
const ocrOptions = {
label: t('settings.tool.ocr.provider'),
title: t('settings.tool.ocr.provider'),
options: ocrProviders.filter((p) => p.apiKey !== '').map((p) => ({ value: p.id, label: p.name }))
}
return isMac ? [preprocessOptions, ocrOptions] : [preprocessOptions]
}, [ocrProviders, preprocessProviders, t])
return [preprocessOptions]
}, [preprocessProviders, t])
const handleEmbeddingModelChange = useCallback(
(value: string) => {
@@ -92,21 +83,20 @@ export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
const handleDocPreprocessChange = useCallback(
(value: string) => {
const type = preprocessProviders.find((p) => p.id === value) ? 'preprocess' : 'ocr'
const provider = (type === 'preprocess' ? preprocessProviders : ocrProviders).find((p) => p.id === value)
const provider = preprocessProviders.find((p) => p.id === value)
if (!provider) {
setNewBase((prev) => ({ ...prev, preprocessOrOcrProvider: undefined }))
setNewBase((prev) => ({ ...prev, preprocessProvider: undefined }))
return
}
setNewBase((prev) => ({
...prev,
preprocessOrOcrProvider: {
type,
preprocessProvider: {
type: 'preprocess',
provider
}
}))
},
[preprocessProviders, ocrProviders]
[preprocessProviders]
)
const handleChunkSizeChange = useCallback(
@@ -152,7 +142,6 @@ export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
const providerData = {
providers,
preprocessProviders,
ocrProviders,
selectedDocPreprocessProvider,
docPreprocessSelectOptions
}
-45
View File
@@ -1,45 +0,0 @@
import { RootState } from '@renderer/store'
import {
setDefaultOcrProvider as _setDefaultOcrProvider,
updateOcrProvider as _updateOcrProvider,
updateOcrProviders as _updateOcrProviders
} from '@renderer/store/ocr'
import { OcrProvider } from '@renderer/types'
import { useDispatch, useSelector } from 'react-redux'
export const useOcrProvider = (id: string) => {
const dispatch = useDispatch()
const ocrProviders = useSelector((state: RootState) => state.ocr.providers)
const provider = ocrProviders.find((provider) => provider.id === id)
if (!provider) {
throw new Error(`OCR provider with id ${id} not found`)
}
const updateOcrProvider = (ocrProvider: OcrProvider) => {
dispatch(_updateOcrProvider(ocrProvider))
}
return { provider, updateOcrProvider }
}
export const useOcrProviders = () => {
const dispatch = useDispatch()
const ocrProviders = useSelector((state: RootState) => state.ocr.providers)
return {
ocrProviders: ocrProviders,
updateOcrProviders: (ocrProviders: OcrProvider[]) => dispatch(_updateOcrProviders(ocrProviders))
}
}
export const useDefaultOcrProvider = () => {
const defaultProviderId = useSelector((state: RootState) => state.ocr.defaultProvider)
const { ocrProviders } = useOcrProviders()
const dispatch = useDispatch()
const provider = defaultProviderId ? ocrProviders.find((provider) => provider.id === defaultProviderId) : undefined
const setDefaultOcrProvider = (ocrProvider: OcrProvider) => {
dispatch(_setDefaultOcrProvider(ocrProvider.id))
}
const updateDefaultOcrProvider = (ocrProvider: OcrProvider) => {
dispatch(_updateOcrProvider(ocrProvider))
}
return { provider, setDefaultOcrProvider, updateDefaultOcrProvider }
}
@@ -0,0 +1,34 @@
import { loggerService } from '@logger'
import { containsSupportedVariables, replacePromptVariables } from '@renderer/utils/prompt'
import { useEffect, useState } from 'react'
const logger = loggerService.withContext('usePromptProcessor')
interface PromptProcessor {
prompt: string
modelName?: string
}
export function usePromptProcessor({ prompt, modelName }: PromptProcessor): string {
const [processedPrompt, setProcessedPrompt] = useState(prompt)
useEffect(() => {
const processPrompt = async () => {
try {
if (containsSupportedVariables(prompt)) {
const result = await replacePromptVariables(prompt, modelName)
setProcessedPrompt(result)
} else {
setProcessedPrompt(prompt)
}
} catch (error) {
logger.error('Failed to process prompt variables, falling back:', error as Error)
setProcessedPrompt(prompt)
}
}
processPrompt()
}, [prompt, modelName])
return processedPrompt
}
+6 -19
View File
@@ -703,6 +703,7 @@
"no_results": "No results",
"open": "Open",
"paste": "Paste",
"preview": "Preview",
"prompt": "Prompt",
"provider": "Provider",
"reasoning_content": "Deep reasoning",
@@ -711,6 +712,7 @@
"rename": "Rename",
"reset": "Reset",
"save": "Save",
"saved": "Saved",
"search": "Search",
"select": "Select",
"selectedItems": "Selected {{count}} items",
@@ -886,7 +888,7 @@
"error": {
"failed_to_create": "Knowledge base creation failed",
"failed_to_edit": "Knowledge base editing failed",
"model_invalid": "No model selected or deleted"
"model_invalid": "No model selected"
},
"file_hint": "Support {{file_types}}",
"index_all": "Index All",
@@ -927,7 +929,7 @@
"search_placeholder": "Enter text to search",
"settings": {
"preprocessing": "Preprocessing",
"preprocessing_tooltip": "Preprocess uploaded files with OCR",
"preprocessing_tooltip": "Preprocess uploaded files",
"title": "Knowledge Base Settings"
},
"sitemap_added": "Added successfully",
@@ -3307,26 +3309,11 @@
},
"title": "Settings",
"tool": {
"ocr": {
"mac_system_ocr_options": {
"min_confidence": "Minimum Confidence",
"mode": {
"accurate": "Accurate",
"fast": "Fast",
"title": "Recognition Mode"
}
},
"provider": "OCR Provider",
"provider_placeholder": "Choose an OCR provider",
"title": "OCR Settings"
},
"preprocess": {
"provider": "Pre Process Provider",
"provider_placeholder": "Choose a Pre Process provider",
"title": "Pre Process"
},
"preprocessOrOcr": {
"tooltip": "In Settings -> Tools, set a document preprocessing service provider or OCR. Document preprocessing can effectively improve the retrieval performance of complex format documents and scanned documents. OCR can only recognize text within images in documents or scanned PDF text."
"title": "Pre Process",
"tooltip": "In Settings -> Tools, set a document preprocessing service provider. Document preprocessing can effectively improve the retrieval performance of complex format documents and scanned documents."
},
"title": "Tools Settings",
"websearch": {
+6 -19
View File
@@ -703,6 +703,7 @@
"no_results": "検索結果なし",
"open": "開く",
"paste": "貼り付け",
"preview": "プレビュー",
"prompt": "プロンプト",
"provider": "プロバイダー",
"reasoning_content": "深く考察済み",
@@ -711,6 +712,7 @@
"rename": "名前を変更",
"reset": "リセット",
"save": "保存",
"saved": "保存されました",
"search": "検索",
"select": "選択",
"selectedItems": "{{count}}件の項目を選択しました",
@@ -886,7 +888,7 @@
"error": {
"failed_to_create": "ナレッジベースの作成に失敗しました",
"failed_to_edit": "ナレッジベースの編集に失敗しました",
"model_invalid": "モデルが選択されていないか、削除されています"
"model_invalid": "モデルが選択されていません"
},
"file_hint": "{{file_types}} 形式をサポート",
"index_all": "すべてをインデックス",
@@ -927,7 +929,7 @@
"search_placeholder": "検索するテキストを入力",
"settings": {
"preprocessing": "預処理",
"preprocessing_tooltip": "アップロードされたファイルのOCR預処理",
"preprocessing_tooltip": "アップロードされたファイルの預処理",
"title": "ナレッジベース設定"
},
"sitemap_added": "追加成功",
@@ -3307,26 +3309,11 @@
},
"title": "設定",
"tool": {
"ocr": {
"mac_system_ocr_options": {
"min_confidence": "最小信頼度",
"mode": {
"accurate": "正確",
"fast": "速い",
"title": "認識モード"
}
},
"provider": "OCRプロバイダー",
"provider_placeholder": "OCRプロバイダーを選択",
"title": "OCR(オーシーアール)"
},
"preprocess": {
"provider": "プレプロセスプロバイダー",
"provider_placeholder": "前処理プロバイダーを選択してください",
"title": "前処理"
},
"preprocessOrOcr": {
"tooltip": "設定 → ツールで、ドキュメント前処理サービスプロバイダーまたはOCRを設定します。ドキュメント前処理は、複雑な形式のドキュメントやスキャンされたドキュメントの検索性能を効果的に向上させます。OCRは、ドキュメント内の画像内のテキストまたはスキャンされたPDFテキストのみを認識できます。"
"title": "前処理",
"tooltip": "設定 → ツールで、ドキュメント前処理サービスプロバイダーを設定します。ドキュメント前処理は、複雑な形式のドキュメントやスキャンされたドキュメントの検索性能を効果的に向上させます。"
},
"title": "ツール設定",
"websearch": {
+6 -19
View File
@@ -703,6 +703,7 @@
"no_results": "Результатов не найдено",
"open": "Открыть",
"paste": "Вставить",
"preview": "Предварительный просмотр",
"prompt": "Промпт",
"provider": "Провайдер",
"reasoning_content": "Глубокий анализ",
@@ -711,6 +712,7 @@
"rename": "Переименовать",
"reset": "Сбросить",
"save": "Сохранить",
"saved": "Сохранено",
"search": "Поиск",
"select": "Выбрать",
"selectedItems": "Выбрано {{count}} элементов",
@@ -886,7 +888,7 @@
"error": {
"failed_to_create": "Создание базы знаний завершено с ошибками",
"failed_to_edit": "Редактирование базы знаний завершено с ошибками",
"model_invalid": "Модель не выбрана или удалена"
"model_invalid": "Модель не выбрана"
},
"file_hint": "Поддерживаются {{file_types}}",
"index_all": "Индексировать все",
@@ -927,7 +929,7 @@
"search_placeholder": "Введите текст для поиска",
"settings": {
"preprocessing": "Предварительная обработка",
"preprocessing_tooltip": "Предварительная обработка изображений с помощью OCR",
"preprocessing_tooltip": "Предварительная обработка документов",
"title": "Настройки базы знаний"
},
"sitemap_added": "添加成功",
@@ -3307,26 +3309,11 @@
},
"title": "Настройки",
"tool": {
"ocr": {
"mac_system_ocr_options": {
"min_confidence": "Минимальная достоверность",
"mode": {
"accurate": "Точный",
"fast": "Быстро",
"title": "Режим распознавания"
}
},
"provider": "Поставщик OCR",
"provider_placeholder": "Выберите провайдера OCR",
"title": "OCR (оптическое распознавание символов)"
},
"preprocess": {
"provider": "Предварительная обработка Поставщик",
"provider_placeholder": "Выберите поставщика услуг предварительной обработки",
"title": "Предварительная обработка"
},
"preprocessOrOcr": {
"tooltip": "В настройках (Настройки -> Инструменты) укажите поставщика услуги предварительной обработки документов или OCR. Предварительная обработка документов может значительно повысить эффективность поиска для документов сложных форматов и отсканированных документов. OCR способен распознавать только текст внутри изображений в документах или текст в отсканированных PDF."
"title": "Предварительная обработка",
"tooltip": "В настройках (Настройки -> Инструменты) укажите поставщика услуги предварительной обработки документов. Предварительная обработка документов может значительно повысить эффективность поиска для документов сложных форматов и отсканированных документов."
},
"title": "Настройки инструментов",
"websearch": {
+5 -18
View File
@@ -703,6 +703,7 @@
"no_results": "无结果",
"open": "打开",
"paste": "粘贴",
"preview": "预览",
"prompt": "提示词",
"provider": "提供商",
"reasoning_content": "已深度思考",
@@ -711,6 +712,7 @@
"rename": "重命名",
"reset": "重置",
"save": "保存",
"saved": "已保存",
"search": "搜索",
"select": "选择",
"selectedItems": "已选择 {{count}} 项",
@@ -886,7 +888,7 @@
"error": {
"failed_to_create": "知识库创建失败",
"failed_to_edit": "知识库编辑失败",
"model_invalid": "未选择模型或已删除"
"model_invalid": "未选择模型"
},
"file_hint": "支持 {{file_types}} 格式",
"index_all": "索引全部",
@@ -3307,26 +3309,11 @@
},
"title": "设置",
"tool": {
"ocr": {
"mac_system_ocr_options": {
"min_confidence": "最低置信度",
"mode": {
"accurate": "准确",
"fast": "快速",
"title": "识别模式"
}
},
"provider": "OCR 服务商",
"provider_placeholder": "选择一个 OCR 服务商",
"title": "OCR 文字识别"
},
"preprocess": {
"provider": "文档预处理服务商",
"provider_placeholder": "选择一个文档预处理服务商",
"title": "文档预处理"
},
"preprocessOrOcr": {
"tooltip": "在设置 -> 工具中设置文档预处理服务商或OCR,文档预处理可以有效提升复杂格式文档与扫描版文档的检索效果,OCR仅可识别文档内图片或扫描版PDF的文本"
"title": "文档预处理",
"tooltip": "在设置 -> 工具中设置文档预处理服务商,文档预处理可以有效提升复杂格式文档与扫描版文档的检索效果"
},
"title": "工具设置",
"websearch": {
+5 -18
View File
@@ -703,6 +703,7 @@
"no_results": "沒有結果",
"open": "開啟",
"paste": "貼上",
"preview": "預覽",
"prompt": "提示詞",
"provider": "供應商",
"reasoning_content": "已深度思考",
@@ -711,6 +712,7 @@
"rename": "重新命名",
"reset": "重設",
"save": "儲存",
"saved": "已儲存",
"search": "搜尋",
"select": "選擇",
"selectedItems": "已選擇 {{count}} 項",
@@ -886,7 +888,7 @@
"error": {
"failed_to_create": "知識庫創建失敗",
"failed_to_edit": "知識庫編輯失敗",
"model_invalid": "未選擇模型或已刪除"
"model_invalid": "未選擇模型"
},
"file_hint": "支援 {{file_types}} 格式",
"index_all": "索引全部",
@@ -3307,26 +3309,11 @@
},
"title": "設定",
"tool": {
"ocr": {
"mac_system_ocr_options": {
"min_confidence": "最小置信度",
"mode": {
"accurate": "準確",
"fast": "快速",
"title": "識別模式"
}
},
"provider": "OCR 供應商",
"provider_placeholder": "選擇一個OCR服務提供商",
"title": "OCR 文字識別"
},
"preprocess": {
"provider": "前置處理供應商",
"provider_placeholder": "選擇一個預處理供應商",
"title": "前置處理"
},
"preprocessOrOcr": {
"tooltip": "在「設定」->「工具」中設定文件預處理服務供應商或OCR。文件預處理可有效提升複雜格式文件及掃描文件的檢索效能,而OCR僅能辨識文件內圖片文字或掃描PDF文字。"
"title": "前置處理",
"tooltip": "在「設定」->「工具」中設定文件預處理服務供應商。文件預處理可有效提升複雜格式文件及掃描文件的檢索效能"
},
"title": "工具設定",
"websearch": {
+1 -1
View File
@@ -3325,7 +3325,7 @@
"provider_placeholder": "Επιλέξτε έναν πάροχο προεπεξεργασίας εγγράφων",
"title": "Προεπεξεργασία Εγγράφων"
},
"preprocessOrOcr": {
"preprocess": {
"tooltip": "Ορίστε πάροχο προεπεξεργασίας εγγράφων ή OCR στις Ρυθμίσεις -> Εργαλεία. Η προεπεξεργασία εγγράφων μπορεί να βελτιώσει σημαντικά την απόδοση αναζήτησης για έγγραφα πολύπλοκης μορφής ή εγγράφων σε μορφή σάρωσης. Το OCR μπορεί να αναγνωρίσει μόνο κείμενο μέσα σε εικόνες εγγράφων ή σε PDF σε μορφή σάρωσης."
},
"title": "Ρυθμίσεις Εργαλείων",
+1 -1
View File
@@ -3325,7 +3325,7 @@
"provider_placeholder": "Selecciona un proveedor de preprocesamiento de documentos",
"title": "Preprocesamiento de Documentos"
},
"preprocessOrOcr": {
"preprocess": {
"tooltip": "Configure un proveedor de preprocesamiento de documentos o OCR en Configuración -> Herramientas. El preprocesamiento de documentos puede mejorar significativamente la eficacia de búsqueda en documentos con formatos complejos o versiones escaneadas. El OCR solo puede reconocer texto en imágenes o en archivos PDF escaneados."
},
"title": "Configuración de Herramientas",
+1 -1
View File
@@ -3325,7 +3325,7 @@
"provider_placeholder": "Sélectionnez un fournisseur de traitement préalable de documents",
"title": "Traitement Préliminaire de Documents"
},
"preprocessOrOcr": {
"preprocess": {
"tooltip": "Configurer un fournisseur de prétraitement de documents ou OCR dans Paramètres -> Outils. Le prétraitement des documents améliore efficacement la précision de recherche pour les documents à format complexe ou les versions scannées, tandis que l'OCR permet uniquement d'extraire le texte contenu dans les images ou les PDF scannés."
},
"title": "Paramètres des outils",
+1 -1
View File
@@ -3325,7 +3325,7 @@
"provider_placeholder": "Selecione um prestador de serviços de pré-processamento de documentos",
"title": "Pré-processamento de Documentos"
},
"preprocessOrOcr": {
"preprocess": {
"tooltip": "Configure o provedor de pré-processamento de documentos ou OCR em Configurações -> Ferramentas. O pré-processamento de documentos pode melhorar significativamente a eficácia da busca em documentos com formatos complexos ou versões escaneadas. O OCR só consegue reconhecer texto em imagens ou PDFs escaneados."
},
"title": "Configurações de Ferramentas",
@@ -52,18 +52,11 @@ export function useSystemAgents() {
// 如果没有远程配置或获取失败,加载本地代理
if (resourcesPath) {
try {
let fileName = 'agents.json'
if (currentLanguage === 'zh-CN') {
fileName = 'agents-zh.json'
} else {
fileName = 'agents-en.json'
}
const fileName = currentLanguage === 'zh-CN' ? 'agents-zh.json' : 'agents-en.json'
const localAgentsData = await window.api.fs.read(`${resourcesPath}/data/${fileName}`, 'utf-8')
_agents = JSON.parse(localAgentsData) as Agent[]
} catch (error) {
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json', 'utf-8')
_agents = JSON.parse(localAgentsData) as Agent[]
logger.error('Failed to load local agents:', error as Error)
}
}
+24 -31
View File
@@ -1,12 +1,12 @@
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { handleDelete } from '@renderer/services/FileAction'
import FileManager from '@renderer/services/FileManager'
import { FileMetadata, FileTypes } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import { Col, Image, Row, Spin } from 'antd'
import { t } from 'i18next'
import VirtualList from 'rc-virtual-list'
import React, { memo } from 'react'
import React, { memo, useCallback } from 'react'
import styled from 'styled-components'
import FileItem from './FileItem'
@@ -27,6 +27,8 @@ interface FileItemProps {
}
const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
const estimateSize = useCallback(() => 75, [])
if (id === FileTypes.IMAGE && files?.length && files?.length > 0) {
return (
<div style={{ padding: 16, overflowY: 'auto' }}>
@@ -78,38 +80,29 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
}
return (
<VirtualList
data={list}
height={window.innerHeight - 100}
itemHeight={75}
itemKey="key"
style={{ padding: '0 16px 16px 16px' }}
styles={{
verticalScrollBar: {
width: 6
},
verticalScrollBarThumb: {
background: 'var(--color-scrollbar-thumb)'
}
<DynamicVirtualList
list={list}
estimateSize={estimateSize}
overscan={2}
scrollerStyle={{
padding: '0 16px 16px 16px'
}}
itemContainerStyle={{
height: '75px',
paddingTop: '12px'
}}>
{(item) => (
<div
style={{
height: '75px',
paddingTop: '12px'
}}>
<FileItem
key={item.key}
fileInfo={{
name: item.file,
ext: item.ext,
extra: `${item.created_at} · ${item.count}${t('files.count')} · ${item.size}`,
actions: item.actions
}}
/>
</div>
<FileItem
key={item.key}
fileInfo={{
name: item.file,
ext: item.ext,
extra: `${item.created_at} · ${item.count}${t('files.count')} · ${item.size}`,
actions: item.actions
}}
/>
)}
</VirtualList>
</DynamicVirtualList>
)
}
@@ -704,7 +704,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
useEffect(() => {
if (!document.querySelector('.topview-fullscreen-container')) {
textareaRef.current?.focus()
const lastFocusedComponent = PasteService.getLastFocusedComponent()
if (lastFocusedComponent === 'inputbar') {
textareaRef.current?.focus()
}
}
}, [assistant, topic])
@@ -2,6 +2,7 @@ import { loggerService } from '@logger'
import Scrollbar from '@renderer/components/Scrollbar'
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
import { useModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings'
@@ -38,6 +39,16 @@ interface Props {
const logger = loggerService.withContext('MessageItem')
const WrapperContainer = ({
isMultiSelectMode,
children
}: {
isMultiSelectMode: boolean
children: React.ReactNode
}) => {
return isMultiSelectMode ? <label style={{ cursor: 'pointer' }}>{children}</label> : children
}
const MessageItem: FC<Props> = ({
message,
topic,
@@ -49,6 +60,7 @@ const MessageItem: FC<Props> = ({
}) => {
const { t } = useTranslation()
const { assistant, setModel } = useAssistant(message.assistantId)
const { isMultiSelectMode } = useChatContext(topic)
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
const { messageFont, fontSize, messageStyle } = useSettings()
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
@@ -122,7 +134,15 @@ const MessageItem: FC<Props> = ({
if (message.type === 'clear') {
return (
<NewContextMessage className="clear-context-divider" onClick={() => EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)}>
<NewContextMessage
isMultiSelectMode={isMultiSelectMode}
className="clear-context-divider"
onClick={() => {
if (isMultiSelectMode) {
return
}
EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)
}}>
<Divider dashed style={{ padding: '0 20px' }} plain>
{t('chat.message.new.context')}
</Divider>
@@ -131,56 +151,64 @@ const MessageItem: FC<Props> = ({
}
return (
<MessageContainer
key={message.id}
className={classNames({
message: true,
'message-assistant': isAssistantMessage,
'message-user': !isAssistantMessage
})}
ref={messageContainerRef}>
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} topic={topic} />
{isEditing && (
<MessageEditor
<WrapperContainer isMultiSelectMode={isMultiSelectMode}>
<MessageContainer
key={message.id}
className={classNames({
message: true,
'message-assistant': isAssistantMessage,
'message-user': !isAssistantMessage
})}
ref={messageContainerRef}>
<MessageHeader
message={message}
topicId={topic.id}
onSave={handleEditSave}
onResend={handleEditResend}
onCancel={handleEditCancel}
assistant={assistant}
model={model}
key={getModelUniqId(model)}
topic={topic}
/>
)}
{!isEditing && (
<>
<MessageContentContainer
className="message-content-container"
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize,
overflowY: 'visible'
}}>
<MessageErrorBoundary>
<MessageContent message={message} />
</MessageErrorBoundary>
</MessageContentContainer>
{showMenubar && (
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
/>
</MessageFooter>
)}
</>
)}
</MessageContainer>
{isEditing && (
<MessageEditor
message={message}
topicId={topic.id}
onSave={handleEditSave}
onResend={handleEditResend}
onCancel={handleEditCancel}
/>
)}
{!isEditing && (
<>
<MessageContentContainer
className="message-content-container"
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize,
overflowY: 'visible'
}}>
<MessageErrorBoundary>
<MessageContent message={message} />
</MessageErrorBoundary>
</MessageContentContainer>
{showMenubar && (
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
/>
</MessageFooter>
)}
</>
)}
</MessageContainer>
</WrapperContainer>
)
}
@@ -232,9 +260,11 @@ const MessageFooter = styled.div<{ $isLastMessage: boolean; $messageStyle: 'plai
margin-top: 8px;
`
const NewContextMessage = styled.div`
const NewContextMessage = styled.div<{ isMultiSelectMode: boolean }>`
cursor: pointer;
flex: 1;
${({ isMultiSelectMode }) => isMultiSelectMode && 'cursor: default;'}
`
export default memo(MessageItem)
@@ -9,22 +9,23 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
const NarrowLayout: FC<Props> = ({ children, ...props }) => {
const { narrowMode } = useSettings()
if (narrowMode) {
return (
<Container className="narrow-mode" {...props}>
{children}
</Container>
)
}
return children
return (
<Container className={`narrow-mode ${narrowMode ? 'active' : ''}`} {...props}>
{children}
</Container>
)
}
const Container = styled.div`
max-width: 800px;
max-width: 100%;
width: 100%;
margin: 0 auto;
position: relative;
transition: max-width 0.3s ease-in-out;
&.active {
max-width: 800px;
}
`
export default NarrowLayout
@@ -1,7 +1,9 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
import { Assistant, Topic } from '@renderer/types'
import { FC } from 'react'
import { containsSupportedVariables } from '@renderer/utils/prompt'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -18,13 +20,50 @@ const Prompt: FC<Props> = ({ assistant, topic }) => {
const topicPrompt = topic?.prompt || ''
const isDark = theme === 'dark'
const processedPrompt = usePromptProcessor({ prompt, modelName: assistant.model?.name })
// 用于控制显示的状态
const [displayText, setDisplayText] = useState(prompt)
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
// 如果没有变量需要替换,直接显示处理后的内容
if (!containsSupportedVariables(prompt)) {
setDisplayText(processedPrompt)
setIsVisible(true)
return
}
// 如果有变量需要替换,先显示原始prompt
setDisplayText(prompt)
setIsVisible(true)
// 延迟过渡
let innerTimer: NodeJS.Timeout
const outerTimer = setTimeout(() => {
// 先淡出
setIsVisible(false)
// 切换内容并淡入
innerTimer = setTimeout(() => {
setDisplayText(processedPrompt)
setIsVisible(true)
}, 300)
}, 300)
return () => {
clearTimeout(outerTimer)
clearTimeout(innerTimer)
}
}, [prompt, processedPrompt])
if (!prompt && !topicPrompt) {
return null
}
return (
<Container className="system-prompt" onClick={() => AssistantSettingsPopup.show({ assistant })} $isDark={isDark}>
<Text>{prompt}</Text>
<Text $isVisible={isVisible}>{displayText}</Text>
</Container>
)
}
@@ -38,13 +77,17 @@ const Container = styled.div<{ $isDark: boolean }>`
margin-bottom: 0;
`
const Text = styled.div`
const Text = styled.div<{ $isVisible: boolean }>`
color: var(--color-text-2);
font-size: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
user-select: none;
opacity: ${(props) => (props.$isVisible ? 1 : 0)};
transition: opacity 0.3s ease-in-out;
`
export default Prompt
@@ -17,9 +17,14 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const [dragCurrent, setDragCurrent] = useState({ x: 0, y: 0 })
const [isMouseDown, setIsMouseDown] = useState(false)
const dragSelectedIds = useRef<Set<string>>(new Set())
// 拖拽阈值,只有移动距离超过这个值才开始框选
// 避免触控板点击触发拖拽
const DRAG_THRESHOLD = 5
useEffect(() => {
if (!isMultiSelectMode) return
@@ -39,20 +44,30 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
e.preventDefault()
setIsDragging(true)
setIsMouseDown(true)
const pos = updateDragPos(e)
setDragStart(pos)
setDragCurrent(pos)
dragSelectedIds.current.clear()
document.body.classList.add('no-select')
}
const handleMouseMove = (e: MouseEvent) => {
if (!isMouseDown) return
const pos = updateDragPos(e)
const deltaX = Math.abs(pos.x - dragStart.x)
const deltaY = Math.abs(pos.y - dragStart.y)
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (!isDragging && distance > DRAG_THRESHOLD) {
setIsDragging(true)
document.body.classList.add('no-select')
}
if (!isDragging) return
e.preventDefault()
const pos = updateDragPos(e)
setDragCurrent(pos)
// 计算当前框选矩形
@@ -69,6 +84,9 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
const checkbox = el.querySelector('input[type="checkbox"]') as HTMLInputElement | null
const isAlreadySelected = checkbox?.checked || false
// 清除上下文这类消息也会被选中,所以需要跳过
if (!checkbox) return
// 如果已经被记录为拖动选中,跳过
if (dragSelectedIds.current.has(id)) return
@@ -94,9 +112,11 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
}
const handleMouseUp = () => {
if (!isDragging) return
setIsDragging(false)
document.body.classList.remove('no-select')
setIsMouseDown(false)
if (isDragging) {
setIsDragging(false)
document.body.classList.remove('no-select')
}
}
const container = scrollContainerRef.current!
@@ -110,7 +130,7 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
window.removeEventListener('mouseup', handleMouseUp)
document.body.classList.remove('no-select')
}
}, [isMultiSelectMode, isDragging, dragStart, scrollContainerRef, messageElements, handleSelectMessage])
}, [isMultiSelectMode, isDragging, isMouseDown, dragStart, scrollContainerRef, messageElements, handleSelectMessage])
if (!isDragging || !isMultiSelectMode) return null
@@ -10,7 +10,7 @@ import {
QuestionCircleOutlined,
UploadOutlined
} from '@ant-design/icons'
import { DraggableVirtualList as DraggableList } from '@renderer/components/DraggableList'
import { DraggableVirtualList } from '@renderer/components/DraggableList'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
@@ -438,11 +438,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
const singlealone = topicPosition === 'right' && position === 'right'
return (
<DraggableList
<DraggableVirtualList
className="topics-tab"
list={sortedTopics}
onUpdate={updateTopics}
style={{ height: '100%', padding: '13px 0 10px 10px', display: 'flex', flexDirection: 'column' }}
style={{ height: '100%', padding: '13px 0 10px 10px' }}
itemContainerStyle={{ paddingBottom: '8px' }}
header={
<AddTopicButton onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
@@ -521,7 +521,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
</Dropdown>
)
}}
</DraggableList>
</DraggableVirtualList>
)
}
@@ -139,8 +139,8 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
</div>
</Tooltip>
{base.rerankModel && <Tag style={{ borderRadius: 20, margin: 0 }}>{base.rerankModel.name}</Tag>}
{base.preprocessOrOcrProvider && base.preprocessOrOcrProvider.type === 'preprocess' && (
<QuotaTag base={base} providerId={base.preprocessOrOcrProvider?.provider.id} quota={quota} />
{base.preprocessProvider && base.preprocessProvider.type === 'preprocess' && (
<QuotaTag base={base} providerId={base.preprocessProvider?.provider.id} quota={quota} />
)}
</div>
</ModelInfo>
@@ -104,36 +104,34 @@ const KnowledgePage: FC = () => {
</Navbar>
<ContentContainer id="content-container">
<KnowledgeSideNav>
<ScrollContainer>
<DraggableList
list={bases}
onUpdate={updateKnowledgeBases}
style={{ marginBottom: 0, paddingBottom: isDragging ? 50 : 0 }}
onDragStart={() => setIsDragging(true)}
onDragEnd={() => setIsDragging(false)}>
{(base: KnowledgeBase) => (
<Dropdown menu={{ items: getMenuItems(base) }} trigger={['contextMenu']} key={base.id}>
<div>
<ListItem
active={selectedBase?.id === base.id}
icon={<Book size={16} />}
title={base.name}
onClick={() => setSelectedBase(base)}
/>
</div>
</Dropdown>
)}
</DraggableList>
{!isDragging && (
<AddKnowledgeItem onClick={handleAddKnowledge}>
<AddKnowledgeName>
<Plus size={18} />
{t('button.add')}
</AddKnowledgeName>
</AddKnowledgeItem>
<DraggableList
list={bases}
onUpdate={updateKnowledgeBases}
style={{ marginBottom: 0, paddingBottom: isDragging ? 50 : 0 }}
onDragStart={() => setIsDragging(true)}
onDragEnd={() => setIsDragging(false)}>
{(base: KnowledgeBase) => (
<Dropdown menu={{ items: getMenuItems(base) }} trigger={['contextMenu']} key={base.id}>
<div>
<ListItem
active={selectedBase?.id === base.id}
icon={<Book size={16} />}
title={base.name}
onClick={() => setSelectedBase(base)}
/>
</div>
</Dropdown>
)}
<div style={{ minHeight: '10px' }}></div>
</ScrollContainer>
</DraggableList>
{!isDragging && (
<AddKnowledgeItem onClick={handleAddKnowledge}>
<AddKnowledgeName>
<Plus size={18} />
{t('button.add')}
</AddKnowledgeName>
</AddKnowledgeItem>
)}
<div style={{ minHeight: '10px' }}></div>
</KnowledgeSideNav>
{bases.length === 0 ? (
<MainContent>
@@ -169,13 +167,14 @@ const MainContent = styled(Scrollbar)`
padding-bottom: 50px;
`
export const KnowledgeSideNav = styled.div`
min-width: var(--settings-width);
border-right: 0.5px solid var(--color-border);
padding: 12px 10px;
const KnowledgeSideNav = styled(Scrollbar)`
display: flex;
flex-direction: column;
width: calc(var(--settings-width) + 100px);
border-right: 0.5px solid var(--color-border);
padding: 12px 10px;
.ant-menu {
border-inline-end: none !important;
background: transparent;
@@ -197,12 +196,6 @@ export const KnowledgeSideNav = styled.div`
color: var(--color-primary);
}
}
`
const ScrollContainer = styled(Scrollbar)`
display: flex;
flex-direction: column;
flex: 1;
> div {
margin-bottom: 8px;
@@ -34,45 +34,6 @@ exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = `
value="Test Knowledge Base"
/>
</div>
<div
class="c1"
>
<div
class="settings-label"
>
settings.tool.preprocess.title
/
settings.tool.ocr.title
<span
data-placement="right"
data-testid="info-tooltip"
title="settings.tool.preprocessOrOcr.tooltip"
>
</span>
</div>
<select
data-allow-clear="true"
data-placeholder="settings.tool.preprocess.provider_placeholder"
data-testid="preprocess-select"
>
<option
value=""
>
Select option
</option>
<option
value="doc2x"
>
Doc2X
</option>
<option
value="mistral"
>
Mistral
</option>
</select>
</div>
<div
class="c1"
>
@@ -172,6 +133,43 @@ exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = `
</option>
</select>
</div>
<div
class="c1"
>
<div
class="settings-label"
>
settings.tool.preprocess.title
<span
data-placement="right"
data-testid="info-tooltip"
title="settings.tool.preprocess.tooltip"
>
</span>
</div>
<select
data-allow-clear="true"
data-placeholder="settings.tool.preprocess.provider_placeholder"
data-testid="preprocess-select"
>
<option
value=""
>
Select option
</option>
<option
value="doc2x"
>
Doc2X
</option>
<option
value="mistral"
>
Mistral
</option>
</select>
</div>
<div
class="c1"
>
@@ -6,7 +6,7 @@ import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { KnowledgeBase, PreprocessProvider } from '@renderer/types'
import { Input, Select, Slider } from 'antd'
import { Input, Select, SelectProps, Slider } from 'antd'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsPanel } from './styles'
@@ -15,7 +15,7 @@ interface GeneralSettingsPanelProps {
newBase: KnowledgeBase
setNewBase: React.Dispatch<React.SetStateAction<KnowledgeBase>>
selectedDocPreprocessProvider?: PreprocessProvider
docPreprocessSelectOptions: any[]
docPreprocessSelectOptions: SelectProps['options']
handlers: {
handleEmbeddingModelChange: (value: string) => void
handleDimensionChange: (value: number | null) => void
@@ -47,21 +47,6 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = ({
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('settings.tool.preprocess.title')} / {t('settings.tool.ocr.title')}
<InfoTooltip title={t('settings.tool.preprocessOrOcr.tooltip')} placement="right" />
</div>
<Select
value={selectedDocPreprocessProvider?.id}
style={{ width: '100%' }}
onChange={handleDocPreprocessChange}
placeholder={t('settings.tool.preprocess.provider_placeholder')}
options={docPreprocessSelectOptions}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('models.embedding_model')}
@@ -106,6 +91,21 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = ({
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('settings.tool.preprocess.title')}
<InfoTooltip title={t('settings.tool.preprocess.tooltip')} placement="right" />
</div>
<Select
value={selectedDocPreprocessProvider?.id}
style={{ width: '100%' }}
onChange={handleDocPreprocessChange}
placeholder={t('settings.tool.preprocess.provider_placeholder')}
options={docPreprocessSelectOptions}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.document_count')}
@@ -1,7 +1,7 @@
import { DeleteOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import Ellipsis from '@renderer/components/Ellipsis'
import Scrollbar from '@renderer/components/Scrollbar'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileItem from '@renderer/pages/files/FileItem'
import { getProviderName } from '@renderer/services/ProviderService'
@@ -9,7 +9,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { Button, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { Plus } from 'lucide-react'
import { FC } from 'react'
import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -46,6 +46,9 @@ const KnowledgeDirectories: FC<KnowledgeContentProps> = ({ selectedBase, progres
const providerName = getProviderName(base?.model.provider || '')
const disabled = !base?.version || !providerName
const reversedItems = useMemo(() => [...directoryItems].reverse(), [directoryItems])
const estimateSize = useCallback(() => 75, [])
if (!base) {
return null
}
@@ -76,46 +79,51 @@ const KnowledgeDirectories: FC<KnowledgeContentProps> = ({ selectedBase, progres
</ItemHeader>
<ItemFlexColumn>
{directoryItems.length === 0 && <KnowledgeEmptyView />}
{directoryItems.reverse().map((item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
<Ellipsis>
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
</Ellipsis>
</ClickableSpan>
),
ext: '.folder',
extra: getDisplayTime(item),
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
progress={progressMap.get(item.id)}
type="directory"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
<DynamicVirtualList
list={reversedItems}
estimateSize={estimateSize}
overscan={2}
scrollerStyle={{ paddingRight: 2 }}
itemContainerStyle={{ paddingBottom: 10 }}
autoHideScrollbar>
{(item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
<Ellipsis>
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
</Ellipsis>
</ClickableSpan>
),
ext: '.folder',
extra: getDisplayTime(item),
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
progress={progressMap.get(item.id)}
type="directory"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
)}
</DynamicVirtualList>
</ItemFlexColumn>
</ItemContainer>
)
}
const ItemFlexColumn = styled(Scrollbar)`
display: flex;
flex-direction: column;
gap: 10px;
const ItemFlexColumn = styled.div`
padding: 20px 16px;
height: calc(100vh - 135px);
`
@@ -12,13 +12,14 @@ import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@sh
import { Button, Tooltip, Upload } from 'antd'
import dayjs from 'dayjs'
import { Plus } from 'lucide-react'
import VirtualList from 'rc-virtual-list'
import { FC, useEffect, useState } from 'react'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const logger = loggerService.withContext('KnowledgeFiles')
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import {
ClickableSpan,
FlexAlignCenter,
@@ -64,6 +65,8 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
const providerName = getProviderName(base?.model.provider || '')
const disabled = !base?.version || !providerName
const estimateSize = useCallback(() => 75, [])
if (!base) {
return null
}
@@ -122,10 +125,10 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
}
const showPreprocessIcon = (item: KnowledgeItem) => {
if (base.preprocessOrOcrProvider && item.isPreprocessed !== false) {
if (base.preprocessProvider && item.isPreprocessed !== false) {
return true
}
if (!base.preprocessOrOcrProvider && item.isPreprocessed === true) {
if (!base.preprocessProvider && item.isPreprocessed === true) {
return true
}
return false
@@ -160,15 +163,12 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
{fileItems.length === 0 ? (
<KnowledgeEmptyView />
) : (
<VirtualList
data={fileItems.reverse()}
height={windowHeight - 270}
itemHeight={75}
itemKey="id"
styles={{
verticalScrollBar: { width: 6 },
verticalScrollBarThumb: { background: 'var(--color-scrollbar-thumb)' }
}}>
<DynamicVirtualList
list={fileItems.reverse()}
estimateSize={estimateSize}
overscan={2}
scrollerStyle={{ height: windowHeight - 270 }}
autoHideScrollbar>
{(item) => {
const file = item.content as FileType
return (
@@ -218,7 +218,7 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
</div>
)
}}
</VirtualList>
</DynamicVirtualList>
)}
</ItemFlexColumn>
</ItemContainer>
@@ -1,6 +1,6 @@
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
import Scrollbar from '@renderer/components/Scrollbar'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileItem from '@renderer/pages/files/FileItem'
import { getProviderName } from '@renderer/services/ProviderService'
@@ -8,7 +8,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { Button } from 'antd'
import dayjs from 'dayjs'
import { Plus } from 'lucide-react'
import { FC } from 'react'
import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -34,6 +34,9 @@ const KnowledgeNotes: FC<KnowledgeContentProps> = ({ selectedBase }) => {
const providerName = getProviderName(base?.model.provider || '')
const disabled = !base?.version || !providerName
const reversedItems = useMemo(() => [...noteItems].reverse(), [noteItems])
const estimateSize = useCallback(() => 75, [])
if (!base) {
return null
}
@@ -72,34 +75,44 @@ const KnowledgeNotes: FC<KnowledgeContentProps> = ({ selectedBase }) => {
</ItemHeader>
<ItemFlexColumn>
{noteItems.length === 0 && <KnowledgeEmptyView />}
{noteItems.reverse().map((note) => (
<FileItem
key={note.id}
fileInfo={{
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
ext: '.txt',
extra: getDisplayTime(note),
actions: (
<FlexAlignCenter>
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
<StatusIconWrapper>
<StatusIcon sourceId={note.id} base={base} getProcessingStatus={getProcessingStatus} type="note" />
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
<DynamicVirtualList
list={reversedItems}
estimateSize={estimateSize}
overscan={2}
scrollerStyle={{ paddingRight: 2 }}
itemContainerStyle={{ paddingBottom: 10 }}
autoHideScrollbar>
{(note) => (
<FileItem
key={note.id}
fileInfo={{
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
ext: '.txt',
extra: getDisplayTime(note),
actions: (
<FlexAlignCenter>
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
<StatusIconWrapper>
<StatusIcon
sourceId={note.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="note"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
)}
</DynamicVirtualList>
</ItemFlexColumn>
</ItemContainer>
)
}
const ItemFlexColumn = styled(Scrollbar)`
display: flex;
flex-direction: column;
gap: 10px;
const ItemFlexColumn = styled.div`
padding: 20px 16px;
height: calc(100vh - 135px);
`
@@ -2,7 +2,7 @@ import { DeleteOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import Ellipsis from '@renderer/components/Ellipsis'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import Scrollbar from '@renderer/components/Scrollbar'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileItem from '@renderer/pages/files/FileItem'
import { getProviderName } from '@renderer/services/ProviderService'
@@ -10,7 +10,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { Button, message, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { Plus } from 'lucide-react'
import { FC } from 'react'
import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -46,6 +46,9 @@ const KnowledgeSitemaps: FC<KnowledgeContentProps> = ({ selectedBase }) => {
const providerName = getProviderName(base?.model.provider || '')
const disabled = !base?.version || !providerName
const reversedItems = useMemo(() => [...sitemapItems].reverse(), [sitemapItems])
const estimateSize = useCallback(() => 75, [])
if (!base) {
return null
}
@@ -95,49 +98,54 @@ const KnowledgeSitemaps: FC<KnowledgeContentProps> = ({ selectedBase }) => {
</ItemHeader>
<ItemFlexColumn>
{sitemapItems.length === 0 && <KnowledgeEmptyView />}
{sitemapItems.reverse().map((item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan>
<Tooltip title={item.content as string}>
<Ellipsis>
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.content as string}
</a>
</Ellipsis>
</Tooltip>
</ClickableSpan>
),
ext: '.sitemap',
extra: getDisplayTime(item),
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="sitemap"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
<DynamicVirtualList
list={reversedItems}
estimateSize={estimateSize}
overscan={2}
scrollerStyle={{ paddingRight: 2 }}
itemContainerStyle={{ paddingBottom: 10 }}
autoHideScrollbar>
{(item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<ClickableSpan>
<Tooltip title={item.content as string}>
<Ellipsis>
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.content as string}
</a>
</Ellipsis>
</Tooltip>
</ClickableSpan>
),
ext: '.sitemap',
extra: getDisplayTime(item),
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
type="sitemap"
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
)}
</DynamicVirtualList>
</ItemFlexColumn>
</ItemContainer>
)
}
const ItemFlexColumn = styled(Scrollbar)`
display: flex;
flex-direction: column;
gap: 10px;
const ItemFlexColumn = styled.div`
padding: 20px 16px;
height: calc(100vh - 135px);
`
@@ -1,7 +1,7 @@
import { CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
import Ellipsis from '@renderer/components/Ellipsis'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import Scrollbar from '@renderer/components/Scrollbar'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { useKnowledge } from '@renderer/hooks/useKnowledge'
import FileItem from '@renderer/pages/files/FileItem'
import { getProviderName } from '@renderer/services/ProviderService'
@@ -9,7 +9,7 @@ import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { Button, Dropdown, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { Plus } from 'lucide-react'
import { FC } from 'react'
import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -43,6 +43,9 @@ const KnowledgeUrls: FC<KnowledgeContentProps> = ({ selectedBase }) => {
const providerName = getProviderName(base?.model.provider || '')
const disabled = !base?.version || !providerName
const reversedItems = useMemo(() => [...urlItems].reverse(), [urlItems])
const estimateSize = useCallback(() => 75, [])
if (!base) {
return null
}
@@ -123,66 +126,71 @@ const KnowledgeUrls: FC<KnowledgeContentProps> = ({ selectedBase }) => {
</ItemHeader>
<ItemFlexColumn>
{urlItems.length === 0 && <KnowledgeEmptyView />}
{urlItems.reverse().map((item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<Dropdown
menu={{
items: [
{
key: 'edit',
icon: <EditOutlined />,
label: t('knowledge.edit_remark'),
onClick: () => handleEditRemark(item)
},
{
key: 'copy',
icon: <CopyOutlined />,
label: t('common.copy'),
onClick: () => {
navigator.clipboard.writeText(item.content as string)
window.message.success(t('message.copied'))
<DynamicVirtualList
list={reversedItems}
estimateSize={estimateSize}
overscan={2}
scrollerStyle={{ paddingRight: 2 }}
itemContainerStyle={{ paddingBottom: 10 }}
autoHideScrollbar>
{(item) => (
<FileItem
key={item.id}
fileInfo={{
name: (
<Dropdown
menu={{
items: [
{
key: 'edit',
icon: <EditOutlined />,
label: t('knowledge.edit_remark'),
onClick: () => handleEditRemark(item)
},
{
key: 'copy',
icon: <CopyOutlined />,
label: t('common.copy'),
onClick: () => {
navigator.clipboard.writeText(item.content as string)
window.message.success(t('message.copied'))
}
}
}
]
}}
trigger={['contextMenu']}>
<ClickableSpan>
<Tooltip title={item.content as string}>
<Ellipsis>
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.remark || (item.content as string)}
</a>
</Ellipsis>
</Tooltip>
</ClickableSpan>
</Dropdown>
),
ext: '.url',
extra: getDisplayTime(item),
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
))}
]
}}
trigger={['contextMenu']}>
<ClickableSpan>
<Tooltip title={item.content as string}>
<Ellipsis>
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
{item.remark || (item.content as string)}
</a>
</Ellipsis>
</Tooltip>
</ClickableSpan>
</Dropdown>
),
ext: '.url',
extra: getDisplayTime(item),
actions: (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
</FlexAlignCenter>
)
}}
/>
)}
</DynamicVirtualList>
</ItemFlexColumn>
</ItemContainer>
)
}
const ItemFlexColumn = styled(Scrollbar)`
display: flex;
flex-direction: column;
gap: 10px;
const ItemFlexColumn = styled.div`
padding: 20px 16px;
height: calc(100vh - 135px);
`
@@ -7,7 +7,7 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { useAppDispatch } from '@renderer/store'
import { handleSaveData, useAppDispatch } from '@renderer/store'
import { setUpdateState } from '@renderer/store/runtime'
import { ThemeMode } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
@@ -41,6 +41,7 @@ const AboutSettings: FC = () => {
}
if (update.downloaded) {
await handleSaveData()
window.api.showUpdateDialog()
return
}
@@ -1,423 +0,0 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { loggerService } from '@renderer/services/LoggerService'
import { RootState, useAppDispatch } from '@renderer/store'
import { setApiServerApiKey, setApiServerEnabled, setApiServerPort } from '@renderer/store/settings'
import { IpcChannel } from '@shared/IpcChannel'
import { Button, Input, InputNumber, Tooltip, Typography } from 'antd'
import { Copy, ExternalLink, Play, RotateCcw, Square } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
import { v4 as uuidv4 } from 'uuid'
import { SettingContainer } from '..'
const logger = loggerService.withContext('ApiServerSettings')
const { Text, Title } = Typography
const ApiServerSettings: FC = () => {
const { theme } = useTheme()
const dispatch = useAppDispatch()
const { t } = useTranslation()
// API Server state with proper defaults
const apiServerConfig = useSelector((state: RootState) => state.settings.apiServer)
const [apiServerRunning, setApiServerRunning] = useState(false)
const [apiServerLoading, setApiServerLoading] = useState(false)
// API Server functions
const checkApiServerStatus = async () => {
try {
const status = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus)
setApiServerRunning(status.running)
} catch (error: any) {
logger.error('Failed to check API server status:', error)
}
}
useEffect(() => {
checkApiServerStatus()
}, [])
const handleApiServerToggle = async (enabled: boolean) => {
setApiServerLoading(true)
try {
if (enabled) {
const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Start)
if (result.success) {
setApiServerRunning(true)
window.message.success(t('apiServer.messages.startSuccess'))
} else {
window.message.error(t('apiServer.messages.startError') + result.error)
}
} else {
const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
if (result.success) {
setApiServerRunning(false)
window.message.success(t('apiServer.messages.stopSuccess'))
} else {
window.message.error(t('apiServer.messages.stopError') + result.error)
}
}
} catch (error) {
window.message.error(t('apiServer.messages.operationFailed') + (error as Error).message)
} finally {
dispatch(setApiServerEnabled(enabled))
setApiServerLoading(false)
}
}
const handleApiServerRestart = async () => {
setApiServerLoading(true)
try {
const result = await window.electron.ipcRenderer.invoke(IpcChannel.ApiServer_Restart)
if (result.success) {
await checkApiServerStatus()
window.message.success(t('apiServer.messages.restartSuccess'))
} else {
window.message.error(t('apiServer.messages.restartError') + result.error)
}
} catch (error) {
window.message.error(t('apiServer.messages.restartFailed') + (error as Error).message)
} finally {
setApiServerLoading(false)
}
}
const copyApiKey = () => {
navigator.clipboard.writeText(apiServerConfig.apiKey)
window.message.success(t('apiServer.messages.apiKeyCopied'))
}
const regenerateApiKey = () => {
const newApiKey = `cs-sk-${uuidv4()}`
dispatch(setApiServerApiKey(newApiKey))
window.message.success(t('apiServer.messages.apiKeyRegenerated'))
}
const handlePortChange = (value: string) => {
const port = parseInt(value) || 23333
if (port >= 1000 && port <= 65535) {
dispatch(setApiServerPort(port))
}
}
const openApiDocs = () => {
if (apiServerRunning) {
window.open(`http://localhost:${apiServerConfig.port}/api-docs`, '_blank')
}
}
return (
<Container theme={theme}>
{/* Header Section */}
<HeaderSection>
<HeaderContent>
<Title level={3} style={{ margin: 0, marginBottom: 8 }}>
{t('apiServer.title')}
</Title>
<Text type="secondary">{t('apiServer.description')}</Text>
</HeaderContent>
{apiServerRunning && (
<Button type="primary" icon={<ExternalLink size={14} />} onClick={openApiDocs}>
{t('apiServer.documentation.title')}
</Button>
)}
</HeaderSection>
{/* Server Control Panel with integrated configuration */}
<ServerControlPanel $status={apiServerRunning}>
<StatusSection>
<StatusIndicator $status={apiServerRunning} />
<StatusContent>
<StatusText $status={apiServerRunning}>
{apiServerRunning ? t('apiServer.status.running') : t('apiServer.status.stopped')}
</StatusText>
<StatusSubtext>
{apiServerRunning ? `http://localhost:${apiServerConfig.port}` : t('apiServer.fields.port.description')}
</StatusSubtext>
</StatusContent>
</StatusSection>
<ControlSection>
{apiServerRunning && (
<Tooltip title={t('apiServer.actions.restart.tooltip')}>
<RestartButton
$loading={apiServerLoading}
onClick={apiServerLoading ? undefined : handleApiServerRestart}>
<RotateCcw size={14} />
<span>{t('apiServer.actions.restart.button')}</span>
</RestartButton>
</Tooltip>
)}
{/* Port input when server is stopped */}
{!apiServerRunning && (
<StyledInputNumber
value={apiServerConfig.port}
onChange={(value) => handlePortChange(String(value || 23333))}
min={1000}
max={65535}
disabled={apiServerRunning}
placeholder="23333"
size="middle"
/>
)}
<Tooltip title={apiServerRunning ? t('apiServer.actions.stop') : t('apiServer.actions.start')}>
{apiServerRunning ? (
<StopButton
$loading={apiServerLoading}
onClick={apiServerLoading ? undefined : () => handleApiServerToggle(false)}>
<Square size={20} style={{ color: 'var(--color-status-error)' }} />
</StopButton>
) : (
<StartButton
$loading={apiServerLoading}
onClick={apiServerLoading ? undefined : () => handleApiServerToggle(true)}>
<Play size={20} style={{ color: 'var(--color-status-success)' }} />
</StartButton>
)}
</Tooltip>
</ControlSection>
</ServerControlPanel>
{/* API Key Configuration */}
<ConfigurationField>
<FieldLabel>{t('apiServer.fields.apiKey.label')}</FieldLabel>
<FieldDescription>{t('apiServer.fields.apiKey.description')}</FieldDescription>
<StyledInput
value={apiServerConfig.apiKey}
readOnly
placeholder={t('apiServer.fields.apiKey.placeholder')}
size="middle"
suffix={
<InputButtonContainer>
{!apiServerRunning && (
<RegenerateButton onClick={regenerateApiKey} disabled={apiServerRunning} type="link">
{t('apiServer.actions.regenerate')}
</RegenerateButton>
)}
<Tooltip title={t('apiServer.fields.apiKey.copyTooltip')}>
<InputButton icon={<Copy size={14} />} onClick={copyApiKey} disabled={!apiServerConfig.apiKey} />
</Tooltip>
</InputButtonContainer>
}
/>
{/* Authorization header info */}
<AuthHeaderSection>
<FieldLabel>{t('apiServer.authHeader.title')}</FieldLabel>
<StyledInput
style={{ height: 38 }}
value={`Authorization: Bearer ${apiServerConfig.apiKey || 'your-api-key'}`}
readOnly
size="middle"
/>
</AuthHeaderSection>
</ConfigurationField>
</Container>
)
}
// Styled Components
const Container = styled(SettingContainer)`
display: flex;
flex-direction: column;
height: calc(100vh - var(--navbar-height));
`
const HeaderSection = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
`
const HeaderContent = styled.div`
flex: 1;
`
const ServerControlPanel = styled.div<{ $status: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-radius: 8px;
background: var(--color-background);
border: 1px solid ${(props) => (props.$status ? 'var(--color-status-success)' : 'var(--color-border)')};
transition: all 0.3s ease;
margin-bottom: 16px;
`
const StatusSection = styled.div`
display: flex;
align-items: center;
gap: 10px;
`
const StatusIndicator = styled.div<{ $status: boolean }>`
position: relative;
width: 10px;
height: 10px;
border-radius: 50%;
background: ${(props) => (props.$status ? 'var(--color-status-success)' : 'var(--color-status-error)')};
&::before {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
background: ${(props) => (props.$status ? 'var(--color-status-success)' : 'var(--color-status-error)')};
opacity: 0.2;
animation: ${(props) => (props.$status ? 'pulse 2s infinite' : 'none')};
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 0.2;
}
50% {
transform: scale(1.5);
opacity: 0.1;
}
}
`
const StatusContent = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
`
const StatusText = styled.div<{ $status: boolean }>`
font-weight: 600;
font-size: 14px;
color: ${(props) => (props.$status ? 'var(--color-status-success)' : 'var(--color-text-1)')};
margin: 0;
`
const StatusSubtext = styled.div`
font-size: 12px;
color: var(--color-text-3);
margin: 0;
`
const ControlSection = styled.div`
display: flex;
align-items: center;
gap: 12px;
`
const RestartButton = styled.div<{ $loading: boolean }>`
display: flex;
align-items: center;
gap: 4px;
color: var(--color-text-2);
cursor: ${(props) => (props.$loading ? 'not-allowed' : 'pointer')};
opacity: ${(props) => (props.$loading ? 0.5 : 1)};
font-size: 12px;
transition: all 0.2s ease;
&:hover {
color: ${(props) => (props.$loading ? 'var(--color-text-2)' : 'var(--color-primary)')};
}
`
const StyledInputNumber = styled(InputNumber)`
width: 80px;
border-radius: 6px;
border: 1.5px solid var(--color-border);
margin-right: 5px;
`
const StartButton = styled.div<{ $loading: boolean }>`
display: inline-flex;
align-items: center;
justify-content: center;
cursor: ${(props) => (props.$loading ? 'not-allowed' : 'pointer')};
opacity: ${(props) => (props.$loading ? 0.5 : 1)};
transition: all 0.2s ease;
&:hover {
transform: ${(props) => (props.$loading ? 'scale(1)' : 'scale(1.1)')};
}
`
const StopButton = styled.div<{ $loading: boolean }>`
display: inline-flex;
align-items: center;
justify-content: center;
cursor: ${(props) => (props.$loading ? 'not-allowed' : 'pointer')};
opacity: ${(props) => (props.$loading ? 0.5 : 1)};
transition: all 0.2s ease;
&:hover {
transform: ${(props) => (props.$loading ? 'scale(1)' : 'scale(1.1)')};
}
`
const ConfigurationField = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: var(--color-background);
border-radius: 8px;
border: 1px solid var(--color-border);
`
const FieldLabel = styled.div`
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
margin: 0;
`
const FieldDescription = styled.div`
font-size: 12px;
color: var(--color-text-3);
margin: 0;
`
const StyledInput = styled(Input)`
width: 100%;
border-radius: 6px;
border: 1.5px solid var(--color-border);
`
const InputButtonContainer = styled.div`
display: flex;
align-items: center;
gap: 4px;
`
const InputButton = styled(Button)`
border: none;
padding: 0 4px;
background: transparent;
`
const RegenerateButton = styled(Button)`
padding: 0 4px;
font-size: 12px;
height: auto;
line-height: 1;
border: none;
background: transparent;
`
const AuthHeaderSection = styled.div`
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
`
export default ApiServerSettings
@@ -1 +0,0 @@
export { default as ApiServerSettings } from './ApiServerSettings'
@@ -1,13 +1,15 @@
import 'emoji-picker-element'
import { CloseCircleFilled, QuestionCircleOutlined } from '@ant-design/icons'
import { CloseCircleFilled } from '@ant-design/icons'
import CodeEditor from '@renderer/components/CodeEditor'
import EmojiPicker from '@renderer/components/EmojiPicker'
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
import { estimateTextTokens } from '@renderer/services/TokenService'
import { Assistant, AssistantSettings } from '@renderer/types'
import { getLeadingEmoji } from '@renderer/utils'
import { Button, Input, Popover } from 'antd'
import { Edit, Eye, HelpCircle } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
@@ -28,7 +30,7 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
const [prompt, setPrompt] = useState(assistant.prompt)
const [tokenCount, setTokenCount] = useState(0)
const { t } = useTranslation()
const [showMarkdown, setShowMarkdown] = useState(prompt.length > 0)
const [showPreview, setShowPreview] = useState(prompt.length > 0)
useEffect(() => {
const updateTokenCount = async () => {
@@ -38,9 +40,15 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
updateTokenCount()
}, [prompt])
const processedPrompt = usePromptProcessor({
prompt,
modelName: assistant.model?.name
})
const onUpdate = () => {
const _assistant = { ...assistant, name: name.trim(), emoji, prompt }
updateAssistant(_assistant)
window.message.success(t('common.saved'))
}
const handleEmojiSelect = (selectedEmoji: string) => {
@@ -106,13 +114,13 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
<HStack mb={8} alignItems="center" gap={4}>
<Box style={{ fontWeight: 'bold' }}>{t('common.prompt')}</Box>
<Popover title={t('agents.add.prompt.variables.tip.title')} content={promptVarsContent}>
<QuestionCircleOutlined size={14} color="var(--color-text-2)" />
<HelpCircle size={14} color="var(--color-text-2)" />
</Popover>
</HStack>
<TextAreaContainer>
{showMarkdown ? (
<MarkdownContainer className="markdown" onClick={() => setShowMarkdown(false)}>
<ReactMarkdown>{prompt}</ReactMarkdown>
{showPreview ? (
<MarkdownContainer className="markdown" onClick={() => setShowPreview(false)}>
<ReactMarkdown>{processedPrompt || prompt}</ReactMarkdown>
<div style={{ height: '30px' }} />
</MarkdownContainer>
) : (
@@ -141,8 +149,11 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
</TextAreaContainer>
<HSpaceBetweenStack width="100%" justifyContent="flex-end" mt="10px">
<TokenCount>Tokens: {tokenCount}</TokenCount>
<Button type="primary" onClick={() => setShowMarkdown((prev) => !prev)}>
{t(showMarkdown ? 'common.edit' : 'common.save')}
<Button
type="primary"
icon={showPreview ? <Edit size={14} /> : <Eye size={14} />}
onClick={() => setShowPreview((prev) => !prev)}>
{showPreview ? t('common.edit') : t('common.preview')}
</Button>
</HSpaceBetweenStack>
</Container>
@@ -154,6 +165,10 @@ const Container = styled.div`
flex: 1;
flex-direction: column;
overflow: hidden;
.ant-btn {
line-height: 0;
}
`
const EmojiButtonWrapper = styled.div`
@@ -1,4 +1,5 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import Selector from '@renderer/components/Selector'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useEnableDeveloperMode, useSettings } from '@renderer/hooks/useSettings'
@@ -199,37 +200,6 @@ const GeneralSettings: FC = () => {
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.spell_check.label')}</SettingRowTitle>
<Switch checked={enableSpellCheck} onChange={handleSpellCheckChange} />
</SettingRow>
{enableSpellCheck && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.spell_check.languages')}</SettingRowTitle>
<Selector<string>
size={14}
multiple
value={spellCheckLanguages}
placeholder={t('settings.general.spell_check.languages')}
onChange={handleSpellCheckLanguagesChange}
options={spellCheckLanguageOptions.map((lang) => ({
value: lang.value,
label: (
<Flex align="center" gap={8}>
<span role="img" aria-label={lang.flag}>
{lang.flag}
</span>
{lang.label}
</Flex>
)
}))}
/>
</SettingRow>
</>
)}
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.proxy.mode.title')}</SettingRowTitle>
<Selector value={storeProxyMode} onChange={onProxyModeChange} options={proxyModeOptions} />
@@ -251,6 +221,33 @@ const GeneralSettings: FC = () => {
</>
)}
<SettingDivider />
<SettingRow>
<HStack justifyContent="space-between" alignItems="center" style={{ flex: 1, marginRight: 16 }}>
<SettingRowTitle>{t('settings.general.spell_check.label')}</SettingRowTitle>
{enableSpellCheck && (
<Selector<string>
size={14}
multiple
value={spellCheckLanguages}
placeholder={t('settings.general.spell_check.languages')}
onChange={handleSpellCheckLanguagesChange}
options={spellCheckLanguageOptions.map((lang) => ({
value: lang.value,
label: (
<Flex align="center" gap={8}>
<span role="img" aria-label={lang.flag}>
{lang.flag}
</span>
{lang.label}
</Flex>
)
}))}
/>
)}
</HStack>
<Switch checked={enableSpellCheck} onChange={handleSpellCheckChange} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.hardware_acceleration.title')}</SettingRowTitle>
<Switch checked={disableHardwareAcceleration} onChange={handleHardwareAccelerationChange} />
@@ -1,7 +1,6 @@
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { loggerService } from '@logger'
import Scrollbar from '@renderer/components/Scrollbar'
import { DraggableVirtualList } from '@renderer/components/DraggableList'
import { getProviderLogo } from '@renderer/config/providers'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
import { getProviderLabel } from '@renderer/i18n/label'
@@ -9,7 +8,6 @@ import ImageStorage from '@renderer/services/ImageStorage'
import { INITIAL_PROVIDERS } from '@renderer/store/llm'
import { Provider, ProviderType } from '@renderer/types'
import {
droppableReorder,
generateColorFromChar,
getFancyProviderName,
getFirstCharacter,
@@ -30,6 +28,8 @@ import ProviderSetting from './ProviderSetting'
const logger = loggerService.withContext('ProvidersList')
const BUTTON_WRAPPER_HEIGHT = 50
const ProvidersList: FC = () => {
const [searchParams] = useSearchParams()
const providers = useAllProviders()
@@ -272,14 +272,9 @@ const ProvidersList: FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
const onDragEnd = (result: DropResult) => {
const handleUpdateProviders = (reorderProviders: Provider[]) => {
setDragging(false)
if (result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
const reorderProviders = droppableReorder<Provider>(providers, sourceIndex, destIndex)
updateProviders(reorderProviders)
}
updateProviders(reorderProviders)
}
const onAddProvider = async () => {
@@ -462,50 +457,37 @@ const ProvidersList: FC = () => {
disabled={dragging}
/>
</AddButtonWrapper>
<Scrollbar>
<ProviderList>
<DragDropContext onDragStart={() => setDragging(true)} onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
{filteredProviders.map((provider, index) => (
<Draggable
key={`draggable_${provider.id}_${index}`}
draggableId={provider.id}
index={index}
isDragDisabled={searchText.length > 0}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{ ...provided.draggableProps.style, marginBottom: 5 }}>
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}>
<ProviderListItem
key={JSON.stringify(provider)}
className={provider.id === selectedProvider?.id ? 'active' : ''}
onClick={() => setSelectedProvider(provider)}>
{getProviderAvatar(provider)}
<ProviderItemName className="text-nowrap">
{getFancyProviderName(provider)}
</ProviderItemName>
{provider.enabled && (
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
ON
</Tag>
)}
</ProviderListItem>
</Dropdown>
</div>
)}
</Draggable>
))}
</div>
<DraggableVirtualList
list={filteredProviders}
onUpdate={handleUpdateProviders}
onDragStart={() => setDragging(true)}
estimateSize={useCallback(() => 40, [])}
overscan={3}
style={{
height: `calc(100% - 2 * ${BUTTON_WRAPPER_HEIGHT}px)`
}}
scrollerStyle={{
padding: 8,
paddingRight: 5
}}
itemContainerStyle={{ paddingBottom: 5 }}>
{(provider) => (
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}>
<ProviderListItem
key={JSON.stringify(provider)}
className={provider.id === selectedProvider?.id ? 'active' : ''}
onClick={() => setSelectedProvider(provider)}>
{getProviderAvatar(provider)}
<ProviderItemName className="text-nowrap">{getFancyProviderName(provider)}</ProviderItemName>
{provider.enabled && (
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
ON
</Tag>
)}
</Droppable>
</DragDropContext>
</ProviderList>
</Scrollbar>
</ProviderListItem>
</Dropdown>
)}
</DraggableVirtualList>
<AddButtonWrapper>
<Button
style={{ width: '100%', borderRadius: 'var(--list-item-border-radius)' }}
@@ -536,14 +518,6 @@ const ProviderListContainer = styled.div`
border-right: 0.5px solid var(--color-border);
`
const ProviderList = styled.div`
display: flex;
flex: 1;
flex-direction: column;
padding: 8px;
padding-right: 5px;
`
const ProviderListItem = styled.div`
display: flex;
flex-direction: row;
@@ -575,7 +549,7 @@ const ProviderItemName = styled.div`
`
const AddButtonWrapper = styled.div`
height: 50px;
height: ${BUTTON_WRAPPER_HEIGHT}px;
flex-direction: row;
justify-content: center;
align-items: center;
@@ -8,8 +8,8 @@ import {
Info,
MonitorCog,
Package,
PencilRuler,
Rocket,
Server,
Settings2,
SquareTerminal,
TextCursorInput,
@@ -21,7 +21,6 @@ import { Link, Route, Routes, useLocation } from 'react-router-dom'
import styled from 'styled-components'
import AboutSettings from './AboutSettings'
import { ApiServerSettings } from './ApiServerSettings'
import DataSettings from './DataSettings/DataSettings'
import DisplaySettings from './DisplaySettings/DisplaySettings'
import GeneralSettings from './GeneralSettings'
@@ -77,18 +76,18 @@ const SettingsPage: FC = () => {
{t('settings.mcp.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/api-server">
<MenuItem className={isRoute('/settings/api-server')}>
<Server size={18} />
{t('apiServer.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/memory">
<MenuItem className={isRoute('/settings/memory')}>
<Brain size={18} />
{t('memory.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/tool">
<MenuItem className={isRoute('/settings/tool')}>
<PencilRuler size={18} />
{t('settings.tool.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/shortcut">
<MenuItem className={isRoute('/settings/shortcut')}>
<Command size={18} />
@@ -133,7 +132,6 @@ const SettingsPage: FC = () => {
<Route path="tool/*" element={<ToolSettings />} />
<Route path="mcp/*" element={<MCPSettings />} />
<Route path="memory" element={<MemorySettings />} />
<Route path="api-server" element={<ApiServerSettings />} />
<Route path="general/*" element={<GeneralSettings />} />
<Route path="display" element={<DisplaySettings />} />
<Route path="shortcut" element={<ShortcutSettings />} />
@@ -1,168 +0,0 @@
import { ExportOutlined } from '@ant-design/icons'
import { getOcrProviderLogo, OCR_PROVIDER_CONFIG } from '@renderer/config/ocrProviders'
import { useOcrProvider } from '@renderer/hooks/useOcr'
import { OcrProvider } from '@renderer/types'
import { formatApiKeys, hasObjectKey } from '@renderer/utils'
import { Avatar, Divider, Flex, Input, InputNumber, Segmented } from 'antd'
import Link from 'antd/es/typography/Link'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import {
SettingDivider,
SettingHelpLink,
SettingHelpText,
SettingHelpTextRow,
SettingRow,
SettingRowTitle,
SettingSubtitle,
SettingTitle
} from '../..'
interface Props {
provider: OcrProvider
}
const OcrProviderSettings: FC<Props> = ({ provider: _provider }) => {
const { provider: ocrProvider, updateOcrProvider } = useOcrProvider(_provider.id)
const { t } = useTranslation()
const [apiKey, setApiKey] = useState(ocrProvider.apiKey || '')
const [apiHost, setApiHost] = useState(ocrProvider.apiHost || '')
const [options, setOptions] = useState(ocrProvider.options || {})
const ocrProviderConfig = OCR_PROVIDER_CONFIG[ocrProvider.id]
const apiKeyWebsite = ocrProviderConfig?.websites?.apiKey
const officialWebsite = ocrProviderConfig?.websites?.official
useEffect(() => {
setApiKey(ocrProvider.apiKey ?? '')
setApiHost(ocrProvider.apiHost ?? '')
setOptions(ocrProvider.options ?? {})
}, [ocrProvider.apiKey, ocrProvider.apiHost, ocrProvider.options])
const onUpdateApiKey = () => {
if (apiKey !== ocrProvider.apiKey) {
updateOcrProvider({ ...ocrProvider, apiKey })
}
}
const onUpdateApiHost = () => {
let trimmedHost = apiHost?.trim() || ''
if (trimmedHost.endsWith('/')) {
trimmedHost = trimmedHost.slice(0, -1)
}
if (trimmedHost !== ocrProvider.apiHost) {
updateOcrProvider({ ...ocrProvider, apiHost: trimmedHost })
} else {
setApiHost(ocrProvider.apiHost || '')
}
}
const onUpdateOptions = (key: string, value: any) => {
const newOptions = { ...options, [key]: value }
setOptions(newOptions)
updateOcrProvider({ ...ocrProvider, options: newOptions })
}
return (
<>
<SettingTitle>
<Flex align="center" gap={8}>
<ProviderLogo shape="square" src={getOcrProviderLogo(ocrProvider.id)} size={16} />
<ProviderName> {ocrProvider.name}</ProviderName>
{officialWebsite && ocrProviderConfig?.websites && (
<Link target="_blank" href={ocrProviderConfig.websites.official}>
<ExportOutlined style={{ color: 'var(--color-text)', fontSize: '12px' }} />
</Link>
)}
</Flex>
</SettingTitle>
<Divider style={{ width: '100%', margin: '10px 0' }} />
{hasObjectKey(ocrProvider, 'apiKey') && (
<>
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
{t('settings.provider.api_key.label')}
</SettingSubtitle>
<Flex gap={8}>
<Input.Password
value={apiKey}
placeholder={t('settings.provider.api_key.label')}
onChange={(e) => setApiKey(formatApiKeys(e.target.value))}
onBlur={onUpdateApiKey}
spellCheck={false}
type="password"
autoFocus={apiKey === ''}
/>
</Flex>
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: 5 }}>
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>
</>
)}
{hasObjectKey(ocrProvider, 'apiHost') && (
<>
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
{t('settings.provider.api_host')}
</SettingSubtitle>
<Flex>
<Input
value={apiHost}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost}
/>
</Flex>
</>
)}
{hasObjectKey(ocrProvider, 'options') && ocrProvider.id === 'system' && (
<>
<SettingRow>
<SettingRowTitle>{t('settings.tool.ocr.mac_system_ocr_options.mode.title')}</SettingRowTitle>
<Segmented
options={[
{
label: t('settings.tool.ocr.mac_system_ocr_options.mode.accurate'),
value: 1
},
{
label: t('settings.tool.ocr.mac_system_ocr_options.mode.fast'),
value: 0
}
]}
value={options.recognitionLevel}
onChange={(value) => onUpdateOptions('recognitionLevel', value)}
/>
</SettingRow>
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
<SettingRow>
<SettingRowTitle>{t('settings.tool.ocr.mac_system_ocr_options.min_confidence')}</SettingRowTitle>
<InputNumber
value={options.minConfidence}
onChange={(value) => onUpdateOptions('minConfidence', value)}
min={0}
max={1}
step={0.1}
/>
</SettingRow>
</>
)}
</>
)
}
const ProviderName = styled.span`
font-size: 14px;
font-weight: 500;
`
const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border);
`
export default OcrProviderSettings
@@ -1,58 +0,0 @@
import { isMac } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useDefaultOcrProvider, useOcrProviders } from '@renderer/hooks/useOcr'
import { PreprocessProvider } from '@renderer/types'
import { Select } from 'antd'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
import OcrProviderSettings from './OcrSettings'
const OcrSettings: FC = () => {
const { ocrProviders } = useOcrProviders()
const { provider: defaultProvider, setDefaultOcrProvider } = useDefaultOcrProvider()
const { t } = useTranslation()
const [selectedProvider, setSelectedProvider] = useState<PreprocessProvider | undefined>(defaultProvider)
const { theme: themeMode } = useTheme()
function updateSelectedOcrProvider(providerId: string) {
const provider = ocrProviders.find((p) => p.id === providerId)
if (!provider) {
return
}
setDefaultOcrProvider(provider)
setSelectedProvider(provider)
}
return (
<SettingContainer theme={themeMode}>
<SettingGroup theme={themeMode}>
<SettingTitle>{t('settings.tool.ocr.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.tool.ocr.provider')}</SettingRowTitle>
<div style={{ display: 'flex', gap: '8px' }}>
<Select
value={selectedProvider?.id}
style={{ width: '200px' }}
onChange={(value: string) => updateSelectedOcrProvider(value)}
placeholder={t('settings.tool.ocr.provider_placeholder')}
options={ocrProviders.map((p) => ({
value: p.id,
label: p.name,
disabled: !isMac && p.id === 'system' // 在非 Mac 系统下禁用 system 选项
}))}
/>
</div>
</SettingRow>
</SettingGroup>
{selectedProvider && (
<SettingGroup theme={themeMode}>
<OcrProviderSettings provider={selectedProvider} />
</SettingGroup>
)}
</SettingContainer>
)
}
export default OcrSettings
@@ -1,5 +1,4 @@
import { GlobalOutlined } from '@ant-design/icons'
import OcrIcon from '@renderer/components/Icons/OcrIcon'
import { HStack } from '@renderer/components/Layout'
import ListItem from '@renderer/components/ListItem'
import { FileCode } from 'lucide-react'
@@ -7,18 +6,21 @@ import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import OcrSettings from './OcrSettings'
import PreprocessSettings from './PreprocessSettings'
import WebSearchSettings from './WebSearchSettings'
let _menu: string = 'web-search'
const ToolSettings: FC = () => {
const { t } = useTranslation()
const [menu, setMenu] = useState<string>('web-search')
const [menu, setMenu] = useState<string>(_menu)
const menuItems = [
{ key: 'web-search', title: 'settings.tool.websearch.title', icon: <GlobalOutlined style={{ fontSize: 16 }} /> },
{ key: 'preprocess', title: 'settings.tool.preprocess.title', icon: <FileCode size={16} /> },
{ key: 'ocr', title: 'settings.tool.ocr.title', icon: <OcrIcon /> }
{ key: 'preprocess', title: 'settings.tool.preprocess.title', icon: <FileCode size={16} /> }
]
_menu = menu
return (
<Container>
<MenuList>
@@ -35,7 +37,6 @@ const ToolSettings: FC = () => {
</MenuList>
{menu == 'web-search' && <WebSearchSettings />}
{menu == 'preprocess' && <PreprocessSettings />}
{menu == 'ocr' && <OcrSettings />}
</Container>
)
}
+1 -1
View File
@@ -195,7 +195,7 @@ class KnowledgeQueue {
updateBaseItemIsPreprocessed({
baseId,
itemId: item.id,
isPreprocessed: !!base.preprocessOrOcrProvider
isPreprocessed: !!base.preprocessProvider
})
)
}
+17 -4
View File
@@ -41,7 +41,12 @@ import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import { isAbortError } from '@renderer/utils/error'
import { extractInfoFromXML, ExtractResults } from '@renderer/utils/extract'
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { buildSystemPromptWithThinkTool, buildSystemPromptWithTools } from '@renderer/utils/prompt'
import {
buildSystemPromptWithThinkTool,
buildSystemPromptWithTools,
containsSupportedVariables,
replacePromptVariables
} from '@renderer/utils/prompt'
import { findLast, isEmpty, takeRight } from 'lodash'
import AiProvider from '../aiCore'
@@ -375,8 +380,8 @@ async function fetchExternalTool(
.map((result) => result.value)
.flat()
// 添加内置工具
const { BUILT_IN_TOOLS } = await import('../tools')
mcpTools.push(...BUILT_IN_TOOLS)
// const { BUILT_IN_TOOLS } = await import('../tools')
// mcpTools.push(...BUILT_IN_TOOLS)
// 根据toolUseMode决定如何构建系统提示词
const basePrompt = assistant.prompt
@@ -426,6 +431,10 @@ export async function fetchChatCompletion({
}) {
logger.debug('fetchChatCompletion', messages, assistant)
if (assistant.prompt && containsSupportedVariables(assistant.prompt)) {
assistant.prompt = await replacePromptVariables(assistant.prompt, assistant.model?.name)
}
const provider = getAssistantProvider(assistant)
const AI = new AiProvider(provider)
@@ -643,9 +652,13 @@ export async function fetchTranslate({ content, assistant, onResponse }: FetchTr
}
export async function fetchMessagesSummary({ messages, assistant }: { messages: Message[]; assistant: Assistant }) {
const prompt = (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
let prompt = (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
const model = getTopNamingModel() || assistant.model || getDefaultModel()
if (prompt && containsSupportedVariables(prompt)) {
prompt = await replacePromptVariables(prompt, model.name)
}
// 总结上下文总是取最后5条消息
const contextMessages = takeRight(messages, 5)
@@ -57,7 +57,7 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
apiKey: rerankAiProvider.getApiKey() || 'secret',
baseURL: rerankHost
},
preprocessOrOcrProvider: base.preprocessOrOcrProvider,
preprocessProvider: base.preprocessProvider,
documentCount: base.documentCount
}
}
+10 -4
View File
@@ -1,4 +1,5 @@
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import { loggerService } from '@renderer/services/LoggerService'
import { useDispatch, useSelector, useStore } from 'react-redux'
import { FLUSH, PAUSE, PERSIST, persistReducer, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist'
import storage from 'redux-persist/lib/storage'
@@ -18,7 +19,6 @@ import migrate from './migrate'
import minapps from './minapps'
import newMessagesReducer from './newMessage'
import nutstore from './nutstore'
import ocr from './ocr'
import paintings from './paintings'
import preprocess from './preprocess'
import runtime from './runtime'
@@ -29,6 +29,8 @@ import tabs from './tabs'
import translate from './translate'
import websearch from './websearch'
const logger = loggerService.withContext('Store')
const rootReducer = combineReducers({
assistants,
agents,
@@ -38,7 +40,6 @@ const rootReducer = combineReducers({
llm,
settings,
runtime,
ocr,
shortcuts,
knowledge,
minapps,
@@ -48,7 +49,6 @@ const rootReducer = combineReducers({
copilot,
selectionStore,
tabs,
// messages: messagesReducer,
preprocess,
messages: newMessagesReducer,
messageBlocks: messageBlocksReducer,
@@ -60,7 +60,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 126,
version: 127,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},
@@ -104,4 +104,10 @@ export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<typeof store>()
window.store = store
export async function handleSaveData() {
logger.info('Flushing redux persistor data')
await persistor.flush()
logger.info('Flushed redux persistor data')
}
export default store
+23
View File
@@ -1938,6 +1938,28 @@ const migrateConfig = {
}
},
'126': (state: RootState) => {
try {
state.knowledge.bases.forEach((base) => {
// @ts-ignore eslint-disable-next-line
if (base.preprocessOrOcrProvider) {
// @ts-ignore eslint-disable-next-line
base.preprocessProvider = base.preprocessOrOcrProvider
// @ts-ignore eslint-disable-next-line
delete base.preprocessOrOcrProvider
// @ts-ignore eslint-disable-next-line
if (base.preprocessProvider.type === 'ocr') {
// @ts-ignore eslint-disable-next-line
delete base.preprocessProvider
}
}
})
return state
} catch (error) {
logger.error('migrate 126 error', error as Error)
return state
}
},
'127': (state: RootState) => {
try {
const visibleIcons = state.settings.sidebarIcons.visible
if (visibleIcons.includes('discover')) {
@@ -1955,6 +1977,7 @@ const migrateConfig = {
}
}
} catch (error) {
logger.error('migrate 127 error', error as Error)
return state
}
}
-46
View File
@@ -1,46 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { OcrProvider } from '@renderer/types'
export interface OcrState {
providers: OcrProvider[]
defaultProvider: string
}
const initialState: OcrState = {
providers: [
{
id: 'system',
name: 'System(Mac Only)',
options: {
recognitionLevel: 0,
minConfidence: 0.5
}
}
],
defaultProvider: ''
}
const ocrSlice = createSlice({
name: 'ocr',
initialState,
reducers: {
setDefaultOcrProvider(state, action: PayloadAction<string>) {
state.defaultProvider = action.payload
},
setOcrProviders(state, action: PayloadAction<OcrProvider[]>) {
state.providers = action.payload
},
updateOcrProviders(state, action: PayloadAction<OcrProvider[]>) {
state.providers = action.payload
},
updateOcrProvider(state, action: PayloadAction<OcrProvider>) {
const index = state.providers.findIndex((provider) => provider.id === action.payload.id)
if (index !== -1) {
state.providers[index] = action.payload
}
}
}
})
export const { updateOcrProviders, updateOcrProvider, setDefaultOcrProvider, setOcrProviders } = ocrSlice.actions
export default ocrSlice.reducer
@@ -17,7 +17,6 @@ import {
createTranslationBlock,
resetAssistantMessage
} from '@renderer/utils/messageUtils/create'
import { buildSystemPrompt } from '@renderer/utils/prompt'
import { getTopicQueue } from '@renderer/utils/queue'
import { waitForTopicQueue } from '@renderer/utils/queue'
import { t } from 'i18next'
@@ -878,8 +877,6 @@ const fetchAndProcessAssistantResponseImpl = async (
// }
// }
assistant.prompt = await buildSystemPrompt(assistant.prompt || '', assistant)
callbacks = createCallbacks({
blockManager,
dispatch,
+6 -16
View File
@@ -444,9 +444,9 @@ export interface KnowledgeBase {
rerankModel?: Model
// topN?: number
// preprocessing?: boolean
preprocessOrOcrProvider?: {
type: 'preprocess' | 'ocr'
provider: PreprocessProvider | OcrProvider
preprocessProvider?: {
type: 'preprocess'
provider: PreprocessProvider
}
}
@@ -467,9 +467,9 @@ export type KnowledgeBaseParams = {
rerankApiClient?: ApiClient
documentCount?: number
// preprocessing?: boolean
preprocessOrOcrProvider?: {
type: 'preprocess' | 'ocr'
provider: PreprocessProvider | OcrProvider
preprocessProvider?: {
type: 'preprocess'
provider: PreprocessProvider
}
}
@@ -483,16 +483,6 @@ export interface PreprocessProvider {
quota?: number
}
export interface OcrProvider {
id: string
name: string
apiKey?: string
apiHost?: string
model?: string
options?: any
quota?: number
}
export type GenerateImageParams = {
model: string
prompt: string
@@ -4,9 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
AvailableTools,
buildSystemPrompt,
buildSystemPromptWithThinkTool,
buildSystemPromptWithTools,
replacePromptVariables,
SYSTEM_PROMPT,
THINK_TOOL_PROMPT,
ToolUseExamples
@@ -130,7 +130,7 @@ describe('prompt', () => {
- : {{username}};
`
const assistant = createMockAssistant('MyAssistant', 'Super-Model-X')
const result = await buildSystemPrompt(userPrompt, assistant)
const result = await replacePromptVariables(userPrompt, assistant.model?.name)
const expectedPrompt = `
:
- 日期和时间: ${mockDate.toLocaleString()};
@@ -148,13 +148,13 @@ describe('prompt', () => {
mockApi.getAppInfo.mockRejectedValue(new Error('API Error'))
const userPrompt = 'System: {{system}}, Architecture: {{arch}}'
const result = await buildSystemPrompt(userPrompt)
const result = await replacePromptVariables(userPrompt)
const expectedPrompt = 'System: Unknown System, Architecture: Unknown Architecture'
expect(result).toEqual(expectedPrompt)
})
it('should handle non-string input gracefully', async () => {
const result = await buildSystemPrompt(null as any)
const result = await replacePromptVariables(null as any)
expect(result).toBe(null)
})
})
@@ -173,7 +173,7 @@ describe('prompt', () => {
Instructions: Be helpful.
`
const assistant = createMockAssistant('Test Assistant', 'Advanced-AI-Model')
basePrompt = await buildSystemPrompt(initialPrompt, assistant)
basePrompt = await replacePromptVariables(initialPrompt, assistant.model?.name)
expectedBasePrompt = `
System Information:
- Date: ${mockDate.toLocaleDateString()}
@@ -200,7 +200,7 @@ describe('prompt', () => {
expect(finalPrompt).toEqual(expectedFinalPrompt)
expect(finalPrompt).not.toContain('## Tool Use Formatting')
expect(finalPrompt).toContain('## Using the think tool')
// expect(finalPrompt).toContain('## Using the think tool')
})
it('should return the original prompt if no tools are provided to buildSystemPromptWithTools', () => {
@@ -212,7 +212,7 @@ describe('prompt', () => {
describe('buildSystemPromptWithTools', () => {
it('should build a full prompt for "prompt" toolUseMode', async () => {
const assistant = createMockAssistant('Test Assistant', 'Advanced-AI-Model')
const basePrompt = await buildSystemPrompt('Be helpful.', assistant)
const basePrompt = await replacePromptVariables('Be helpful.', assistant.model?.name)
const tools = [createMockTool('web_search', 'Search the web')]
const finalPrompt = buildSystemPromptWithTools(basePrompt, tools)
@@ -236,7 +236,7 @@ describe('prompt', () => {
Instructions: Be helpful.
`
const assistant = createMockAssistant('Test Assistant', 'Advanced-AI-Model')
const basePrompt = await buildSystemPrompt(initialPrompt, assistant)
const basePrompt = await replacePromptVariables(initialPrompt, assistant.model?.name)
const expectedBasePrompt = `
System Information:
- Date: ${mockDate.toLocaleDateString()}
@@ -252,7 +252,7 @@ describe('prompt', () => {
// 3. 验证结果
expect(finalPrompt).toEqual(expectedFinalPrompt)
expect(finalPrompt).not.toContain('## Tool Use Formatting') // 验证不包含工具定义
expect(finalPrompt).toContain('## Using the think tool') // 验证包含思考指令
// expect(finalPrompt).toContain('## Using the think tool') // 验证包含思考指令
})
})
})
+78 -102
View File
@@ -1,6 +1,6 @@
import { loggerService } from '@logger'
import store from '@renderer/store'
import { Assistant, MCPTool } from '@renderer/types'
import { MCPTool } from '@renderer/types'
const logger = loggerService.withContext('Utils:Prompt')
@@ -60,50 +60,7 @@ Response in user query language.
Now Begin! If you solve the task correctly, you will receive a reward of $1,000,000.
`
export const THINK_TOOL_PROMPT = `{{ USER_SYSTEM_PROMPT }}
## Using the think tool
Before taking any action or responding to the user after receiving tool results, use the think tool as a scratchpad to:
- List the specific rules that apply to the current request
- Check if all required information is collected
- Verify that the planned action complies with all policies
- Iterate over tool results for correctness
- Response in user query language
Here are some examples of what to iterate over inside the think tool:
<think_tool_example_1>
User wants to cancel flight ABC123
- Need to verify: user ID, reservation ID, reason
- Check cancellation rules:
* Is it within 24h of booking?
* If not, check ticket class and insurance
- Verify no segments flown or are in the past
- Plan: collect missing info, verify rules, get confirmation
</think_tool_example_1>
<think_tool_example_2>
User wants to book 3 tickets to NYC with 2 checked bags each
- Need user ID to check:
* Membership tier for baggage allowance
* Which payments methods exist in profile
- Baggage calculation:
* Economy class × 3 passengers
* If regular member: 1 free bag each 3 extra bags = $150
* If silver member: 2 free bags each 0 extra bags = $0
* If gold member: 3 free bags each 0 extra bags = $0
- Payment rules to verify:
* Max 1 travel certificate, 1 credit card, 3 gift cards
* All payment methods must be in profile
* Travel certificate remainder goes to waste
- Plan:
1. Get user ID
2. Verify membership level for bag fees
3. Check which payment methods in profile and if their combination is allowed
4. Calculate total: ticket price + any bag fees
5. Get explicit confirmation for booking
</think_tool_example_2>
`
export const THINK_TOOL_PROMPT = `{{ USER_SYSTEM_PROMPT }}`
export const ToolUseExamples = `
Here are a few examples using notional tools:
@@ -196,71 +153,90 @@ ${availableTools}
</tools>`
}
export const buildSystemPrompt = async (userSystemPrompt: string, assistant?: Assistant): Promise<string> => {
if (typeof userSystemPrompt === 'string') {
const now = new Date()
if (userSystemPrompt.includes('{{date}}')) {
const date = now.toLocaleDateString()
userSystemPrompt = userSystemPrompt.replace(/{{date}}/g, date)
}
const supportedVariables = [
'{{username}}',
'{{date}}',
'{{time}}',
'{{datetime}}',
'{{system}}',
'{{language}}',
'{{arch}}',
'{{model_name}}'
]
if (userSystemPrompt.includes('{{time}}')) {
const time = now.toLocaleTimeString()
userSystemPrompt = userSystemPrompt.replace(/{{time}}/g, time)
}
export const containsSupportedVariables = (userSystemPrompt: string): boolean => {
return supportedVariables.some((variable) => userSystemPrompt.includes(variable))
}
if (userSystemPrompt.includes('{{datetime}}')) {
const datetime = now.toLocaleString()
userSystemPrompt = userSystemPrompt.replace(/{{datetime}}/g, datetime)
}
export const replacePromptVariables = async (userSystemPrompt: string, modelName?: string): Promise<string> => {
if (typeof userSystemPrompt !== 'string') {
logger.warn('User system prompt is not a string:', userSystemPrompt)
return userSystemPrompt
}
if (userSystemPrompt.includes('{{system}}')) {
try {
const systemType = await window.api.system.getDeviceType()
userSystemPrompt = userSystemPrompt.replace(/{{system}}/g, systemType)
} catch (error) {
logger.error('Failed to get system type:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{system}}/g, 'Unknown System')
}
}
const now = new Date()
if (userSystemPrompt.includes('{{date}}')) {
const date = now.toLocaleDateString()
userSystemPrompt = userSystemPrompt.replace(/{{date}}/g, date)
}
if (userSystemPrompt.includes('{{language}}')) {
try {
const language = store.getState().settings.language
userSystemPrompt = userSystemPrompt.replace(/{{language}}/g, language)
} catch (error) {
logger.error('Failed to get language:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{language}}/g, 'Unknown System Language')
}
}
if (userSystemPrompt.includes('{{time}}')) {
const time = now.toLocaleTimeString()
userSystemPrompt = userSystemPrompt.replace(/{{time}}/g, time)
}
if (userSystemPrompt.includes('{{arch}}')) {
try {
const appInfo = await window.api.getAppInfo()
userSystemPrompt = userSystemPrompt.replace(/{{arch}}/g, appInfo.arch)
} catch (error) {
logger.error('Failed to get architecture:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{arch}}/g, 'Unknown Architecture')
}
}
if (userSystemPrompt.includes('{{datetime}}')) {
const datetime = now.toLocaleString()
userSystemPrompt = userSystemPrompt.replace(/{{datetime}}/g, datetime)
}
if (userSystemPrompt.includes('{{model_name}}')) {
try {
userSystemPrompt = userSystemPrompt.replace(/{{model_name}}/g, assistant?.model?.name || 'Unknown Model')
} catch (error) {
logger.error('Failed to get model name:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{model_name}}/g, 'Unknown Model')
}
if (userSystemPrompt.includes('{{username}}')) {
try {
const userName = store.getState().settings.userName || 'Unknown Username'
userSystemPrompt = userSystemPrompt.replace(/{{username}}/g, userName)
} catch (error) {
logger.error('Failed to get username:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{username}}/g, 'Unknown Username')
}
}
if (userSystemPrompt.includes('{{username}}')) {
try {
const username = store.getState().settings.userName || 'Unknown Username'
userSystemPrompt = userSystemPrompt.replace(/{{username}}/g, username)
} catch (error) {
logger.error('Failed to get username:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{username}}/g, 'Unknown Username')
}
if (userSystemPrompt.includes('{{system}}')) {
try {
const systemType = await window.api.system.getDeviceType()
userSystemPrompt = userSystemPrompt.replace(/{{system}}/g, systemType)
} catch (error) {
logger.error('Failed to get system type:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{system}}/g, 'Unknown System')
}
}
if (userSystemPrompt.includes('{{language}}')) {
try {
const language = store.getState().settings.language
userSystemPrompt = userSystemPrompt.replace(/{{language}}/g, language)
} catch (error) {
logger.error('Failed to get language:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{language}}/g, 'Unknown System Language')
}
}
if (userSystemPrompt.includes('{{arch}}')) {
try {
const appInfo = await window.api.getAppInfo()
userSystemPrompt = userSystemPrompt.replace(/{{arch}}/g, appInfo.arch)
} catch (error) {
logger.error('Failed to get architecture:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{arch}}/g, 'Unknown Architecture')
}
}
if (userSystemPrompt.includes('{{model_name}}')) {
try {
const name = modelName || store.getState().llm.defaultModel?.name
userSystemPrompt = userSystemPrompt.replace(/{{model_name}}/g, name)
} catch (error) {
logger.error('Failed to get model name:', error as Error)
userSystemPrompt = userSystemPrompt.replace(/{{model_name}}/g, 'Unknown Model')
}
}