Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2

This commit is contained in:
fullex 2025-10-11 09:56:42 +08:00
commit 06b6f2b9d8
31 changed files with 934 additions and 285 deletions

View File

@ -5,8 +5,8 @@ export enum IpcChannel {
// App_SetLanguage = 'app:set-language', // App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check', App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages', App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update', App_CheckForUpdate = 'app:check-for-update',
App_QuitAndInstall = 'app:quit-and-install',
App_Reload = 'app:reload', App_Reload = 'app:reload',
App_Quit = 'app:quit', App_Quit = 'app:quit',
App_Info = 'app:info', App_Info = 'app:info',
@ -251,7 +251,6 @@ export enum IpcChannel {
BackupProgress = 'backup-progress', BackupProgress = 'backup-progress',
DataMigrateProgress = 'data-migrate-progress', DataMigrateProgress = 'data-migrate-progress',
NativeThemeUpdated = 'native-theme:updated', NativeThemeUpdated = 'native-theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress', RestoreProgress = 'restore-progress',
UpdateError = 'update-error', UpdateError = 'update-error',
UpdateAvailable = 'update-available', UpdateAvailable = 'update-available',

View File

@ -143,7 +143,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url)) ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update // Update
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow)) ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
// language // language
// ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => { // ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {

View File

@ -3,21 +3,18 @@ import { loggerService } from '@logger'
import { isWin } from '@main/constant' import { isWin } from '@main/constant'
import { configManager } from '@main/services/ConfigManager' import { configManager } from '@main/services/ConfigManager'
import { getIpCountry } from '@main/utils/ipService' import { getIpCountry } from '@main/utils/ipService'
import { getI18n } from '@main/utils/language'
import { generateUserAgent } from '@main/utils/systemInfo' import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl } from '@shared/config/constant' import { FeedUrl } from '@shared/config/constant'
import { UpgradeChannel } from '@shared/data/preference/preferenceTypes' import { UpgradeChannel } from '@shared/data/preference/preferenceTypes'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import type { UpdateInfo } from 'builder-util-runtime' import type { UpdateInfo } from 'builder-util-runtime'
import { CancellationToken } from 'builder-util-runtime' import { CancellationToken } from 'builder-util-runtime'
import type { BrowserWindow } from 'electron' import { app, net } from 'electron'
import { app, dialog, net } from 'electron'
import type { AppUpdater as _AppUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater' import type { AppUpdater as _AppUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import { autoUpdater } from 'electron-updater' import { autoUpdater } from 'electron-updater'
import path from 'path' import path from 'path'
import semver from 'semver' import semver from 'semver'
import icon from '../../../build/icon.png?asset'
import { windowService } from './WindowService' import { windowService } from './WindowService'
const logger = loggerService.withContext('AppUpdater') const logger = loggerService.withContext('AppUpdater')
@ -31,7 +28,6 @@ const LANG_MARKERS = {
export default class AppUpdater { export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken() private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null private updateCheckResult: UpdateCheckResult | null = null
@ -71,7 +67,6 @@ export default class AppUpdater {
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => { autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
const processedReleaseInfo = this.processReleaseInfo(releaseInfo) const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo) windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
this.releaseInfo = processedReleaseInfo
logger.info('update downloaded', processedReleaseInfo) logger.info('update downloaded', processedReleaseInfo)
}) })
@ -252,37 +247,9 @@ export default class AppUpdater {
} }
} }
public showUpdateDialog(mainWindow: BrowserWindow) { public quitAndInstall() {
if (!this.releaseInfo) { app.isQuitting = true
return setImmediate(() => autoUpdater.quitAndInstall())
}
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)
}
})
} }
/** /**
@ -354,38 +321,9 @@ export default class AppUpdater {
return processedInfo 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 { interface GithubReleaseInfo {
draft: boolean draft: boolean
prerelease: boolean prerelease: boolean
tag_name: string tag_name: string
} }
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null
}

View File

@ -30,7 +30,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
import { fileStorage } from '@main/services/FileStorage' import { fileStorage } from '@main/services/FileStorage'
import { windowService } from '@main/services/WindowService' import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils' 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 { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant' import { MB } from '@shared/config/constant'
import type { LoaderReturn } from '@shared/config/types' 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 * Delete knowledge base file
*/ */
private deleteKnowledgeFile = (id: string): boolean => { private deleteKnowledgeFile = (id: string): boolean => {
const dbPath = path.join(this.storageDir, id) const dbPath = this.getDbPath(id)
if (fs.existsSync(dbPath)) { if (fs.existsSync(dbPath)) {
try { try {
fs.rmSync(dbPath, { recursive: true }) fs.rmSync(dbPath, { recursive: true })
@ -245,7 +250,8 @@ class KnowledgeService {
dimensions dimensions
}) })
try { 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 // Save database instance for later closing
this.dbInstances.set(id, libSqlDb) this.dbInstances.set(id, libSqlDb)

View File

@ -283,46 +283,4 @@ describe('AppUpdater', () => {
expect(result.releaseNotes).toBeNull() 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('')
})
})
}) })

View File

@ -56,7 +56,7 @@ const api = {
setProxy: (proxy: string | undefined, bypassRules?: string) => setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules), ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate), 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), // setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable), setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages), setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
@ -228,7 +228,7 @@ const api = {
create: (base: KnowledgeBaseParams, context?: SpanContext) => create: (base: KnowledgeBaseParams, context?: SpanContext) =>
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base), tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, 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: ({ add: ({
base, base,
item, item,

View File

@ -10,6 +10,7 @@ interface ShowParams {
providerId: string providerId: string
title?: string title?: string
showHealthCheck?: boolean showHealthCheck?: boolean
providerType?: 'llm' | 'webSearch' | 'preprocess'
} }
interface Props extends ShowParams { interface Props extends ShowParams {
@ -19,7 +20,7 @@ interface Props extends ShowParams {
/** /**
* API Key * 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 [open, setOpen] = useState(true)
const { t } = useTranslation() const { t } = useTranslation()
@ -32,14 +33,20 @@ const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealt
} }
const ListComponent = useMemo(() => { const ListComponent = useMemo(() => {
if (isWebSearchProviderId(providerId)) { const type =
return <WebSearchApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} /> 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)) { }, [providerId, showHealthCheck, providerType])
return <DocPreprocessApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}, [providerId, showHealthCheck])
return ( return (
<Modal <Modal

View 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

View File

@ -145,7 +145,7 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
{ {
id: 'dashscope', id: 'dashscope',
name: i18n.t('minapps.qwen'), name: i18n.t('minapps.qwen'),
url: 'https://tongyi.aliyun.com/qianwen/', url: 'https://www.tongyi.com/',
logo: QwenModelLogo logo: QwenModelLogo
}, },
{ {

View File

@ -353,7 +353,7 @@ export const useKnowledgeBases = () => {
const deleteKnowledgeBase = (baseId: string) => { const deleteKnowledgeBase = (baseId: string) => {
const base = bases.find((b) => b.id === baseId) const base = bases.find((b) => b.id === baseId)
if (!base) return if (!base) return
dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) })) dispatch(deleteBase({ baseId }))
// remove assistant knowledge_base // remove assistant knowledge_base
const _assistants = assistants.map((assistant) => { const _assistants = assistants.map((assistant) => {

View File

@ -4641,6 +4641,7 @@
"later": "Later", "later": "Later",
"message": "New version {{version}} is ready, do you want to install it now?", "message": "New version {{version}} is ready, do you want to install it now?",
"noReleaseNotes": "No release notes", "noReleaseNotes": "No release notes",
"saveDataError": "Failed to save data, please try again.",
"title": "Update" "title": "Update"
}, },
"warning": { "warning": {

View File

@ -4641,6 +4641,7 @@
"later": "稍后", "later": "稍后",
"message": "发现新版本 {{version}},是否立即安装?", "message": "发现新版本 {{version}},是否立即安装?",
"noReleaseNotes": "暂无更新日志", "noReleaseNotes": "暂无更新日志",
"saveDataError": "保存数据失败,请重试",
"title": "更新提示" "title": "更新提示"
}, },
"warning": { "warning": {

View File

@ -4641,6 +4641,7 @@
"later": "稍後", "later": "稍後",
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?", "message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
"noReleaseNotes": "暫無更新日誌", "noReleaseNotes": "暫無更新日誌",
"saveDataError": "保存數據失敗,請重試",
"title": "更新提示" "title": "更新提示"
}, },
"warning": { "warning": {

View File

@ -4641,6 +4641,7 @@
"later": "Μετά", "later": "Μετά",
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;", "message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
"noReleaseNotes": "Χωρίς σημειώσεις", "noReleaseNotes": "Χωρίς σημειώσεις",
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
"title": "Ενημέρωση" "title": "Ενημέρωση"
}, },
"warning": { "warning": {

View File

@ -4641,6 +4641,7 @@
"later": "Más tarde", "later": "Más tarde",
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?", "message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
"noReleaseNotes": "Sin notas de la versión", "noReleaseNotes": "Sin notas de la versión",
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
"title": "Actualización" "title": "Actualización"
}, },
"warning": { "warning": {

View File

@ -4641,6 +4641,7 @@
"later": "Plus tard", "later": "Plus tard",
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?", "message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
"noReleaseNotes": "Aucune note de version", "noReleaseNotes": "Aucune note de version",
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
"title": "Mise à jour" "title": "Mise à jour"
}, },
"warning": { "warning": {

View File

@ -4641,6 +4641,7 @@
"later": "後で", "later": "後で",
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?", "message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
"noReleaseNotes": "暫無更新日誌", "noReleaseNotes": "暫無更新日誌",
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
"title": "更新" "title": "更新"
}, },
"warning": { "warning": {

View File

@ -4641,6 +4641,7 @@
"later": "Mais tarde", "later": "Mais tarde",
"message": "Nova versão {{version}} disponível, deseja instalar agora?", "message": "Nova versão {{version}} disponível, deseja instalar agora?",
"noReleaseNotes": "Sem notas de versão", "noReleaseNotes": "Sem notas de versão",
"saveDataError": "Falha ao salvar os dados, tente novamente",
"title": "Atualização" "title": "Atualização"
}, },
"warning": { "warning": {

View File

@ -4641,6 +4641,7 @@
"later": "Позже", "later": "Позже",
"message": "Новая версия {{version}} готова, установить сейчас?", "message": "Новая версия {{version}} готова, установить сейчас?",
"noReleaseNotes": "Нет заметок об обновлении", "noReleaseNotes": "Нет заметок об обновлении",
"saveDataError": "Ошибка сохранения данных, повторите попытку",
"title": "Обновление" "title": "Обновление"
}, },
"warning": { "warning": {

View File

@ -1,5 +1,7 @@
import { SyncOutlined } from '@ant-design/icons' import { SyncOutlined } from '@ant-design/icons'
import { Button } from '@cherrystudio/ui' import { Button } from '@cherrystudio/ui'
import { useDisclosure } from '@heroui/react'
import UpdateDialog from '@renderer/components/UpdateDialog'
import { usePreference } from '@data/hooks/usePreference' import { usePreference } from '@data/hooks/usePreference'
import { useAppUpdateState } from '@renderer/hooks/useAppUpdate' import { useAppUpdateState } from '@renderer/hooks/useAppUpdate'
import type { FC } from 'react' import type { FC } from 'react'
@ -10,6 +12,7 @@ const UpdateAppButton: FC = () => {
const { appUpdateState } = useAppUpdateState() const { appUpdateState } = useAppUpdateState()
const [autoCheckUpdate] = usePreference('app.dist.auto_update.enabled') const [autoCheckUpdate] = usePreference('app.dist.auto_update.enabled')
const { t } = useTranslation() const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
if (!appUpdateState) { if (!appUpdateState) {
return null return null
@ -23,13 +26,15 @@ const UpdateAppButton: FC = () => {
<Container> <Container>
<UpdateButton <UpdateButton
className="nodrag" className="nodrag"
onPress={() => window.api.showUpdateDialog()} onPress={onOpen}
startContent={<SyncOutlined />} startContent={<SyncOutlined />}
color="warning" color="warning"
variant="bordered" variant="bordered"
size="sm"> size="sm">
{t('button.update_available')} {t('button.update_available')}
</UpdateButton> </UpdateButton>
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={appUpdateState.info || null} />
</Container> </Container>
) )
} }

View File

@ -15,6 +15,7 @@ import styled from 'styled-components'
// Tab 模式下新的页面壳,不再直接创建 WebView而是依赖全局 MinAppTabsPool // Tab 模式下新的页面壳,不再直接创建 WebView而是依赖全局 MinAppTabsPool
import MinimalToolbar from './components/MinimalToolbar' import MinimalToolbar from './components/MinimalToolbar'
import WebviewSearch from './components/WebviewSearch'
const logger = loggerService.withContext('MinAppPage') const logger = loggerService.withContext('MinAppPage')
@ -185,6 +186,7 @@ const MinAppPage: FC = () => {
onOpenDevTools={handleOpenDevTools} onOpenDevTools={handleOpenDevTools}
/> />
</ToolbarWrapper> </ToolbarWrapper>
<WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
{!isReady && ( {!isReady && (
<LoadingMask> <LoadingMask>
<Avatar src={app.logo} className="h-[60px] w-[60px] border border-border" /> <Avatar src={app.logo} className="h-[60px] w-[60px] border border-border" />

View 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

View File

@ -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()
})
})

View File

@ -386,21 +386,25 @@ const NotesPage: FC = () => {
}, [activeFilePath]) }, [activeFilePath])
// 获取目标文件夹路径(选中文件夹或根目录) // 获取目标文件夹路径(选中文件夹或根目录)
const getTargetFolderPath = useCallback(() => { const getTargetFolderPath = useCallback(
if (selectedFolderId) { (targetFolderId?: string) => {
const selectedNode = findNode(notesTree, selectedFolderId) const folderId = targetFolderId || selectedFolderId
if (selectedNode && selectedNode.type === 'folder') { if (folderId) {
return selectedNode.externalPath const selectedNode = findNode(notesTree, folderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
}
} }
} return notesPath // 默认返回根目录
return notesPath // 默认返回根目录 },
}, [selectedFolderId, notesTree, notesPath]) [selectedFolderId, notesTree, notesPath]
)
// 创建文件夹 // 创建文件夹
const handleCreateFolder = useCallback( const handleCreateFolder = useCallback(
async (name: string) => { async (name: string, targetFolderId?: string) => {
try { try {
const targetPath = getTargetFolderPath() const targetPath = getTargetFolderPath(targetFolderId)
if (!targetPath) { if (!targetPath) {
throw new Error('No folder path selected') throw new Error('No folder path selected')
} }
@ -416,11 +420,11 @@ const NotesPage: FC = () => {
// 创建笔记 // 创建笔记
const handleCreateNote = useCallback( const handleCreateNote = useCallback(
async (name: string) => { async (name: string, targetFolderId?: string) => {
try { try {
isCreatingNoteRef.current = true isCreatingNoteRef.current = true
const targetPath = getTargetFolderPath() const targetPath = getTargetFolderPath(targetFolderId)
if (!targetPath) { if (!targetPath) {
throw new Error('No folder path selected') throw new Error('No folder path selected')
} }

View File

@ -37,8 +37,8 @@ import { useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
interface NotesSidebarProps { interface NotesSidebarProps {
onCreateFolder: (name: string, parentId?: string) => void onCreateFolder: (name: string, targetFolderId?: string) => void
onCreateNote: (name: string, parentId?: string) => void onCreateNote: (name: string, targetFolderId?: string) => void
onSelectNode: (node: NotesTreeNode) => void onSelectNode: (node: NotesTreeNode) => void
onDeleteNode: (nodeId: string) => void onDeleteNode: (nodeId: string) => void
onRenameNode: (nodeId: string, newName: string) => void onRenameNode: (nodeId: string, newName: string) => void
@ -74,6 +74,8 @@ interface TreeNodeProps {
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
onDragEnd: () => void onDragEnd: () => void
renderChildren?: boolean // 控制是否渲染子节点 renderChildren?: boolean // 控制是否渲染子节点
openDropdownKey: string | null
onDropdownOpenChange: (key: string | null) => void
} }
const TreeNode = memo<TreeNodeProps>( const TreeNode = memo<TreeNodeProps>(
@ -97,7 +99,9 @@ const TreeNode = memo<TreeNodeProps>(
onDragLeave, onDragLeave,
onDrop, onDrop,
onDragEnd, onDragEnd,
renderChildren = true renderChildren = true,
openDropdownKey,
onDropdownOpenChange
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -122,8 +126,12 @@ const TreeNode = memo<TreeNodeProps>(
return ( return (
<div key={node.id}> <div key={node.id}>
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}> <Dropdown
<div> menu={{ items: getMenuItems(node) }}
trigger={['contextMenu']}
open={openDropdownKey === node.id}
onOpenChange={(open) => onDropdownOpenChange(open ? node.id : null)}>
<div onContextMenu={(e) => e.stopPropagation()}>
<TreeNodeContainer <TreeNodeContainer
active={isActive} active={isActive}
depth={depth} depth={depth}
@ -209,6 +217,8 @@ const TreeNode = memo<TreeNodeProps>(
onDrop={onDrop} onDrop={onDrop}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
renderChildren={renderChildren} renderChildren={renderChildren}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={onDropdownOpenChange}
/> />
))} ))}
</div> </div>
@ -247,6 +257,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const [isShowSearch, setIsShowSearch] = useState(false) const [isShowSearch, setIsShowSearch] = useState(false)
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false) const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null)
const dragNodeRef = useRef<HTMLDivElement | null>(null) const dragNodeRef = useRef<HTMLDivElement | null>(null)
const scrollbarRef = useRef<any>(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( baseMenuItems.push(
{ {
label: t('notes.rename'), label: t('notes.rename'),
@ -705,7 +738,9 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
handleDeleteNode, handleDeleteNode,
renamingNodeIds, renamingNodeIds,
handleAutoRename, handleAutoRename,
exportMenuOptions exportMenuOptions,
onCreateNote,
onCreateFolder
] ]
) )
@ -786,6 +821,23 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
fileInput.click() fileInput.click()
}, [onUploadFiles]) }, [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 ( return (
<SidebarContainer <SidebarContainer
onDragOver={(e) => { onDragOver={(e) => {
@ -815,31 +867,90 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
<NotesTreeContainer> <NotesTreeContainer>
{shouldUseVirtualization ? ( {shouldUseVirtualization ? (
<VirtualizedTreeContainer ref={parentRef}> <Dropdown
<div menu={{ items: getEmptyAreaMenuItems() }}
style={{ trigger={['contextMenu']}
height: `${virtualizer.getTotalSize()}px`, open={openDropdownKey === 'empty-area'}
width: '100%', onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
position: 'relative' <VirtualizedTreeContainer ref={parentRef}>
}}> <div
{virtualizer.getVirtualItems().map((virtualItem) => { style={{
const { node, depth } = flattenedNodes[virtualItem.index] height: `${virtualizer.getTotalSize()}px`,
return ( width: '100%',
<div position: 'relative'
key={virtualItem.key} }}>
data-index={virtualItem.index} {virtualizer.getVirtualItems().map((virtualItem) => {
ref={virtualizer.measureElement} const { node, depth } = flattenedNodes[virtualItem.index]
style={{ return (
position: 'absolute', <div
top: 0, key={virtualItem.key}
left: 0, data-index={virtualItem.index}
width: '100%', ref={virtualizer.measureElement}
transform: `translateY(${virtualItem.start}px)` style={{
}}> position: 'absolute',
<div style={{ padding: '0 8px' }}> 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 <TreeNode
key={node.id}
node={node} node={node}
depth={depth} depth={0}
selectedFolderId={selectedFolderId} selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id} activeNodeId={activeNode?.id}
editingNodeId={editingNodeId} editingNodeId={editingNodeId}
@ -857,92 +968,51 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
renderChildren={false} openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/> />
</div> ))
</div> : notesTree.map((node) => (
) <TreeNode
})} key={node.id}
</div> node={node}
{!isShowStarred && !isShowSearch && ( depth={0}
<DropHintNode> selectedFolderId={selectedFolderId}
<TreeNodeContainer active={false} depth={0}> activeNodeId={activeNode?.id}
<TreeNodeContent> editingNodeId={editingNodeId}
<NodeIcon> renamingNodeIds={renamingNodeIds}
<FilePlus size={16} /> newlyRenamedNodeIds={newlyRenamedNodeIds}
</NodeIcon> draggedNodeId={draggedNodeId}
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText> dragOverNodeId={dragOverNodeId}
</TreeNodeContent> dragPosition={dragPosition}
</TreeNodeContainer> inPlaceEdit={inPlaceEdit}
</DropHintNode> getMenuItems={getMenuItems}
)} onSelectNode={onSelectNode}
</VirtualizedTreeContainer> onToggleExpanded={onToggleExpanded}
) : ( onDragStart={handleDragStart}
<StyledScrollbar ref={scrollbarRef}> onDragOver={handleDragOver}
<TreeContent> onDragLeave={handleDragLeave}
{isShowStarred || isShowSearch onDrop={handleDrop}
? filteredTree.map((node) => ( onDragEnd={handleDragEnd}
<TreeNode openDropdownKey={openDropdownKey}
key={node.id} onDropdownOpenChange={setOpenDropdownKey}
node={node} />
depth={0} ))}
selectedFolderId={selectedFolderId} {!isShowStarred && !isShowSearch && (
activeNodeId={activeNode?.id} <DropHintNode>
editingNodeId={editingNodeId} <TreeNodeContainer active={false} depth={0}>
renamingNodeIds={renamingNodeIds} <TreeNodeContent>
newlyRenamedNodeIds={newlyRenamedNodeIds} <NodeIcon>
draggedNodeId={draggedNodeId} <FilePlus size={16} />
dragOverNodeId={dragOverNodeId} </NodeIcon>
dragPosition={dragPosition} <DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
inPlaceEdit={inPlaceEdit} </TreeNodeContent>
getMenuItems={getMenuItems} </TreeNodeContainer>
onSelectNode={onSelectNode} </DropHintNode>
onToggleExpanded={onToggleExpanded} )}
onDragStart={handleDragStart} </TreeContent>
onDragOver={handleDragOver} </StyledScrollbar>
onDragLeave={handleDragLeave} </Dropdown>
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>
)} )}
</NotesTreeContainer> </NotesTreeContainer>

View File

@ -1,22 +1,22 @@
import { GithubOutlined } from '@ant-design/icons' import { GithubOutlined } from '@ant-design/icons'
import { RowFlex } from '@cherrystudio/ui' import { RowFlex, Switch, Tooltip, Avatar, Button } from '@cherrystudio/ui'
import { Switch } from '@cherrystudio/ui'
import { Avatar, Button, Tooltip } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference' 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 IndicatorLight from '@renderer/components/IndicatorLight'
import UpdateDialog from '@renderer/components/UpdateDialog'
import { APP_NAME, AppLogo } from '@renderer/config/env' import { APP_NAME, AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useAppUpdateState } from '@renderer/hooks/useAppUpdate' import { useAppUpdateState } from '@renderer/hooks/useAppUpdate'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
// import { useRuntime } from '@renderer/hooks/useRuntime' // import { useRuntime } from '@renderer/hooks/useRuntime'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { handleSaveData } from '@renderer/store' // import { handleSaveData } from '@renderer/store'
// import { setUpdateState as setAppUpdateState } from '@renderer/store/runtime' // import { setUpdateState as setAppUpdateState } from '@renderer/store/runtime'
import { runAsyncFunction } from '@renderer/utils' import { runAsyncFunction } from '@renderer/utils'
import { UpgradeChannel } from '@shared/data/preference/preferenceTypes' import { UpgradeChannel } from '@shared/data/preference/preferenceTypes'
import { ThemeMode } from '@shared/data/preference/preferenceTypes' import { ThemeMode } from '@shared/data/preference/preferenceTypes'
import { Progress, Row, Tag } from 'antd' import { Progress, Row, Tag } from 'antd'
import type { UpdateInfo } from 'builder-util-runtime'
import { debounce } from 'lodash' import { debounce } from 'lodash'
import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react' import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react'
import { BadgeQuestionMark } from 'lucide-react' import { BadgeQuestionMark } from 'lucide-react'
@ -36,6 +36,8 @@ const AboutSettings: FC = () => {
const [version, setVersion] = useState('') const [version, setVersion] = useState('')
const [isPortable, setIsPortable] = useState(false) const [isPortable, setIsPortable] = useState(false)
const [updateDialogInfo, setUpdateDialogInfo] = useState<UpdateInfo | null>(null)
const { isOpen, onOpen, onClose } = useDisclosure()
const { t } = useTranslation() const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
// const dispatch = useAppDispatch() // const dispatch = useAppDispatch()
@ -51,8 +53,9 @@ const AboutSettings: FC = () => {
} }
if (appUpdateState.downloaded) { if (appUpdateState.downloaded) {
await handleSaveData() // Open update dialog directly in renderer
window.api.showUpdateDialog() setUpdateDialogInfo(appUpdateState.info || null)
onOpen()
return return
} }
@ -347,6 +350,9 @@ const AboutSettings: FC = () => {
<Button onPress={debug}>{t('settings.about.debug.open')}</Button> <Button onPress={debug}>{t('settings.about.debug.open')}</Button>
</SettingRow> </SettingRow>
</SettingGroup> </SettingGroup>
{/* Update Dialog */}
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={updateDialogInfo} />
</SettingContainer> </SettingContainer>
) )
} }

View File

@ -174,15 +174,22 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
const onUpdateApiVersion = () => updateProvider({ apiVersion }) const onUpdateApiVersion = () => updateProvider({ apiVersion })
const openApiKeyList = async () => { const openApiKeyList = async () => {
if (localApiKey !== provider.apiKey) {
updateProvider({ apiKey: formatApiKeys(localApiKey) })
await new Promise((resolve) => setTimeout(resolve, 0))
}
await ApiKeyListPopup.show({ await ApiKeyListPopup.show({
providerId: provider.id, 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 onCheckApi = async () => {
const formattedLocalKey = formatApiKeys(localApiKey)
// 如果存在多个密钥,直接打开管理窗口 // 如果存在多个密钥,直接打开管理窗口
if (provider.apiKey.includes(',')) { if (formattedLocalKey.includes(',')) {
await openApiKeyList() await openApiKeyList()
return return
} }
@ -206,7 +213,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
try { try {
setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: HealthStatus.NOT_CHECKED })) setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: HealthStatus.NOT_CHECKED }))
await checkApi({ ...provider, apiHost }, model) await checkApi({ ...provider, apiHost, apiKey: formattedLocalKey }, model)
window.toast.success({ window.toast.success({
timeout: 2000, timeout: 2000,
@ -475,14 +482,13 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
onBlur={onUpdateAnthropicHost} onBlur={onUpdateAnthropicHost}
/> />
</Space.Compact> </Space.Compact>
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}> <SettingHelpTextRow style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '4px' }}>
<SettingHelpText <SettingHelpText style={{ marginLeft: 6, whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
style={{ marginLeft: 6, marginRight: '1em', whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
{t('settings.provider.anthropic_api_host_preview', { {t('settings.provider.anthropic_api_host_preview', {
url: anthropicHostPreview || '—' url: anthropicHostPreview || '—'
})} })}
</SettingHelpText> </SettingHelpText>
<SettingHelpText style={{ minWidth: 'fit-content', whiteSpace: 'normal' }}> <SettingHelpText style={{ marginLeft: 6 }}>
{t('settings.provider.anthropic_api_host_tip')} {t('settings.provider.anthropic_api_host_tip')}
</SettingHelpText> </SettingHelpText>
</SettingHelpTextRow> </SettingHelpTextRow>

View File

@ -15,7 +15,7 @@ import type {
WebSearchProviderResult, WebSearchProviderResult,
WebSearchStatus WebSearchStatus
} from '@renderer/types' } from '@renderer/types'
import { hasObjectKey, uuid } from '@renderer/utils' import { hasObjectKey, removeSpecialCharactersForFileName, uuid } from '@renderer/utils'
import { addAbortController } from '@renderer/utils/abortController' import { addAbortController } from '@renderer/utils/abortController'
import { formatErrorMessage } from '@renderer/utils/error' import { formatErrorMessage } from '@renderer/utils/error'
import type { ExtractResults } from '@renderer/utils/extract' import type { ExtractResults } from '@renderer/utils/extract'
@ -55,7 +55,7 @@ class WebSearchService {
dispose: (requestState: RequestState, requestId: string) => { dispose: (requestState: RequestState, requestId: string) => {
if (!requestState.searchBase) return if (!requestState.searchBase) return
window.api.knowledgeBase 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)) .catch((error) => logger.warn(`Failed to cleanup search base for ${requestId}:`, error))
} }
}) })
@ -219,6 +219,7 @@ class WebSearchService {
documentCount: number, documentCount: number,
requestId: string requestId: string
): Promise<KnowledgeBase> { ): Promise<KnowledgeBase> {
// requestId: eg: openai-responses-openai/gpt-5-timestamp-uuid
const baseId = `websearch-compression-${requestId}` const baseId = `websearch-compression-${requestId}`
const state = this.getRequestState(requestId) const state = this.getRequestState(requestId)
@ -229,7 +230,8 @@ class WebSearchService {
// 清理旧的知识库 // 清理旧的知识库
if (state.searchBase) { 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) { if (!config.embeddingModel) {
@ -465,7 +467,9 @@ class WebSearchService {
// 处理 summarize // 处理 summarize
if (questions[0] === 'summarize' && links && links.length > 0) { 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 && webSearchProvider.topicId &&
endSpan({ endSpan({
topicId: webSearchProvider.topicId, topicId: webSearchProvider.topicId,

View File

@ -122,7 +122,8 @@ export class BlockManager {
newMessagesActions.upsertBlockReference({ newMessagesActions.upsertBlockReference({
messageId: this.deps.assistantMsgId, messageId: this.deps.assistantMsgId,
blockId: newBlock.id, blockId: newBlock.id,
status: newBlock.status status: newBlock.status,
blockType: newBlock.type
}) })
) )

View File

@ -2,14 +2,7 @@ import { loggerService } from '@logger'
import type { PayloadAction } from '@reduxjs/toolkit' import type { PayloadAction } from '@reduxjs/toolkit'
import { createSlice } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import type { import type { FileMetadata, KnowledgeBase, KnowledgeItem, PreprocessProvider, ProcessingStatus } from '@renderer/types'
FileMetadata,
KnowledgeBase,
KnowledgeBaseParams,
KnowledgeItem,
PreprocessProvider,
ProcessingStatus
} from '@renderer/types'
const logger = loggerService.withContext('Store:Knowledge') const logger = loggerService.withContext('Store:Knowledge')
@ -29,13 +22,13 @@ const knowledgeSlice = createSlice({
state.bases.push(action.payload) 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) const base = state.bases.find((b) => b.id === action.payload.baseId)
if (base) { if (base) {
state.bases = state.bases.filter((b) => b.id !== action.payload.baseId) state.bases = state.bases.filter((b) => b.id !== action.payload.baseId)
const files = base.items.filter((item) => item.type === 'file') const files = base.items.filter((item) => item.type === 'file')
FileManager.deleteFiles(files.map((item) => item.content) as FileMetadata[]) 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)
} }
}, },

View File

@ -3,7 +3,7 @@ import type { EntityState, PayloadAction } from '@reduxjs/toolkit'
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit' import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
// Separate type-only imports from value imports // Separate type-only imports from value imports
import type { Message } from '@renderer/types/newMessage' 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') const logger = loggerService.withContext('newMessage')
@ -51,6 +51,7 @@ interface UpsertBlockReferencePayload {
messageId: string messageId: string
blockId: string blockId: string
status?: MessageBlockStatus status?: MessageBlockStatus
blockType?: MessageBlockType
} }
// Payload for removing a single message // Payload for removing a single message
@ -219,7 +220,7 @@ export const messagesSlice = createSlice({
messagesAdapter.removeMany(state, messageIds) messagesAdapter.removeMany(state, messageIds)
}, },
upsertBlockReference(state, action: PayloadAction<UpsertBlockReferencePayload>) { upsertBlockReference(state, action: PayloadAction<UpsertBlockReferencePayload>) {
const { messageId, blockId, status } = action.payload const { messageId, blockId, status, blockType } = action.payload
const messageToUpdate = state.entities[messageId] const messageToUpdate = state.entities[messageId]
if (!messageToUpdate) { if (!messageToUpdate) {
@ -232,7 +233,11 @@ export const messagesSlice = createSlice({
// Update Block ID // Update Block ID
const currentBlocks = messageToUpdate.blocks || [] const currentBlocks = messageToUpdate.blocks || []
if (!currentBlocks.includes(blockId)) { 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 // Update Message Status based on Block Status