refactor(Knowledge): simplify dimension settings, support base migration (#8315)

* refactor(knowledge): simplify dimension settings, support base migration

Embedding dimension
- remove 'auto set dimension', let the user take control
- reuse findModelById
- improve error messages for VoyageEmbeddings

Knowledgebase migration
- enable migration when model or dimension changes
- add knowledgeThunk to reuse code

KnowledgeSettings
- unify UI for AddKnowledgeBasePopup and EditKnowledgeBasePopup
- refactor KnowledgeSettings, split it to smaller components

Tests:
- knowledgeThunk
- InputEmbeddingDimension
- KnowledgeBaseFormModal
- GeneralSettingsPanel
- AdvancedSettingsPanel
- InfoTooltip

Misc.
- add InfoTooltip
- remove MemoriesSettingsModal

* fix: i18n and vitest config
This commit is contained in:
one 2025-07-26 10:54:06 +08:00 committed by GitHub
parent 640985a5e6
commit 08c5f82a04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 3385 additions and 1558 deletions

View File

@ -64,13 +64,14 @@ Never use flat structures like `"add.button.tip": "Add"`. Instead, adopt a clear
#### 1. **Plugin Cannot Track Dynamic Keys**
Tools like i18n Ally cannot parse dynamic content within template strings, resulting in:
- No real-time preview
- No detection of missing translations
- No navigation to key definitions
```javascript
// Not recommended - Plugin cannot resolve
const message = t(`fruits.${fruit}`);
const message = t(`fruits.${fruit}`)
```
#### 2. **No Real-time Rendering in Editor**
@ -103,6 +104,7 @@ The project includes several scripts to automate i18n-related tasks:
### `check:i18n` - Validate i18n Structure
This script checks:
- Whether all language files use nested structure
- For missing or unused keys
- Whether keys are properly sorted

View File

@ -60,13 +60,14 @@ i18n ally是一个强大的VSCode插件它能在开发阶段提供实时反
1. **插件无法跟踪**
i18n ally等工具无法解析模板字符串中的动态内容导致
- 无法正确显示实时预览
- 无法检测翻译缺失
- 无法提供跳转到定义的功能
```javascript
// 不推荐 - 插件无法解析
const message = t(`fruits.${fruit}`);
const message = t(`fruits.${fruit}`)
```
2. **编辑器无法实时渲染**
@ -97,6 +98,7 @@ const label = fruitLabels[fruit]
### `check:i18n` - 检查i18n结构
此脚本会检查:
- 所有语言文件是否为嵌套结构
- 是否存在缺失的key
- 是否存在多余的key

View File

@ -136,6 +136,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@tryfabric/martian": "^1.2.4",
"@types/cli-progress": "^3",
"@types/diff": "^7",

View File

@ -4,7 +4,6 @@ import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
import { ApiClient } from '@types'
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
import { VoyageEmbeddings } from './VoyageEmbeddings'
export default class EmbeddingsFactory {
@ -15,7 +14,7 @@ export default class EmbeddingsFactory {
return new VoyageEmbeddings({
modelName: model,
apiKey,
outputDimension: VOYAGE_SUPPORTED_DIM_MODELS.includes(model) ? dimensions : undefined,
outputDimension: dimensions,
batchSize: 8
})
}

View File

@ -1,10 +1,5 @@
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
import { loggerService } from '@logger'
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
const logger = loggerService.withContext('VoyageEmbeddings')
/**
*
@ -14,23 +9,24 @@ export class VoyageEmbeddings extends BaseEmbeddings {
constructor(private readonly configuration?: ConstructorParameters<typeof _VoyageEmbeddings>[0]) {
super()
if (!this.configuration) {
throw new Error('Pass in a configuration.')
throw new Error('Invalid configuration')
}
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
if (!VOYAGE_SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
logger.error(`VoyageEmbeddings only supports ${VOYAGE_SUPPORTED_DIM_MODELS.join(', ')} to set outputDimension.`)
this.model = new _VoyageEmbeddings({ ...this.configuration, outputDimension: undefined })
} else {
this.model = new _VoyageEmbeddings(this.configuration)
}
}
override async getDimensions(): Promise<number> {
return this.configuration?.outputDimension ?? (this.configuration?.modelName === 'voyage-code-2' ? 1536 : 1024)
}
override async embedDocuments(texts: string[]): Promise<number[][]> {
try {
return this.model.embedDocuments(texts)
} catch (error) {
throw new Error('Embedding documents failed - you may have hit the rate limit or there is an internal error', {
cause: error
})
}
}
override async embedQuery(text: string): Promise<number[]> {

View File

@ -1,45 +0,0 @@
export const VOYAGE_SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
// NOTE: 下面的暂时没用上,但先留着吧
export const OPENAI_SUPPORTED_DIM_MODELS = ['text-embedding-3-small', 'text-embedding-3-large']
export const DASHSCOPE_SUPPORTED_DIM_MODELS = ['text-embedding-v3', 'text-embedding-v4']
export const OPENSOURCE_SUPPORTED_DIM_MODELS = ['qwen3-embedding-0.6B', 'qwen3-embedding-4B', 'qwen3-embedding-8B']
export const GOOGLE_SUPPORTED_DIM_MODELS = ['gemini-embedding-exp-03-07', 'gemini-embedding-exp']
export const SUPPORTED_DIM_MODELS = [
...VOYAGE_SUPPORTED_DIM_MODELS,
...OPENAI_SUPPORTED_DIM_MODELS,
...DASHSCOPE_SUPPORTED_DIM_MODELS,
...OPENSOURCE_SUPPORTED_DIM_MODELS,
...GOOGLE_SUPPORTED_DIM_MODELS
]
/**
* ID
*
* - 'deepseek/deepseek-r1' => 'deepseek-r1'
* - 'deepseek-ai/deepseek/deepseek-r1' => 'deepseek-r1'
* @param {string} id ID
* @param {string} [delimiter='/'] '/'
* @returns {string}
*/
export const getBaseModelName = (id: string, delimiter: string = '/'): string => {
const parts = id.split(delimiter)
return parts[parts.length - 1]
}
/**
* ID
*
* - 'deepseek/DeepSeek-R1' => 'deepseek-r1'
* - 'deepseek-ai/deepseek/DeepSeek-R1' => 'deepseek-r1'
* @param {string} id ID
* @param {string} [delimiter='/'] '/'
* @returns {string}
*/
export const getLowerBaseModelName = (id: string, delimiter: string = '/'): string => {
return getBaseModelName(id, delimiter).toLowerCase()
}

View File

@ -0,0 +1,19 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { Tooltip, TooltipProps } from 'antd'
type InheritedTooltipProps = Omit<TooltipProps, 'children'>
interface InfoTooltipProps extends InheritedTooltipProps {
iconColor?: string
iconStyle?: React.CSSProperties
}
const InfoTooltip = ({ iconColor = 'var(--color-text-3)', iconStyle, ...rest }: InfoTooltipProps) => {
return (
<Tooltip {...rest}>
<InfoCircleOutlined style={{ color: iconColor, ...iconStyle }} role="img" aria-label="Information" />
</Tooltip>
)
}
export default InfoTooltip

View File

@ -0,0 +1,90 @@
import { loggerService } from '@logger'
import AiProvider from '@renderer/aiCore'
import { useProvider } from '@renderer/hooks/useProvider'
import { Model } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils'
import { Button, InputNumber, Space, Tooltip } from 'antd'
import { RefreshCw } from 'lucide-react'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('DimensionsInput')
interface InputEmbeddingDimensionProps {
value?: number | null
onChange?: (value: number | null) => void
model?: Model
disabled?: boolean
style?: React.CSSProperties
}
const InputEmbeddingDimension = ({
ref,
value,
onChange,
model,
disabled: _disabled,
style
}: InputEmbeddingDimensionProps & { ref?: React.RefObject<HTMLInputElement> | null }) => {
const { t } = useTranslation()
const { provider } = useProvider(model?.provider ?? '')
const [loading, setLoading] = useState(false)
const disabled = useMemo(() => _disabled || !model || !provider, [_disabled, model, provider])
const handleFetchDimension = useCallback(async () => {
if (!model) {
logger.warn('Failed to get embedding dimensions: no model')
window.message.error(t('knowledge.embedding_model_required'))
return
}
if (!provider) {
logger.warn('Failed to get embedding dimensions: no provider')
window.message.error(t('knowledge.provider_not_found'))
return
}
setLoading(true)
try {
const aiProvider = new AiProvider(provider)
const dimension = await aiProvider.getEmbeddingDimensions(model)
// for controlled input
if (ref?.current) {
ref.current.value = dimension.toString()
}
onChange?.(dimension)
} catch (error) {
logger.error(t('message.error.get_embedding_dimensions'), error as Error)
window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
} finally {
setLoading(false)
}
}, [model, provider, t, onChange, ref])
return (
<Space.Compact style={{ width: '100%', ...style }}>
<InputNumber
ref={ref}
min={1}
style={{ flex: 1 }}
placeholder={t('knowledge.dimensions_size_placeholder')}
value={value}
onChange={onChange}
disabled={disabled}
/>
<Tooltip title={t('knowledge.dimensions_auto_set')}>
<Button
role="button"
aria-label="Get embedding dimension"
icon={<RefreshCw size={16} />}
loading={loading}
disabled={disabled}
onClick={handleFetchDimension}
/>
</Tooltip>
</Space.Compact>
)
}
export default memo(InputEmbeddingDimension)

View File

@ -1,3 +1,4 @@
import { Typography } from 'antd'
import { ReactNode } from 'react'
import styled from 'styled-components'
@ -18,7 +19,9 @@ const ListItem = ({ active, icon, title, subtitle, titleStyle, onClick, rightCon
<ListItemContent>
{icon && <IconWrapper>{icon}</IconWrapper>}
<TextContainer>
<TitleText style={titleStyle}>{title}</TitleText>
<Typography.Text style={titleStyle} ellipsis={{ expanded: false, tooltip: title }}>
{title}
</Typography.Text>
{subtitle && <SubtitleText>{subtitle}</SubtitleText>}
</TextContainer>
{rightContent && <RightContentWrapper>{rightContent}</RightContentWrapper>}
@ -70,12 +73,6 @@ const TextContainer = styled.div`
overflow: hidden;
`
const TitleText = styled.div<{ $active?: boolean }>`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`
const SubtitleText = styled.div`
font-size: 10px;
color: var(--color-text-soft);

View File

@ -0,0 +1,24 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import InfoTooltip from '../InfoTooltip'
describe('InfoTooltip', () => {
it('should match snapshot', () => {
const { container } = render(
<InfoTooltip title="Test tooltip" placement="top" iconColor="#1890ff" iconStyle={{ fontSize: '16px' }} />
)
expect(container.firstChild).toMatchSnapshot()
})
it('should show tooltip on hover', async () => {
const tooltipText = 'This is helpful information'
render(<InfoTooltip title={tooltipText} />)
const icon = screen.getByRole('img', { name: 'Information' })
await userEvent.hover(icon)
expect(await screen.findByText(tooltipText)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,197 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import InputEmbeddingDimension from '../InputEmbeddingDimension'
const mocks = vi.hoisted(() => {
return {
aiCore: {
getEmbeddingDimensions: vi.fn()
},
i18n: {
t: (k: string) => {
const translations: Record<string, string> = {
'knowledge.embedding_model_required': '请选择嵌入模型',
'knowledge.provider_not_found': '找不到提供商',
'message.error.get_embedding_dimensions': '获取嵌入维度失败',
'knowledge.dimensions_size_placeholder': '请输入维度大小',
'knowledge.dimensions_auto_set': '自动设置维度'
}
return translations[k] || k
}
}
}
})
// Mock dependencies
vi.mock('@renderer/aiCore', () => ({
default: vi.fn().mockImplementation(() => ({
getEmbeddingDimensions: mocks.aiCore.getEmbeddingDimensions
}))
}))
vi.mock('@renderer/hooks/useProvider', () => ({
useProvider: () => ({ provider: { id: 'test-provider', name: 'Test Provider' } })
}))
// mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: mocks.i18n.t
}),
initReactI18next: {
type: '3rdParty',
init: vi.fn()
}
}))
// Mock logger
vi.mock('@logger', () => ({
loggerService: {
withContext: () => ({
warn: vi.fn(),
error: vi.fn()
})
}
}))
vi.mock('lucide-react', () => ({
RefreshCw: (props: React.SVGProps<SVGSVGElement>) => (
<svg data-testid="refresh-icon" aria-label="refresh" role="img" {...props}>
RefreshCw
</svg>
)
}))
// Mock window.message
Object.assign(window, {
message: {
error: vi.fn(),
success: vi.fn()
}
})
describe('InputEmbeddingDimension', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const mockModel = {
id: 'test-model',
name: 'Test Model',
provider: 'test-provider',
group: 'test-group'
}
const getRefreshButton = () => screen.getByRole('button', { name: /get embedding dimension/i })
describe('basic rendering', () => {
it('should match snapshot with all props', () => {
const { container } = render(<InputEmbeddingDimension value={1536} model={mockModel} style={{ width: '100%' }} />)
expect(container.firstChild).toMatchSnapshot()
})
it('should match snapshot with loading state', async () => {
// Manually control the promise to ensure we can snapshot the loading state.
// This promise is intentionally never resolved.
const promise = new Promise(() => {})
mocks.aiCore.getEmbeddingDimensions.mockReturnValue(promise)
const { container } = render(<InputEmbeddingDimension model={mockModel} />)
const refreshButton = getRefreshButton()
await userEvent.click(refreshButton)
// At this point, the button is guaranteed to be in the loading state
// because the promise it's awaiting will never resolve.
expect(container.firstChild).toMatchSnapshot()
})
it('should be enabled when model is provided', () => {
render(<InputEmbeddingDimension model={mockModel} />)
const input = screen.getByPlaceholderText('请输入维度大小')
expect(input).not.toBeDisabled()
})
})
describe('functionality', () => {
it('should call onChange when input value changes', async () => {
const handleChange = vi.fn()
const user = userEvent.setup()
render(<InputEmbeddingDimension model={mockModel} onChange={handleChange} />)
const input = screen.getByPlaceholderText('请输入维度大小')
await user.clear(input)
await user.type(input, '2048')
expect(handleChange).toHaveBeenCalledWith(2048)
})
it('should fetch and set dimension on refresh click', async () => {
mocks.aiCore.getEmbeddingDimensions.mockResolvedValue(1536)
const handleChange = vi.fn()
const user = userEvent.setup()
render(<InputEmbeddingDimension model={mockModel} onChange={handleChange} />)
const refreshButton = getRefreshButton()
await user.click(refreshButton)
await waitFor(() => {
expect(mocks.aiCore.getEmbeddingDimensions).toHaveBeenCalledWith(mockModel)
expect(handleChange).toHaveBeenCalledWith(1536)
})
})
})
describe('error handling', () => {
it('should be disabled and show no error when no model is provided', async () => {
render(<InputEmbeddingDimension />)
const refreshButton = getRefreshButton()
expect(refreshButton).toBeDisabled()
const input = screen.getByPlaceholderText('请输入维度大小')
expect(input).toBeDisabled()
// To be absolutely sure, we try to click the disabled button.
// `userEvent` will not trigger an event on a disabled element by default.
// We can skip this check to be explicit.
await userEvent.click(refreshButton, { pointerEventsCheck: 0 })
expect(window.message.error).not.toHaveBeenCalled()
})
it('should show error when API call fails', async () => {
mocks.aiCore.getEmbeddingDimensions.mockRejectedValue(new Error('API Error'))
const user = userEvent.setup()
render(<InputEmbeddingDimension model={mockModel} />)
const refreshButton = getRefreshButton()
await user.click(refreshButton)
await waitFor(() => {
expect(window.message.error).toHaveBeenCalledWith('获取嵌入维度失败\nAPI Error')
})
})
it('should handle null value correctly', async () => {
const handleChange = vi.fn()
const user = userEvent.setup()
render(<InputEmbeddingDimension model={mockModel} value={null} onChange={handleChange} />)
const input = screen.getByPlaceholderText('请输入维度大小') as HTMLInputElement
expect(input.value).toBe('')
// Should allow typing new value
await user.type(input, '1024')
expect(handleChange).toHaveBeenCalledWith(1024)
})
})
})

View File

@ -0,0 +1,28 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InfoTooltip > should match snapshot 1`] = `
<span
aria-describedby="test-id"
aria-label="Information"
class="anticon anticon-info-circle"
role="img"
style="color: rgb(24, 144, 255); font-size: 16px;"
>
<svg
aria-hidden="true"
data-icon="info-circle"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"
/>
<path
d="M464 336a48 48 0 1096 0 48 48 0 10-96 0zm72 112h-48c-4.4 0-8 3.6-8 8v272c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V456c0-4.4-3.6-8-8-8z"
/>
</svg>
</span>
`;

View File

@ -0,0 +1,221 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InputEmbeddingDimension > basic rendering > should match snapshot with all props 1`] = `
<div
class="ant-space-compact css-dev-only-do-not-override-1261szd"
style="width: 100%;"
>
<div
class="ant-input-number css-dev-only-do-not-override-1261szd ant-input-number-outlined ant-input-number-compact-item ant-input-number-compact-first-item"
style="flex: 1;"
>
<div
class="ant-input-number-handler-wrap"
>
<span
aria-disabled="false"
aria-label="Increase Value"
class="ant-input-number-handler ant-input-number-handler-up"
role="button"
unselectable="on"
>
<span
aria-label="up"
class="anticon anticon-up ant-input-number-handler-up-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="up"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M890.5 755.3L537.9 269.2c-12.8-17.6-39-17.6-51.7 0L133.5 755.3A8 8 0 00140 768h75c5.1 0 9.9-2.5 12.9-6.6L512 369.8l284.1 391.6c3 4.1 7.8 6.6 12.9 6.6h75c6.5 0 10.3-7.4 6.5-12.7z"
/>
</svg>
</span>
</span>
<span
aria-disabled="false"
aria-label="Decrease Value"
class="ant-input-number-handler ant-input-number-handler-down"
role="button"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-input-number-handler-down-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
<div
class="ant-input-number-input-wrap"
>
<input
aria-valuemin="1"
aria-valuenow="1536"
autocomplete="off"
class="ant-input-number-input"
placeholder="请输入维度大小"
role="spinbutton"
step="1"
value="1536"
/>
</div>
</div>
<button
aria-describedby="test-id"
aria-label="Get embedding dimension"
class="ant-btn css-dev-only-do-not-override-1261szd ant-btn-default ant-btn-color-default ant-btn-variant-outlined ant-btn-icon-only ant-btn-compact-item ant-btn-compact-last-item"
role="button"
type="button"
>
<span
class="ant-btn-icon"
>
<svg
aria-label="refresh"
data-testid="refresh-icon"
role="img"
size="16"
>
RefreshCw
</svg>
</span>
</button>
</div>
`;
exports[`InputEmbeddingDimension > basic rendering > should match snapshot with loading state 1`] = `
<div
class="ant-space-compact css-dev-only-do-not-override-1261szd"
style="width: 100%;"
>
<div
class="ant-input-number css-dev-only-do-not-override-1261szd ant-input-number-outlined ant-input-number-compact-item ant-input-number-compact-first-item"
style="flex: 1;"
>
<div
class="ant-input-number-handler-wrap"
>
<span
aria-disabled="false"
aria-label="Increase Value"
class="ant-input-number-handler ant-input-number-handler-up"
role="button"
unselectable="on"
>
<span
aria-label="up"
class="anticon anticon-up ant-input-number-handler-up-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="up"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M890.5 755.3L537.9 269.2c-12.8-17.6-39-17.6-51.7 0L133.5 755.3A8 8 0 00140 768h75c5.1 0 9.9-2.5 12.9-6.6L512 369.8l284.1 391.6c3 4.1 7.8 6.6 12.9 6.6h75c6.5 0 10.3-7.4 6.5-12.7z"
/>
</svg>
</span>
</span>
<span
aria-disabled="false"
aria-label="Decrease Value"
class="ant-input-number-handler ant-input-number-handler-down"
role="button"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-input-number-handler-down-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
<div
class="ant-input-number-input-wrap"
>
<input
aria-valuemin="1"
autocomplete="off"
class="ant-input-number-input"
placeholder="请输入维度大小"
role="spinbutton"
step="1"
value=""
/>
</div>
</div>
<button
aria-describedby="test-id"
aria-label="Get embedding dimension"
class="ant-btn css-dev-only-do-not-override-1261szd ant-btn-default ant-btn-color-default ant-btn-variant-outlined ant-btn-icon-only ant-btn-loading ant-btn-compact-item ant-btn-compact-last-item"
role="button"
type="button"
>
<span
class="ant-btn-icon ant-btn-loading-icon"
>
<span
aria-label="loading"
class="anticon anticon-loading anticon-spin"
role="img"
>
<svg
aria-hidden="true"
data-icon="loading"
fill="currentColor"
focusable="false"
height="1em"
viewBox="0 0 1024 1024"
width="1em"
>
<path
d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"
/>
</svg>
</span>
</span>
</button>
</div>
`;

View File

@ -47,6 +47,7 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
colorBorder: 'var(--color-border)'
},
InputNumber: {
controlHeight: 30,
colorBorder: 'var(--color-border)'
},
Select: {

View File

@ -1,12 +1,9 @@
import { loggerService } from '@logger'
import { db } from '@renderer/databases'
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import { RootState } from '@renderer/store'
import { RootState, useAppDispatch } from '@renderer/store'
import {
addBase,
addFiles as addFilesAction,
addItem,
clearAllProcessing,
clearCompletedProcessing,
deleteBase,
@ -18,19 +15,19 @@ import {
updateItemProcessingStatus,
updateNotes
} from '@renderer/store/knowledge'
import { addFilesThunk, addItemThunk, addNoteThunk } from '@renderer/store/thunk/knowledgeThunk'
import { FileMetadata, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import dayjs from 'dayjs'
import { cloneDeep } from 'lodash'
import { useCallback, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { v4 as uuidv4 } from 'uuid'
import { useAgents } from './useAgents'
import { useAssistants } from './useAssistant'
const logger = loggerService.withContext('useKnowledge')
export const useKnowledge = (baseId: string) => {
const dispatch = useDispatch()
const dispatch = useAppDispatch()
const base = useSelector((state: RootState) => state.knowledge.bases.find((b) => b.id === baseId))
// 重命名知识库
@ -45,71 +42,33 @@ export const useKnowledge = (baseId: string) => {
// 批量添加文件
const addFiles = (files: FileMetadata[]) => {
const filesItems: KnowledgeItem[] = files.map((file) => ({
id: uuidv4(),
type: 'file' as const,
content: file,
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
}))
logger.debug('Adding files:', filesItems)
dispatch(addFilesAction({ baseId, items: filesItems }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// 添加URL
const addUrl = (url: string) => {
const newUrlItem: KnowledgeItem = {
id: uuidv4(),
type: 'url' as const,
content: url,
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
}
dispatch(addItem({ baseId, item: newUrlItem }))
dispatch(addFilesThunk(baseId, files))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// 添加笔记
const addNote = async (content: string) => {
const noteId = uuidv4()
const note: KnowledgeItem = {
id: noteId,
type: 'note',
content,
created_at: Date.now(),
updated_at: Date.now()
}
// 存储完整笔记到数据库
await db.knowledge_notes.add(note)
// 在 store 中只存储引用
const noteRef: KnowledgeItem = {
id: noteId,
baseId,
type: 'note',
content: '', // store中不需要存储实际内容
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
}
dispatch(updateNotes({ baseId, item: noteRef }))
await dispatch(addNoteThunk(baseId, content))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// 添加URL
const addUrl = (url: string) => {
dispatch(addItemThunk(baseId, 'url', url))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// 添加 Sitemap
const addSitemap = (url: string) => {
dispatch(addItemThunk(baseId, 'sitemap', url))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// Add directory support
const addDirectory = (path: string) => {
dispatch(addItemThunk(baseId, 'directory', path))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// 更新笔记内容
const updateNoteContent = async (noteId: string, content: string) => {
const note = await db.knowledge_notes.get(noteId)
@ -214,37 +173,62 @@ export const useKnowledge = (baseId: string) => {
dispatch(clearAllProcessing({ baseId }))
}
// 添加 Sitemap
const addSitemap = (url: string) => {
const newSitemapItem: KnowledgeItem = {
id: uuidv4(),
type: 'sitemap' as const,
content: url,
// 迁移知识库(保留原知识库)
const migrateBase = async (newBase: KnowledgeBase) => {
if (!base) return
const timestamp = dayjs().format('YYMMDDHHmmss')
const newName = `${newBase.name || base.name}-${timestamp}`
const migratedBase = {
...cloneDeep(base), // 深拷贝原始知识库
...newBase,
id: newBase.id, // 确保使用新的ID
name: newName,
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
items: []
} as KnowledgeBase
dispatch(addBase(migratedBase))
const files: FileMetadata[] = []
// 遍历原知识库的 items重新添加到新知识库
for (const item of base.items) {
switch (item.type) {
case 'file':
if (typeof item.content === 'object' && item.content !== null && 'path' in item.content) {
files.push(item.content as FileMetadata)
}
break
case 'note':
try {
const note = await db.knowledge_notes.get(item.id)
const content = (note?.content || '') as string
await dispatch(addNoteThunk(newBase.id, content))
} catch (error) {
throw new Error(`Failed to migrate note item ${item.id}: ${error}`)
}
break
default:
try {
dispatch(addItemThunk(newBase.id, item.type, item.content as string))
} catch (error) {
throw new Error(`Failed to migrate item ${item.id}: ${error}`)
}
break
}
dispatch(addItem({ baseId, item: newSitemapItem }))
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
// Add directory support
const addDirectory = (path: string) => {
const newDirectoryItem: KnowledgeItem = {
id: uuidv4(),
type: 'directory',
content: path,
created_at: Date.now(),
updated_at: Date.now(),
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0
try {
if (files.length > 0) {
dispatch(addFilesThunk(newBase.id, files))
}
dispatch(addItem({ baseId, item: newDirectoryItem }))
} catch (error) {
throw new Error(`Failed to migrate files ${files}: ${error}`)
}
setTimeout(() => KnowledgeQueue.checkAllBases(), 0)
}
@ -275,6 +259,7 @@ export const useKnowledge = (baseId: string) => {
noteItems,
renameKnowledgeBase,
updateKnowledgeBase,
migrateBase,
addFiles,
addUrl,
addSitemap,

View File

@ -0,0 +1,161 @@
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'
import { KnowledgeBase } from '@renderer/types'
import { nanoid } from 'nanoid'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const createInitialKnowledgeBase = (): KnowledgeBase => ({
id: nanoid(),
name: '',
model: null as any, // model is required, but will be set by user interaction
items: [],
created_at: Date.now(),
updated_at: Date.now(),
version: 1
})
/**
* A hook that manages the state and handlers for a knowledge base form.
*
* The hook provides:
* - A state object `newBase` that tracks the current form values.
* - A function `setNewBase` to update the form state.
* - A set of handlers for various form actions:
* - `handleEmbeddingModelChange`: Updates the embedding model.
* - `handleRerankModelChange`: Updates the rerank model.
* - `handleDimensionChange`: Updates the dimensions.
* - `handleDocPreprocessChange`: Updates the document preprocess provider.
* - `handleChunkSizeChange`: Updates the chunk size.
* - `handleChunkOverlapChange`: Updates the chunk overlap.
* - `handleThresholdChange`: Updates the threshold.
* @param base - The base knowledge base to use as the initial state. If not provided, an empty base will be used.
* @returns An object containing the new base state, a function to update the base, and handlers for various form actions.
* Also includes provider data for dropdown options and selected provider.
*/
export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
const { t } = useTranslation()
const [newBase, setNewBase] = useState<KnowledgeBase>(base || createInitialKnowledgeBase())
const { providers } = useProviders()
const { preprocessProviders } = usePreprocessProviders()
const { ocrProviders } = useOcrProviders()
const selectedDocPreprocessProvider = useMemo(
() => newBase.preprocessOrOcrProvider?.provider,
[newBase.preprocessOrOcrProvider]
)
const docPreprocessSelectOptions = useMemo(() => {
const preprocessOptions = {
label: t('settings.tool.preprocess.provider'),
title: t('settings.tool.preprocess.provider'),
options: preprocessProviders
.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])
const handleEmbeddingModelChange = useCallback(
(value: string) => {
const model = providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === value)
if (model) {
setNewBase((prev) => ({ ...prev, model }))
}
},
[providers]
)
const handleRerankModelChange = useCallback(
(value: string) => {
const rerankModel = value
? providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === value)
: undefined
setNewBase((prev) => ({ ...prev, rerankModel }))
},
[providers]
)
const handleDimensionChange = useCallback((value: number | null) => {
setNewBase((prev) => ({ ...prev, dimensions: value || undefined }))
}, [])
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)
if (!provider) {
setNewBase((prev) => ({ ...prev, preprocessOrOcrProvider: undefined }))
return
}
setNewBase((prev) => ({
...prev,
preprocessOrOcrProvider: {
type,
provider
}
}))
},
[preprocessProviders, ocrProviders]
)
const handleChunkSizeChange = useCallback(
(value: number | null) => {
const modelId = newBase.model?.id || base?.model?.id
if (!modelId) return
const maxContext = getEmbeddingMaxContext(modelId)
if (!value || !maxContext || value <= maxContext) {
setNewBase((prev) => ({ ...prev, chunkSize: value || undefined }))
}
},
[newBase.model, base?.model]
)
const handleChunkOverlapChange = useCallback(
(value: number | null) => {
if (!value || (newBase.chunkSize && newBase.chunkSize > value)) {
setNewBase((prev) => ({ ...prev, chunkOverlap: value || undefined }))
} else {
window.message.error(t('message.error.chunk_overlap_too_large'))
}
},
[newBase.chunkSize, t]
)
const handleThresholdChange = useCallback(
(value: number | null) => {
setNewBase((prev) => ({ ...prev, threshold: value || undefined }))
},
[setNewBase]
)
const handlers = {
handleEmbeddingModelChange,
handleRerankModelChange,
handleDimensionChange,
handleDocPreprocessChange,
handleChunkSizeChange,
handleChunkOverlapChange,
handleThresholdChange
}
const providerData = {
providers,
preprocessProviders,
ocrProviders,
selectedDocPreprocessProvider,
docPreprocessSelectOptions
}
return { newBase, setNewBase, handlers, providerData }
}

View File

@ -804,11 +804,11 @@
"dimensions": "Embedding dimension",
"dimensions_auto_set": "Auto-set embedding dimensions",
"dimensions_default": "The model will use default embedding dimensions",
"dimensions_error_invalid": "Please enter embedding dimension size",
"dimensions_error_invalid": "Invalid embedding dimension",
"dimensions_set_right": "⚠️ Please ensure the model supports the set embedding dimension size",
"dimensions_size_placeholder": " Embedding dimension size, e.g. 1024",
"dimensions_size_placeholder": "Leave empty to not pass dimensions",
"dimensions_size_too_large": "The embedding dimension cannot exceed the model's context limit ({{max_context}}).",
"dimensions_size_tooltip": "The size of the embedding dimension; the larger the value, the larger the embedding dimension, but it also consumes more tokens.",
"dimensions_size_tooltip": "Embedding dimension size, the larger the value, the more tokens will be consumed. Leave empty to not pass dimensions parameter.",
"directories": "Directories",
"directory_placeholder": "Enter Directory Path",
"document_count": "Requested Document Chunks",
@ -817,13 +817,35 @@
"drag_file": "Drag file here",
"edit_remark": "Edit Remark",
"edit_remark_placeholder": "Please enter remark content",
"embedding_model": "Embedding Model",
"embedding_model_required": "Knowledge Base Embedding Model is required",
"empty": "No knowledge base found",
"error": {
"failed_to_create": "Knowledge base creation failed",
"failed_to_edit": "Knowledge base editing failed"
},
"file_hint": "Support {{file_types}}",
"index_all": "Index All",
"index_cancelled": "Indexing cancelled",
"index_started": "Indexing started",
"invalid_url": "Invalid URL",
"migrate": {
"button": {
"text": "Migrate"
},
"confirm": {
"content": "Detected changes in embedding model or dimension, cannot save configuration directly. Knowledge base migration will not delete the existing knowledge base, but will create a copy and then reprocess all knowledge base entries, which may consume a large number of tokens, please proceed with caution.",
"ok": "Start Migration",
"title": "Knowledge Base Migration"
},
"error": {
"failed": "Migration failed"
},
"source_dimensions": "Source Dimensions",
"source_model": "Source Model",
"target_dimensions": "Target Dimensions",
"target_model": "Target Model"
},
"model_info": "Model Info",
"name_required": "Knowledge Base Name is required",
"no_bases": "No knowledge bases available",
@ -833,6 +855,7 @@
"not_support": "Knowledge base database engine updated, the knowledge base will no longer be supported, please create a new knowledge base",
"notes": "Notes",
"notes_placeholder": "Enter additional information or context for this knowledge base...",
"provider_not_found": "Provider not found",
"quota": "{{name}} Left Quota: {{quota}}",
"quota_infinity": "{{name}} Quota: Unlimited",
"rename": "Rename",
@ -3226,9 +3249,6 @@
}
},
"error": {
"dimensions_auto_failed": "Failed to auto-obtain dimensions",
"embedding_model_required": "Please select an embedding model first",
"provider_not_found": "Provider not found",
"rag_failed": "RAG failed"
},
"info": {
@ -3244,11 +3264,6 @@
"document_count": {
"label": "Document Chunks Count",
"tooltip": "Expected number of document chunks to extract from each search result, the actual total number of extracted document chunks is this value multiplied by the number of search results."
},
"embedding_dimensions": {
"auto_get": "Auto Get Dimensions",
"placeholder": "Leave empty",
"tooltip": "If left blank, the dimensions parameter will not be passed"
}
},
"title": "Search Result Compression"

View File

@ -804,11 +804,11 @@
"dimensions": "埋め込み次元",
"dimensions_auto_set": "埋め込み次元を自動設定",
"dimensions_default": "モデルはデフォルトの埋め込み次元を使用します",
"dimensions_error_invalid": "埋め込み次元のサイズを入力してください",
"dimensions_error_invalid": "無効な埋め込み次元",
"dimensions_set_right": "⚠️ モデルが設定した埋め込み次元のサイズをサポートしていることを確認してください",
"dimensions_size_placeholder": " 埋め込み次元のサイズ1024",
"dimensions_size_placeholder": "次元数を設定しない場合は空欄のままにしてください",
"dimensions_size_too_large": "埋め込み次元はモデルのコンテキスト制限({{max_context}})を超えてはなりません。",
"dimensions_size_tooltip": "埋め込み次元のサイズは、数値が大きいほど埋め込み次元も大きくなりますが、消費するトークンも増えます。",
"dimensions_size_tooltip": "埋め込み次元のサイズは、数値が大きいほど消費するトークンも増えます。空欄の場合はdimensionsパラメータを渡しません。",
"directories": "ディレクトリ",
"directory_placeholder": "ディレクトリパスを入力",
"document_count": "要求されたドキュメント分段数",
@ -817,13 +817,35 @@
"drag_file": "ファイルをここにドラッグ",
"edit_remark": "備考を編集",
"edit_remark_placeholder": "備考内容を入力してください",
"embedding_model": "埋め込みモデル",
"embedding_model_required": "ナレッジベース埋め込みモデルが必要です",
"empty": "ナレッジベースが見つかりません",
"error": {
"failed_to_create": "ナレッジベースの作成に失敗しました",
"failed_to_edit": "ナレッジベースの編集に失敗しました"
},
"file_hint": "{{file_types}} 形式をサポート",
"index_all": "すべてをインデックス",
"index_cancelled": "インデックスがキャンセルされました",
"index_started": "インデックスを開始",
"invalid_url": "無効なURL",
"migrate": {
"button": {
"text": "移行"
},
"confirm": {
"content": "埋め込みモデルまたは次元に変更が検出されました。設定を直接保存することはできませんが、移行を実行できます。ナレッジベースの移行では古いナレッジベースは削除されず、代わりにコピーを作成してすべてのエントリを再処理します。大量のトークンを消費する可能性があるため、慎重に操作してください。",
"ok": "移行を開始",
"title": "ナレッジベースの移行"
},
"error": {
"failed": "移行が失敗しました"
},
"source_dimensions": "ソース次元",
"source_model": "ソースモデル",
"target_dimensions": "ターゲット次元",
"target_model": "ターゲットモデル"
},
"model_info": "モデル情報",
"name_required": "ナレッジベース名は必須です",
"no_bases": "ナレッジベースがありません",
@ -833,6 +855,7 @@
"not_support": "ナレッジベースデータベースエンジンが更新されました。このナレッジベースはもうサポートされていません。新しいナレッジベースを作成してください",
"notes": "ノート",
"notes_placeholder": "このナレッジベースの追加情報やコンテキストを入力...",
"provider_not_found": "プロバイダーが見つかりません",
"quota": "{{name}} 残りクォータ: {{quota}}",
"quota_infinity": "{{name}} クォータ: 無制限",
"rename": "名前を変更",
@ -3226,9 +3249,6 @@
}
},
"error": {
"dimensions_auto_failed": "次元の自動取得に失敗しました",
"embedding_model_required": "まず埋め込みモデルを選択してください",
"provider_not_found": "プロバイダーが見つかりません",
"rag_failed": "RAG に失敗しました"
},
"info": {
@ -3244,11 +3264,6 @@
"document_count": {
"label": "文書チャンク数",
"tooltip": "単一の検索結果から抽出する文書チャンク数。実際に抽出される文書チャンク数は、この値に検索結果数を乗じたものです。"
},
"embedding_dimensions": {
"auto_get": "次元を自動取得",
"placeholder": "次元を設定しない",
"tooltip": "空の場合、dimensions パラメーターは渡されません"
}
},
"title": "検索結果の圧縮"

View File

@ -804,11 +804,11 @@
"dimensions": "векторное пространство",
"dimensions_auto_set": "Автоматическая установка размерности эмбеддинга",
"dimensions_default": "Модель будет использовать размер эмбеддинга по умолчанию",
"dimensions_error_invalid": "Пожалуйста, введите размерность эмбеддинга",
"dimensions_error_invalid": "Неверная размерность эмбеддинга",
"dimensions_set_right": "⚠️ Убедитесь, что модель поддерживает заданный размер эмбеддинга",
"dimensions_size_placeholder": " Размерность эмбеддинга, например 1024",
"dimensions_size_placeholder": "Оставьте пустым, чтобы не устанавливать",
"dimensions_size_too_large": "Размерность вложения не может превышать ограничение контекста модели ({{max_context}})",
"dimensions_size_tooltip": "Размерность вложения, чем больше значение, тем больше размерность вложения, но и потребляемых токенов также становится больше.",
"dimensions_size_tooltip": "Размерность вложения - чем больше значение, тем больше токенов потребляется. Если оставить пустым, параметр dimensions не будет передаваться.",
"directories": "Директории",
"directory_placeholder": "Введите путь к директории",
"document_count": "Количество запрошенных документов",
@ -817,13 +817,35 @@
"drag_file": "Перетащите файл сюда",
"edit_remark": "Изменить примечание",
"edit_remark_placeholder": "Пожалуйста, введите содержание примечания",
"embedding_model": "Модель встраивания",
"embedding_model_required": "Модель встраивания базы знаний требуется",
"empty": "База знаний не найдена",
"error": {
"failed_to_create": "Создание базы знаний завершено с ошибками",
"failed_to_edit": "Редактирование базы знаний завершено с ошибками"
},
"file_hint": "Поддерживаются {{file_types}}",
"index_all": "Индексировать все",
"index_cancelled": "Индексирование отменено",
"index_started": "Индексирование началось",
"invalid_url": "Неверный URL",
"migrate": {
"button": {
"text": "Миграция"
},
"confirm": {
"content": "Обнаружена изменение модели встраивания или размерности, невозможно сохранить конфигурацию напрямую. Миграция базы знаний не удалит существующую базу знаний, а создаст ее копию, после чего перепроцессит все записи базы знаний, что может потреблять большое количество токенов. Пожалуйста, действуйте осторожно.",
"ok": "Начать миграцию",
"title": "Миграция базы знаний"
},
"error": {
"failed": "Миграция завершена с ошибками"
},
"source_dimensions": "Исходная размерность",
"source_model": "Исходная модель",
"target_dimensions": "Целевая размерность",
"target_model": "Целевая модель"
},
"model_info": "Модель информации",
"name_required": "Название базы знаний обязательно",
"no_bases": "База знаний не найдена",
@ -833,6 +855,7 @@
"not_support": "База знаний базы данных движок обновлен, база знаний больше не поддерживается, пожалуйста, создайте новую базу знаний",
"notes": "Заметки",
"notes_placeholder": "Введите дополнительную информацию или контекст для этой базы знаний...",
"provider_not_found": "Поставщик не найден",
"quota": "{{name}} Остаток квоты: {{quota}}",
"quota_infinity": "{{name}} Квота: Не ограничена",
"rename": "Переименовать",
@ -3226,9 +3249,6 @@
}
},
"error": {
"dimensions_auto_failed": "Не удалось получить размерности",
"embedding_model_required": "Пожалуйста, сначала выберите модель встраивания",
"provider_not_found": "Поставщик не найден",
"rag_failed": "RAG не удалось"
},
"info": {
@ -3244,11 +3264,6 @@
"document_count": {
"label": "Количество фрагментов документов",
"tooltip": "Ожидаемое количество фрагментов документов, которые будут извлечены из каждого результата поиска. Фактическое количество извлеченных фрагментов документов равно этому значению, умноженному на количество результатов поиска."
},
"embedding_dimensions": {
"auto_get": "Автоматически получить размерности",
"placeholder": "Не устанавливать размерности",
"tooltip": "Если оставить пустым, параметр dimensions не будет передан"
}
},
"title": "Сжатие результатов поиска"

View File

@ -804,11 +804,11 @@
"dimensions": "嵌入维度",
"dimensions_auto_set": "自动设置嵌入维度",
"dimensions_default": "模型将使用默认嵌入维度",
"dimensions_error_invalid": "请输入嵌入维度大小",
"dimensions_error_invalid": "无效的嵌入维度",
"dimensions_set_right": "⚠️ 请确保模型支持所设置的嵌入维度大小",
"dimensions_size_placeholder": "嵌入维度大小,如 1024",
"dimensions_size_placeholder": "留空表示不设置",
"dimensions_size_too_large": "嵌入维度不能超过模型上下文限制({{max_context}}",
"dimensions_size_tooltip": "嵌入维度大小,数值越大,嵌入维度越大,但消耗的 Token 也越多",
"dimensions_size_tooltip": "嵌入维度大小,数值越大消耗的 Token 也越多。留空则不传递 dimensions 参数。",
"directories": "目录",
"directory_placeholder": "请输入目录路径",
"document_count": "请求文档片段数量",
@ -817,13 +817,35 @@
"drag_file": "拖拽文件到这里",
"edit_remark": "修改备注",
"edit_remark_placeholder": "请输入备注内容",
"embedding_model": "嵌入模型",
"embedding_model_required": "知识库嵌入模型是必需的",
"empty": "暂无知识库",
"error": {
"failed_to_create": "知识库创建失败",
"failed_to_edit": "知识库编辑失败"
},
"file_hint": "支持 {{file_types}} 格式",
"index_all": "索引全部",
"index_cancelled": "索引已取消",
"index_started": "索引开始",
"invalid_url": "无效的网址",
"migrate": {
"button": {
"text": "迁移"
},
"confirm": {
"content": "检测到嵌入模型或维度有变更,无法直接保存配置,可以执行迁移。知识库迁移不会删除旧知识库,而是创建一个副本之后重新处理所有知识库条目,可能消耗大量 tokens请谨慎操作。",
"ok": "开始迁移",
"title": "知识库迁移"
},
"error": {
"failed": "迁移失败"
},
"source_dimensions": "源维度",
"source_model": "源模型",
"target_dimensions": "目标维度",
"target_model": "目标模型"
},
"model_info": "模型信息",
"name_required": "知识库名称为必填项",
"no_bases": "暂无知识库",
@ -833,6 +855,7 @@
"not_support": "知识库数据库引擎已更新,该知识库将不再支持,请重新创建知识库",
"notes": "笔记",
"notes_placeholder": "输入此知识库的附加信息或上下文...",
"provider_not_found": "未找到服务商",
"quota": "{{name}} 剩余额度:{{quota}}",
"quota_infinity": "{{name}} 剩余额度:无限制",
"rename": "重命名",
@ -3226,9 +3249,6 @@
}
},
"error": {
"dimensions_auto_failed": "维度自动获取失败",
"embedding_model_required": "请先选择嵌入模型",
"provider_not_found": "未找到服务商",
"rag_failed": "RAG 失败"
},
"info": {
@ -3244,11 +3264,6 @@
"document_count": {
"label": "文档片段数量",
"tooltip": "预期从单个搜索结果中提取的文档片段数量,实际提取的总数量是这个值乘以搜索结果数量。"
},
"embedding_dimensions": {
"auto_get": "自动获取维度",
"placeholder": "不设置维度",
"tooltip": "留空则不传递 dimensions 参数"
}
},
"title": "搜索结果压缩"

View File

@ -804,11 +804,11 @@
"dimensions": "嵌入維度",
"dimensions_auto_set": "自動設定嵌入維度",
"dimensions_default": "模型將使用預設嵌入維度",
"dimensions_error_invalid": "請輸入嵌入維度大小",
"dimensions_error_invalid": "無效的嵌入維度",
"dimensions_set_right": "⚠️ 請確保模型支援所設置的嵌入維度大小",
"dimensions_size_placeholder": " 嵌入維度大小,例如 1024",
"dimensions_size_placeholder": "留空表示不設置",
"dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}}",
"dimensions_size_tooltip": "嵌入維度大小,數值越大,嵌入維度越大,但消耗的 Token 也越多",
"dimensions_size_tooltip": "嵌入維度大小,數值越大消耗的 Token 也越多。留空則不傳遞 dimensions 參數。",
"directories": "目錄",
"directory_placeholder": "請輸入目錄路徑",
"document_count": "請求文件片段數量",
@ -817,13 +817,35 @@
"drag_file": "拖拽檔案到這裡",
"edit_remark": "修改備註",
"edit_remark_placeholder": "請輸入備註內容",
"embedding_model": "嵌入模型",
"embedding_model_required": "知識庫嵌入模型是必需的",
"empty": "暫無知識庫",
"error": {
"failed_to_create": "知識庫創建失敗",
"failed_to_edit": "知識庫編輯失敗"
},
"file_hint": "支援 {{file_types}} 格式",
"index_all": "索引全部",
"index_cancelled": "索引已取消",
"index_started": "索引開始",
"invalid_url": "無效的網址",
"migrate": {
"button": {
"text": "遷移"
},
"confirm": {
"content": "檢測到嵌入模型或維度有變更,無法直接保存配置,可以執行遷移。知識庫遷移不會刪除舊知識庫,而是建立一個副本之後重新處理所有知識庫條目,可能消耗大量 tokens請謹慎操作。",
"ok": "開始遷移",
"title": "知識庫遷移"
},
"error": {
"failed": "遷移失敗"
},
"source_dimensions": "源維度",
"source_model": "源模型",
"target_dimensions": "目標維度",
"target_model": "目標模型"
},
"model_info": "模型資訊",
"name_required": "知識庫名稱為必填項目",
"no_bases": "暫無知識庫",
@ -833,6 +855,7 @@
"not_support": "知識庫資料庫引擎已更新,該知識庫將不再支援,請重新建立知識庫",
"notes": "筆記",
"notes_placeholder": "輸入此知識庫的附加資訊或上下文...",
"provider_not_found": "未找到服務商",
"quota": "{{name}} 剩餘配額:{{quota}}",
"quota_infinity": "{{name}} 配額:無限制",
"rename": "重新命名",
@ -3226,9 +3249,6 @@
}
},
"error": {
"dimensions_auto_failed": "維度自動獲取失敗",
"embedding_model_required": "請先選擇嵌入模型",
"provider_not_found": "未找到服務商",
"rag_failed": "RAG 失敗"
},
"info": {
@ -3244,11 +3264,6 @@
"document_count": {
"label": "文檔片段數量",
"tooltip": "預期從單個搜尋結果中提取的文檔片段數量,實際提取的總數量是這個值乘以搜尋結果數量。"
},
"embedding_dimensions": {
"auto_get": "自動獲取維度",
"placeholder": "不設置維度",
"tooltip": "留空則不傳遞 dimensions 參數"
}
},
"title": "搜尋結果壓縮"

View File

@ -817,13 +817,35 @@
"drag_file": "Βάλτε το αρχείο εδώ",
"edit_remark": "Μεταβολή σημειώματος",
"edit_remark_placeholder": "Εισάγετε το σημείωμα",
"embedding_model": "Μοντέλο ενσωμάτωσης",
"embedding_model_required": "Το μοντέλο ενσωμάτωσης της βάσης γνώσης είναι υποχρεωτικό",
"empty": "Λεηλασία βάσης γνώσεων",
"error": {
"failed_to_create": "Αποτυχία δημιουργίας βάσης γνώσεων",
"failed_to_edit": "Αποτυχία επεξεργασίας βάσης γνώσεων"
},
"file_hint": "Υποστηρίζεται το {{file_types}} μορφάττων",
"index_all": "Ευρετήριοποίηση όλων",
"index_cancelled": "Η ευρετήριοποίηση διακόπηκε",
"index_started": "Η ευρετήριοποίηση ξεκίνησε",
"invalid_url": "Μη έγκυρη διευθύνση",
"migrate": {
"button": {
"text": "Μεταφορά"
},
"confirm": {
"content": "Εντοπίστηκαν αλλαγές στο μοντέλο ενσωμάτωσης ή τις διαστάσεις, οπότε δεν είναι δυνατή η αποθήκευση των ρυθμίσεων. Μπορείτε να εκτελέσετε μεταφορά για να αποφύγετε την απώλεια δεδομένων. Η μεταφορά της βάσης γνώσεων δεν διαγράφει την παλιά βάση γνώσεων, αλλά δημιουργεί ένα αντίγραφο και επεξεργάζεται όλα τα στοιχεία της βάσης γνώσεων, η οποία μπορεί να καταναλώσει πολλές μονάδες (Tokens). Παρακαλώ είστε προσεκτικοί.",
"ok": "Ξεκινήστε τη μεταφορά",
"title": "Μεταφορά βάσης γνώσεων"
},
"error": {
"failed": "Αποτυχία μεταφοράς"
},
"source_dimensions": "Πηγαίες διαστάσεις",
"source_model": "Πηγαίο μοντέλο",
"target_dimensions": "Προορισμένες διαστάσεις",
"target_model": "Προορισμένο μοντέλο"
},
"model_info": "Πληροφορίες μοντέλου",
"name_required": "Το όνομα της βάσης γνώσης είναι υποχρεωτικό",
"no_bases": "Λεηλασία βάσης γνώσεων",
@ -833,6 +855,7 @@
"not_support": "Το μοντέλο βάσης γνώσεων έχει ενημερωθεί, αυτή η βάση γνώσεων δεν θα υποστηρίζεται πλέον, παρακαλείστε να δημιουργήσετε ξανά μια βάση γνώσεων",
"notes": "Σημειώματα",
"notes_placeholder": "Εισάγετε πρόσθετες πληροφορίες ή πληροφορίες προσδιορισμού για αυτή τη βάση γνώσεων...",
"provider_not_found": "Η παροχή υπηρεσιών μοντέλου βάσης γνώσεων χαθηκε, αυτή η βάση γνώσεων δεν θα υποστηρίζεται πλέον, παρακαλείστε να δημιουργήσετε ξανά μια βάση γνώσεων",
"quota": "Διαθέσιμο όριο για {{name}}: {{quota}}",
"quota_infinity": "Διαθέσιμο όριο για {{name}}: Απεριόριστο",
"rename": "Μετονομασία",
@ -3076,6 +3099,7 @@
"search_placeholder": "Αναζήτηση ID ή ονόματος μονάδας",
"title": "Υπηρεσία μονάδων",
"vertex_ai": {
"api_host_help": "[to be translated]:Vertex AI 的 API 地址,不建议填写,通常适用于反向代理",
"documentation": "Δείτε την επίσημη τεκμηρίωση για περισσότερες λεπτομέρειες ρύθμισης:",
"learn_more": "Μάθετε περισσότερα",
"location": "Περιοχή",
@ -3225,9 +3249,6 @@
}
},
"error": {
"dimensions_auto_failed": "Αποτυχία αυτόματης λήψης διαστάσεων",
"embedding_model_required": "Παρακαλώ επιλέξτε πρώτα ένα μοντέλο ενσωμάτωσης",
"provider_not_found": "Ο πάροχος δεν βρέθηκε",
"rag_failed": "Το RAG απέτυχε"
},
"info": {
@ -3243,11 +3264,6 @@
"document_count": {
"label": "Αριθμός αποσπασμάτων εγγράφου",
"tooltip": "Ο αναμενόμενος αριθμός αποσπασμάτων εγγράφου που θα εξαχθούν από κάθε αποτέλεσμα αναζήτησης· ο πραγματικός συνολικός αριθμός είναι αυτή η τιμή επί τον αριθμό των αποτελεσμάτων αναζήτησης"
},
"embedding_dimensions": {
"auto_get": "Αυτόματη λήψη διαστάσεων",
"placeholder": "Χωρίς καθορισμό διαστάσεων",
"tooltip": "Αν αφεθεί κενό, δεν θα μεταδοθεί η παράμετρος dimensions"
}
},
"title": "Συμπίεση αποτελεσμάτων αναζήτησης"

View File

@ -817,13 +817,35 @@
"drag_file": "Arrastre archivos aquí",
"edit_remark": "Editar observación",
"edit_remark_placeholder": "Ingrese el contenido de la observación",
"embedding_model": "Modelo de incrustación",
"embedding_model_required": "El modelo de incrustación de la base de conocimientos es obligatorio",
"empty": "Sin bases de conocimientos",
"error": {
"failed_to_create": "Error al crear la base de conocimientos",
"failed_to_edit": "Error al editar la base de conocimientos"
},
"file_hint": "Formatos soportados: {{file_types}}",
"index_all": "Indexar todo",
"index_cancelled": "Índice cancelado",
"index_started": "Índice iniciado",
"invalid_url": "URL inválida",
"migrate": {
"button": {
"text": "Migrar"
},
"confirm": {
"content": "Se detectaron cambios en el modelo de incrustación o las dimensiones, por lo que no se puede guardar la configuración. Puede ejecutar la migración para evitar la pérdida de datos. La migración de la base de conocimientos no elimina la base de conocimientos anterior, sino que crea una copia y procesa todos los elementos de la base de conocimientos, lo que puede consumir muchos tokens. Por favor, tenga cuidado.",
"ok": "Iniciar migración",
"title": "Migración de base de conocimientos"
},
"error": {
"failed": "Error en la migración"
},
"source_dimensions": "Dimensiones de origen",
"source_model": "Modelo de origen",
"target_dimensions": "Dimensiones de destino",
"target_model": "Modelo de destino"
},
"model_info": "Información del modelo",
"name_required": "El nombre de la base de conocimientos es obligatorio",
"no_bases": "Sin bases de conocimientos",
@ -833,6 +855,7 @@
"not_support": "El motor de base de datos de la base de conocimientos ha sido actualizado, esta base de conocimientos ya no es compatible, por favor cree una nueva base de conocimientos",
"notes": "Notas",
"notes_placeholder": "Ingrese información adicional o contexto para esta base de conocimientos...",
"provider_not_found": "El proveedor del modelo de la base de conocimientos ha sido perdido, esta base de conocimientos ya no es compatible, por favor cree una nueva base de conocimientos",
"quota": "Cupo restante de {{name}}: {{quota}}",
"quota_infinity": "Cupo restante de {{name}}: ilimitado",
"rename": "Renombrar",
@ -3076,6 +3099,7 @@
"search_placeholder": "Buscar ID o nombre del modelo",
"title": "Servicio de modelos",
"vertex_ai": {
"api_host_help": "[to be translated]:Vertex AI 的 API 地址,不建议填写,通常适用于反向代理",
"documentation": "Consulte la documentación oficial para obtener más detalles de configuración:",
"learn_more": "Más información",
"location": "Región",
@ -3225,9 +3249,6 @@
}
},
"error": {
"dimensions_auto_failed": "Error al obtener automáticamente las dimensiones",
"embedding_model_required": "Por favor, seleccione primero un modelo de incrustación",
"provider_not_found": "Proveedor no encontrado",
"rag_failed": "RAG fallido"
},
"info": {
@ -3243,11 +3264,6 @@
"document_count": {
"label": "Número de fragmentos de documento",
"tooltip": "Número esperado de fragmentos de documento extraídos de un único resultado de búsqueda; el número total extraído será este valor multiplicado por la cantidad de resultados de búsqueda"
},
"embedding_dimensions": {
"auto_get": "Obtener automáticamente las dimensiones",
"placeholder": "Sin configuración de dimensiones",
"tooltip": "Si se deja vacío, no se enviará el parámetro dimensions"
}
},
"title": "Compresión de resultados de búsqueda"

View File

@ -817,13 +817,35 @@
"drag_file": "Glissez-déposez un fichier ici",
"edit_remark": "Modifier la remarque",
"edit_remark_placeholder": "Entrez le contenu de la remarque",
"embedding_model": "Modèle d'intégration",
"embedding_model_required": "Le modèle d'intégration de la base de connaissances est obligatoire",
"empty": "Aucune base de connaissances pour le moment",
"error": {
"failed_to_create": "Erreur lors de la création de la base de connaissances",
"failed_to_edit": "Erreur lors de la modification de la base de connaissances"
},
"file_hint": "Format supporté : {{file_types}}",
"index_all": "Indexer tout",
"index_cancelled": "L'indexation a été annulée",
"index_started": "L'indexation a commencé",
"invalid_url": "URL invalide",
"migrate": {
"button": {
"text": "Migrer"
},
"confirm": {
"content": "Des modifications ont été détectées dans le modèle d'intégration ou les dimensions, ce qui empêche la sauvegarde de la configuration. Vous pouvez exécuter la migration pour éviter la perte de données. La migration de la base de connaissances ne supprime pas la base de connaissances précédente, mais crée une copie et traite tous les éléments de la base de connaissances, ce qui peut consommer beaucoup de jetons. Veuillez agir avec prudence.",
"ok": "Commencer la migration",
"title": "Migration de la base de connaissances"
},
"error": {
"failed": "Erreur lors de la migration"
},
"source_dimensions": "Dimensions source",
"source_model": "Modèle source",
"target_dimensions": "Dimensions cible",
"target_model": "Modèle cible"
},
"model_info": "Informations sur le modèle",
"name_required": "Le nom de la base de connaissances est obligatoire",
"no_bases": "Aucune base de connaissances pour le moment",
@ -833,6 +855,7 @@
"not_support": "Le moteur de base de données de la base de connaissances a été mis à jour, cette base de connaissances ne sera plus supportée, veuillez en créer une nouvelle",
"notes": "Notes",
"notes_placeholder": "Entrez des informations supplémentaires ou un contexte pour cette base de connaissances...",
"provider_not_found": "Le fournisseur du modèle de la base de connaissances a été perdu, cette base de connaissances ne sera plus supportée, veuillez en créer une nouvelle",
"quota": "Quota restant pour {{name}} : {{quota}}",
"quota_infinity": "Quota restant pour {{name}} : illimité",
"rename": "Renommer",
@ -3076,6 +3099,7 @@
"search_placeholder": "Rechercher un ID ou un nom de modèle",
"title": "Services de modèles",
"vertex_ai": {
"api_host_help": "[to be translated]:Vertex AI 的 API 地址,不建议填写,通常适用于反向代理",
"documentation": "Consultez la documentation officielle pour plus de détails sur la configuration :",
"learn_more": "En savoir plus",
"location": "Région",
@ -3225,9 +3249,6 @@
}
},
"error": {
"dimensions_auto_failed": "Échec de l'obtention automatique des dimensions",
"embedding_model_required": "Veuillez d'abord sélectionner un modèle d'incorporation",
"provider_not_found": "Fournisseur non trouvé",
"rag_failed": "Échec du RAG"
},
"info": {
@ -3243,11 +3264,6 @@
"document_count": {
"label": "Nombre de fragments de document",
"tooltip": "Nombre prévu de fragments de document à extraire d'un seul résultat de recherche. Le nombre total réellement extrait est ce nombre multiplié par le nombre de résultats de recherche."
},
"embedding_dimensions": {
"auto_get": "Obtenir automatiquement les dimensions",
"placeholder": "Ne pas définir de dimension",
"tooltip": "Laisser vide pour ne pas transmettre le paramètre dimensions"
}
},
"title": "Compression des résultats de recherche"

View File

@ -817,13 +817,35 @@
"drag_file": "Arraste o arquivo aqui",
"edit_remark": "Editar observação",
"edit_remark_placeholder": "Digite o conteúdo da observação",
"embedding_model": "Modelo de incorporação",
"embedding_model_required": "O modelo de incorporação da base de conhecimento é obrigatório",
"empty": "Sem repositório de conhecimento",
"error": {
"failed_to_create": "Falha ao criar o repositório de conhecimento",
"failed_to_edit": "Falha ao editar o repositório de conhecimento"
},
"file_hint": "Formatos suportados: {{file_types}}",
"index_all": "Índice total",
"index_cancelled": "Índice cancelado",
"index_started": "Índice iniciado",
"invalid_url": "URL inválida",
"migrate": {
"button": {
"text": "Migrar"
},
"confirm": {
"content": "Foram detectadas alterações no modelo de incorporação ou dimensões, o que impede a gravação da configuração. Você pode executar a migração para evitar a perda de dados. A migração do repositório de conhecimento não exclui o repositório de conhecimento anterior, mas cria uma cópia e processa todos os itens do repositório de conhecimento, o que pode consumir muitos tokens. Por favor, agir com cuidado.",
"ok": "Iniciar migração",
"title": "Migração do repositório de conhecimento"
},
"error": {
"failed": "Falha na migração"
},
"source_dimensions": "Dimensões de origem",
"source_model": "Modelo de origem",
"target_dimensions": "Dimensões de destino",
"target_model": "Modelo de destino"
},
"model_info": "Informações do modelo",
"name_required": "O nome da base de conhecimento é obrigatório",
"no_bases": "Sem repositório de conhecimento",
@ -833,6 +855,7 @@
"not_support": "O motor de banco de dados do repositório de conhecimento foi atualizado, este repositório de conhecimento não será mais suportado, por favor, crie um novo repositório de conhecimento",
"notes": "Notas",
"notes_placeholder": "Digite informações adicionais ou contexto para este repositório de conhecimento...",
"provider_not_found": "O provedor do modelo do repositório de conhecimento foi perdido, este repositório de conhecimento não será mais suportado, por favor, crie um novo repositório de conhecimento",
"quota": "Cota restante de {{name}}: {{quota}}",
"quota_infinity": "Cota restante de {{name}}: ilimitada",
"rename": "Renomear",
@ -3076,6 +3099,7 @@
"search_placeholder": "Procurar ID ou nome do modelo",
"title": "Serviços de Modelos",
"vertex_ai": {
"api_host_help": "[to be translated]:Vertex AI 的 API 地址,不建议填写,通常适用于反向代理",
"documentation": "Consulte a documentação oficial para obter mais detalhes de configuração:",
"learn_more": "Saiba mais",
"location": "Região",
@ -3225,9 +3249,6 @@
}
},
"error": {
"dimensions_auto_failed": "Falha ao obter automaticamente as dimensões",
"embedding_model_required": "Por favor, selecione primeiro o modelo de incorporação",
"provider_not_found": "Provedor não encontrado",
"rag_failed": "RAG falhou"
},
"info": {
@ -3243,11 +3264,6 @@
"document_count": {
"label": "Número de fragmentos de documentos",
"tooltip": "Número esperado de fragmentos de documentos a serem extraídos de um único resultado de pesquisa. O número total real extraído será esse valor multiplicado pelo número de resultados de pesquisa."
},
"embedding_dimensions": {
"auto_get": "Obter automaticamente dimensões",
"placeholder": "Não definir dimensões",
"tooltip": "Se deixado em branco, o parâmetro dimensions não será enviado"
}
},
"title": "Compressão de resultados de pesquisa"

View File

@ -12,8 +12,8 @@ import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import EditKnowledgeBasePopup from './components/EditKnowledgeBasePopup'
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
import KnowledgeSettings from './components/KnowledgeSettings'
import QuotaTag from './components/QuotaTag'
import KnowledgeDirectories from './items/KnowledgeDirectories'
import KnowledgeFiles from './items/KnowledgeFiles'
@ -126,7 +126,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
<Button
type="text"
icon={<Settings size={18} color="var(--color-icon)" />}
onClick={() => KnowledgeSettings.show({ base })}
onClick={() => EditKnowledgeBasePopup.show({ base })}
size="small"
/>
<div className="model-row">

View File

@ -14,8 +14,8 @@ import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AddKnowledgePopup from './components/AddKnowledgePopup'
import KnowledgeSettings from './components/KnowledgeSettings'
import AddKnowledgeBasePopup from './components/AddKnowledgeBasePopup'
import EditKnowledgeBasePopup from './components/EditKnowledgeBasePopup'
import KnowledgeContent from './KnowledgeContent'
const KnowledgePage: FC = () => {
@ -24,12 +24,19 @@ const KnowledgePage: FC = () => {
const [selectedBase, setSelectedBase] = useState<KnowledgeBase | undefined>(bases[0])
const [isDragging, setIsDragging] = useState(false)
const handleAddKnowledge = async () => {
const newBase = await AddKnowledgePopup.show({ title: t('knowledge.add.title') })
const handleAddKnowledge = useCallback(async () => {
const newBase = await AddKnowledgeBasePopup.show({ title: t('knowledge.add.title') })
if (newBase) {
setSelectedBase(newBase)
}
}, [t])
const handleEditKnowledgeBase = useCallback(async (base: KnowledgeBase) => {
const newBase = await EditKnowledgeBasePopup.show({ base })
if (newBase && newBase?.id !== base.id) {
setSelectedBase(newBase)
}
}, [])
useEffect(() => {
const hasSelectedBase = bases.find((base) => base.id === selectedBase?.id)
@ -58,7 +65,7 @@ const KnowledgePage: FC = () => {
label: t('knowledge.settings.title'),
key: 'settings',
icon: <SettingOutlined />,
onClick: () => KnowledgeSettings.show({ base })
onClick: () => handleEditKnowledgeBase(base)
},
{ type: 'divider' },
{
@ -81,7 +88,7 @@ const KnowledgePage: FC = () => {
return menus
},
[deleteKnowledgeBase, renameKnowledgeBase, t]
[deleteKnowledgeBase, handleEditKnowledgeBase, renameKnowledgeBase, t]
)
useShortcut('search_message', () => {

View File

@ -0,0 +1,101 @@
import type { KnowledgeBase, Model } from '@renderer/types'
import { render, screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AdvancedSettingsPanel from '../components/KnowledgeSettings/AdvancedSettingsPanel'
const mocks = vi.hoisted(() => {
return {
i18n: {
t: (k: string) => {
const translations: Record<string, string> = {
'knowledge.chunk_size': '分块大小',
'knowledge.chunk_overlap': '分块重叠',
'knowledge.threshold': '检索相似度阈值',
'knowledge.chunk_size_change_warning': '避免修改这个高级设置。'
}
return translations[k] || k
}
},
handlers: {
handleChunkSizeChange: vi.fn(),
handleChunkOverlapChange: vi.fn(),
handleThresholdChange: vi.fn()
}
}
})
vi.mock('@renderer/components/InfoTooltip', () => ({
default: ({ title }: { title: string }) => <div>{mocks.i18n.t(title)}</div>
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: mocks.i18n.t
})
}))
/**
* KnowledgeBase
* @param overrides
* @returns KnowledgeBase
*/
function createKnowledgeBase(overrides: Partial<KnowledgeBase> = {}): KnowledgeBase {
return {
id: '1',
name: 'Test KB',
model: {
id: 'test-model',
provider: 'test-provider',
name: 'Test Model',
group: 'test'
} as Model,
items: [],
created_at: Date.now(),
updated_at: Date.now(),
version: 1,
chunkSize: 500,
chunkOverlap: 200,
threshold: 0.5,
...overrides
}
}
describe('AdvancedSettingsPanel', () => {
const mockBase = createKnowledgeBase()
beforeEach(() => {
vi.clearAllMocks()
})
describe('basic rendering', () => {
it('should match snapshot', () => {
const { container } = render(<AdvancedSettingsPanel newBase={mockBase} handlers={mocks.handlers} />)
expect(container.firstChild).toMatchSnapshot()
})
})
describe('handlers', () => {
it('should call handlers when values are changed', async () => {
const user = userEvent.setup()
render(<AdvancedSettingsPanel newBase={mockBase} handlers={mocks.handlers} />)
const chunkSizeInput = screen.getByLabelText('分块大小')
await user.clear(chunkSizeInput)
await user.type(chunkSizeInput, '600')
expect(mocks.handlers.handleChunkSizeChange).toHaveBeenCalledWith(600)
const chunkOverlapInput = screen.getByLabelText('分块重叠')
await user.clear(chunkOverlapInput)
await user.type(chunkOverlapInput, '300')
expect(mocks.handlers.handleChunkOverlapChange).toHaveBeenCalledWith(300)
const thresholdInput = screen.getByLabelText('检索相似度阈值')
await user.clear(thresholdInput)
await user.type(thresholdInput, '0.6')
expect(mocks.handlers.handleThresholdChange).toHaveBeenCalledWith(0.6)
})
})
})

View File

@ -0,0 +1,301 @@
import { KnowledgeBase, Model, PreprocessProvider } from '@renderer/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import GeneralSettingsPanel from '../components/KnowledgeSettings/GeneralSettingsPanel'
// Mock dependencies
const mocks = vi.hoisted(() => ({
t: vi.fn((key: string) => key),
providers: [
{
id: 'openai',
name: 'OpenAI',
models: [
{
id: 'text-embedding-3-small',
provider: 'openai',
name: 'text-embedding-3-small',
group: 'embedding'
}
]
}
],
handlers: {
handleEmbeddingModelChange: vi.fn(),
handleDimensionChange: vi.fn(),
handleRerankModelChange: vi.fn(),
handleDocPreprocessChange: vi.fn()
}
}))
// Mock InfoTooltip component
vi.mock('@renderer/components/InfoTooltip', () => ({
default: ({ title, placement }: { title: string; placement: string }) => (
<span data-testid="info-tooltip" title={title} data-placement={placement}>
</span>
)
}))
// Mock ModelSelector component
vi.mock('@renderer/components/ModelSelector', () => ({
default: ({ value, onChange, placeholder, allowClear, providers, predicate }: any) => {
// Determine if this is for embedding or rerank models based on predicate
const isEmbedding = predicate?.toString().includes('embedding')
const isRerank = predicate?.toString().includes('rerank')
// Use providers parameter to avoid lint error
const hasProviders = providers && providers.length > 0
return (
<select
data-testid="model-selector"
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
data-placeholder={placeholder}
data-allow-clear={allowClear}
data-model-type={isEmbedding ? 'embedding' : isRerank ? 'rerank' : 'unknown'}
data-has-providers={hasProviders}>
<option value="">Select model</option>
{isEmbedding && (
<>
<option value="openai/text-embedding-3-small">text-embedding-3-small</option>
<option value="openai/text-embedding-ada-002">text-embedding-ada-002</option>
</>
)}
{isRerank && (
<>
<option value="openai/rerank-model">rerank-model</option>
<option value="cohere/rerank-english-v2.0">rerank-english-v2.0</option>
</>
)}
</select>
)
}
}))
// Mock InputEmbeddingDimension component
vi.mock('@renderer/components/InputEmbeddingDimension', () => ({
default: ({ value, onChange, model, disabled }: any) => (
<input
data-testid="embedding-dimension-input"
type="number"
value={value || ''}
onChange={(e) => onChange?.(Number(e.target.value))}
disabled={disabled}
data-model={model?.id}
/>
)
}))
// Mock useProviders hook
vi.mock('@renderer/hooks/useProvider', () => ({
useProviders: () => ({ providers: mocks.providers })
}))
// Mock ModelService
vi.mock('@renderer/services/ModelService', () => ({
getModelUniqId: (model: Model | undefined) => (model ? `${model.provider}/${model.id}` : undefined)
}))
// Mock model predicates
vi.mock('@renderer/config/models', () => ({
isEmbeddingModel: (model: Model) => model.group === 'embedding',
isRerankModel: (model: Model) => model.group === 'rerank'
}))
// Mock constant
vi.mock('@renderer/config/constant', () => ({
DEFAULT_KNOWLEDGE_DOCUMENT_COUNT: 6
}))
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mocks.t })
}))
// Mock antd components
vi.mock('antd', () => ({
Input: ({ value, onChange, placeholder }: any) => (
<input data-testid="name-input" value={value} onChange={onChange} placeholder={placeholder} />
),
Select: ({ value, onChange, placeholder, options, allowClear, children }: any) => (
<select
data-testid="preprocess-select"
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
data-placeholder={placeholder}
data-allow-clear={allowClear}>
<option value="">Select option</option>
{options?.map((option: any) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
{children}
</select>
),
Slider: ({ value, onChange, min, max, step, marks }: any) => (
<input
data-testid="document-count-slider"
type="range"
value={value}
onChange={(e) => onChange?.(Number(e.target.value))}
min={min}
max={max}
step={step}
data-marks={JSON.stringify(marks)}
/>
)
}))
/**
* KnowledgeBase
* @param overrides -
* @returns KnowledgeBase
*/
function createKnowledgeBase(overrides: Partial<KnowledgeBase> = {}): KnowledgeBase {
const defaultModel: Model = {
id: 'text-embedding-3-small',
provider: 'openai',
name: 'text-embedding-3-small',
group: 'embedding'
}
return {
id: 'test-base-id',
name: 'Test Knowledge Base',
model: defaultModel,
items: [],
created_at: Date.now(),
updated_at: Date.now(),
version: 1,
...overrides
}
}
/**
* PreprocessProvider
* @param overrides -
* @returns PreprocessProvider
*/
function createPreprocessProvider(overrides: Partial<PreprocessProvider> = {}): PreprocessProvider {
return {
id: 'doc2x',
name: 'Doc2X',
apiKey: 'test-api-key',
...overrides
}
}
describe('GeneralSettingsPanel', () => {
const mockBase = createKnowledgeBase()
const mockSetNewBase = vi.fn()
const mockSelectedDocPreprocessProvider = createPreprocessProvider()
const mockDocPreprocessSelectOptions = [
{ value: 'doc2x', label: 'Doc2X' },
{ value: 'mistral', label: 'Mistral' }
]
// 提取公共渲染函数
const renderComponent = (props: Partial<any> = {}) => {
return render(
<GeneralSettingsPanel
newBase={mockBase}
setNewBase={mockSetNewBase}
selectedDocPreprocessProvider={mockSelectedDocPreprocessProvider}
docPreprocessSelectOptions={mockDocPreprocessSelectOptions}
handlers={mocks.handlers}
{...props}
/>
)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('basic rendering', () => {
it('should match snapshot', () => {
const { container } = renderComponent()
expect(container.firstChild).toMatchSnapshot()
})
it('should render without selectedDocPreprocessProvider', () => {
renderComponent({ selectedDocPreprocessProvider: undefined })
expect(screen.getByTestId('preprocess-select')).toHaveValue('')
})
it('should render with empty docPreprocessSelectOptions', () => {
renderComponent({ docPreprocessSelectOptions: [] })
const preprocessSelect = screen.getByTestId('preprocess-select')
expect(preprocessSelect.children).toHaveLength(1)
})
})
describe('functionality', () => {
const user = userEvent.setup()
it('should handle name input change', async () => {
renderComponent()
const nameInput = screen.getByTestId('name-input')
await user.type(nameInput, 'New Knowledge Base Name')
expect(mockSetNewBase).toHaveBeenCalledWith(expect.any(Function))
})
it('should handle preprocess provider change', async () => {
renderComponent()
const preprocessSelect = screen.getByTestId('preprocess-select')
await user.selectOptions(preprocessSelect, 'mistral')
expect(mocks.handlers.handleDocPreprocessChange).toHaveBeenCalledWith('mistral')
})
it('should handle model selection changes', async () => {
renderComponent()
const modelSelectors = screen.getAllByTestId('model-selector')
// Test embedding model change
const embeddingModelSelector = modelSelectors[0]
await user.selectOptions(embeddingModelSelector, 'openai/text-embedding-ada-002')
expect(mocks.handlers.handleEmbeddingModelChange).toHaveBeenCalledWith('openai/text-embedding-ada-002')
// Test rerank model change
const rerankModelSelector = modelSelectors[1]
await user.selectOptions(rerankModelSelector, 'openai/rerank-model')
expect(mocks.handlers.handleRerankModelChange).toHaveBeenCalledWith('openai/rerank-model')
})
it('should handle dimension change', async () => {
renderComponent()
const dimensionInput = screen.getByTestId('embedding-dimension-input')
fireEvent.change(dimensionInput, { target: { value: '1536' } })
expect(mocks.handlers.handleDimensionChange).toHaveBeenCalledWith(1536)
})
it('should handle document count change', async () => {
renderComponent()
const documentCountSlider = screen.getByTestId('document-count-slider')
fireEvent.change(documentCountSlider, { target: { value: '10' } })
expect(mockSetNewBase).toHaveBeenCalledWith(expect.any(Function))
})
it('should disable dimension input when no model is selected', () => {
const baseWithoutModel = createKnowledgeBase({ model: undefined as any })
renderComponent({ newBase: baseWithoutModel })
const dimensionInput = screen.getByTestId('embedding-dimension-input')
expect(dimensionInput).toBeDisabled()
})
})
})

View File

@ -0,0 +1,235 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import KnowledgeBaseFormModal, { PanelConfig } from '../components/KnowledgeSettings/KnowledgeBaseFormModal'
// Mock dependencies
const mocks = vi.hoisted(() => ({
onCancel: vi.fn(),
onOk: vi.fn()
}))
// Mock HStack component
vi.mock('@renderer/components/Layout', () => ({
HStack: ({ children, ...props }: any) => (
<div data-testid="hstack" {...props}>
{children}
</div>
)
}))
// Mock antd components
vi.mock('antd', () => ({
Modal: ({ children, open, title, onCancel, onOk, ...props }: any) =>
open ? (
<div data-testid="modal" data-title={title} {...props}>
<div data-testid="modal-header">
<span>{title}</span>
<button type="button" data-testid="modal-close" onClick={onCancel}>
×
</button>
</div>
<div data-testid="modal-body">{children}</div>
<div data-testid="modal-footer">
<button type="button" data-testid="modal-cancel" onClick={onCancel}>
Cancel
</button>
<button type="button" data-testid="modal-ok" onClick={onOk}>
OK
</button>
</div>
</div>
) : null,
Menu: ({ items, defaultSelectedKeys, onSelect, ...props }: any) => (
<div data-testid="menu" data-default-selected={defaultSelectedKeys?.[0]} {...props}>
{items?.map((item: any) => (
<div
key={item.key}
data-testid={`menu-item-${item.key}`}
onClick={() => onSelect?.({ key: item.key })}
style={{ cursor: 'pointer' }}>
{item.label}
</div>
))}
</div>
)
}))
/**
*
* @param overrides
* @returns PanelConfig
*/
function createPanelConfigs(overrides: Partial<PanelConfig>[] = []): PanelConfig[] {
const defaultPanels: PanelConfig[] = [
{
key: 'general',
label: 'General Settings',
panel: <div data-testid="general-panel">General Settings Panel</div>
},
{
key: 'advanced',
label: 'Advanced Settings',
panel: <div data-testid="advanced-panel">Advanced Settings Panel</div>
}
]
return defaultPanels.map((panel, index) => ({
...panel,
...overrides[index]
}))
}
/**
* KnowledgeBaseFormModal
* @param props
* @returns render
*/
function renderModal(props: Partial<any> = {}) {
const defaultProps = {
open: true,
title: 'Knowledge Base Settings',
panels: createPanelConfigs(),
onCancel: mocks.onCancel,
onOk: mocks.onOk
}
return render(<KnowledgeBaseFormModal {...defaultProps} {...props} />)
}
describe('KnowledgeBaseFormModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('basic rendering', () => {
it('should match snapshot', () => {
const { container } = renderModal()
expect(container.firstChild).toMatchSnapshot()
})
it('should render modal when open is true', () => {
renderModal({ open: true })
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByTestId('hstack')).toBeInTheDocument()
expect(screen.getByTestId('menu')).toBeInTheDocument()
})
it('should render first panel by default', () => {
renderModal()
expect(screen.getByTestId('general-panel')).toBeInTheDocument()
expect(screen.queryByTestId('advanced-panel')).not.toBeInTheDocument()
})
it('should handle empty panels array', () => {
renderModal({ panels: [] })
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByTestId('menu')).toBeInTheDocument()
})
})
describe('menu interaction', () => {
it('should switch panels when menu item is clicked', () => {
renderModal()
// Initially shows general panel
expect(screen.getByTestId('general-panel')).toBeInTheDocument()
expect(screen.queryByTestId('advanced-panel')).not.toBeInTheDocument()
// Click advanced menu item
fireEvent.click(screen.getByTestId('menu-item-advanced'))
// Should now show advanced panel
expect(screen.queryByTestId('general-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('advanced-panel')).toBeInTheDocument()
})
it('should set default selected menu to first panel key', () => {
const panels = createPanelConfigs()
renderModal({ panels })
const menu = screen.getByTestId('menu')
expect(menu).toHaveAttribute('data-default-selected', panels[0].key)
})
it('should handle menu selection with custom panels', () => {
const customPanels: PanelConfig[] = [
{
key: 'custom1',
label: 'Custom Panel 1',
panel: <div data-testid="custom1-panel">Custom Panel 1</div>
},
{
key: 'custom2',
label: 'Custom Panel 2',
panel: <div data-testid="custom2-panel">Custom Panel 2</div>
}
]
renderModal({ panels: customPanels })
// Initially shows first custom panel
expect(screen.getByTestId('custom1-panel')).toBeInTheDocument()
// Click second custom menu item
fireEvent.click(screen.getByTestId('menu-item-custom2'))
// Should now show second custom panel
expect(screen.queryByTestId('custom1-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('custom2-panel')).toBeInTheDocument()
})
})
describe('modal props', () => {
const user = userEvent.setup()
it('should pass through modal props correctly', () => {
const customTitle = 'Custom Modal Title'
renderModal({ title: customTitle })
const modal = screen.getByTestId('modal')
expect(modal).toHaveAttribute('data-title', customTitle)
})
it('should call onOk when ok button is clicked', async () => {
renderModal()
await user.click(screen.getByTestId('modal-ok'))
expect(mocks.onOk).toHaveBeenCalledTimes(1)
})
})
describe('edge cases', () => {
it('should handle single panel', () => {
const singlePanel: PanelConfig[] = [
{
key: 'only',
label: 'Only Panel',
panel: <div data-testid="only-panel">Only Panel</div>
}
]
renderModal({ panels: singlePanel })
expect(screen.getByTestId('only-panel')).toBeInTheDocument()
expect(screen.getByTestId('menu-item-only')).toBeInTheDocument()
})
it('should handle panel with undefined key gracefully', () => {
const panelsWithUndefined = [
{
key: 'valid',
label: 'Valid Panel',
panel: <div data-testid="valid-panel">Valid Panel</div>
}
]
renderModal({ panels: panelsWithUndefined })
expect(screen.getByTestId('valid-panel')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,329 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AdvancedSettingsPanel > basic rendering > should match snapshot 1`] = `
.c0 {
padding: 0 16px;
}
.c1 {
margin-bottom: 24px;
}
.c1 .settings-label {
font-size: 14px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
<div
class="c0"
>
<div
class="c1"
>
<div
class="settings-label"
>
分块大小
<div>
knowledge.chunk_size_tooltip
</div>
</div>
<div
class="ant-input-number css-dev-only-do-not-override-1261szd ant-input-number-outlined"
style="width: 100%;"
>
<div
class="ant-input-number-handler-wrap"
>
<span
aria-disabled="false"
aria-label="Increase Value"
class="ant-input-number-handler ant-input-number-handler-up"
role="button"
unselectable="on"
>
<span
aria-label="up"
class="anticon anticon-up ant-input-number-handler-up-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="up"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M890.5 755.3L537.9 269.2c-12.8-17.6-39-17.6-51.7 0L133.5 755.3A8 8 0 00140 768h75c5.1 0 9.9-2.5 12.9-6.6L512 369.8l284.1 391.6c3 4.1 7.8 6.6 12.9 6.6h75c6.5 0 10.3-7.4 6.5-12.7z"
/>
</svg>
</span>
</span>
<span
aria-disabled="false"
aria-label="Decrease Value"
class="ant-input-number-handler ant-input-number-handler-down"
role="button"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-input-number-handler-down-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
<div
class="ant-input-number-input-wrap"
>
<input
aria-label="分块大小"
aria-valuemin="100"
aria-valuenow="500"
autocomplete="off"
class="ant-input-number-input"
placeholder="knowledge.chunk_size_placeholder"
role="spinbutton"
step="1"
value="500"
/>
</div>
</div>
</div>
<div
class="c1"
>
<div
class="settings-label"
>
分块重叠
<div>
knowledge.chunk_overlap_tooltip
</div>
</div>
<div
class="ant-input-number css-dev-only-do-not-override-1261szd ant-input-number-outlined"
style="width: 100%;"
>
<div
class="ant-input-number-handler-wrap"
>
<span
aria-disabled="false"
aria-label="Increase Value"
class="ant-input-number-handler ant-input-number-handler-up"
role="button"
unselectable="on"
>
<span
aria-label="up"
class="anticon anticon-up ant-input-number-handler-up-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="up"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M890.5 755.3L537.9 269.2c-12.8-17.6-39-17.6-51.7 0L133.5 755.3A8 8 0 00140 768h75c5.1 0 9.9-2.5 12.9-6.6L512 369.8l284.1 391.6c3 4.1 7.8 6.6 12.9 6.6h75c6.5 0 10.3-7.4 6.5-12.7z"
/>
</svg>
</span>
</span>
<span
aria-disabled="false"
aria-label="Decrease Value"
class="ant-input-number-handler ant-input-number-handler-down"
role="button"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-input-number-handler-down-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
<div
class="ant-input-number-input-wrap"
>
<input
aria-label="分块重叠"
aria-valuemin="0"
aria-valuenow="200"
autocomplete="off"
class="ant-input-number-input"
placeholder="knowledge.chunk_overlap_placeholder"
role="spinbutton"
step="1"
value="200"
/>
</div>
</div>
</div>
<div
class="c1"
>
<div
class="settings-label"
>
检索相似度阈值
<div>
knowledge.threshold_tooltip
</div>
</div>
<div
class="ant-input-number css-dev-only-do-not-override-1261szd ant-input-number-outlined"
style="width: 100%;"
>
<div
class="ant-input-number-handler-wrap"
>
<span
aria-disabled="false"
aria-label="Increase Value"
class="ant-input-number-handler ant-input-number-handler-up"
role="button"
unselectable="on"
>
<span
aria-label="up"
class="anticon anticon-up ant-input-number-handler-up-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="up"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M890.5 755.3L537.9 269.2c-12.8-17.6-39-17.6-51.7 0L133.5 755.3A8 8 0 00140 768h75c5.1 0 9.9-2.5 12.9-6.6L512 369.8l284.1 391.6c3 4.1 7.8 6.6 12.9 6.6h75c6.5 0 10.3-7.4 6.5-12.7z"
/>
</svg>
</span>
</span>
<span
aria-disabled="false"
aria-label="Decrease Value"
class="ant-input-number-handler ant-input-number-handler-down"
role="button"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-input-number-handler-down-inner"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
<div
class="ant-input-number-input-wrap"
>
<input
aria-label="检索相似度阈值"
aria-valuemax="1"
aria-valuemin="0"
aria-valuenow="0.5"
autocomplete="off"
class="ant-input-number-input"
placeholder="knowledge.threshold_placeholder"
role="spinbutton"
step="0.1"
value="0.5"
/>
</div>
</div>
</div>
<div
class="ant-alert ant-alert-warning css-dev-only-do-not-override-1261szd"
data-show="true"
role="alert"
>
<span
aria-label="warning"
class="anticon anticon-warning ant-alert-icon"
role="img"
>
<svg
aria-hidden="true"
data-icon="warning"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M464 720a48 48 0 1096 0 48 48 0 10-96 0zm16-304v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8zm475.7 440l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48zm-783.5-27.9L512 239.9l339.8 588.2H172.2z"
/>
</svg>
</span>
<div
class="ant-alert-content"
>
<div
class="ant-alert-message"
>
避免修改这个高级设置。
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,201 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = `
.c0 {
padding: 0 16px;
}
.c1 {
margin-bottom: 24px;
}
.c1 .settings-label {
font-size: 14px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
<div
class="c0"
>
<div
class="c1"
>
<div
class="settings-label"
>
common.name
</div>
<input
data-testid="name-input"
placeholder="common.name"
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"
>
<div
class="settings-label"
>
models.embedding_model
<span
data-placement="right"
data-testid="info-tooltip"
title="models.embedding_model_tooltip"
>
</span>
</div>
<select
data-has-providers="true"
data-model-type="embedding"
data-placeholder="settings.models.empty"
data-testid="model-selector"
>
<option
value=""
>
Select model
</option>
<option
value="openai/text-embedding-3-small"
>
text-embedding-3-small
</option>
<option
value="openai/text-embedding-ada-002"
>
text-embedding-ada-002
</option>
</select>
</div>
<div
class="c1"
>
<div
class="settings-label"
>
knowledge.dimensions
<span
data-placement="right"
data-testid="info-tooltip"
title="knowledge.dimensions_size_tooltip"
>
</span>
</div>
<input
data-model="text-embedding-3-small"
data-testid="embedding-dimension-input"
type="number"
value=""
/>
</div>
<div
class="c1"
>
<div
class="settings-label"
>
models.rerank_model
<span
data-placement="right"
data-testid="info-tooltip"
title="models.rerank_model_tooltip"
>
</span>
</div>
<select
data-allow-clear="true"
data-has-providers="true"
data-model-type="rerank"
data-placeholder="settings.models.empty"
data-testid="model-selector"
>
<option
value=""
>
Select model
</option>
<option
value="openai/rerank-model"
>
rerank-model
</option>
<option
value="cohere/rerank-english-v2.0"
>
rerank-english-v2.0
</option>
</select>
</div>
<div
class="c1"
>
<div
class="settings-label"
>
knowledge.document_count
<span
data-placement="right"
data-testid="info-tooltip"
title="knowledge.document_count_help"
>
</span>
</div>
<input
data-marks="{"1":"1","6":"knowledge.document_count_default","30":"30","50":"50"}"
data-testid="document-count-slider"
max="50"
min="1"
step="1"
type="range"
value="6"
/>
</div>
</div>
`;

View File

@ -0,0 +1,141 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`KnowledgeBaseFormModal > basic rendering > should match snapshot 1`] = `
.c0 .ant-modal-title {
font-size: 14px;
}
.c0 .ant-modal-close {
top: 4px;
right: 4px;
}
.c1 {
display: flex;
height: 100%;
border-right: 0.5px solid var(--color-border);
}
.c3 {
flex: 1;
padding: 16px 16px;
overflow-y: scroll;
}
.c2 {
width: 200px;
padding: 5px;
background: transparent;
margin-top: 2px;
border-inline-end: none!important;
}
.c2 .ant-menu-item {
height: 36px;
color: var(--color-text-2);
display: flex;
align-items: center;
border: 0.5px solid transparent;
border-radius: 6px;
margin-bottom: 7px;
}
.c2 .ant-menu-item .ant-menu-title-content {
line-height: 36px;
}
.c2 .ant-menu-item-active {
background-color: var(--color-background-soft)!important;
transition: none;
}
.c2 .ant-menu-item-selected {
background-color: var(--color-background-soft);
border: 0.5px solid var(--color-border);
}
.c2 .ant-menu-item-selected .ant-menu-title-content {
color: var(--color-text-1);
font-weight: 500;
}
<div
class="c0"
data-testid="modal"
data-title="Knowledge Base Settings"
styles="[object Object]"
transitionname="animation-move-down"
width="min(800px, 70vw)"
>
<div
data-testid="modal-header"
>
<span>
Knowledge Base Settings
</span>
<button
data-testid="modal-close"
type="button"
>
×
</button>
</div>
<div
data-testid="modal-body"
>
<div
data-testid="hstack"
height="100%"
>
<div
class="c1"
>
<div
class="c2"
data-default-selected="general"
data-testid="menu"
mode="vertical"
>
<div
data-testid="menu-item-general"
style="cursor: pointer;"
>
General Settings
</div>
<div
data-testid="menu-item-advanced"
style="cursor: pointer;"
>
Advanced Settings
</div>
</div>
</div>
<div
class="c3"
>
<div
data-testid="general-panel"
>
General Settings Panel
</div>
</div>
</div>
</div>
<div
data-testid="modal-footer"
>
<button
data-testid="modal-cancel"
type="button"
>
Cancel
</button>
<button
data-testid="modal-ok"
type="button"
>
OK
</button>
</div>
</div>
`;

View File

@ -0,0 +1,117 @@
import { loggerService } from '@logger'
import { TopView } from '@renderer/components/TopView'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useKnowledgeBaseForm } from '@renderer/hooks/useKnowledgeBaseForm'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import { formatErrorMessage } from '@renderer/utils/error'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
AdvancedSettingsPanel,
GeneralSettingsPanel,
KnowledgeBaseFormModal,
type PanelConfig
} from './KnowledgeSettings'
const logger = loggerService.withContext('AddKnowledgeBasePopup')
interface ShowParams {
title: string
}
interface PopupContainerProps extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<PopupContainerProps> = ({ title, resolve }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const { addKnowledgeBase } = useKnowledgeBases()
const {
newBase,
setNewBase,
handlers,
providerData: { selectedDocPreprocessProvider, docPreprocessSelectOptions }
} = useKnowledgeBaseForm()
const onOk = async () => {
if (!newBase.name?.trim()) {
window.message.error(t('knowledge.name_required'))
return
}
if (!newBase.model) {
window.message.error(t('knowledge.embedding_model_required'))
return
}
try {
const _newBase = {
...newBase,
created_at: Date.now(),
updated_at: Date.now()
}
await window.api.knowledgeBase.create(getKnowledgeBaseParams(_newBase))
addKnowledgeBase(_newBase)
setOpen(false)
resolve(_newBase)
} catch (error) {
logger.error('KnowledgeBase creation failed:', error as Error)
window.message.error(t('knowledge.error.failed_to_create') + formatErrorMessage(error))
}
}
const onCancel = () => {
setOpen(false)
resolve(null)
}
const panelConfigs: PanelConfig[] = [
{
key: 'general',
label: t('settings.general.label'),
panel: (
<GeneralSettingsPanel
newBase={newBase}
setNewBase={setNewBase}
selectedDocPreprocessProvider={selectedDocPreprocessProvider}
docPreprocessSelectOptions={docPreprocessSelectOptions}
handlers={handlers}
/>
)
},
{
key: 'advanced',
label: t('settings.advanced.title'),
panel: <AdvancedSettingsPanel newBase={newBase} handlers={handlers} />
}
]
return <KnowledgeBaseFormModal title={title} open={open} onOk={onOk} onCancel={onCancel} panels={panelConfigs} />
}
export default class AddKnowledgeBasePopup {
static TopViewKey = 'AddKnowledgeBasePopup'
static hide() {
TopView.hide(this.TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
this.hide()
}}
/>,
this.TopViewKey
)
})
}
}

View File

@ -1,533 +0,0 @@
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import AiProvider from '@renderer/aiCore'
import { HStack } from '@renderer/components/Layout'
import ModelSelector from '@renderer/components/ModelSelector'
import { TopView } from '@renderer/components/TopView'
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, isMac } from '@renderer/config/constant'
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useOcrProviders } from '@renderer/hooks/useOcr'
import { usePreprocessProviders } from '@renderer/hooks/usePreprocess'
import { useProviders } from '@renderer/hooks/useProvider'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { KnowledgeBase, Model, OcrProvider, PreprocessProvider } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils/error'
import { Alert, Input, InputNumber, Modal, Select, Slider, Switch, Tooltip } from 'antd'
import { find } from 'lodash'
import { ChevronDown } from 'lucide-react'
import { nanoid } from 'nanoid'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ShowParams {
title: string
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const logger = loggerService.withContext('AddKnowledgePopup')
const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
const [open, setOpen] = useState(true)
const [loading, setLoading] = useState(false)
const [autoDims, setAutoDims] = useState(true)
const [showAdvanced, setShowAdvanced] = useState(false)
const { t } = useTranslation()
const { providers } = useProviders()
const { addKnowledgeBase } = useKnowledgeBases()
const [newBase, setNewBase] = useState<KnowledgeBase>({} as KnowledgeBase)
const [dimensions, setDimensions] = useState<number | undefined>(undefined)
const { preprocessProviders } = usePreprocessProviders()
const { ocrProviders } = useOcrProviders()
const [selectedProvider, setSelectedProvider] = useState<PreprocessProvider | OcrProvider | undefined>(undefined)
const embeddingModels = useMemo(() => {
return providers
.map((p) => p.models)
.flat()
.filter((model) => isEmbeddingModel(model))
}, [providers])
const rerankModels = useMemo(() => {
return providers
.map((p) => p.models)
.flat()
.filter((model) => isRerankModel(model))
}, [providers])
const nameInputRef = useRef<any>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const preprocessOrOcrSelectOptions = useMemo(() => {
const preprocessOptions = {
label: t('settings.tool.preprocess.provider'),
title: t('settings.tool.preprocess.provider'),
options: preprocessProviders
// todo: 免费期结束后删除
.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])
const onOk = async () => {
try {
if (!newBase.name?.trim()) {
window.message.error(t('knowledge.name_required'))
return
}
if (!newBase.model) {
window.message.error(t('knowledge.embedding_model_required'))
return
}
// const values = await form.validateFields()
const selectedEmbeddingModel = find(embeddingModels, newBase.model) as Model
const selectedRerankModel = newBase.rerankModel ? (find(rerankModels, newBase.rerankModel) as Model) : undefined
if (selectedEmbeddingModel) {
setLoading(true)
const provider = providers.find((p) => p.id === selectedEmbeddingModel.provider)
if (!provider) {
return
}
let finalDimensions: number // 用于存储最终确定的维度值
if (autoDims || dimensions === undefined) {
try {
const aiProvider = new AiProvider(provider)
finalDimensions = await aiProvider.getEmbeddingDimensions(selectedEmbeddingModel)
setDimensions(finalDimensions)
} catch (error) {
logger.error('Error getting embedding dimensions:', error as Error)
window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
setLoading(false)
return
}
} else {
finalDimensions = dimensions
}
const _newBase = {
...newBase,
id: nanoid(),
name: newBase.name,
model: selectedEmbeddingModel,
rerankModel: selectedRerankModel,
dimensions: finalDimensions,
documentCount: newBase.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT,
items: [],
created_at: Date.now(),
updated_at: Date.now(),
version: 1
}
await window.api.knowledgeBase.create(getKnowledgeBaseParams(_newBase))
addKnowledgeBase(_newBase as any)
setOpen(false)
resolve(_newBase)
}
} catch (error) {
logger.error('Validation failed:', error as Error)
}
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve(null)
}
useEffect(() => {
if (showAdvanced && scrollContainerRef.current) {
// 延迟滚动确保DOM更新完成
setTimeout(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth'
})
}
}, 300)
}
}, [showAdvanced])
return (
<SettingsModal
title={title}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
afterOpenChange={(visible) => visible && nameInputRef.current?.focus()}
destroyOnClose
centered
transitionName="animation-move-down"
okButtonProps={{ loading }}
width="min(600px, 60vw)"
styles={{
body: { padding: 0 },
header: {
padding: '10px 15px',
borderBottom: '0.5px solid var(--color-border)',
margin: 0,
borderRadius: 0
},
content: {
padding: 0,
paddingBottom: 10,
overflow: 'hidden'
}
}}>
<HStack>
<SettingsContentPanel ref={scrollContainerRef}>
<SettingsPanel>
<SettingsItem>
<div className="settings-label">{t('common.name')}</div>
<Input
ref={nameInputRef}
placeholder={t('common.name')}
onChange={(e) => {
if (e.target.value) {
setNewBase({ ...newBase, name: e.target.value })
}
}}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('settings.tool.preprocess.title')} / {t('settings.tool.ocr.title')}
<Tooltip title={t('settings.tool.preprocessOrOcr.tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<Select
value={selectedProvider?.id}
style={{ width: '100%' }}
onChange={(value: string) => {
const type = preprocessProviders.find((p) => p.id === value) ? 'preprocess' : 'ocr'
const provider = (type === 'preprocess' ? preprocessProviders : ocrProviders).find(
(p) => p.id === value
)
if (!provider) {
setSelectedProvider(undefined)
setNewBase({
...newBase,
preprocessOrOcrProvider: undefined
})
return
}
setSelectedProvider(provider)
setNewBase({
...newBase,
preprocessOrOcrProvider: {
type: type,
provider: provider
}
})
}}
placeholder={t('settings.tool.preprocess.provider_placeholder')}
options={preprocessOrOcrSelectOptions}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('models.embedding_model')}
<Tooltip title={t('models.embedding_model_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<ModelSelector
providers={providers}
predicate={isEmbeddingModel}
style={{ width: '100%' }}
placeholder={t('settings.models.empty')}
onChange={(value) => {
const model = value
? providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === value)
: undefined
if (!model) return
setNewBase({ ...newBase, model })
}}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('models.rerank_model')}
<Tooltip title={t('models.rerank_model_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<ModelSelector
providers={providers}
predicate={isRerankModel}
style={{ width: '100%' }}
placeholder={t('settings.models.empty')}
onChange={(value) => {
const rerankModel = value
? providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === value)
: undefined
setNewBase({ ...newBase, rerankModel })
}}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.document_count')}
<Tooltip title={t('knowledge.document_count_help')}>
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<Slider
min={1}
max={50}
step={1}
defaultValue={DEFAULT_KNOWLEDGE_DOCUMENT_COUNT}
marks={{ 1: '1', 6: t('knowledge.document_count_default'), 30: '30', 50: '50' }}
onChange={(value) => setNewBase({ ...newBase, documentCount: value })}
/>
</SettingsItem>
{/* dimensions */}
<SettingsItem style={{ marginTop: 35 }}>
<div
className="settings-label"
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<span>
{t('knowledge.dimensions_auto_set')}
<Tooltip title={t('knowledge.dimensions_default')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</span>
<Switch
checked={autoDims}
onChange={(checked) => {
setAutoDims(checked)
if (checked) {
setDimensions(undefined)
}
}}
/>
</div>
</SettingsItem>
{!autoDims && (
<SettingsItem>
<div className="settings-label">
{t('knowledge.dimensions')}
<Tooltip title={t('knowledge.dimensions_size_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<InputNumber
min={1}
style={{ width: '100%' }}
placeholder={t('knowledge.dimensions_size_placeholder')}
value={newBase.dimensions}
onChange={(value) => {
setDimensions(value === null ? undefined : value)
}}
/>
</SettingsItem>
)}
</SettingsPanel>
<AdvancedSettingsButton onClick={() => setShowAdvanced(!showAdvanced)}>
<ChevronDown
size={18}
style={{
transform: showAdvanced ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.3s',
marginRight: 8,
stroke: 'var(--color-primary)'
}}
/>
{t('common.advanced_settings')}
</AdvancedSettingsButton>
{showAdvanced && (
<SettingsPanel>
<SettingsItem>
<div className="settings-label">
{t('knowledge.chunk_size')}
<Tooltip title={t('knowledge.chunk_size_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</div>
<InputNumber
style={{ width: '100%' }}
min={100}
value={newBase.chunkSize}
placeholder={t('knowledge.chunk_size_placeholder')}
onChange={(value) => {
const maxContext = getEmbeddingMaxContext(newBase.model.id)
if (!value || !maxContext || value <= maxContext) {
setNewBase({ ...newBase, chunkSize: value || undefined })
}
}}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.chunk_overlap')}
<Tooltip title={t('knowledge.chunk_overlap_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</div>
<InputNumber
style={{ width: '100%' }}
min={0}
value={newBase.chunkOverlap}
placeholder={t('knowledge.chunk_overlap_placeholder')}
onChange={async (value) => {
if (!value || (newBase.chunkSize && newBase.chunkSize > value)) {
setNewBase({ ...newBase, chunkOverlap: value || undefined })
} else {
await window.message.error(t('message.error.chunk_overlap_too_large'))
}
}}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.threshold')}
<Tooltip title={t('knowledge.threshold_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8 }} />
</Tooltip>
</div>
<InputNumber
style={{ width: '100%' }}
step={0.1}
min={0}
max={1}
value={newBase.threshold}
placeholder={t('knowledge.threshold_placeholder')}
onChange={(value) => setNewBase({ ...newBase, threshold: value || undefined })}
/>
</SettingsItem>
<Alert
message={t('knowledge.chunk_size_change_warning')}
type="warning"
showIcon
icon={<WarningOutlined />}
/>
</SettingsPanel>
)}
</SettingsContentPanel>
</HStack>
</SettingsModal>
)
}
const SettingsPanel = styled.div`
padding: 0 16px;
`
const SettingsItem = styled.div`
margin-bottom: 24px;
.settings-label {
font-size: 14px;
margin-bottom: 8px;
display: flex;
align-items: center;
}
`
const SettingsModal = styled(Modal)`
.ant-modal-title {
font-size: 14px;
}
.ant-modal-close {
top: 4px;
right: 4px;
}
.ant-menu-item {
height: 36px;
color: var(--color-text-2);
display: flex;
align-items: center;
border: 0.5px solid transparent;
border-radius: 6px;
.ant-menu-title-content {
line-height: 36px;
}
}
.ant-menu-item-active {
background-color: var(--color-background-soft) !important;
transition: none;
}
.ant-menu-item-selected {
background-color: var(--color-background-soft);
border: 0.5px solid var(--color-border);
.ant-menu-title-content {
color: var(--color-text-1);
font-weight: 500;
}
}
`
const SettingsContentPanel = styled.div`
flex: 1;
padding: 16px 16px;
max-height: calc(80vh - 80px);
overflow-y: auto;
`
const AdvancedSettingsButton = styled.div`
cursor: pointer;
margin-bottom: 16px;
color: var(--color-primary);
display: flex;
align-items: center;
margin: 0 16px;
padding: 16px 0;
border-top: 0.5px solid var(--color-border);
`
export default class AddKnowledgePopup {
static hide() {
TopView.hide('AddKnowledgePopup')
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
this.hide()
}}
/>,
'AddKnowledgePopup'
)
})
}
}

View File

@ -0,0 +1,161 @@
import { loggerService } from '@logger'
import { nanoid } from '@reduxjs/toolkit'
import { TopView } from '@renderer/components/TopView'
import { useKnowledge } from '@renderer/hooks/useKnowledge'
import { useKnowledgeBaseForm } from '@renderer/hooks/useKnowledgeBaseForm'
import { getModelUniqId } from '@renderer/services/ModelService'
import { KnowledgeBase } from '@renderer/types'
import { formatErrorMessage } from '@renderer/utils/error'
import { Flex } from 'antd'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
AdvancedSettingsPanel,
GeneralSettingsPanel,
KnowledgeBaseFormModal,
type PanelConfig
} from './KnowledgeSettings'
const logger = loggerService.withContext('EditKnowledgeBasePopup')
interface ShowParams {
base: KnowledgeBase
}
interface PopupContainerProps extends ShowParams {
resolve: (data: KnowledgeBase | null) => void
}
const PopupContainer: React.FC<PopupContainerProps> = ({ base: _base, resolve }) => {
const { t } = useTranslation()
const { base, updateKnowledgeBase, migrateBase } = useKnowledge(_base.id)
const {
newBase,
setNewBase,
handlers,
providerData: { selectedDocPreprocessProvider, docPreprocessSelectOptions }
} = useKnowledgeBaseForm(_base)
const [open, setOpen] = useState(true)
const hasCriticalChanges = useMemo(
() => getModelUniqId(base?.model) !== getModelUniqId(newBase?.model) || base?.dimensions !== newBase?.dimensions,
[base, newBase]
)
const handleMigration = useCallback(async () => {
const migratedBase = { ...newBase, id: nanoid() }
try {
await migrateBase(migratedBase)
setOpen(false)
resolve(migratedBase)
} catch (error) {
logger.error('KnowledgeBase migration failed:', error as Error)
window.message.error(t('knowledge.migrate.error.failed') + ': ' + formatErrorMessage(error))
}
}, [newBase, migrateBase, resolve, t])
if (!base) {
resolve(null)
return null
}
const onOk = async () => {
if (hasCriticalChanges) {
window.modal.confirm({
title: t('knowledge.migrate.confirm.title'),
content: (
<Flex vertical align="self-start">
<span>{t('knowledge.migrate.confirm.content')}</span>
<span>{t('knowledge.embedding_model')}:</span>
<span style={{ paddingLeft: '1em' }}>{`${t('knowledge.migrate.source_model')}: ${base.model.name}`}</span>
<span
style={{ paddingLeft: '1em' }}>{`${t('knowledge.migrate.target_model')}: ${newBase.model.name}`}</span>
<span>{t('knowledge.dimensions')}:</span>
<span
style={{ paddingLeft: '1em' }}>{`${t('knowledge.migrate.source_dimensions')}: ${base.dimensions}`}</span>
<span
style={{
paddingLeft: '1em'
}}>{`${t('knowledge.migrate.target_dimensions')}: ${newBase.dimensions}`}</span>
</Flex>
),
okText: t('knowledge.migrate.confirm.ok'),
centered: true,
onOk: handleMigration
})
} else {
try {
logger.debug('newbase', newBase)
updateKnowledgeBase(newBase)
setOpen(false)
resolve(newBase)
} catch (error) {
logger.error('KnowledgeBase edit failed:', error as Error)
window.message.error(t('knowledge.error.failed_to_edit') + formatErrorMessage(error))
}
}
}
const onCancel = () => {
setOpen(false)
resolve(null)
}
const panelConfigs: PanelConfig[] = [
{
key: 'general',
label: t('settings.general.label'),
panel: (
<GeneralSettingsPanel
newBase={newBase}
setNewBase={setNewBase}
selectedDocPreprocessProvider={selectedDocPreprocessProvider}
docPreprocessSelectOptions={docPreprocessSelectOptions}
handlers={handlers}
/>
)
},
{
key: 'advanced',
label: t('settings.advanced.title'),
panel: <AdvancedSettingsPanel newBase={newBase} handlers={handlers} />
}
]
return (
<KnowledgeBaseFormModal
title={t('knowledge.settings.title')}
okText={hasCriticalChanges ? t('knowledge.migrate.button.text') : t('common.save')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={() => resolve(null)}
panels={panelConfigs}
/>
)
}
export default class EditKnowledgeBasePopup {
static TopViewKey = 'EditKnowledgeBasePopup'
static hide() {
TopView.hide(this.TopViewKey)
}
static show(props: ShowParams): Promise<KnowledgeBase | null> {
return new Promise<KnowledgeBase | null>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
this.hide()
resolve(v)
}}
/>,
this.TopViewKey
)
})
}
}

View File

@ -1,423 +0,0 @@
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { HStack } from '@renderer/components/Layout'
import ModelSelector from '@renderer/components/ModelSelector'
import { TopView } from '@renderer/components/TopView'
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, isMac } from '@renderer/config/constant'
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { useKnowledge } from '@renderer/hooks/useKnowledge'
import { useOcrProviders } from '@renderer/hooks/useOcr'
import { usePreprocessProviders } from '@renderer/hooks/usePreprocess'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { KnowledgeBase, PreprocessProvider } from '@renderer/types'
import { Alert, Input, InputNumber, Menu, Modal, Select, Slider, Tooltip } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const logger = loggerService.withContext('KnowledgeSettings')
interface ShowParams {
base: KnowledgeBase
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ base: _base, resolve }) => {
const { preprocessProviders } = usePreprocessProviders()
const { ocrProviders } = useOcrProviders()
const [selectedProvider, setSelectedProvider] = useState<PreprocessProvider | undefined>(
_base.preprocessOrOcrProvider?.provider
)
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const { providers } = useProviders()
const { base, updateKnowledgeBase } = useKnowledge(_base.id)
const [newBase, setNewBase] = useState<KnowledgeBase>(_base)
const [selectedMenu, setSelectedMenu] = useState('general')
if (!base) {
resolve(null)
return null
}
const preprocessOptions = {
label: t('settings.tool.preprocess.provider'),
title: t('settings.tool.preprocess.provider'),
options: preprocessProviders
// todo: 免费期结束后删除
.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 }))
}
const preprocessOrOcrSelectOptions = [
...(preprocessOptions.options.length > 0 ? [preprocessOptions] : []),
...(isMac && ocrOptions.options.length > 0 ? [ocrOptions] : [])
]
const onOk = async () => {
try {
logger.debug('newbase', newBase)
updateKnowledgeBase(newBase)
setOpen(false)
resolve(newBase)
} catch (error) {
logger.error('Validation failed:', error as Error)
}
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve(null)
}
const menuItems = [
{
key: 'general',
label: t('settings.general.label')
},
{
key: 'advanced',
label: t('settings.advanced.title')
}
]
const renderSettings = () => {
if (selectedMenu === 'general') {
return (
<SettingsPanel>
<SettingsItem>
<div className="settings-label">{t('common.name')}</div>
<Input
placeholder={t('common.name')}
defaultValue={base.name}
onChange={(e) => setNewBase({ ...newBase, name: e.target.value })}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('settings.tool.preprocess.title')} / {t('settings.tool.ocr.title')}
<Tooltip title={t('settings.tool.preprocessOrOcr.tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<Select
value={selectedProvider?.id}
style={{ width: '100%' }}
onChange={(value: string) => {
const type = preprocessProviders.find((p) => p.id === value) ? 'preprocess' : 'ocr'
const provider = (type === 'preprocess' ? preprocessProviders : ocrProviders).find(
(p) => p.id === value
)
if (!provider) {
setSelectedProvider(undefined)
setNewBase({
...newBase,
preprocessOrOcrProvider: undefined
})
return
}
setSelectedProvider(provider)
setNewBase({
...newBase,
preprocessOrOcrProvider: {
type: type,
provider: provider
}
})
}}
placeholder={t('settings.tool.preprocess.provider_placeholder')}
options={preprocessOrOcrSelectOptions}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('models.embedding_model')}
<Tooltip title={t('models.embedding_model_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<ModelSelector
providers={providers}
predicate={isEmbeddingModel}
style={{ width: '100%' }}
placeholder={t('settings.models.empty')}
defaultValue={getModelUniqId(base.model)}
disabled
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">{t('knowledge.dimensions')}</div>
<Input value={base.dimensions ?? t('knowledge.not_set')} style={{ width: '100%' }} disabled></Input>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('models.rerank_model')}
<Tooltip title={t('models.rerank_model_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<ModelSelector
providers={providers}
predicate={isRerankModel}
style={{ width: '100%' }}
defaultValue={getModelUniqId(base.rerankModel) || undefined}
placeholder={t('settings.models.empty')}
onChange={(value) => {
const rerankModel = value
? providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === value)
: undefined
setNewBase({ ...newBase, rerankModel })
}}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.document_count')}
<Tooltip title={t('knowledge.document_count_help')}>
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<Slider
style={{ width: '100%' }}
min={1}
max={50}
step={1}
defaultValue={base.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT}
marks={{ 1: '1', 6: t('knowledge.document_count_default'), 30: '30', 50: '50' }}
onChange={(value) => setNewBase({ ...newBase, documentCount: value })}
/>
</SettingsItem>
</SettingsPanel>
)
}
if (selectedMenu === 'advanced') {
return (
<SettingsPanel>
<SettingsItem>
<div className="settings-label">
{t('knowledge.chunk_size')}
<Tooltip title={t('knowledge.chunk_size_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<InputNumber
style={{ width: '100%' }}
min={100}
value={base.chunkSize}
placeholder={t('knowledge.chunk_size_placeholder')}
onChange={(value) => {
const maxContext = getEmbeddingMaxContext(base.model.id)
if (!value || !maxContext || value <= maxContext) {
setNewBase({ ...newBase, chunkSize: value || undefined })
}
}}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.chunk_overlap')}
<Tooltip title={t('knowledge.chunk_overlap_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<InputNumber
style={{ width: '100%' }}
min={0}
value={base.chunkOverlap}
placeholder={t('knowledge.chunk_overlap_placeholder')}
onChange={async (value) => {
if (!value || (newBase.chunkSize && newBase.chunkSize > value)) {
setNewBase({ ...newBase, chunkOverlap: value || undefined })
}
await window.message.error(t('message.error.chunk_overlap_too_large'))
}}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.threshold')}
<Tooltip title={t('knowledge.threshold_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 8, color: 'var(--color-text-3)' }} />
</Tooltip>
</div>
<InputNumber
style={{ width: '100%' }}
step={0.1}
min={0}
max={1}
value={base.threshold}
placeholder={t('knowledge.threshold_placeholder')}
onChange={(value) => setNewBase({ ...newBase, threshold: value || undefined })}
/>
</SettingsItem>
<Alert
message={t('knowledge.chunk_size_change_warning')}
type="warning"
showIcon
icon={<WarningOutlined />}
/>
</SettingsPanel>
)
}
return null
}
KnowledgeSettings.hide = onCancel
return (
<SettingsModal
title={t('knowledge.settings.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
destroyOnClose
maskClosable={false}
centered
transitionName="animation-move-down"
width="min(800px, 70vw)"
styles={{
body: { padding: 0, height: 550 },
header: {
padding: '10px 15px',
borderBottom: '0.5px solid var(--color-border)',
margin: 0,
borderRadius: 0
},
content: {
padding: 0,
paddingBottom: 10,
overflow: 'hidden'
}
}}>
<HStack height="100%">
<LeftMenu>
<StyledMenu
defaultSelectedKeys={['general']}
mode="vertical"
items={menuItems}
onSelect={({ key }) => setSelectedMenu(key)}
/>
</LeftMenu>
<SettingsContentPanel>{renderSettings()}</SettingsContentPanel>
</HStack>
</SettingsModal>
)
}
const TopViewKey = 'KnowledgeSettingsPopup'
const SettingsPanel = styled.div`
padding: 0 16px;
`
const SettingsItem = styled.div`
margin-bottom: 24px;
.settings-label {
font-size: 14px;
margin-bottom: 8px;
display: flex;
align-items: center;
}
`
const SettingsModal = styled(Modal)`
.ant-modal-title {
font-size: 14px;
}
.ant-modal-close {
top: 4px;
right: 4px;
}
.ant-menu-item {
height: 36px;
color: var(--color-text-2);
display: flex;
align-items: center;
border: 0.5px solid transparent;
border-radius: 6px;
.ant-menu-title-content {
line-height: 36px;
}
}
.ant-menu-item-active {
background-color: var(--color-background-soft) !important;
transition: none;
}
.ant-menu-item-selected {
background-color: var(--color-background-soft);
border: 0.5px solid var(--color-border);
.ant-menu-title-content {
color: var(--color-text-1);
font-weight: 500;
}
}
`
const LeftMenu = styled.div`
display: flex;
height: 100%;
border-right: 0.5px solid var(--color-border);
`
const SettingsContentPanel = styled.div`
flex: 1;
padding: 16px 16px;
overflow-y: scroll;
`
const StyledMenu = styled(Menu)`
width: 200px;
padding: 5px;
background: transparent;
margin-top: 2px;
border-inline-end: none !important;
.ant-menu-item {
margin-bottom: 7px;
}
`
export default class KnowledgeSettings {
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -0,0 +1,76 @@
import { WarningOutlined } from '@ant-design/icons'
import InfoTooltip from '@renderer/components/InfoTooltip'
import { KnowledgeBase } from '@renderer/types'
import { Alert, InputNumber } from 'antd'
import { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsPanel } from './styles'
interface AdvancedSettingsPanelProps {
newBase: KnowledgeBase
handlers: {
handleChunkSizeChange: (value: number | null) => void
handleChunkOverlapChange: (value: number | null) => void
handleThresholdChange: (value: number | null) => void
}
}
const AdvancedSettingsPanel: React.FC<AdvancedSettingsPanelProps> = ({ newBase, handlers }) => {
const { t } = useTranslation()
const { handleChunkSizeChange, handleChunkOverlapChange, handleThresholdChange } = handlers
return (
<SettingsPanel>
<SettingsItem>
<div className="settings-label">
{t('knowledge.chunk_size')}
<InfoTooltip title={t('knowledge.chunk_size_tooltip')} placement="right" />
</div>
<InputNumber
style={{ width: '100%' }}
min={100}
value={newBase.chunkSize}
placeholder={t('knowledge.chunk_size_placeholder')}
onChange={handleChunkSizeChange}
aria-label={t('knowledge.chunk_size')}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.chunk_overlap')}
<InfoTooltip title={t('knowledge.chunk_overlap_tooltip')} placement="right" />
</div>
<InputNumber
style={{ width: '100%' }}
min={0}
value={newBase.chunkOverlap}
placeholder={t('knowledge.chunk_overlap_placeholder')}
onChange={handleChunkOverlapChange}
aria-label={t('knowledge.chunk_overlap')}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.threshold')}
<InfoTooltip title={t('knowledge.threshold_tooltip')} placement="right" />
</div>
<InputNumber
style={{ width: '100%' }}
step={0.1}
min={0}
max={1}
value={newBase.threshold}
placeholder={t('knowledge.threshold_placeholder')}
onChange={handleThresholdChange}
aria-label={t('knowledge.threshold')}
/>
</SettingsItem>
<Alert message={t('knowledge.chunk_size_change_warning')} type="warning" showIcon icon={<WarningOutlined />} />
</SettingsPanel>
)
}
export default AdvancedSettingsPanel

View File

@ -0,0 +1,128 @@
import InfoTooltip from '@renderer/components/InfoTooltip'
import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimension'
import ModelSelector from '@renderer/components/ModelSelector'
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT } from '@renderer/config/constant'
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 { useTranslation } from 'react-i18next'
import { SettingsItem, SettingsPanel } from './styles'
interface GeneralSettingsPanelProps {
newBase: KnowledgeBase
setNewBase: React.Dispatch<React.SetStateAction<KnowledgeBase>>
selectedDocPreprocessProvider?: PreprocessProvider
docPreprocessSelectOptions: any[]
handlers: {
handleEmbeddingModelChange: (value: string) => void
handleDimensionChange: (value: number | null) => void
handleRerankModelChange: (value: string) => void
handleDocPreprocessChange: (value: string) => void
}
}
const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = ({
newBase,
setNewBase,
selectedDocPreprocessProvider,
docPreprocessSelectOptions,
handlers
}) => {
const { t } = useTranslation()
const { providers } = useProviders()
const { handleEmbeddingModelChange, handleDimensionChange, handleRerankModelChange, handleDocPreprocessChange } =
handlers
return (
<SettingsPanel>
<SettingsItem>
<div className="settings-label">{t('common.name')}</div>
<Input
placeholder={t('common.name')}
value={newBase.name}
onChange={(e) => setNewBase((prev) => ({ ...prev, name: e.target.value }))}
/>
</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')}
<InfoTooltip title={t('models.embedding_model_tooltip')} placement="right" />
</div>
<ModelSelector
providers={providers}
predicate={isEmbeddingModel}
style={{ width: '100%' }}
placeholder={t('settings.models.empty')}
value={getModelUniqId(newBase.model)}
onChange={handleEmbeddingModelChange}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.dimensions')}
<InfoTooltip title={t('knowledge.dimensions_size_tooltip')} placement="right" />
</div>
<InputEmbeddingDimension
value={newBase.dimensions}
onChange={handleDimensionChange}
model={newBase.model}
disabled={!newBase.model}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('models.rerank_model')}
<InfoTooltip title={t('models.rerank_model_tooltip')} placement="right" />
</div>
<ModelSelector
providers={providers}
predicate={isRerankModel}
style={{ width: '100%' }}
value={getModelUniqId(newBase.rerankModel) || undefined}
placeholder={t('settings.models.empty')}
onChange={handleRerankModelChange}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">
{t('knowledge.document_count')}
<InfoTooltip title={t('knowledge.document_count_help')} placement="right" />
</div>
<Slider
style={{ width: '100%' }}
min={1}
max={50}
step={1}
value={newBase.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT}
marks={{ 1: '1', 6: t('knowledge.document_count_default'), 30: '30', 50: '50' }}
onChange={(value) => setNewBase((prev) => ({ ...prev, documentCount: value }))}
/>
</SettingsItem>
</SettingsPanel>
)
}
export default GeneralSettingsPanel

View File

@ -0,0 +1,115 @@
import { HStack } from '@renderer/components/Layout'
import { Menu, Modal, ModalProps } from 'antd'
import React, { useState } from 'react'
import styled from 'styled-components'
export interface PanelConfig {
key: string
label: string
panel: React.ReactNode
}
interface KnowledgeBaseFormModalProps extends Omit<ModalProps, 'children'> {
panels: PanelConfig[]
}
const KnowledgeBaseFormModal: React.FC<KnowledgeBaseFormModalProps> = ({ panels, ...rest }) => {
const [selectedMenu, setSelectedMenu] = useState(panels[0]?.key)
const menuItems = panels.map(({ key, label }) => ({ key, label }))
const activePanel = panels.find((p) => p.key === selectedMenu)?.panel
return (
<StyledModal
destroyOnClose
maskClosable={false}
centered
transitionName="animation-move-down"
width="min(800px, 70vw)"
styles={{
body: { padding: 0, height: 550 },
header: {
padding: '10px 15px',
borderBottom: '0.5px solid var(--color-border)',
margin: 0,
borderRadius: 0
},
content: {
padding: 0,
paddingBottom: 10,
overflow: 'hidden'
}
}}
{...rest}>
<HStack height="100%">
<LeftMenu>
<StyledMenu
defaultSelectedKeys={[selectedMenu]}
mode="vertical"
items={menuItems}
onSelect={({ key }) => setSelectedMenu(key)}
/>
</LeftMenu>
<SettingsContentPanel>{activePanel}</SettingsContentPanel>
</HStack>
</StyledModal>
)
}
const StyledModal = styled(Modal)`
.ant-modal-title {
font-size: 14px;
}
.ant-modal-close {
top: 4px;
right: 4px;
}
`
const LeftMenu = styled.div`
display: flex;
height: 100%;
border-right: 0.5px solid var(--color-border);
`
const SettingsContentPanel = styled.div`
flex: 1;
padding: 16px 16px;
overflow-y: scroll;
`
const StyledMenu = styled(Menu)`
width: 200px;
padding: 5px;
background: transparent;
margin-top: 2px;
border-inline-end: none !important;
.ant-menu-item {
height: 36px;
color: var(--color-text-2);
display: flex;
align-items: center;
border: 0.5px solid transparent;
border-radius: 6px;
margin-bottom: 7px;
.ant-menu-title-content {
line-height: 36px;
}
}
.ant-menu-item-active {
background-color: var(--color-background-soft) !important;
transition: none;
}
.ant-menu-item-selected {
background-color: var(--color-background-soft);
border: 0.5px solid var(--color-border);
.ant-menu-title-content {
color: var(--color-text-1);
font-weight: 500;
}
}
`
export default KnowledgeBaseFormModal

View File

@ -0,0 +1,4 @@
export { default as AdvancedSettingsPanel } from './AdvancedSettingsPanel'
export { default as GeneralSettingsPanel } from './GeneralSettingsPanel'
export type { PanelConfig } from './KnowledgeBaseFormModal'
export { default as KnowledgeBaseFormModal } from './KnowledgeBaseFormModal'

View File

@ -0,0 +1,17 @@
import styled from 'styled-components'
export const SettingsPanel = styled.div`
padding: 0 16px;
`
export const SettingsItem = styled.div`
margin-bottom: 24px;
.settings-label {
font-size: 14px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
`

View File

@ -1,5 +1,7 @@
import { loggerService } from '@logger'
import AiProvider from '@renderer/aiCore'
import InfoTooltip from '@renderer/components/InfoTooltip'
import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimension'
import ModelSelector from '@renderer/components/ModelSelector'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { useModel } from '@renderer/hooks/useModel'
@ -7,12 +9,13 @@ import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { selectMemoryConfig, updateMemoryConfig } from '@renderer/store/memory'
import { Model } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils/error'
import { Form, InputNumber, Modal, Switch } from 'antd'
import { Flex, Form, Modal } from 'antd'
import { t } from 'i18next'
import { FC, useCallback, useEffect, useState } from 'react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
const logger = loggerService.withContext('MemoriesSettingsModal')
interface MemoriesSettingsModalProps {
visible: boolean
onSubmit: (values: any) => void
@ -24,35 +27,31 @@ type formValue = {
llmModel: string
embedderModel: string
embedderDimensions: number
autoDims: boolean
}
const logger = loggerService.withContext('MemoriesSettingsModal')
const MemoriesSettingsModal: FC<MemoriesSettingsModalProps> = ({ visible, onSubmit, onCancel, form }) => {
const { providers } = useProviders()
const dispatch = useDispatch()
const memoryConfig = useSelector(selectMemoryConfig)
const [autoDims, setAutoDims] = useState(true)
const [loading, setLoading] = useState(false)
// Get all models for lookup
const allModels = providers.flatMap((p) => p.models)
const allModels = useMemo(() => providers.flatMap((p) => p.models), [providers])
const llmModel = useModel(memoryConfig.llmApiClient?.model, memoryConfig.llmApiClient?.provider)
const embedderModel = useModel(memoryConfig.embedderApiClient?.model, memoryConfig.embedderApiClient?.provider)
const findModelById = useCallback(
(id: string | undefined) => (id ? allModels.find((m) => getModelUniqId(m) === id) : undefined),
[allModels]
)
// Initialize form with current memory config when modal opens
useEffect(() => {
if (visible && memoryConfig) {
// Use isAutoDimensions to determine autoDims state, defaulting to true if not set
const isAutoDims = memoryConfig.isAutoDimensions !== false
setAutoDims(isAutoDims)
form.setFieldsValue({
llmModel: getModelUniqId(llmModel),
embedderModel: getModelUniqId(embedderModel),
embedderDimensions: memoryConfig.embedderDimensions,
autoDims: isAutoDims
embedderDimensions: memoryConfig.embedderDimensions
// customFactExtractionPrompt: memoryConfig.customFactExtractionPrompt,
// customUpdateMemoryPrompt: memoryConfig.customUpdateMemoryPrompt
})
@ -62,12 +61,10 @@ const MemoriesSettingsModal: FC<MemoriesSettingsModalProps> = ({ visible, onSubm
const handleFormSubmit = async (values: formValue) => {
try {
// Convert model IDs back to Model objects
const llmModel = values.llmModel ? allModels.find((m) => getModelUniqId(m) === values.llmModel) : undefined
const llmModel = findModelById(values.llmModel)
const llmProvider = providers.find((p) => p.id === llmModel?.provider)
const aiLlmProvider = new AiProvider(llmProvider!)
const embedderModel = values.embedderModel
? allModels.find((m) => getModelUniqId(m) === values.embedderModel)
: undefined
const embedderModel = findModelById(values.embedderModel)
const embedderProvider = providers.find((p) => p.id === embedderModel?.provider)
const aiEmbedderProvider = new AiProvider(embedderProvider!)
if (embedderModel) {
@ -78,25 +75,10 @@ const MemoriesSettingsModal: FC<MemoriesSettingsModalProps> = ({ visible, onSubm
return
}
let finalDimensions: number | undefined
// Auto-detect dimensions if autoDims is enabled or dimensions not provided
if (values.autoDims || values.embedderDimensions === undefined) {
try {
const aiProvider = new AiProvider(provider)
finalDimensions = await aiProvider.getEmbeddingDimensions(embedderModel)
} catch (error) {
logger.error('Error getting embedding dimensions:', error as Error)
window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
setLoading(false)
return
}
} else {
finalDimensions =
const finalDimensions =
typeof values.embedderDimensions === 'string'
? parseInt(values.embedderDimensions)
: values.embedderDimensions
}
const updatedConfig = {
...memoryConfig,
@ -114,8 +96,7 @@ const MemoriesSettingsModal: FC<MemoriesSettingsModalProps> = ({ visible, onSubm
baseURL: aiEmbedderProvider.getBaseURL(),
apiVersion: embedderProvider?.apiVersion
},
embedderDimensions: finalDimensions,
isAutoDimensions: values.autoDims
embedderDimensions: finalDimensions
// customFactExtractionPrompt: values.customFactExtractionPrompt,
// customUpdateMemoryPrompt: values.customUpdateMemoryPrompt
}
@ -177,39 +158,35 @@ const MemoriesSettingsModal: FC<MemoriesSettingsModalProps> = ({ visible, onSubm
/>
</Form.Item>
<Form.Item
label={t('knowledge.dimensions_auto_set')}
name="autoDims"
tooltip={{ title: t('knowledge.dimensions_default') }}
valuePropName="checked">
<Switch
checked={autoDims}
onChange={(checked) => {
setAutoDims(checked)
form.setFieldValue('autoDims', checked)
if (checked) {
form.setFieldValue('embedderDimensions', undefined)
}
}}
/>
</Form.Item>
{!autoDims && (
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.embedderModel !== currentValues.embedderModel}>
{({ getFieldValue }) => {
const embedderModelId = getFieldValue('embedderModel')
const embedderModel = findModelById(embedderModelId)
return (
<Form.Item
label={t('memory.embedding_dimensions')}
label={
<Flex align="center" gap={4}>
{t('memory.embedding_dimensions')}
<InfoTooltip title={t('knowledge.dimensions_size_tooltip')} />
</Flex>
}
name="embedderDimensions"
rules={[
{
validator(_, value) {
if (form.getFieldValue('autoDims') || value > 0) {
if (value === undefined || value === null || value > 0) {
return Promise.resolve()
}
return Promise.reject(new Error(t('knowledge.dimensions_error_invalid')))
}
}
]}>
<InputNumber style={{ width: '100%' }} min={1} placeholder={t('knowledge.dimensions_size_placeholder')} />
<InputEmbeddingDimension model={embedderModel} disabled={!embedderModel} />
</Form.Item>
)
}}
</Form.Item>
)}
{/* <Form.Item label="Custom Fact Extraction Prompt" name="customFactExtractionPrompt">
<Input.TextArea placeholder="Optional custom prompt for fact extraction..." rows={3} />
</Form.Item>

View File

@ -1,6 +1,7 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { Box } from '@renderer/components/Layout'
import MemoriesSettingsModal from '@renderer/pages/memory/settings-modal'
import MemoryService from '@renderer/services/MemoryService'
import { selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory'
import { Assistant, AssistantSettings } from '@renderer/types'
@ -12,8 +13,6 @@ import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
import MemoriesSettingsModal from '../../memory/settings-modal'
const logger = loggerService.withContext('AssistantMemorySettings')
const { Text } = Typography

View File

@ -1,214 +0,0 @@
import { loggerService } from '@logger'
import AiProvider from '@renderer/aiCore'
import ModelSelector from '@renderer/components/ModelSelector'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { useModel } from '@renderer/hooks/useModel'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { selectMemoryConfig, updateMemoryConfig } from '@renderer/store/memory'
import { Model } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils/error'
import { Form, InputNumber, Modal, Switch } from 'antd'
import { t } from 'i18next'
import { FC, useCallback, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
const logger = loggerService.withContext('MemoriesSettingsModal')
interface MemoriesSettingsModalProps {
visible: boolean
onSubmit: (values: any) => void
onCancel: () => void
form: any
}
type formValue = {
llmModel: string
embedderModel: string
embedderDimensions: number
autoDims: boolean
}
const MemoriesSettingsModal: FC<MemoriesSettingsModalProps> = ({ visible, onSubmit, onCancel, form }) => {
const { providers } = useProviders()
const dispatch = useDispatch()
const memoryConfig = useSelector(selectMemoryConfig)
const [autoDims, setAutoDims] = useState(true)
const [loading, setLoading] = useState(false)
// Get all models for lookup
const allModels = providers.flatMap((p) => p.models)
const llmModel = useModel(memoryConfig.llmApiClient?.model, memoryConfig.llmApiClient?.provider)
const embedderModel = useModel(memoryConfig.embedderApiClient?.model, memoryConfig.embedderApiClient?.provider)
// Initialize form with current memory config when modal opens
useEffect(() => {
if (visible && memoryConfig) {
// Use isAutoDimensions to determine autoDims state, defaulting to true if not set
const isAutoDims = memoryConfig.isAutoDimensions !== false
setAutoDims(isAutoDims)
form.setFieldsValue({
llmModel: getModelUniqId(llmModel),
embedderModel: getModelUniqId(embedderModel),
embedderDimensions: memoryConfig.embedderDimensions,
autoDims: isAutoDims
})
}
}, [visible, memoryConfig, form, llmModel, embedderModel])
const handleFormSubmit = async (values: formValue) => {
try {
// Convert model IDs back to Model objects
const llmModel = values.llmModel ? allModels.find((m) => getModelUniqId(m) === values.llmModel) : undefined
const llmProvider = providers.find((p) => p.id === llmModel?.provider)
const aiLlmProvider = new AiProvider(llmProvider!)
const embedderModel = values.embedderModel
? allModels.find((m) => getModelUniqId(m) === values.embedderModel)
: undefined
const embedderProvider = providers.find((p) => p.id === embedderModel?.provider)
const aiEmbedderProvider = new AiProvider(embedderProvider!)
if (embedderModel) {
setLoading(true)
const provider = providers.find((p) => p.id === embedderModel.provider)
if (!provider) {
return
}
let finalDimensions: number | undefined
// Auto-detect dimensions if autoDims is enabled or dimensions not provided
if (values.autoDims || values.embedderDimensions === undefined) {
try {
const aiProvider = new AiProvider(provider)
finalDimensions = await aiProvider.getEmbeddingDimensions(embedderModel)
} catch (error) {
logger.error('Error getting embedding dimensions:', error as Error)
window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
setLoading(false)
return
}
} else {
finalDimensions =
typeof values.embedderDimensions === 'string'
? parseInt(values.embedderDimensions)
: values.embedderDimensions
}
const updatedConfig = {
...memoryConfig,
llmApiClient: {
model: llmModel?.id ?? '',
provider: llmProvider?.id ?? '',
apiKey: aiLlmProvider.getApiKey(),
baseURL: aiLlmProvider.getBaseURL(),
apiVersion: llmProvider?.apiVersion
},
embedderApiClient: {
model: embedderModel?.id ?? '',
provider: embedderProvider?.id ?? '',
apiKey: aiEmbedderProvider.getApiKey(),
baseURL: aiEmbedderProvider.getBaseURL(),
apiVersion: embedderProvider?.apiVersion
},
embedderDimensions: finalDimensions,
isAutoDimensions: values.autoDims
}
dispatch(updateMemoryConfig(updatedConfig))
onSubmit(updatedConfig)
setLoading(false)
}
} catch (error) {
logger.error('Error submitting form:', error as Error)
setLoading(false)
}
}
const llmPredicate = useCallback((m: Model) => !isEmbeddingModel(m) && !isRerankModel(m), [])
const embeddingPredicate = useCallback((m: Model) => isEmbeddingModel(m) && !isRerankModel(m), [])
return (
<Modal
title={t('memory.settings_title')}
open={visible}
onOk={form.submit}
onCancel={onCancel}
width={600}
centered
transitionName="animation-move-down"
confirmLoading={loading}
styles={{
header: {
borderBottom: '0.5px solid var(--color-border)',
paddingBottom: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0
},
body: {
paddingTop: 24
}
}}>
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
<Form.Item
label={t('memory.llm_model')}
name="llmModel"
rules={[{ required: true, message: t('memory.please_select_llm_model') }]}>
<ModelSelector
providers={providers}
predicate={llmPredicate}
placeholder={t('memory.select_llm_model_placeholder')}
/>
</Form.Item>
<Form.Item
label={t('memory.embedding_model')}
name="embedderModel"
rules={[{ required: true, message: t('memory.please_select_embedding_model') }]}>
<ModelSelector
providers={providers}
predicate={embeddingPredicate}
placeholder={t('memory.select_embedding_model_placeholder')}
/>
</Form.Item>
<Form.Item
label={t('knowledge.dimensions_auto_set')}
name="autoDims"
tooltip={{ title: t('knowledge.dimensions_default') }}
valuePropName="checked">
<Switch
checked={autoDims}
onChange={(checked) => {
setAutoDims(checked)
form.setFieldValue('autoDims', checked)
if (checked) {
form.setFieldValue('embedderDimensions', undefined)
}
}}
/>
</Form.Item>
{!autoDims && (
<Form.Item
label={t('memory.embedding_dimensions')}
name="embedderDimensions"
rules={[
{
validator(_, value) {
if (form.getFieldValue('autoDims') || value > 0) {
return Promise.resolve()
}
return Promise.reject(new Error(t('knowledge.dimensions_error_invalid')))
}
}
]}>
<InputNumber style={{ width: '100%' }} min={1} placeholder={t('knowledge.dimensions_size_placeholder')} />
</Form.Item>
)}
</Form>
</Modal>
)
}
export default MemoriesSettingsModal

View File

@ -15,6 +15,7 @@ import { HStack } from '@renderer/components/Layout'
import TextBadge from '@renderer/components/TextBadge'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useModel } from '@renderer/hooks/useModel'
import MemoriesSettingsModal from '@renderer/pages/memory/settings-modal'
import MemoryService from '@renderer/services/MemoryService'
import {
selectCurrentUserId,
@ -56,7 +57,6 @@ import {
SettingRowTitle,
SettingTitle
} from '../index'
import MemoriesSettingsModal from './MemoriesSettingsModal'
const logger = loggerService.withContext('MemorySettings')

View File

@ -1,5 +1,4 @@
import { loggerService } from '@logger'
import AiProvider from '@renderer/aiCore'
import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimension'
import ModelSelector from '@renderer/components/ModelSelector'
import { DEFAULT_WEBSEARCH_RAG_DOCUMENT_COUNT } from '@renderer/config/constant'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
@ -9,21 +8,18 @@ import { useWebSearchSettings } from '@renderer/hooks/useWebSearchProviders'
import { SettingDivider, SettingRow, SettingRowTitle } from '@renderer/pages/settings'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { Button, InputNumber, Slider, Tooltip } from 'antd'
import { Slider, Tooltip } from 'antd'
import { find } from 'lodash'
import { Info, RefreshCw } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Info } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('RagSettings')
const INPUT_BOX_WIDTH = 'min(350px, 60%)'
const RagSettings = () => {
const { t } = useTranslation()
const { providers } = useProviders()
const { compressionConfig, updateCompressionConfig } = useWebSearchSettings()
const [loadingDimensions, setLoadingDimensions] = useState(false)
const embeddingModels = useMemo(() => {
return providers.flatMap((p) => p.models).filter((model) => isEmbeddingModel(model))
@ -55,36 +51,6 @@ const RagSettings = () => {
updateCompressionConfig({ documentCount: value })
}
const handleAutoGetDimensions = async () => {
if (!compressionConfig?.embeddingModel) {
logger.info('handleAutoGetDimensions: no embedding model')
window.message.error(t('settings.tool.websearch.compression.error.embedding_model_required'))
return
}
const provider = providers.find((p) => p.id === compressionConfig.embeddingModel?.provider)
if (!provider) {
logger.info('handleAutoGetDimensions: provider not found')
window.message.error(t('settings.tool.websearch.compression.error.provider_not_found'))
return
}
setLoadingDimensions(true)
try {
const aiProvider = new AiProvider(provider)
const dimensions = await aiProvider.getEmbeddingDimensions(compressionConfig.embeddingModel)
updateCompressionConfig({ embeddingDimensions: dimensions })
window.message.success(t('settings.tool.websearch.compression.info.dimensions_auto_success', { dimensions }))
} catch (error) {
logger.error('handleAutoGetDimensions: failed to get embedding dimensions', error as Error)
window.message.error(t('settings.tool.websearch.compression.error.dimensions_auto_failed'))
} finally {
setLoadingDimensions(false)
}
}
return (
<>
<SettingRow>
@ -104,27 +70,17 @@ const RagSettings = () => {
<SettingRow>
<SettingRowTitle>
{t('models.embedding_dimensions')}
<Tooltip title={t('settings.tool.websearch.compression.rag.embedding_dimensions.tooltip')}>
<Tooltip title={t('knowledge.dimensions_size_tooltip')}>
<Info size={16} color="var(--color-icon)" style={{ marginLeft: 5, cursor: 'pointer' }} />
</Tooltip>
</SettingRowTitle>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', width: INPUT_BOX_WIDTH }}>
<InputNumber
<InputEmbeddingDimension
value={compressionConfig?.embeddingDimensions}
style={{ flex: 1 }}
placeholder={t('settings.tool.websearch.compression.rag.embedding_dimensions.placeholder')}
min={0}
onChange={handleEmbeddingDimensionsChange}
/>
<Tooltip title={t('settings.tool.websearch.compression.rag.embedding_dimensions.auto_get')}>
<Button
icon={<RefreshCw size={16} />}
loading={loadingDimensions}
model={compressionConfig?.embeddingModel}
disabled={!compressionConfig?.embeddingModel}
onClick={handleAutoGetDimensions}
style={{ width: INPUT_BOX_WIDTH }}
/>
</Tooltip>
</div>
</SettingRow>
<SettingDivider />

View File

@ -0,0 +1,241 @@
import { addFiles as addFilesAction, addItem, updateNotes } from '@renderer/store/knowledge'
import { FileMetadata, FileTypes, KnowledgeItem } from '@renderer/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { addFilesThunk, addItemThunk, addNoteThunk } from '../knowledgeThunk'
const mocks = vi.hoisted(() => {
return {
db: {
knowledge_notes: {
add: vi.fn()
}
},
uuid: {
v4: vi.fn()
},
actions: {
addFiles: vi.fn((payload) => ({ type: 'ADD_FILES', payload })),
addItem: vi.fn((payload) => ({ type: 'ADD_ITEM', payload })),
updateNotes: vi.fn((payload) => ({ type: 'UPDATE_NOTES', payload }))
}
}
})
// Mock dependencies
vi.mock('@renderer/databases', () => ({
db: mocks.db
}))
vi.mock('uuid', () => ({
v4: mocks.uuid.v4
}))
// Mock action creators
vi.mock('@renderer/store/knowledge', () => ({
addFiles: mocks.actions.addFiles,
addItem: mocks.actions.addItem,
updateNotes: mocks.actions.updateNotes
}))
// Create a mock dispatch function
const mockDispatch = vi.fn()
// Mock uuid to return predictable values
const mockUuid = 'test-uuid-123'
/**
* Helper function to create a mock KnowledgeItem with default values.
* @param type - The type of the knowledge item.
* @param content - The content of the knowledge item.
* @param timestamp - The timestamp for creation and update.
* @param overrides - Optional overrides for any property.
* @returns A mock KnowledgeItem.
*/
const createMockKnowledgeItem = (
type: KnowledgeItem['type'],
content: any,
timestamp: number,
overrides: Partial<KnowledgeItem> = {}
): KnowledgeItem => ({
id: mockUuid,
type,
content,
created_at: timestamp,
updated_at: timestamp,
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0,
...overrides
})
describe('knowledgeThunk', () => {
const mockFileMetadata: FileMetadata[] = [
{
id: 'file1',
name: 'test.pdf',
origin_name: 'test.pdf',
path: '/fake/path/test.pdf',
size: 1024,
ext: '.pdf',
type: FileTypes.DOCUMENT,
created_at: new Date().toISOString(),
count: 1
},
{
id: 'file2',
name: 'document.txt',
origin_name: 'document.txt',
path: '/fake/path/document.txt',
size: 512,
ext: '.txt',
type: FileTypes.TEXT,
created_at: new Date().toISOString(),
count: 1
}
]
beforeEach(() => {
vi.clearAllMocks()
mocks.uuid.v4.mockReturnValue(mockUuid)
mockDispatch.mockClear()
})
describe('addFilesThunk', () => {
it('should dispatch addFiles action with properly formatted and unique file items', () => {
const baseId = 'test-base-id'
const timestamp = Date.now()
vi.spyOn(Date, 'now').mockReturnValue(timestamp)
// Ensure uuid mock returns unique values for this test
mocks.uuid.v4.mockReturnValueOnce('test-uuid-1').mockReturnValueOnce('test-uuid-2')
addFilesThunk(baseId, mockFileMetadata)(mockDispatch)
const expectedItems: KnowledgeItem[] = mockFileMetadata.map((file, index) =>
createMockKnowledgeItem('file', file, timestamp, { id: `test-uuid-${index + 1}` })
)
expect(mockDispatch).toHaveBeenCalledWith(addFilesAction({ baseId, items: expectedItems }))
// Also verify that v4 was called for each file
expect(mocks.uuid.v4).toHaveBeenCalledTimes(mockFileMetadata.length)
})
it('should handle empty file array', () => {
const baseId = 'test-base-id'
vi.spyOn(Date, 'now').mockReturnValue(Date.now())
addFilesThunk(baseId, [])(mockDispatch)
expect(mockDispatch).toHaveBeenCalledWith(addFilesAction({ baseId, items: [] }))
})
it('should use same timestamp for all files', () => {
const baseId = 'test-base-id'
const timestamp = 123456789
vi.spyOn(Date, 'now').mockReturnValue(timestamp)
addFilesThunk(baseId, mockFileMetadata)(mockDispatch)
const dispatchedAction = mockDispatch.mock.calls[0][0]
const items = dispatchedAction.payload.items
items.forEach((item: KnowledgeItem) => {
expect(item.created_at).toBe(timestamp)
expect(item.updated_at).toBe(timestamp)
})
})
})
describe('addNoteThunk', () => {
it('should add note to database and dispatch updateNotes action', async () => {
const baseId = 'test-base-id'
const noteContent = 'This is a test note'
const timestamp = Date.now()
vi.spyOn(Date, 'now').mockReturnValue(timestamp)
mocks.db.knowledge_notes.add.mockResolvedValue(undefined)
await addNoteThunk(baseId, noteContent)(mockDispatch)
const expectedNote = createMockKnowledgeItem('note', noteContent, timestamp)
expect(mocks.db.knowledge_notes.add).toHaveBeenCalledWith(expectedNote)
const expectedNoteRef = createMockKnowledgeItem('note', '', timestamp)
expect(mockDispatch).toHaveBeenCalledWith(updateNotes({ baseId, item: expectedNoteRef }))
})
it('should handle empty note content', async () => {
const baseId = 'test-base-id'
const noteContent = ''
const timestamp = Date.now()
vi.spyOn(Date, 'now').mockReturnValue(timestamp)
mocks.db.knowledge_notes.add.mockResolvedValue(undefined)
await addNoteThunk(baseId, noteContent)(mockDispatch)
const expectedNote = createMockKnowledgeItem('note', '', timestamp)
expect(mocks.db.knowledge_notes.add).toHaveBeenCalledWith(expectedNote)
const expectedNoteRef = createMockKnowledgeItem('note', '', timestamp)
expect(mockDispatch).toHaveBeenCalledWith(updateNotes({ baseId, item: expectedNoteRef }))
})
it('should not dispatch and re-throw the error on database failure', async () => {
const baseId = 'test-base-id'
const noteContent = 'Test note'
const dbError = new Error('Database error')
mocks.db.knowledge_notes.add.mockRejectedValue(dbError)
await expect(addNoteThunk(baseId, noteContent)(mockDispatch)).rejects.toThrow(dbError)
expect(mockDispatch).not.toHaveBeenCalled()
})
})
describe('addItemThunk', () => {
it('should dispatch addItem action with url type', () => {
const baseId = 'test-base-id'
const content = 'Test content'
const type = 'url'
const timestamp = Date.now()
vi.spyOn(Date, 'now').mockReturnValue(timestamp)
addItemThunk(baseId, type, content)(mockDispatch)
const expectedItem = createMockKnowledgeItem(type, content, timestamp)
expect(mockDispatch).toHaveBeenCalledWith(addItem({ baseId, item: expectedItem }))
})
it('should handle empty content', () => {
const baseId = 'test-base-id'
const content = ''
const type = 'url'
const timestamp = Date.now()
vi.spyOn(Date, 'now').mockReturnValue(timestamp)
addItemThunk(baseId, type, content)(mockDispatch)
const expectedItem = createMockKnowledgeItem(type, '', timestamp)
expect(mockDispatch).toHaveBeenCalledWith(addItem({ baseId, item: expectedItem }))
})
it('should use consistent initial values', () => {
const baseId = 'test-base-id'
const content = 'Test'
const type = 'url'
const timestamp = Date.now()
vi.spyOn(Date, 'now').mockReturnValue(timestamp)
addItemThunk(baseId, type, content)(mockDispatch)
const dispatchedAction = mockDispatch.mock.calls[0][0]
const item = dispatchedAction.payload.item
expect(item.processingStatus).toBe('pending')
expect(item.processingProgress).toBe(0)
expect(item.processingError).toBe('')
expect(item.retryCount).toBe(0)
})
})
})

View File

@ -0,0 +1,73 @@
import { db } from '@renderer/databases'
import { addFiles as addFilesAction, addItem, updateNotes } from '@renderer/store/knowledge'
import { FileMetadata, KnowledgeItem } from '@renderer/types'
import { v4 as uuidv4 } from 'uuid'
import { AppDispatch } from '..'
/**
* Creates a new knowledge item with default values.
* @param type The type of the knowledge item.
* @param content The content of the knowledge item.
* @param overrides Optional overrides for the default values.
* @returns A new knowledge item.
*/
export const createKnowledgeItem = (
type: KnowledgeItem['type'],
content: KnowledgeItem['content'],
overrides: Partial<KnowledgeItem> = {}
): KnowledgeItem => {
const timestamp = Date.now()
return {
id: uuidv4(),
type,
content,
created_at: timestamp,
updated_at: timestamp,
processingStatus: 'pending',
processingProgress: 0,
processingError: '',
retryCount: 0,
...overrides
}
}
/**
* KnowledgeQueue.checkAllBases()
* @param baseId ID
* @param files
*/
export const addFilesThunk = (baseId: string, files: FileMetadata[]) => (dispatch: AppDispatch) => {
const filesItems = files.map((file) => createKnowledgeItem('file', file))
dispatch(addFilesAction({ baseId, items: filesItems }))
}
/**
* KnowledgeQueue.checkAllBases()
* @param baseId ID
* @param content
*/
export const addNoteThunk = (baseId: string, content: string) => async (dispatch: AppDispatch) => {
const noteId = uuidv4()
const note = createKnowledgeItem('note', content, { id: noteId })
// 存储完整笔记到数据库,出错时交给调用者处理
await db.knowledge_notes.add(note)
// 在 store 中只存储引用
const noteRef = { ...note, content: '' } // store中不需要存储实际内容
dispatch(updateNotes({ baseId, item: noteRef }))
}
/**
* KnowledgeQueue.checkAllBases()
* @param baseId ID
* @param type
* @param content
*/
export const addItemThunk =
(baseId: string, type: KnowledgeItem['type'], content: string) => (dispatch: AppDispatch) => {
const newItem = createKnowledgeItem(type, content)
dispatch(addItem({ baseId, item: newItem }))
}

View File

@ -7,7 +7,7 @@ const rendererConfig = (electronViteConfig as any).renderer
export default defineConfig({
test: {
workspace: [
projects: [
// 主进程单元测试配置
{
extends: true,

View File

@ -7261,6 +7261,7 @@ __metadata:
"@testing-library/dom": "npm:^10.4.0"
"@testing-library/jest-dom": "npm:^6.6.3"
"@testing-library/react": "npm:^16.3.0"
"@testing-library/user-event": "npm:^14.6.1"
"@tryfabric/martian": "npm:^1.2.4"
"@types/cli-progress": "npm:^3"
"@types/diff": "npm:^7"