mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 13:31:32 +08:00
Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2
This commit is contained in:
commit
06b6f2b9d8
@ -5,8 +5,8 @@ export enum IpcChannel {
|
||||
// App_SetLanguage = 'app:set-language',
|
||||
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
|
||||
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
|
||||
App_ShowUpdateDialog = 'app:show-update-dialog',
|
||||
App_CheckForUpdate = 'app:check-for-update',
|
||||
App_QuitAndInstall = 'app:quit-and-install',
|
||||
App_Reload = 'app:reload',
|
||||
App_Quit = 'app:quit',
|
||||
App_Info = 'app:info',
|
||||
@ -251,7 +251,6 @@ export enum IpcChannel {
|
||||
BackupProgress = 'backup-progress',
|
||||
DataMigrateProgress = 'data-migrate-progress',
|
||||
NativeThemeUpdated = 'native-theme:updated',
|
||||
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
||||
RestoreProgress = 'restore-progress',
|
||||
UpdateError = 'update-error',
|
||||
UpdateAvailable = 'update-available',
|
||||
|
||||
@ -143,7 +143,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
|
||||
|
||||
// Update
|
||||
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
|
||||
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
|
||||
|
||||
// language
|
||||
// ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
||||
|
||||
@ -3,21 +3,18 @@ import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { configManager } from '@main/services/ConfigManager'
|
||||
import { getIpCountry } from '@main/utils/ipService'
|
||||
import { getI18n } from '@main/utils/language'
|
||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { UpgradeChannel } from '@shared/data/preference/preferenceTypes'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import type { UpdateInfo } from 'builder-util-runtime'
|
||||
import { CancellationToken } from 'builder-util-runtime'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { app, dialog, net } from 'electron'
|
||||
import { app, net } from 'electron'
|
||||
import type { AppUpdater as _AppUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import path from 'path'
|
||||
import semver from 'semver'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
const logger = loggerService.withContext('AppUpdater')
|
||||
@ -31,7 +28,6 @@ const LANG_MARKERS = {
|
||||
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
private releaseInfo: UpdateInfo | undefined
|
||||
private cancellationToken: CancellationToken = new CancellationToken()
|
||||
private updateCheckResult: UpdateCheckResult | null = null
|
||||
|
||||
@ -71,7 +67,6 @@ export default class AppUpdater {
|
||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
||||
this.releaseInfo = processedReleaseInfo
|
||||
logger.info('update downloaded', processedReleaseInfo)
|
||||
})
|
||||
|
||||
@ -252,37 +247,9 @@ export default class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
public showUpdateDialog(mainWindow: BrowserWindow) {
|
||||
if (!this.releaseInfo) {
|
||||
return
|
||||
}
|
||||
const i18n = getI18n()
|
||||
const { update: updateLocale } = i18n.translation
|
||||
|
||||
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
|
||||
if (detail === '') {
|
||||
detail = updateLocale.noReleaseNotes
|
||||
}
|
||||
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: updateLocale.title,
|
||||
icon,
|
||||
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
|
||||
detail,
|
||||
buttons: [updateLocale.later, updateLocale.install],
|
||||
defaultId: 1,
|
||||
cancelId: 0
|
||||
})
|
||||
.then(({ response }) => {
|
||||
if (response === 1) {
|
||||
app.isQuitting = true
|
||||
setImmediate(() => autoUpdater.quitAndInstall())
|
||||
} else {
|
||||
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
|
||||
}
|
||||
})
|
||||
public quitAndInstall() {
|
||||
app.isQuitting = true
|
||||
setImmediate(() => autoUpdater.quitAndInstall())
|
||||
}
|
||||
|
||||
/**
|
||||
@ -354,38 +321,9 @@ export default class AppUpdater {
|
||||
|
||||
return processedInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Format release notes for display
|
||||
* @param releaseNotes - Release notes in various formats
|
||||
* @returns Formatted string for display
|
||||
*/
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof releaseNotes === 'string') {
|
||||
// Check if it contains multi-language markers
|
||||
if (this.hasMultiLanguageMarkers(releaseNotes)) {
|
||||
return this.parseMultiLangReleaseNotes(releaseNotes)
|
||||
}
|
||||
return releaseNotes
|
||||
}
|
||||
|
||||
if (Array.isArray(releaseNotes)) {
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
}
|
||||
interface GithubReleaseInfo {
|
||||
draft: boolean
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
}
|
||||
interface ReleaseNoteInfo {
|
||||
readonly version: string
|
||||
readonly note: string | null
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
|
||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
@ -148,11 +148,16 @@ class KnowledgeService {
|
||||
}
|
||||
}
|
||||
|
||||
private getDbPath = (id: string): string => {
|
||||
// 消除网络搜索requestI d中的特殊字符
|
||||
return path.join(this.storageDir, sanitizeFilename(id, '_'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete knowledge base file
|
||||
*/
|
||||
private deleteKnowledgeFile = (id: string): boolean => {
|
||||
const dbPath = path.join(this.storageDir, id)
|
||||
const dbPath = this.getDbPath(id)
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
fs.rmSync(dbPath, { recursive: true })
|
||||
@ -245,7 +250,8 @@ class KnowledgeService {
|
||||
dimensions
|
||||
})
|
||||
try {
|
||||
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
|
||||
const dbPath = this.getDbPath(id)
|
||||
const libSqlDb = new LibSqlDb({ path: dbPath })
|
||||
// Save database instance for later closing
|
||||
this.dbInstances.set(id, libSqlDb)
|
||||
|
||||
|
||||
@ -283,46 +283,4 @@ describe('AppUpdater', () => {
|
||||
expect(result.releaseNotes).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatReleaseNotes', () => {
|
||||
it('should format string release notes with markers', () => {
|
||||
MockMainPreferenceServiceUtils.setPreferenceValue('app.language', 'en-US')
|
||||
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('English')
|
||||
})
|
||||
|
||||
it('should format string release notes without markers', () => {
|
||||
const notes = 'Simple notes'
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Simple notes')
|
||||
})
|
||||
|
||||
it('should format array release notes', () => {
|
||||
const notes = [
|
||||
{ version: '1.0.0', note: 'Note 1' },
|
||||
{ version: '1.0.1', note: 'Note 2' }
|
||||
]
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Note 1\nNote 2')
|
||||
})
|
||||
|
||||
it('should handle null release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(null)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle undefined release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(undefined)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -56,7 +56,7 @@ const api = {
|
||||
setProxy: (proxy: string | undefined, bypassRules?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
|
||||
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
|
||||
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
|
||||
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
|
||||
// setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
|
||||
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
|
||||
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
|
||||
@ -228,7 +228,7 @@ const api = {
|
||||
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
|
||||
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
|
||||
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
|
||||
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
|
||||
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
|
||||
add: ({
|
||||
base,
|
||||
item,
|
||||
|
||||
@ -10,6 +10,7 @@ interface ShowParams {
|
||||
providerId: string
|
||||
title?: string
|
||||
showHealthCheck?: boolean
|
||||
providerType?: 'llm' | 'webSearch' | 'preprocess'
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
@ -19,7 +20,7 @@ interface Props extends ShowParams {
|
||||
/**
|
||||
* API Key 列表弹窗容器组件
|
||||
*/
|
||||
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true }) => {
|
||||
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true, providerType }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -32,14 +33,20 @@ const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealt
|
||||
}
|
||||
|
||||
const ListComponent = useMemo(() => {
|
||||
if (isWebSearchProviderId(providerId)) {
|
||||
return <WebSearchApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
|
||||
const type =
|
||||
providerType ||
|
||||
(isWebSearchProviderId(providerId) ? 'webSearch' : isPreprocessProviderId(providerId) ? 'preprocess' : 'llm')
|
||||
|
||||
switch (type) {
|
||||
case 'webSearch':
|
||||
return <WebSearchApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
|
||||
case 'preprocess':
|
||||
return <DocPreprocessApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
|
||||
case 'llm':
|
||||
default:
|
||||
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
|
||||
}
|
||||
if (isPreprocessProviderId(providerId)) {
|
||||
return <DocPreprocessApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
|
||||
}
|
||||
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
|
||||
}, [providerId, showHealthCheck])
|
||||
}, [providerId, showHealthCheck, providerType])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
101
src/renderer/src/components/UpdateDialog.tsx
Normal file
101
src/renderer/src/components/UpdateDialog.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import { handleSaveData } from '@renderer/store'
|
||||
import { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Markdown from 'react-markdown'
|
||||
|
||||
const logger = loggerService.withContext('UpdateDialog')
|
||||
|
||||
interface UpdateDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
releaseInfo: UpdateInfo | null
|
||||
}
|
||||
|
||||
const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInfo }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isInstalling, setIsInstalling] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && releaseInfo) {
|
||||
logger.info('Update dialog opened', { version: releaseInfo.version })
|
||||
}
|
||||
}, [isOpen, releaseInfo])
|
||||
|
||||
const handleInstall = async () => {
|
||||
setIsInstalling(true)
|
||||
try {
|
||||
await handleSaveData()
|
||||
await window.api.quitAndInstall()
|
||||
} catch (error) {
|
||||
logger.error('Failed to save data before update', error as Error)
|
||||
setIsInstalling(false)
|
||||
window.toast.error(t('update.saveDataError'))
|
||||
}
|
||||
}
|
||||
|
||||
const releaseNotes = releaseInfo?.releaseNotes
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="2xl"
|
||||
scrollBehavior="inside"
|
||||
classNames={{
|
||||
base: 'max-h-[85vh]',
|
||||
header: 'border-b border-divider',
|
||||
footer: 'border-t border-divider'
|
||||
}}>
|
||||
<ModalContent>
|
||||
{(onModalClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h3 className="font-semibold text-lg">{t('update.title')}</h3>
|
||||
<p className="text-default-500 text-small">
|
||||
{t('update.message').replace('{{version}}', releaseInfo?.version || '')}
|
||||
</p>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<ScrollShadow className="max-h-[450px]" hideScrollBar>
|
||||
<div className="markdown rounded-lg bg-default-50 p-4">
|
||||
<Markdown>
|
||||
{typeof releaseNotes === 'string'
|
||||
? releaseNotes
|
||||
: Array.isArray(releaseNotes)
|
||||
? releaseNotes
|
||||
.map((note: ReleaseNoteInfo) => note.note)
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
: t('update.noReleaseNotes')}
|
||||
</Markdown>
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onModalClose} isDisabled={isInstalling}>
|
||||
{t('update.later')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={async () => {
|
||||
await handleInstall()
|
||||
onModalClose()
|
||||
}}
|
||||
isLoading={isInstalling}>
|
||||
{t('update.install')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdateDialog
|
||||
@ -145,7 +145,7 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
{
|
||||
id: 'dashscope',
|
||||
name: i18n.t('minapps.qwen'),
|
||||
url: 'https://tongyi.aliyun.com/qianwen/',
|
||||
url: 'https://www.tongyi.com/',
|
||||
logo: QwenModelLogo
|
||||
},
|
||||
{
|
||||
|
||||
@ -353,7 +353,7 @@ export const useKnowledgeBases = () => {
|
||||
const deleteKnowledgeBase = (baseId: string) => {
|
||||
const base = bases.find((b) => b.id === baseId)
|
||||
if (!base) return
|
||||
dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) }))
|
||||
dispatch(deleteBase({ baseId }))
|
||||
|
||||
// remove assistant knowledge_base
|
||||
const _assistants = assistants.map((assistant) => {
|
||||
|
||||
@ -4641,6 +4641,7 @@
|
||||
"later": "Later",
|
||||
"message": "New version {{version}} is ready, do you want to install it now?",
|
||||
"noReleaseNotes": "No release notes",
|
||||
"saveDataError": "Failed to save data, please try again.",
|
||||
"title": "Update"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@ -4641,6 +4641,7 @@
|
||||
"later": "稍后",
|
||||
"message": "发现新版本 {{version}},是否立即安装?",
|
||||
"noReleaseNotes": "暂无更新日志",
|
||||
"saveDataError": "保存数据失败,请重试",
|
||||
"title": "更新提示"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@ -4641,6 +4641,7 @@
|
||||
"later": "稍後",
|
||||
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
|
||||
"noReleaseNotes": "暫無更新日誌",
|
||||
"saveDataError": "保存數據失敗,請重試",
|
||||
"title": "更新提示"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@ -4641,6 +4641,7 @@
|
||||
"later": "Μετά",
|
||||
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
|
||||
"noReleaseNotes": "Χωρίς σημειώσεις",
|
||||
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
|
||||
"title": "Ενημέρωση"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@ -4641,6 +4641,7 @@
|
||||
"later": "Más tarde",
|
||||
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
|
||||
"noReleaseNotes": "Sin notas de la versión",
|
||||
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
|
||||
"title": "Actualización"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@ -4641,6 +4641,7 @@
|
||||
"later": "Plus tard",
|
||||
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
|
||||
"noReleaseNotes": "Aucune note de version",
|
||||
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
|
||||
"title": "Mise à jour"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@ -4641,6 +4641,7 @@
|
||||
"later": "後で",
|
||||
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
|
||||
"noReleaseNotes": "暫無更新日誌",
|
||||
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
|
||||
"title": "更新"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@ -4641,6 +4641,7 @@
|
||||
"later": "Mais tarde",
|
||||
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
|
||||
"noReleaseNotes": "Sem notas de versão",
|
||||
"saveDataError": "Falha ao salvar os dados, tente novamente",
|
||||
"title": "Atualização"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@ -4641,6 +4641,7 @@
|
||||
"later": "Позже",
|
||||
"message": "Новая версия {{version}} готова, установить сейчас?",
|
||||
"noReleaseNotes": "Нет заметок об обновлении",
|
||||
"saveDataError": "Ошибка сохранения данных, повторите попытку",
|
||||
"title": "Обновление"
|
||||
},
|
||||
"warning": {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { SyncOutlined } from '@ant-design/icons'
|
||||
import { Button } from '@cherrystudio/ui'
|
||||
import { useDisclosure } from '@heroui/react'
|
||||
import UpdateDialog from '@renderer/components/UpdateDialog'
|
||||
import { usePreference } from '@data/hooks/usePreference'
|
||||
import { useAppUpdateState } from '@renderer/hooks/useAppUpdate'
|
||||
import type { FC } from 'react'
|
||||
@ -10,6 +12,7 @@ const UpdateAppButton: FC = () => {
|
||||
const { appUpdateState } = useAppUpdateState()
|
||||
const [autoCheckUpdate] = usePreference('app.dist.auto_update.enabled')
|
||||
const { t } = useTranslation()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
if (!appUpdateState) {
|
||||
return null
|
||||
@ -23,13 +26,15 @@ const UpdateAppButton: FC = () => {
|
||||
<Container>
|
||||
<UpdateButton
|
||||
className="nodrag"
|
||||
onPress={() => window.api.showUpdateDialog()}
|
||||
onPress={onOpen}
|
||||
startContent={<SyncOutlined />}
|
||||
color="warning"
|
||||
variant="bordered"
|
||||
size="sm">
|
||||
{t('button.update_available')}
|
||||
</UpdateButton>
|
||||
|
||||
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={appUpdateState.info || null} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import styled from 'styled-components'
|
||||
|
||||
// Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool
|
||||
import MinimalToolbar from './components/MinimalToolbar'
|
||||
import WebviewSearch from './components/WebviewSearch'
|
||||
|
||||
const logger = loggerService.withContext('MinAppPage')
|
||||
|
||||
@ -185,6 +186,7 @@ const MinAppPage: FC = () => {
|
||||
onOpenDevTools={handleOpenDevTools}
|
||||
/>
|
||||
</ToolbarWrapper>
|
||||
<WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
|
||||
{!isReady && (
|
||||
<LoadingMask>
|
||||
<Avatar src={app.logo} className="h-[60px] w-[60px] border border-border" />
|
||||
|
||||
298
src/renderer/src/pages/minapps/components/WebviewSearch.tsx
Normal file
298
src/renderer/src/pages/minapps/components/WebviewSearch.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
import { Button, Input } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import type { WebviewTag } from 'electron'
|
||||
import { ChevronDown, ChevronUp, X } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type FoundInPageResult = Electron.FoundInPageResult
|
||||
|
||||
interface WebviewSearchProps {
|
||||
webviewRef: React.RefObject<WebviewTag | null>
|
||||
isWebviewReady: boolean
|
||||
appId: string
|
||||
}
|
||||
|
||||
const logger = loggerService.withContext('WebviewSearch')
|
||||
|
||||
const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, appId }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [matchCount, setMatchCount] = useState(0)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [currentWebview, setCurrentWebview] = useState<WebviewTag | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const focusFrameRef = useRef<number | null>(null)
|
||||
const lastAppIdRef = useRef<string>(appId)
|
||||
const attachedWebviewRef = useRef<WebviewTag | null>(null)
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
if (focusFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(focusFrameRef.current)
|
||||
focusFrameRef.current = null
|
||||
}
|
||||
focusFrameRef.current = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resetSearchState = useCallback((options?: { keepQuery?: boolean }) => {
|
||||
if (!options?.keepQuery) {
|
||||
setQuery('')
|
||||
}
|
||||
setMatchCount(0)
|
||||
setActiveIndex(0)
|
||||
}, [])
|
||||
|
||||
const stopSearch = useCallback(() => {
|
||||
const target = webviewRef.current ?? attachedWebviewRef.current
|
||||
if (!target) return
|
||||
try {
|
||||
target.stopFindInPage('clearSelection')
|
||||
} catch (error) {
|
||||
logger.error('stopFindInPage failed', { error })
|
||||
}
|
||||
}, [webviewRef])
|
||||
|
||||
const closeSearch = useCallback(() => {
|
||||
setIsVisible(false)
|
||||
stopSearch()
|
||||
resetSearchState({ keepQuery: true })
|
||||
}, [resetSearchState, stopSearch])
|
||||
|
||||
const performSearch = useCallback(
|
||||
(text: string, options?: Electron.FindInPageOptions) => {
|
||||
const target = webviewRef.current ?? attachedWebviewRef.current
|
||||
if (!target) {
|
||||
logger.debug('Skip performSearch: webview not attached')
|
||||
return
|
||||
}
|
||||
if (!text) {
|
||||
stopSearch()
|
||||
resetSearchState({ keepQuery: true })
|
||||
return
|
||||
}
|
||||
try {
|
||||
target.findInPage(text, options)
|
||||
} catch (error) {
|
||||
logger.error('findInPage failed', { error })
|
||||
window.toast?.error(t('common.error'))
|
||||
}
|
||||
},
|
||||
[resetSearchState, stopSearch, t, webviewRef]
|
||||
)
|
||||
|
||||
const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
|
||||
if (!event.result) return
|
||||
|
||||
const { activeMatchOrdinal, matches } = event.result
|
||||
|
||||
if (matches !== undefined) {
|
||||
setMatchCount(matches)
|
||||
}
|
||||
|
||||
if (activeMatchOrdinal !== undefined) {
|
||||
setActiveIndex(activeMatchOrdinal)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openSearch = useCallback(() => {
|
||||
if (!isWebviewReady) {
|
||||
logger.debug('Skip openSearch: webview not ready')
|
||||
return
|
||||
}
|
||||
setIsVisible(true)
|
||||
focusInput()
|
||||
}, [focusInput, isWebviewReady])
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (!query) return
|
||||
performSearch(query, { forward: true, findNext: true })
|
||||
}, [performSearch, query])
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
if (!query) return
|
||||
performSearch(query, { forward: false, findNext: true })
|
||||
}, [performSearch, query])
|
||||
|
||||
useEffect(() => {
|
||||
const nextWebview = webviewRef.current ?? null
|
||||
if (currentWebview === nextWebview) return
|
||||
setCurrentWebview(nextWebview)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const target = currentWebview
|
||||
if (!target) {
|
||||
attachedWebviewRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const handle = handleFoundInPage
|
||||
attachedWebviewRef.current = target
|
||||
target.addEventListener('found-in-page', handle)
|
||||
|
||||
return () => {
|
||||
target.removeEventListener('found-in-page', handle)
|
||||
if (attachedWebviewRef.current === target) {
|
||||
try {
|
||||
target.stopFindInPage('clearSelection')
|
||||
} catch (error) {
|
||||
logger.error('stopFindInPage failed', { error })
|
||||
}
|
||||
attachedWebviewRef.current = null
|
||||
}
|
||||
}
|
||||
}, [currentWebview, handleFoundInPage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return
|
||||
focusInput()
|
||||
}, [focusInput, isVisible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return
|
||||
if (!query) {
|
||||
performSearch('')
|
||||
return
|
||||
}
|
||||
performSearch(query)
|
||||
}, [currentWebview, isVisible, performSearch, query])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {
|
||||
event.preventDefault()
|
||||
openSearch()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isVisible) return
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeSearch()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
goToPrevious()
|
||||
} else {
|
||||
goToNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeydown, true)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown, true)
|
||||
}
|
||||
}, [closeSearch, goToNext, goToPrevious, isVisible, openSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWebviewReady) {
|
||||
setIsVisible(false)
|
||||
resetSearchState()
|
||||
stopSearch()
|
||||
return
|
||||
}
|
||||
}, [isWebviewReady, resetSearchState, stopSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId) return
|
||||
if (lastAppIdRef.current === appId) return
|
||||
lastAppIdRef.current = appId
|
||||
setIsVisible(false)
|
||||
resetSearchState()
|
||||
stopSearch()
|
||||
}, [appId, resetSearchState, stopSearch])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopSearch()
|
||||
if (focusFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(focusFrameRef.current)
|
||||
focusFrameRef.current = null
|
||||
}
|
||||
}
|
||||
}, [stopSearch])
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
const matchLabel = `${matchCount > 0 ? Math.max(activeIndex, 1) : 0}/${matchCount}`
|
||||
const noResultTitle = matchCount === 0 && query ? t('common.no_results') : undefined
|
||||
const disableNavigation = !query || matchCount === 0
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto absolute top-3 right-3 z-50 flex items-center gap-2 rounded-xl border border-default-200 bg-background px-2 py-1 shadow-lg">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
spellCheck={'false'}
|
||||
placeholder={t('common.search')}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="flat"
|
||||
classNames={{
|
||||
base: 'w-[240px]',
|
||||
inputWrapper:
|
||||
'h-8 bg-transparent border border-transparent shadow-none hover:border-transparent hover:bg-transparent focus:border-transparent data-[hover=true]:border-transparent data-[focus=true]:border-transparent data-[focus-visible=true]:outline-none data-[focus-visible=true]:ring-0',
|
||||
input: 'text-small focus:outline-none focus-visible:outline-none',
|
||||
innerWrapper: 'gap-0'
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="min-w-[44px] text-center text-default-500 text-small tabular-nums"
|
||||
title={noResultTitle}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true">
|
||||
{matchLabel}
|
||||
</span>
|
||||
<div className="h-4 w-px bg-default-200" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
onPress={goToPrevious}
|
||||
isDisabled={disableNavigation}
|
||||
aria-label="Previous match"
|
||||
className="text-default-500 hover:text-default-900">
|
||||
<ChevronUp size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
onPress={goToNext}
|
||||
isDisabled={disableNavigation}
|
||||
aria-label="Next match"
|
||||
className="text-default-500 hover:text-default-900">
|
||||
<ChevronDown size={16} />
|
||||
</Button>
|
||||
<div className="h-4 w-px bg-default-200" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
onPress={closeSearch}
|
||||
aria-label={t('common.close')}
|
||||
className="text-default-500 hover:text-default-900">
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebviewSearch
|
||||
@ -0,0 +1,237 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { WebviewTag } from 'electron'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import WebviewSearch from '../WebviewSearch'
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
'common.close': 'Close',
|
||||
'common.error': 'Error',
|
||||
'common.no_results': 'No results',
|
||||
'common.search': 'Search'
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => translations[key] ?? key
|
||||
})
|
||||
}))
|
||||
|
||||
const createWebviewMock = () => {
|
||||
const listeners = new Map<string, Set<(event: Event & { result?: Electron.FoundInPageResult }) => void>>()
|
||||
const findInPageMock = vi.fn()
|
||||
const stopFindInPageMock = vi.fn()
|
||||
const webview = {
|
||||
addEventListener: vi.fn(
|
||||
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
|
||||
if (!listeners.has(type)) {
|
||||
listeners.set(type, new Set())
|
||||
}
|
||||
listeners.get(type)!.add(listener)
|
||||
}
|
||||
),
|
||||
removeEventListener: vi.fn(
|
||||
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
|
||||
listeners.get(type)?.delete(listener)
|
||||
}
|
||||
),
|
||||
findInPage: findInPageMock as unknown as WebviewTag['findInPage'],
|
||||
stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage']
|
||||
} as unknown as WebviewTag
|
||||
|
||||
const emit = (type: string, result?: Electron.FoundInPageResult) => {
|
||||
listeners.get(type)?.forEach((listener) => {
|
||||
const event = new CustomEvent(type) as Event & { result?: Electron.FoundInPageResult }
|
||||
event.result = result
|
||||
listener(event)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
emit,
|
||||
findInPageMock,
|
||||
stopFindInPageMock,
|
||||
webview
|
||||
}
|
||||
}
|
||||
|
||||
const openSearchOverlay = async () => {
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true }))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
const originalRAF = window.requestAnimationFrame
|
||||
const originalCAF = window.cancelAnimationFrame
|
||||
|
||||
const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
const cancelAnimationFrameMock = vi.fn()
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
value: requestAnimationFrameMock,
|
||||
writable: true
|
||||
})
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
value: cancelAnimationFrameMock,
|
||||
writable: true
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
value: originalRAF
|
||||
})
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
value: originalCAF
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebviewSearch', () => {
|
||||
const toastMock = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
addToast: vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(window, { toast: toastMock })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens the search overlay with keyboard shortcut', async () => {
|
||||
const { webview } = createWebviewMock()
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
|
||||
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||
|
||||
await openSearchOverlay()
|
||||
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('performs searches and navigates between results', async () => {
|
||||
const { emit, findInPageMock, webview } = createWebviewMock()
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
await openSearchOverlay()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Cherry')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
emit('found-in-page', {
|
||||
requestId: 1,
|
||||
matches: 3,
|
||||
activeMatchOrdinal: 1,
|
||||
selectionArea: undefined as unknown as Electron.Rectangle,
|
||||
finalUpdate: false
|
||||
} as Electron.FoundInPageResult)
|
||||
})
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next match' })
|
||||
await waitFor(() => {
|
||||
expect(nextButton).not.toBeDisabled()
|
||||
})
|
||||
await user.click(nextButton)
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: true, findNext: true })
|
||||
})
|
||||
|
||||
const previousButton = screen.getByRole('button', { name: 'Previous match' })
|
||||
await user.click(previousButton)
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: false, findNext: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('clears search state when appId changes', async () => {
|
||||
const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock()
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
const user = userEvent.setup()
|
||||
|
||||
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
await openSearchOverlay()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Cherry')
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-2" />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
|
||||
})
|
||||
|
||||
it('shows toast error when search fails', async () => {
|
||||
const { findInPageMock, webview } = createWebviewMock()
|
||||
findInPageMock.mockImplementation(() => {
|
||||
throw new Error('findInPage failed')
|
||||
})
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
await openSearchOverlay()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Cherry')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastMock.error).toHaveBeenCalledWith('Error')
|
||||
})
|
||||
})
|
||||
|
||||
it('stops search when component unmounts', async () => {
|
||||
const { stopFindInPageMock, webview } = createWebviewMock()
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
|
||||
const { unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
await openSearchOverlay()
|
||||
|
||||
stopFindInPageMock.mockClear()
|
||||
unmount()
|
||||
|
||||
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
|
||||
})
|
||||
|
||||
it('ignores keyboard shortcut when webview is not ready', async () => {
|
||||
const { findInPageMock, webview } = createWebviewMock()
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
|
||||
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
|
||||
})
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||
expect(findInPageMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -386,21 +386,25 @@ const NotesPage: FC = () => {
|
||||
}, [activeFilePath])
|
||||
|
||||
// 获取目标文件夹路径(选中文件夹或根目录)
|
||||
const getTargetFolderPath = useCallback(() => {
|
||||
if (selectedFolderId) {
|
||||
const selectedNode = findNode(notesTree, selectedFolderId)
|
||||
if (selectedNode && selectedNode.type === 'folder') {
|
||||
return selectedNode.externalPath
|
||||
const getTargetFolderPath = useCallback(
|
||||
(targetFolderId?: string) => {
|
||||
const folderId = targetFolderId || selectedFolderId
|
||||
if (folderId) {
|
||||
const selectedNode = findNode(notesTree, folderId)
|
||||
if (selectedNode && selectedNode.type === 'folder') {
|
||||
return selectedNode.externalPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return notesPath // 默认返回根目录
|
||||
}, [selectedFolderId, notesTree, notesPath])
|
||||
return notesPath // 默认返回根目录
|
||||
},
|
||||
[selectedFolderId, notesTree, notesPath]
|
||||
)
|
||||
|
||||
// 创建文件夹
|
||||
const handleCreateFolder = useCallback(
|
||||
async (name: string) => {
|
||||
async (name: string, targetFolderId?: string) => {
|
||||
try {
|
||||
const targetPath = getTargetFolderPath()
|
||||
const targetPath = getTargetFolderPath(targetFolderId)
|
||||
if (!targetPath) {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
@ -416,11 +420,11 @@ const NotesPage: FC = () => {
|
||||
|
||||
// 创建笔记
|
||||
const handleCreateNote = useCallback(
|
||||
async (name: string) => {
|
||||
async (name: string, targetFolderId?: string) => {
|
||||
try {
|
||||
isCreatingNoteRef.current = true
|
||||
|
||||
const targetPath = getTargetFolderPath()
|
||||
const targetPath = getTargetFolderPath(targetFolderId)
|
||||
if (!targetPath) {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
|
||||
@ -37,8 +37,8 @@ import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface NotesSidebarProps {
|
||||
onCreateFolder: (name: string, parentId?: string) => void
|
||||
onCreateNote: (name: string, parentId?: string) => void
|
||||
onCreateFolder: (name: string, targetFolderId?: string) => void
|
||||
onCreateNote: (name: string, targetFolderId?: string) => void
|
||||
onSelectNode: (node: NotesTreeNode) => void
|
||||
onDeleteNode: (nodeId: string) => void
|
||||
onRenameNode: (nodeId: string, newName: string) => void
|
||||
@ -74,6 +74,8 @@ interface TreeNodeProps {
|
||||
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||
onDragEnd: () => void
|
||||
renderChildren?: boolean // 控制是否渲染子节点
|
||||
openDropdownKey: string | null
|
||||
onDropdownOpenChange: (key: string | null) => void
|
||||
}
|
||||
|
||||
const TreeNode = memo<TreeNodeProps>(
|
||||
@ -97,7 +99,9 @@ const TreeNode = memo<TreeNodeProps>(
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
renderChildren = true
|
||||
renderChildren = true,
|
||||
openDropdownKey,
|
||||
onDropdownOpenChange
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -122,8 +126,12 @@ const TreeNode = memo<TreeNodeProps>(
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={{ items: getMenuItems(node) }}
|
||||
trigger={['contextMenu']}
|
||||
open={openDropdownKey === node.id}
|
||||
onOpenChange={(open) => onDropdownOpenChange(open ? node.id : null)}>
|
||||
<div onContextMenu={(e) => e.stopPropagation()}>
|
||||
<TreeNodeContainer
|
||||
active={isActive}
|
||||
depth={depth}
|
||||
@ -209,6 +217,8 @@ const TreeNode = memo<TreeNodeProps>(
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
renderChildren={renderChildren}
|
||||
openDropdownKey={openDropdownKey}
|
||||
onDropdownOpenChange={onDropdownOpenChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -247,6 +257,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
const [isShowSearch, setIsShowSearch] = useState(false)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
|
||||
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null)
|
||||
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
||||
const scrollbarRef = useRef<any>(null)
|
||||
|
||||
@ -591,6 +602,28 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
})
|
||||
}
|
||||
|
||||
if (node.type === 'folder') {
|
||||
baseMenuItems.push(
|
||||
{
|
||||
label: t('notes.new_note'),
|
||||
key: 'new_note',
|
||||
icon: <FilePlus size={14} />,
|
||||
onClick: () => {
|
||||
onCreateNote(t('notes.untitled_note'), node.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.new_folder'),
|
||||
key: 'new_folder',
|
||||
icon: <Folder size={14} />,
|
||||
onClick: () => {
|
||||
onCreateFolder(t('notes.untitled_folder'), node.id)
|
||||
}
|
||||
},
|
||||
{ type: 'divider' }
|
||||
)
|
||||
}
|
||||
|
||||
baseMenuItems.push(
|
||||
{
|
||||
label: t('notes.rename'),
|
||||
@ -705,7 +738,9 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
handleDeleteNode,
|
||||
renamingNodeIds,
|
||||
handleAutoRename,
|
||||
exportMenuOptions
|
||||
exportMenuOptions,
|
||||
onCreateNote,
|
||||
onCreateFolder
|
||||
]
|
||||
)
|
||||
|
||||
@ -786,6 +821,23 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
fileInput.click()
|
||||
}, [onUploadFiles])
|
||||
|
||||
const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => {
|
||||
return [
|
||||
{
|
||||
label: t('notes.new_note'),
|
||||
key: 'new_note',
|
||||
icon: <FilePlus size={14} />,
|
||||
onClick: handleCreateNote
|
||||
},
|
||||
{
|
||||
label: t('notes.new_folder'),
|
||||
key: 'new_folder',
|
||||
icon: <Folder size={14} />,
|
||||
onClick: handleCreateFolder
|
||||
}
|
||||
]
|
||||
}, [t, handleCreateNote, handleCreateFolder])
|
||||
|
||||
return (
|
||||
<SidebarContainer
|
||||
onDragOver={(e) => {
|
||||
@ -815,31 +867,90 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
|
||||
<NotesTreeContainer>
|
||||
{shouldUseVirtualization ? (
|
||||
<VirtualizedTreeContainer ref={parentRef}>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const { node, depth } = flattenedNodes[virtualItem.index]
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItem.start}px)`
|
||||
}}>
|
||||
<div style={{ padding: '0 8px' }}>
|
||||
<Dropdown
|
||||
menu={{ items: getEmptyAreaMenuItems() }}
|
||||
trigger={['contextMenu']}
|
||||
open={openDropdownKey === 'empty-area'}
|
||||
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
|
||||
<VirtualizedTreeContainer ref={parentRef}>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const { node, depth } = flattenedNodes[virtualItem.index]
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItem.start}px)`
|
||||
}}>
|
||||
<div style={{ padding: '0 8px' }}>
|
||||
<TreeNode
|
||||
node={node}
|
||||
depth={depth}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
renamingNodeIds={renamingNodeIds}
|
||||
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
renderChildren={false}
|
||||
openDropdownKey={openDropdownKey}
|
||||
onDropdownOpenChange={setOpenDropdownKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!isShowStarred && !isShowSearch && (
|
||||
<DropHintNode>
|
||||
<TreeNodeContainer active={false} depth={0}>
|
||||
<TreeNodeContent>
|
||||
<NodeIcon>
|
||||
<FilePlus size={16} />
|
||||
</NodeIcon>
|
||||
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</DropHintNode>
|
||||
)}
|
||||
</VirtualizedTreeContainer>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Dropdown
|
||||
menu={{ items: getEmptyAreaMenuItems() }}
|
||||
trigger={['contextMenu']}
|
||||
open={openDropdownKey === 'empty-area'}
|
||||
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
|
||||
<StyledScrollbar ref={scrollbarRef}>
|
||||
<TreeContent>
|
||||
{isShowStarred || isShowSearch
|
||||
? filteredTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={depth}
|
||||
depth={0}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
@ -857,92 +968,51 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
renderChildren={false}
|
||||
openDropdownKey={openDropdownKey}
|
||||
onDropdownOpenChange={setOpenDropdownKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!isShowStarred && !isShowSearch && (
|
||||
<DropHintNode>
|
||||
<TreeNodeContainer active={false} depth={0}>
|
||||
<TreeNodeContent>
|
||||
<NodeIcon>
|
||||
<FilePlus size={16} />
|
||||
</NodeIcon>
|
||||
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</DropHintNode>
|
||||
)}
|
||||
</VirtualizedTreeContainer>
|
||||
) : (
|
||||
<StyledScrollbar ref={scrollbarRef}>
|
||||
<TreeContent>
|
||||
{isShowStarred || isShowSearch
|
||||
? filteredTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
renamingNodeIds={renamingNodeIds}
|
||||
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))
|
||||
: notesTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
renamingNodeIds={renamingNodeIds}
|
||||
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
{!isShowStarred && !isShowSearch && (
|
||||
<DropHintNode>
|
||||
<TreeNodeContainer active={false} depth={0}>
|
||||
<TreeNodeContent>
|
||||
<NodeIcon>
|
||||
<FilePlus size={16} />
|
||||
</NodeIcon>
|
||||
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</DropHintNode>
|
||||
)}
|
||||
</TreeContent>
|
||||
</StyledScrollbar>
|
||||
))
|
||||
: notesTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedFolderId={selectedFolderId}
|
||||
activeNodeId={activeNode?.id}
|
||||
editingNodeId={editingNodeId}
|
||||
renamingNodeIds={renamingNodeIds}
|
||||
newlyRenamedNodeIds={newlyRenamedNodeIds}
|
||||
draggedNodeId={draggedNodeId}
|
||||
dragOverNodeId={dragOverNodeId}
|
||||
dragPosition={dragPosition}
|
||||
inPlaceEdit={inPlaceEdit}
|
||||
getMenuItems={getMenuItems}
|
||||
onSelectNode={onSelectNode}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
openDropdownKey={openDropdownKey}
|
||||
onDropdownOpenChange={setOpenDropdownKey}
|
||||
/>
|
||||
))}
|
||||
{!isShowStarred && !isShowSearch && (
|
||||
<DropHintNode>
|
||||
<TreeNodeContainer active={false} depth={0}>
|
||||
<TreeNodeContent>
|
||||
<NodeIcon>
|
||||
<FilePlus size={16} />
|
||||
</NodeIcon>
|
||||
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</DropHintNode>
|
||||
)}
|
||||
</TreeContent>
|
||||
</StyledScrollbar>
|
||||
</Dropdown>
|
||||
)}
|
||||
</NotesTreeContainer>
|
||||
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import { GithubOutlined } from '@ant-design/icons'
|
||||
import { RowFlex } from '@cherrystudio/ui'
|
||||
import { Switch } from '@cherrystudio/ui'
|
||||
import { Avatar, Button, Tooltip } from '@cherrystudio/ui'
|
||||
import { RowFlex, Switch, Tooltip, Avatar, Button } from '@cherrystudio/ui'
|
||||
import { usePreference } from '@data/hooks/usePreference'
|
||||
import { Radio, RadioGroup } from '@heroui/react'
|
||||
import { Radio, RadioGroup, useDisclosure } from '@heroui/react'
|
||||
import IndicatorLight from '@renderer/components/IndicatorLight'
|
||||
import UpdateDialog from '@renderer/components/UpdateDialog'
|
||||
import { APP_NAME, AppLogo } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAppUpdateState } from '@renderer/hooks/useAppUpdate'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
// import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { handleSaveData } from '@renderer/store'
|
||||
// import { handleSaveData } from '@renderer/store'
|
||||
// import { setUpdateState as setAppUpdateState } from '@renderer/store/runtime'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { UpgradeChannel } from '@shared/data/preference/preferenceTypes'
|
||||
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
|
||||
import { Progress, Row, Tag } from 'antd'
|
||||
import type { UpdateInfo } from 'builder-util-runtime'
|
||||
import { debounce } from 'lodash'
|
||||
import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react'
|
||||
import { BadgeQuestionMark } from 'lucide-react'
|
||||
@ -36,6 +36,8 @@ const AboutSettings: FC = () => {
|
||||
|
||||
const [version, setVersion] = useState('')
|
||||
const [isPortable, setIsPortable] = useState(false)
|
||||
const [updateDialogInfo, setUpdateDialogInfo] = useState<UpdateInfo | null>(null)
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
// const dispatch = useAppDispatch()
|
||||
@ -51,8 +53,9 @@ const AboutSettings: FC = () => {
|
||||
}
|
||||
|
||||
if (appUpdateState.downloaded) {
|
||||
await handleSaveData()
|
||||
window.api.showUpdateDialog()
|
||||
// Open update dialog directly in renderer
|
||||
setUpdateDialogInfo(appUpdateState.info || null)
|
||||
onOpen()
|
||||
return
|
||||
}
|
||||
|
||||
@ -347,6 +350,9 @@ const AboutSettings: FC = () => {
|
||||
<Button onPress={debug}>{t('settings.about.debug.open')}</Button>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
{/* Update Dialog */}
|
||||
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={updateDialogInfo} />
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@ -174,15 +174,22 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
const onUpdateApiVersion = () => updateProvider({ apiVersion })
|
||||
|
||||
const openApiKeyList = async () => {
|
||||
if (localApiKey !== provider.apiKey) {
|
||||
updateProvider({ apiKey: formatApiKeys(localApiKey) })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
await ApiKeyListPopup.show({
|
||||
providerId: provider.id,
|
||||
title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}`
|
||||
title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}`,
|
||||
providerType: 'llm'
|
||||
})
|
||||
}
|
||||
|
||||
const onCheckApi = async () => {
|
||||
const formattedLocalKey = formatApiKeys(localApiKey)
|
||||
// 如果存在多个密钥,直接打开管理窗口
|
||||
if (provider.apiKey.includes(',')) {
|
||||
if (formattedLocalKey.includes(',')) {
|
||||
await openApiKeyList()
|
||||
return
|
||||
}
|
||||
@ -206,7 +213,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
|
||||
try {
|
||||
setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: HealthStatus.NOT_CHECKED }))
|
||||
await checkApi({ ...provider, apiHost }, model)
|
||||
await checkApi({ ...provider, apiHost, apiKey: formattedLocalKey }, model)
|
||||
|
||||
window.toast.success({
|
||||
timeout: 2000,
|
||||
@ -475,14 +482,13 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
onBlur={onUpdateAnthropicHost}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<SettingHelpText
|
||||
style={{ marginLeft: 6, marginRight: '1em', whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
|
||||
<SettingHelpTextRow style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '4px' }}>
|
||||
<SettingHelpText style={{ marginLeft: 6, whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
|
||||
{t('settings.provider.anthropic_api_host_preview', {
|
||||
url: anthropicHostPreview || '—'
|
||||
})}
|
||||
</SettingHelpText>
|
||||
<SettingHelpText style={{ minWidth: 'fit-content', whiteSpace: 'normal' }}>
|
||||
<SettingHelpText style={{ marginLeft: 6 }}>
|
||||
{t('settings.provider.anthropic_api_host_tip')}
|
||||
</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
|
||||
@ -15,7 +15,7 @@ import type {
|
||||
WebSearchProviderResult,
|
||||
WebSearchStatus
|
||||
} from '@renderer/types'
|
||||
import { hasObjectKey, uuid } from '@renderer/utils'
|
||||
import { hasObjectKey, removeSpecialCharactersForFileName, uuid } from '@renderer/utils'
|
||||
import { addAbortController } from '@renderer/utils/abortController'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import type { ExtractResults } from '@renderer/utils/extract'
|
||||
@ -55,7 +55,7 @@ class WebSearchService {
|
||||
dispose: (requestState: RequestState, requestId: string) => {
|
||||
if (!requestState.searchBase) return
|
||||
window.api.knowledgeBase
|
||||
.delete(getKnowledgeBaseParams(requestState.searchBase), requestState.searchBase.id)
|
||||
.delete(removeSpecialCharactersForFileName(requestState.searchBase.id))
|
||||
.catch((error) => logger.warn(`Failed to cleanup search base for ${requestId}:`, error))
|
||||
}
|
||||
})
|
||||
@ -219,6 +219,7 @@ class WebSearchService {
|
||||
documentCount: number,
|
||||
requestId: string
|
||||
): Promise<KnowledgeBase> {
|
||||
// requestId: eg: openai-responses-openai/gpt-5-timestamp-uuid
|
||||
const baseId = `websearch-compression-${requestId}`
|
||||
const state = this.getRequestState(requestId)
|
||||
|
||||
@ -229,7 +230,8 @@ class WebSearchService {
|
||||
|
||||
// 清理旧的知识库
|
||||
if (state.searchBase) {
|
||||
await window.api.knowledgeBase.delete(getKnowledgeBaseParams(state.searchBase), state.searchBase.id)
|
||||
// 将requestId中的 '/' 映射为 '_'
|
||||
await window.api.knowledgeBase.delete(removeSpecialCharactersForFileName(state.searchBase.id))
|
||||
}
|
||||
|
||||
if (!config.embeddingModel) {
|
||||
@ -465,7 +467,9 @@ class WebSearchService {
|
||||
|
||||
// 处理 summarize
|
||||
if (questions[0] === 'summarize' && links && links.length > 0) {
|
||||
const contents = await fetchWebContents(links, undefined, undefined, { signal })
|
||||
const contents = await fetchWebContents(links, undefined, undefined, {
|
||||
signal
|
||||
})
|
||||
webSearchProvider.topicId &&
|
||||
endSpan({
|
||||
topicId: webSearchProvider.topicId,
|
||||
|
||||
@ -122,7 +122,8 @@ export class BlockManager {
|
||||
newMessagesActions.upsertBlockReference({
|
||||
messageId: this.deps.assistantMsgId,
|
||||
blockId: newBlock.id,
|
||||
status: newBlock.status
|
||||
status: newBlock.status,
|
||||
blockType: newBlock.type
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@ -2,14 +2,7 @@ import { loggerService } from '@logger'
|
||||
import type { PayloadAction } from '@reduxjs/toolkit'
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import type {
|
||||
FileMetadata,
|
||||
KnowledgeBase,
|
||||
KnowledgeBaseParams,
|
||||
KnowledgeItem,
|
||||
PreprocessProvider,
|
||||
ProcessingStatus
|
||||
} from '@renderer/types'
|
||||
import type { FileMetadata, KnowledgeBase, KnowledgeItem, PreprocessProvider, ProcessingStatus } from '@renderer/types'
|
||||
|
||||
const logger = loggerService.withContext('Store:Knowledge')
|
||||
|
||||
@ -29,13 +22,13 @@ const knowledgeSlice = createSlice({
|
||||
state.bases.push(action.payload)
|
||||
},
|
||||
|
||||
deleteBase(state, action: PayloadAction<{ baseId: string; baseParams: KnowledgeBaseParams }>) {
|
||||
deleteBase(state, action: PayloadAction<{ baseId: string }>) {
|
||||
const base = state.bases.find((b) => b.id === action.payload.baseId)
|
||||
if (base) {
|
||||
state.bases = state.bases.filter((b) => b.id !== action.payload.baseId)
|
||||
const files = base.items.filter((item) => item.type === 'file')
|
||||
FileManager.deleteFiles(files.map((item) => item.content) as FileMetadata[])
|
||||
window.api.knowledgeBase.delete(action.payload.baseParams, action.payload.baseId)
|
||||
window.api.knowledgeBase.delete(action.payload.baseId)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import type { EntityState, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
|
||||
// Separate type-only imports from value imports
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
|
||||
const logger = loggerService.withContext('newMessage')
|
||||
|
||||
@ -51,6 +51,7 @@ interface UpsertBlockReferencePayload {
|
||||
messageId: string
|
||||
blockId: string
|
||||
status?: MessageBlockStatus
|
||||
blockType?: MessageBlockType
|
||||
}
|
||||
|
||||
// Payload for removing a single message
|
||||
@ -219,7 +220,7 @@ export const messagesSlice = createSlice({
|
||||
messagesAdapter.removeMany(state, messageIds)
|
||||
},
|
||||
upsertBlockReference(state, action: PayloadAction<UpsertBlockReferencePayload>) {
|
||||
const { messageId, blockId, status } = action.payload
|
||||
const { messageId, blockId, status, blockType } = action.payload
|
||||
|
||||
const messageToUpdate = state.entities[messageId]
|
||||
if (!messageToUpdate) {
|
||||
@ -232,7 +233,11 @@ export const messagesSlice = createSlice({
|
||||
// Update Block ID
|
||||
const currentBlocks = messageToUpdate.blocks || []
|
||||
if (!currentBlocks.includes(blockId)) {
|
||||
changes.blocks = [...currentBlocks, blockId]
|
||||
if (blockType === MessageBlockType.THINKING) {
|
||||
changes.blocks = [blockId, ...currentBlocks]
|
||||
} else {
|
||||
changes.blocks = [...currentBlocks, blockId]
|
||||
}
|
||||
}
|
||||
|
||||
// Update Message Status based on Block Status
|
||||
|
||||
Loading…
Reference in New Issue
Block a user