Merge remote-tracking branch 'origin/main' into feat/cherry-store
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
+58
@@ -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'
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3325,7 +3325,7 @@
|
||||
"provider_placeholder": "Επιλέξτε έναν πάροχο προεπεξεργασίας εγγράφων",
|
||||
"title": "Προεπεξεργασία Εγγράφων"
|
||||
},
|
||||
"preprocessOrOcr": {
|
||||
"preprocess": {
|
||||
"tooltip": "Ορίστε πάροχο προεπεξεργασίας εγγράφων ή OCR στις Ρυθμίσεις -> Εργαλεία. Η προεπεξεργασία εγγράφων μπορεί να βελτιώσει σημαντικά την απόδοση αναζήτησης για έγγραφα πολύπλοκης μορφής ή εγγράφων σε μορφή σάρωσης. Το OCR μπορεί να αναγνωρίσει μόνο κείμενο μέσα σε εικόνες εγγράφων ή σε PDF σε μορφή σάρωσης."
|
||||
},
|
||||
"title": "Ρυθμίσεις Εργαλείων",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+37
-39
@@ -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"
|
||||
>
|
||||
|
||||
+17
-17
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ class KnowledgeQueue {
|
||||
updateBaseItemIsPreprocessed({
|
||||
baseId,
|
||||
itemId: item.id,
|
||||
isPreprocessed: !!base.preprocessOrOcrProvider
|
||||
isPreprocessed: !!base.preprocessProvider
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') // 验证包含思考指令
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user