Compare commits

...

25 Commits

Author SHA1 Message Date
icarus
ca44133e90 Merge branch 'main' of github.com:CherryHQ/cherry-studio into fix/react-hooks 2025-10-19 03:54:39 +08:00
Pleasure1234
b4810bb487 fix: improve api-server startup and error handling logic (#10794)
* fix: improve server startup and error handling logic

Refactored ApiServer to clean up failed server instances and ensure proper handling of server state. Updated loggerService import to use shared logger and improved error handling during server startup.

* Update server.ts
2025-10-18 14:15:08 +08:00
SuYao
dc0f9c5f08 feat: add Claude Haiku 4.5 model support and update related regex patterns (#10800)
* feat: add Claude Haiku 4.5 model support and update related regex patterns

* fix: update Claude model token limits for consistency
2025-10-18 14:10:50 +08:00
SuYao
595fd878a6 fix: handle AISDKError in chunk processing (#10801) 2025-10-18 14:10:00 +08:00
icarus
51dcdf94fb refactor(TraceTree): replace useEffect with useMemo for usedTime calculation
Use useMemo to optimize performance by avoiding unnecessary recalculations and state updates

fix: Error: Calling setState synchronously within an effect can trigger cascading renders
2025-10-17 00:05:07 +08:00
icarus
254051cf62 fix(InputBar): move focus logic into useEffect to prevent side effects
fix: Error: Cannot access refs during render
2025-10-16 23:59:45 +08:00
icarus
24d2e6e6ce refactor(translate): simplify translation component by removing unused state
Remove unused assistant state and refactor topic initialization
Clean up unused imports and simplify logic for translation flow

fix: Error: Calling setState synchronously within an effect can trigger cascading renders
2025-10-16 23:57:09 +08:00
icarus
cf9bfce43c refactor(action): simplify assistant and topic initialization
Move assistant and topic initialization to useState hooks and sync with refs
Remove redundant initialization code from useEffect

fix: Error: Cannot access refs during render

fix: Error: Calling setState synchronously within an effect can trigger cascading renders
2025-10-16 23:18:32 +08:00
icarus
76bf78b810 fix(Footer): move hotkey handler after function definition
fix: Error: Cannot access variable before it is declared
2025-10-14 18:16:50 +08:00
icarus
f4441e2a55 fix(MinAppPage): use startTransition to avoid synchronously setState in useEffect
fix: Error: Calling setState synchronously within an effect can trigger cascading renders
2025-10-14 18:15:43 +08:00
icarus
84f590ec7b fix(HeaderNavbar): use startTransition for title update in useEffect to avoid synchronously setState
fix: Error: Calling setState synchronously within an effect can trigger cascading renders
2025-10-14 18:14:15 +08:00
icarus
a5865cfd01 fix(HeaderNavbar): replace useState with useMemo for breadcrumbItems
fix: Error: Calling setState synchronously within an effect can trigger cascading renders
2025-10-14 18:11:18 +08:00
icarus
4e7c714ea2 fix(MinAppPage): replace useRef with useState for initial navbar state
fix: Error: Cannot access refs during render
2025-10-14 18:04:33 +08:00
icarus
d2c4231458 fix(minapps): replace webview ref with state
fix: Error: Cannot access refs during render
2025-10-14 17:53:50 +08:00
icarus
b5004e2a51 fix(AgentSettingsPopup): inline ModalContent component for better readability
fix: Error: Cannot create components during render
2025-10-14 17:41:42 +08:00
icarus
e0c334b5ed fix(translate): use state instead of ref as hook parameter
fix: Error: Cannot access refs during render
2025-10-14 17:34:15 +08:00
icarus
d482e661fb fix(trace): simplify node selection state management
Replace direct node state with node ID tracking and memoized selection
Remove redundant useEffect for node updates

fix: Error: Calling setState synchronously within an effect can trigger cascading renders
2025-10-14 17:31:43 +08:00
icarus
3ac1caca69 refactor(trace): simplify showList state management with useMemo
Replace manual state updates for showList with derived state using useMemo
2025-10-14 17:16:13 +08:00
icarus
94c112c066 fix(trace): extract trace utility functions to separate module
Move mergeTraceModals, updatePercentAndStart and findNodeById functions from trace page component to a dedicated utils module to fix react-hooks error
2025-10-14 17:00:20 +08:00
icarus
2e694a87f8 fix(SessionSettingsPopup): inline ModalContent component for better readability
fix: Error: Cannot create components during render
2025-10-14 16:25:49 +08:00
icarus
4ae30db53a fix(AssistantSettings): replace useEffect with useMemo for prompts list
fix: Error: Calling setState synchronously within an effect can trigger cascading renders
2025-10-14 16:19:45 +08:00
icarus
f4a6dd91cf fix(ocr): move ProviderSettings component outside main component
Extract ProviderSettings component to fix react-hooks error
2025-10-14 16:16:46 +08:00
icarus
c08a570c27 fix(WindowFooter): avoid earlier access 2025-10-14 16:11:56 +08:00
icarus
9c318c9526 fix(SelectionToolbar): avoid earlier access 2025-10-14 16:10:07 +08:00
icarus
4cee09870a build(deps): upgrade eslint-plugin-react-hooks to v7.0.0
Update react-hooks eslint plugin to latest version and adjust config to use flat recommended rules. This brings improved linting rules and compatibility with newer React versions.
2025-10-14 16:08:31 +08:00
26 changed files with 458 additions and 339 deletions

View File

@@ -12,7 +12,7 @@ export default defineConfig([
eslint.configs.recommended,
tseslint.configs.recommended,
eslintReact.configs['recommended-typescript'],
reactHooks.configs['recommended-latest'],
reactHooks.configs.flat.recommended,
{
plugins: {
'simple-import-sort': simpleImportSort,

View File

@@ -261,7 +261,7 @@
"eslint": "^9.22.0",
"eslint-plugin-import-zod": "^1.2.0",
"eslint-plugin-oxlint": "^1.15.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"express-validator": "^7.2.1",

View File

@@ -1,7 +1,8 @@
import { createServer } from 'node:http'
import { loggerService } from '@logger'
import { agentService } from '../services/agents'
import { loggerService } from '../services/LoggerService'
import { app } from './app'
import { config } from './config'
@@ -15,11 +16,17 @@ export class ApiServer {
private server: ReturnType<typeof createServer> | null = null
async start(): Promise<void> {
if (this.server) {
if (this.server && this.server.listening) {
logger.warn('Server already running')
return
}
// Clean up any failed server instance
if (this.server && !this.server.listening) {
logger.warn('Cleaning up failed server instance')
this.server = null
}
// Load config
const { port, host } = await config.load()
@@ -39,7 +46,11 @@ export class ApiServer {
resolve()
})
this.server!.on('error', reject)
this.server!.on('error', (error) => {
// Clean up the server instance if listen fails
this.server = null
reject(error)
})
})
}

View File

@@ -10,7 +10,7 @@ import { ProviderSpecificError } from '@renderer/types/provider-specific-error'
import { formatErrorMessage } from '@renderer/utils/error'
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
import type { TextStreamPart, ToolSet } from 'ai'
import { AISDKError, type TextStreamPart, type ToolSet } from 'ai'
import { ToolCallChunkHandler } from './handleToolCallChunk'
@@ -357,11 +357,14 @@ export class AiSdkToChunkAdapter {
case 'error':
this.onChunk({
type: ChunkType.ERROR,
error: new ProviderSpecificError({
message: formatErrorMessage(chunk.error),
provider: 'unknown',
cause: chunk.error
})
error:
chunk.error instanceof AISDKError
? chunk.error
: new ProviderSpecificError({
message: formatErrorMessage(chunk.error),
provider: 'unknown',
cause: chunk.error
})
})
break

View File

@@ -430,6 +430,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
}
],
anthropic: [
{
id: 'claude-haiku-4-5-20251001',
provider: 'anthropic',
name: 'Claude Haiku 4.5',
group: 'Claude 4.5'
},
{
id: 'claude-sonnet-4-5-20250929',
provider: 'anthropic',

View File

@@ -335,7 +335,8 @@ export function isClaudeReasoningModel(model?: Model): boolean {
modelId.includes('claude-3-7-sonnet') ||
modelId.includes('claude-3.7-sonnet') ||
modelId.includes('claude-sonnet-4') ||
modelId.includes('claude-opus-4')
modelId.includes('claude-opus-4') ||
modelId.includes('claude-haiku-4')
)
}
@@ -493,8 +494,9 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
'qwen3-(?!max).*$': { min: 1024, max: 38_912 },
// Claude models
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 }
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64_000 },
'claude-(:?haiku|sonnet)-4.*$': { min: 1024, max: 64_000 },
'claude-opus-4-1.*$': { min: 1024, max: 32_000 }
}
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {

View File

@@ -15,6 +15,7 @@ const visionAllowedModels = [
'gemini-(flash|pro|flash-lite)-latest',
'gemini-exp',
'claude-3',
'claude-haiku-4',
'claude-sonnet-4',
'claude-opus-4',
'vision',

View File

@@ -7,7 +7,7 @@ import { isAnthropicModel } from './utils'
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`,
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-(haiku|sonnet|opus)-4(?:-[\\w-]+)?)\\b`,
'i'
)

View File

@@ -7,7 +7,7 @@ import TabsService from '@renderer/services/TabsService'
import { getWebviewLoaded, onWebviewStateChange, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
import { Avatar } from 'antd'
import { WebviewTag } from 'electron'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FC, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
@@ -28,7 +28,9 @@ const MinAppPage: FC = () => {
const navigate = useNavigate()
// Remember the initial navbar position when component mounts
const initialIsTopNavbar = useRef<boolean>(isTopNavbar)
// It's immutable state
const [initialIsTopNavbar] = useState<boolean>(isTopNavbar)
const initialIsTopNavbarRef = useRef<boolean>(initialIsTopNavbar)
const hasRedirected = useRef<boolean>(false)
// Initialize TabsService with cache reference
@@ -40,8 +42,8 @@ const MinAppPage: FC = () => {
// Debug: track navbar position changes
useEffect(() => {
if (initialIsTopNavbar.current !== isTopNavbar) {
logger.debug(`NavBar position changed from ${initialIsTopNavbar.current} to ${isTopNavbar}`)
if (initialIsTopNavbarRef.current !== isTopNavbar) {
logger.debug(`NavBar position changed from ${initialIsTopNavbarRef.current} to ${isTopNavbar}`)
}
}, [isTopNavbar])
@@ -69,7 +71,7 @@ const MinAppPage: FC = () => {
// For sidebar navigation, redirect to apps list and open popup
// Only check once and only if we haven't already redirected
if (!initialIsTopNavbar.current && !hasRedirected.current) {
if (!initialIsTopNavbarRef.current && !hasRedirected.current) {
hasRedirected.current = true
navigate('/apps')
// Open popup after navigation
@@ -80,15 +82,20 @@ const MinAppPage: FC = () => {
}
// For top navbar mode, integrate with cache system
if (initialIsTopNavbar.current) {
if (initialIsTopNavbarRef.current) {
// 无论是否已在缓存,都调用以确保 currentMinappId 同步到路由切换的新 appId
openMinappKeepAlive(app)
}
}, [app, navigate, openMinappKeepAlive, initialIsTopNavbar])
}, [app, navigate, openMinappKeepAlive])
// -------------- 新的 Tab Shell 逻辑 --------------
// 注意Hooks 必须在任何 return 之前调用,因此提前定义,并在内部判空
const webviewRef = useRef<WebviewTag | null>(null)
const [webview, setWebview] = useState<WebviewTag | null>(null)
const webviewRef = useRef<WebviewTag | null>(webview)
useEffect(() => {
webviewRef.current = webview
}, [webview])
const [isReady, setIsReady] = useState<boolean>(() => (app ? getWebviewLoaded(app.id) : false))
const [currentUrl, setCurrentUrl] = useState<string | null>(app?.url ?? null)
@@ -103,7 +110,7 @@ const MinAppPage: FC = () => {
if (webviewRef.current === el) return true // 已附着
webviewRef.current = el
setWebview(el)
const handleInPageNav = (e: any) => setCurrentUrl(e.url)
el.addEventListener('did-navigate-in-page', handleInPageNav)
webviewCleanupRef.current = () => {
@@ -137,7 +144,10 @@ const MinAppPage: FC = () => {
if (!app) return
if (getWebviewLoaded(app.id)) {
// 已经加载
if (!isReady) setIsReady(true)
if (!isReady)
startTransition(() => {
setIsReady(true)
})
return
}
let mounted = true
@@ -155,7 +165,7 @@ const MinAppPage: FC = () => {
}, [app, isReady])
// 如果条件不满足,提前返回(所有 hooks 已调用)
if (!app || !initialIsTopNavbar.current) {
if (!app || !initialIsTopNavbar) {
return null
}
@@ -185,7 +195,7 @@ const MinAppPage: FC = () => {
onOpenDevTools={handleOpenDevTools}
/>
</ToolbarWrapper>
<WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
<WebviewSearch activeWebview={webview} isWebviewReady={isReady} appId={app.id} />
{!isReady && (
<LoadingMask>
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />

View File

@@ -8,14 +8,14 @@ import { useTranslation } from 'react-i18next'
type FoundInPageResult = Electron.FoundInPageResult
interface WebviewSearchProps {
webviewRef: React.RefObject<WebviewTag | null>
activeWebview: WebviewTag | null
isWebviewReady: boolean
appId: string
}
const logger = loggerService.withContext('WebviewSearch')
const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, appId }) => {
const WebviewSearch: FC<WebviewSearchProps> = ({ activeWebview, isWebviewReady, appId }) => {
const { t } = useTranslation()
const [isVisible, setIsVisible] = useState(false)
const [query, setQuery] = useState('')
@@ -25,7 +25,6 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
const focusFrameRef = useRef<number | null>(null)
const lastAppIdRef = useRef<string>(appId)
const attachedWebviewRef = useRef<WebviewTag | null>(null)
const activeWebview = webviewRef.current ?? null
const focusInput = useCallback(() => {
if (focusFrameRef.current !== null) {
@@ -81,7 +80,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
)
const getUsableWebview = useCallback(() => {
const candidates = [webviewRef.current, attachedWebviewRef.current]
const candidates = [activeWebview, attachedWebviewRef.current]
for (const candidate of candidates) {
const usable = ensureWebviewReady(candidate)
@@ -91,7 +90,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
}
return null
}, [ensureWebviewReady, webviewRef])
}, [ensureWebviewReady, activeWebview])
const stopSearch = useCallback(() => {
const target = getUsableWebview()

View File

@@ -136,9 +136,8 @@ describe('WebviewSearch', () => {
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" />)
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />)
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
@@ -149,9 +148,8 @@ describe('WebviewSearch', () => {
it('opens the search overlay when webview shortcut is forwarded', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
@@ -170,13 +168,12 @@ describe('WebviewSearch', () => {
;(webview as any).getWebContentsId = vi.fn(() => {
throw error
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
const { rerender } = render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
@@ -185,8 +182,8 @@ describe('WebviewSearch', () => {
;(webview as any).getWebContentsId = vi.fn(() => 1)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
rerender(<WebviewSearch activeWebview={webview} isWebviewReady={false} appId="app-1" />)
rerender(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
@@ -200,9 +197,8 @@ describe('WebviewSearch', () => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { rerender, unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
const { rerender, unmount } = render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
@@ -212,7 +208,7 @@ describe('WebviewSearch', () => {
throw new Error('should not be called')
})
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
rerender(<WebviewSearch activeWebview={webview} isWebviewReady={false} appId="app-1" />)
expect(stopFindInPageMock).not.toHaveBeenCalled()
unmount()
@@ -221,9 +217,8 @@ describe('WebviewSearch', () => {
it('closes the search overlay when escape is forwarded from the webview', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
@@ -245,10 +240,9 @@ describe('WebviewSearch', () => {
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" />)
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
@@ -286,10 +280,9 @@ describe('WebviewSearch', () => {
it('navigates results when enter is forwarded from the webview', async () => {
const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
@@ -325,10 +318,9 @@ describe('WebviewSearch', () => {
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" />)
const { rerender } = render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
@@ -338,7 +330,7 @@ describe('WebviewSearch', () => {
})
await act(async () => {
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-2" />)
rerender(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-2" />)
})
await waitFor(() => {
@@ -352,10 +344,9 @@ describe('WebviewSearch', () => {
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" />)
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
@@ -368,9 +359,8 @@ describe('WebviewSearch', () => {
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" />)
const { unmount } = render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />)
await openSearchOverlay()
stopFindInPageMock.mockClear()
@@ -382,9 +372,8 @@ describe('WebviewSearch', () => {
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" />)
render(<WebviewSearch activeWebview={webview} isWebviewReady={false} appId="app-1" />)
await act(async () => {
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })

View File

@@ -9,7 +9,7 @@ import { findNode } from '@renderer/services/NotesTreeService'
import { Dropdown, Input, Tooltip } from 'antd'
import { t } from 'i18next'
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
import { menuItems } from './MenuConfig'
@@ -19,9 +19,6 @@ const logger = loggerService.withContext('HeaderNavbar')
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => {
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
const { activeNode } = useActiveNode(notesTree)
const [breadcrumbItems, setBreadcrumbItems] = useState<
Array<{ key: string; title: string; treePath: string; isFolder: boolean }>
>([])
const [titleValue, setTitleValue] = useState('')
const titleInputRef = useRef<any>(null)
const { settings, updateSettings } = useNotesSettings()
@@ -141,18 +138,17 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
// 同步标题值
useEffect(() => {
if (activeNode?.type === 'file') {
setTitleValue(activeNode.name.replace('.md', ''))
startTransition(() => setTitleValue(activeNode.name.replace('.md', '')))
}
}, [activeNode])
// 构建面包屑路径
useEffect(() => {
const breadcrumbItems = useMemo(() => {
if (!activeNode || !notesTree) {
setBreadcrumbItems([])
return
return []
}
const node = findNode(notesTree, activeNode.id)
if (!node) return
if (!node) return []
const pathParts = node.treePath.split('/').filter(Boolean)
const items = pathParts.map((part, index) => {
@@ -166,7 +162,7 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
}
})
setBreadcrumbItems(items)
return items
}, [activeNode, notesTree])
return (

View File

@@ -63,39 +63,6 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
] as const satisfies { key: AgentSettingPopupTab; label: string }[]
).filter(Boolean)
const ModalContent = () => {
if (isLoading) {
// TODO: use skeleton for better ux
return <Spinner />
}
if (error) {
return (
<div>
<Alert color="danger" title={t('agent.get.error.failed')} />
</div>
)
}
return (
<div className="flex w-full flex-1">
<LeftMenu>
<StyledMenu
defaultSelectedKeys={[tab || 'essential'] satisfies AgentSettingPopupTab[]}
mode="vertical"
selectedKeys={[menu]}
items={items}
onSelect={({ key }) => setMenu(key as AgentSettingPopupTab)}
/>
</LeftMenu>
<Settings>
{menu === 'essential' && <EssentialSettings agentBase={agent} update={updateAgent} />}
{menu === 'prompt' && <PromptSettings agentBase={agent} update={updateAgent} />}
{menu === 'tooling' && <ToolingSettings agentBase={agent} update={updateAgent} />}
{menu === 'advanced' && <AdvancedSettings agentBase={agent} update={updateAgent} />}
</Settings>
</div>
)
}
return (
<StyledModal
open={open}
@@ -129,7 +96,31 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
}}
width="min(800px, 70vw)"
centered>
<ModalContent />
{isLoading && <Spinner />}
{!isLoading && error && (
<div>
<Alert color="danger" title={t('agent.get.error.failed')} />
</div>
)}
{!isLoading && !error && (
<div className="flex w-full flex-1">
<LeftMenu>
<StyledMenu
defaultSelectedKeys={[tab || 'essential'] satisfies AgentSettingPopupTab[]}
mode="vertical"
selectedKeys={[menu]}
items={items}
onSelect={({ key }) => setMenu(key as AgentSettingPopupTab)}
/>
</LeftMenu>
<Settings>
{menu === 'essential' && <EssentialSettings agentBase={agent} update={updateAgent} />}
{menu === 'prompt' && <PromptSettings agentBase={agent} update={updateAgent} />}
{menu === 'tooling' && <ToolingSettings agentBase={agent} update={updateAgent} />}
{menu === 'advanced' && <AdvancedSettings agentBase={agent} update={updateAgent} />}
</Settings>
</div>
)}
</StyledModal>
)
}

View File

@@ -65,39 +65,6 @@ const SessionSettingPopupContainer: React.FC<SessionSettingPopupParams> = ({ tab
] as const satisfies { key: AgentSettingPopupTab; label: string }[]
).filter(Boolean)
const ModalContent = () => {
if (isLoading) {
// TODO: use skeleton for better ux
return <Spinner />
}
if (error) {
return (
<div>
<Alert color="danger" title={t('agent.get.error.failed')} />
</div>
)
}
return (
<div className="flex w-full flex-1">
<LeftMenu>
<StyledMenu
defaultSelectedKeys={[tab || 'essential'] satisfies AgentSettingPopupTab[]}
mode="vertical"
selectedKeys={[menu]}
items={items}
onSelect={({ key }) => setMenu(key as AgentSettingPopupTab)}
/>
</LeftMenu>
<Settings>
{menu === 'essential' && <EssentialSettings agentBase={session} update={updateSession} />}
{menu === 'prompt' && <PromptSettings agentBase={session} update={updateSession} />}
{menu === 'tooling' && <ToolingSettings agentBase={session} update={updateSession} />}
{menu === 'advanced' && <AdvancedSettings agentBase={session} update={updateSession} />}
</Settings>
</div>
)
}
return (
<StyledModal
open={open}
@@ -125,7 +92,31 @@ const SessionSettingPopupContainer: React.FC<SessionSettingPopupParams> = ({ tab
}}
width="min(800px, 70vw)"
centered>
<ModalContent />
{isLoading && <Spinner />}
{!isLoading && error && (
<div>
<Alert color="danger" title={t('agent.get.error.failed')} />
</div>
)}
{!isLoading && !error && (
<div className="flex w-full flex-1">
<LeftMenu>
<StyledMenu
defaultSelectedKeys={[tab || 'essential'] satisfies AgentSettingPopupTab[]}
mode="vertical"
selectedKeys={[menu]}
items={items}
onSelect={({ key }) => setMenu(key as AgentSettingPopupTab)}
/>
</LeftMenu>
<Settings>
{menu === 'essential' && <EssentialSettings agentBase={session} update={updateSession} />}
{menu === 'prompt' && <PromptSettings agentBase={session} update={updateSession} />}
{menu === 'tooling' && <ToolingSettings agentBase={session} update={updateSession} />}
{menu === 'advanced' && <AdvancedSettings agentBase={session} update={updateSession} />}
</Settings>
</div>
)}
</StyledModal>
)
}

View File

@@ -5,7 +5,7 @@ import FileItem from '@renderer/pages/files/FileItem'
import { Assistant, QuickPhrase } from '@renderer/types'
import { Button, Flex, Input, Modal, Popconfirm, Space } from 'antd'
import { PlusIcon } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { FC, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { v4 as uuidv4 } from 'uuid'
@@ -21,15 +21,12 @@ interface AssistantRegularPromptsSettingsProps {
const AssistantRegularPromptsSettings: FC<AssistantRegularPromptsSettingsProps> = ({ assistant, updateAssistant }) => {
const { t } = useTranslation()
const [promptsList, setPromptsList] = useState<QuickPhrase[]>([])
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingPrompt, setEditingPrompt] = useState<QuickPhrase | null>(null)
const [formData, setFormData] = useState({ title: '', content: '' })
const [dragging, setDragging] = useState(false)
useEffect(() => {
setPromptsList(assistant.regularPhrases || [])
}, [assistant.regularPhrases])
const promptsList: QuickPhrase[] = useMemo(() => assistant.regularPhrases || [], [assistant.regularPhrases])
const handleAdd = () => {
setEditingPrompt(null)
@@ -45,7 +42,6 @@ const AssistantRegularPromptsSettings: FC<AssistantRegularPromptsSettingsProps>
const handleDelete = async (id: string) => {
const updatedPrompts = promptsList.filter((prompt) => prompt.id !== id)
setPromptsList(updatedPrompts)
updateAssistant({ ...assistant, regularPhrases: updatedPrompts })
}
@@ -68,13 +64,11 @@ const AssistantRegularPromptsSettings: FC<AssistantRegularPromptsSettingsProps>
}
updatedPrompts = [...promptsList, newPrompt]
}
setPromptsList(updatedPrompts)
updateAssistant({ ...assistant, regularPhrases: updatedPrompts })
setIsModalOpen(false)
}
const handleUpdateOrder = async (newPrompts: QuickPhrase[]) => {
setPromptsList(newPrompts)
updateAssistant({ ...assistant, regularPhrases: newPrompts })
}

View File

@@ -27,25 +27,6 @@ const OcrProviderSettings = ({ provider }: Props) => {
return null
}
const ProviderSettings = () => {
if (isBuiltinOcrProvider(provider)) {
switch (provider.id) {
case 'tesseract':
return <OcrTesseractSettings />
case 'system':
return <OcrSystemSettings />
case 'paddleocr':
return <OcrPpocrSettings />
case 'ovocr':
return <OcrOVSettings />
default:
return null
}
} else {
throw new Error('Not supported OCR provider')
}
}
return (
<SettingGroup theme={themeMode}>
<SettingTitle>
@@ -56,7 +37,7 @@ const OcrProviderSettings = ({ provider }: Props) => {
</SettingTitle>
<Divider style={{ width: '100%', margin: '10px 0' }} />
<ErrorBoundary>
<ProviderSettings />
<ProviderSettings provider={provider} />
</ErrorBoundary>
</SettingGroup>
)
@@ -67,4 +48,23 @@ const ProviderName = styled.span`
font-weight: 500;
`
const ProviderSettings = ({ provider }: { provider: OcrProvider }) => {
if (isBuiltinOcrProvider(provider)) {
switch (provider.id) {
case 'tesseract':
return <OcrTesseractSettings />
case 'system':
return <OcrSystemSettings />
case 'paddleocr':
return <OcrPpocrSettings />
case 'ovocr':
return <OcrOVSettings />
default:
return null
}
} else {
throw new Error('Not supported OCR provider')
}
}
export default OcrProviderSettings

View File

@@ -1,7 +1,8 @@
import { TraceModal } from '@renderer/trace/pages/TraceModel'
import { Divider } from 'antd/lib'
import dayjs from 'dayjs'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useMemo, useState } from 'react'
import { Box, GridItem, HStack, IconButton, SimpleGrid, Text } from './Component'
import { ProgressBar } from './ProgressBar'
@@ -38,12 +39,10 @@ export const convertTime = (time: number | null): string => {
const TreeNode: React.FC<TreeNodeProps> = ({ node, handleClick, treeData, paddingLeft = 2 }) => {
const [isOpen, setIsOpen] = useState(true)
const hasChildren = node.children && node.children.length > 0
const [usedTime, setUsedTime] = useState('--')
// 只在 endTime 或 node 变化时更新 usedTime
useEffect(() => {
const endTime = node.endTime || Date.now()
setUsedTime(convertTime(endTime - node.startTime))
const usedTime = useMemo(() => {
const endTime = node.endTime || dayjs().valueOf()
return convertTime(endTime - node.startTime)
}, [node])
return (

View File

@@ -3,9 +3,10 @@ import './Trace.css'
import { SpanEntity } from '@mcp-trace/trace-core'
import { TraceModal } from '@renderer/trace/pages/TraceModel'
import { Divider } from 'antd/lib'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { findNodeById, mergeTraceModals, updatePercentAndStart } from '../utils'
import { Box, GridItem, SimpleGrid, Text, VStack } from './Component'
import SpanDetail from './SpanDetail'
import TraceTree from './TraceTree'
@@ -19,44 +20,12 @@ export interface TracePageProp {
export const TracePage: React.FC<TracePageProp> = ({ topicId, traceId, modelName, reload = false }) => {
const [spans, setSpans] = useState<TraceModal[]>([])
const [selectNode, setSelectNode] = useState<TraceModal | null>(null)
const [showList, setShowList] = useState(true)
const [selectNodeId, setSelectNodeId] = useState<string | null>(null)
const selectNode = useMemo(() => (selectNodeId ? findNodeById(spans, selectNodeId) : null), [selectNodeId, spans])
const showList = useMemo(() => selectNodeId === null || !selectNode, [selectNode, selectNodeId])
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const { t } = useTranslation()
const mergeTraceModals = useCallback((oldNodes: TraceModal[], newNodes: TraceModal[]): TraceModal[] => {
const oldMap = new Map(oldNodes.map((n) => [n.id, n]))
return newNodes.map((newNode) => {
const oldNode = oldMap.get(newNode.id)
if (oldNode) {
// 如果旧节点已经结束,则直接返回旧节点
if (oldNode.endTime) {
return oldNode
}
oldNode.children = mergeTraceModals(oldNode.children, newNode.children)
Object.assign(oldNode, newNode)
return oldNode
} else {
return newNode
}
})
}, [])
const updatePercentAndStart = useCallback((nodes: TraceModal[], rootStart?: number, rootEnd?: number) => {
nodes.forEach((node) => {
const _rootStart = rootStart || node.startTime
const _rootEnd = rootEnd || node.endTime || Date.now()
const endTime = node.endTime || _rootEnd
const usedTime = endTime - node.startTime
const duration = _rootEnd - _rootStart
node.start = ((node.startTime - _rootStart) * 100) / duration
node.percent = duration === 0 ? 0 : (usedTime * 100) / duration
if (node.children) {
updatePercentAndStart(node.children, _rootStart, _rootEnd)
}
})
}, [])
const getRootSpan = (spans: SpanEntity[]): TraceModal[] => {
const map: Map<string, TraceModal> = new Map()
@@ -78,17 +47,6 @@ export const TracePage: React.FC<TracePageProp> = ({ topicId, traceId, modelName
)
}
const findNodeById = useCallback((nodes: TraceModal[], id: string): TraceModal | null => {
for (const n of nodes) {
if (n.id === id) return n
if (n.children) {
const found = findNodeById(n.children, id)
if (found) return found
}
}
return null
}, [])
const getTraceData = useCallback(async (): Promise<boolean> => {
const datas = topicId && traceId ? await window.api.trace.getData(topicId, traceId, modelName) : []
const matchedSpans = getRootSpan(datas)
@@ -96,19 +54,14 @@ export const TracePage: React.FC<TracePageProp> = ({ topicId, traceId, modelName
setSpans((prev) => mergeTraceModals(prev, matchedSpans))
const isEnded = !matchedSpans.find((e) => !e.endTime || e.endTime <= 0)
return isEnded
}, [topicId, traceId, modelName, updatePercentAndStart, mergeTraceModals])
}, [topicId, traceId, modelName])
const handleNodeClick = (nodeId: string) => {
const latestNode = findNodeById(spans, nodeId)
if (latestNode) {
setSelectNode(latestNode)
setShowList(false)
}
setSelectNodeId(nodeId)
}
const handleShowList = () => {
setShowList(true)
setSelectNode(null)
setSelectNodeId(null)
}
useEffect(() => {
@@ -138,18 +91,6 @@ export const TracePage: React.FC<TracePageProp> = ({ topicId, traceId, modelName
}
}, [getTraceData, traceId, topicId, reload])
useEffect(() => {
if (selectNode) {
const latest = findNodeById(spans, selectNode.id)
if (!latest) {
setShowList(true)
setSelectNode(null)
} else if (latest !== selectNode) {
setSelectNode(latest)
}
}
}, [spans, selectNode, findNodeById])
return (
<div className="trace-window">
<div className="tab-container_trace">

View File

@@ -0,0 +1,45 @@
import { TraceModal } from '../pages/TraceModel'
export const updatePercentAndStart = (nodes: TraceModal[], rootStart?: number, rootEnd?: number) => {
nodes.forEach((node) => {
const _rootStart = rootStart || node.startTime
const _rootEnd = rootEnd || node.endTime || Date.now()
const endTime = node.endTime || _rootEnd
const usedTime = endTime - node.startTime
const duration = _rootEnd - _rootStart
node.start = ((node.startTime - _rootStart) * 100) / duration
node.percent = duration === 0 ? 0 : (usedTime * 100) / duration
if (node.children) {
updatePercentAndStart(node.children, _rootStart, _rootEnd)
}
})
}
export const findNodeById = (nodes: TraceModal[], id: string): TraceModal | null => {
for (const n of nodes) {
if (n.id === id) return n
if (n.children) {
const found = findNodeById(n.children, id)
if (found) return found
}
}
return null
}
export const mergeTraceModals = (oldNodes: TraceModal[], newNodes: TraceModal[]): TraceModal[] => {
const oldMap = new Map(oldNodes.map((n) => [n.id, n]))
return newNodes.map((newNode) => {
const oldNode = oldMap.get(newNode.id)
if (oldNode) {
// 如果旧节点已经结束,则直接返回旧节点
if (oldNode.endTime) {
return oldNode
}
oldNode.children = mergeTraceModals(oldNode.children, newNode.children)
Object.assign(oldNode, newNode)
return oldNode
} else {
return newNode
}
})
}

View File

@@ -33,15 +33,15 @@ const Footer: FC<FooterProps> = ({
onEsc()
})
useHotkeys('c', () => {
handleCopy()
})
const handleCopy = () => {
if (loading || !onCopy) return
onCopy()
}
useHotkeys('c', () => {
handleCopy()
})
return (
<WindowFooter className="drag">
<FooterText>

View File

@@ -3,7 +3,7 @@ import { useTimer } from '@renderer/hooks/useTimer'
import { Assistant } from '@renderer/types'
import { Input as AntdInput } from 'antd'
import { InputRef } from 'rc-input/lib/interface'
import React, { useRef } from 'react'
import React, { useEffect, useRef } from 'react'
import styled from 'styled-components'
interface InputBarProps {
@@ -27,9 +27,13 @@ const InputBar = ({
}: InputBarProps & { ref?: React.RefObject<HTMLDivElement | null> }) => {
const inputRef = useRef<InputRef>(null)
const { setTimeoutTimer } = useTimer()
if (!loading) {
setTimeoutTimer('focus', () => inputRef.current?.input?.focus(), 0)
}
useEffect(() => {
if (!loading) {
setTimeoutTimer('focus', () => inputRef.current?.input?.focus(), 0)
}
})
return (
<InputWrapper ref={ref}>
{assistant.model && <ModelAvatar model={assistant.model} size={30} />}

View File

@@ -36,32 +36,38 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => {
const [isContented, setIsContented] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [contentToCopy, setContentToCopy] = useState('')
const [assistant] = useState<Assistant>(() => {
const assistant = action.assistantId
? getAssistantById(action.assistantId) || getDefaultAssistant()
: getDefaultAssistant()
if (!assistant.model) {
return { ...assistant, model: getDefaultModel() }
} else {
return assistant
}
})
const [topic] = useState<Topic | null>(getDefaultTopic(assistant.id))
const initialized = useRef(false)
// Use useRef for values that shouldn't trigger re-renders
const assistantRef = useRef<Assistant | null>(null)
const topicRef = useRef<Topic | null>(null)
const topicRef = useRef<Topic | null>(topic)
const promptContentRef = useRef('')
const askId = useRef('')
// Sync refs
useEffect(() => {
assistantRef.current = assistant
}, [assistant])
useEffect(() => {
topicRef.current = topic
}, [assistant, topic])
// Initialize values only once when action changes
useEffect(() => {
if (initialized.current) return
initialized.current = true
// Initialize assistant
const currentAssistant = action.assistantId
? getAssistantById(action.assistantId) || getDefaultAssistant()
: getDefaultAssistant()
assistantRef.current = {
...currentAssistant,
model: currentAssistant.model || getDefaultModel()
}
// Initialize topic
topicRef.current = getDefaultTopic(currentAssistant.id)
// Initialize prompt content
let userContent = ''
switch (action.id) {
@@ -128,7 +134,7 @@ const ActionGeneral: FC<Props> = React.memo(({ action, scrollToBottom }) => {
fetchResult()
}, [fetchResult])
const allMessages = useTopicMessages(topicRef.current?.id || '')
const allMessages = useTopicMessages(topic?.id || '')
// Memoize the messages to prevent unnecessary re-renders
const messageContent = useMemo(() => {

View File

@@ -9,7 +9,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import useTranslate from '@renderer/hooks/useTranslate'
import MessageContent from '@renderer/pages/home/Messages/MessageContent'
import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { Assistant, Topic, TranslateLanguage, TranslateLanguageCode } from '@renderer/types'
import { Topic, TranslateLanguage, TranslateLanguageCode } from '@renderer/types'
import type { ActionItem } from '@renderer/types/selectionTypes'
import { runAsyncFunction } from '@renderer/utils'
import { abortCompletion } from '@renderer/utils/abortController'
@@ -31,7 +31,7 @@ const logger = loggerService.withContext('ActionTranslate')
const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const { t } = useTranslation()
const { translateModelPrompt, language } = useSettings()
const { language } = useSettings()
const [targetLanguage, setTargetLanguage] = useState<TranslateLanguage>(LanguagesEnum.enUS)
const [alterLanguage, setAlterLanguage] = useState<TranslateLanguage>(LanguagesEnum.zhCN)
@@ -41,14 +41,20 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const [isContented, setIsContented] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [contentToCopy, setContentToCopy] = useState('')
// use default assistant.
// FIXME: this component create a new topic every time, but related data would not be cleared.
const [topic] = useState<Topic>(getDefaultTopic('default'))
const { getLanguageByLangcode } = useTranslate()
// Use useRef for values that shouldn't trigger re-renders
const initialized = useRef(false)
const assistantRef = useRef<Assistant | null>(null)
const topicRef = useRef<Topic | null>(null)
const topicRef = useRef<Topic | null>(topic)
const askId = useRef('')
// Sync ref
useEffect(() => {
topicRef.current = topic
}, [topic])
useEffect(() => {
runAsyncFunction(async () => {
const biDirectionLangPair = await db.settings.get({ id: 'translate:bidirectional:pair' })
@@ -79,22 +85,8 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
})
}, [getLanguageByLangcode, language])
// Initialize values only once when action changes
useEffect(() => {
if (initialized.current || !action.selectedText) return
initialized.current = true
// Initialize assistant
const currentAssistant = getDefaultTranslateAssistant(targetLanguage, action.selectedText)
assistantRef.current = currentAssistant
// Initialize topic
topicRef.current = getDefaultTopic(currentAssistant.id)
}, [action, targetLanguage, translateModelPrompt])
const fetchResult = useCallback(async () => {
if (!assistantRef.current || !topicRef.current || !action.selectedText) return
if (!topicRef.current || !action.selectedText) return
const setAskId = (id: string) => {
askId.current = id
@@ -112,8 +104,6 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
setError(error.message)
}
setIsLoading(true)
let sourceLanguageCode: TranslateLanguageCode
try {
@@ -139,7 +129,6 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
}
const assistant = getDefaultTranslateAssistant(translateLang, action.selectedText)
assistantRef.current = assistant
processMessages(assistant, topicRef.current, assistant.content, setAskId, onStream, onFinish, onError)
}, [action, targetLanguage, alterLanguage, scrollToBottom])
@@ -147,7 +136,7 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
fetchResult()
}, [fetchResult])
const allMessages = useTopicMessages(topicRef.current?.id || '')
const allMessages = useTopicMessages(topic?.id || '')
const messageContent = useMemo(() => {
const assistantMessages = allMessages.filter((message) => message.role === 'assistant')

View File

@@ -30,19 +30,6 @@ const WindowFooter: FC<FooterProps> = ({
const hideTimerRef = useRef<NodeJS.Timeout | null>(null)
const { setTimeoutTimer } = useTimer()
useEffect(() => {
window.addEventListener('focus', handleWindowFocus)
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('focus', handleWindowFocus)
window.removeEventListener('blur', handleWindowBlur)
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current)
}
}
}, [])
useEffect(() => {
hideTimerRef.current = setTimeout(() => {
setIsShowMe(false)
@@ -68,21 +55,6 @@ const WindowFooter: FC<FooterProps> = ({
}, 2000)
}
useHotkeys('c', () => {
showMePeriod()
handleCopy()
})
useHotkeys('r', () => {
showMePeriod()
handleRegenerate()
})
useHotkeys('esc', () => {
showMePeriod()
handleEsc()
})
const handleEsc = () => {
setIsEscHovered(true)
setTimeoutTimer(
@@ -100,6 +72,11 @@ const WindowFooter: FC<FooterProps> = ({
}
}
useHotkeys('esc', () => {
showMePeriod()
handleEsc()
})
const handleRegenerate = () => {
setIsRegenerateHovered(true)
setTimeoutTimer(
@@ -126,6 +103,11 @@ const WindowFooter: FC<FooterProps> = ({
}
}
useHotkeys('r', () => {
showMePeriod()
handleRegenerate()
})
const handleCopy = () => {
if (!content || loading) return
@@ -147,6 +129,11 @@ const WindowFooter: FC<FooterProps> = ({
})
}
useHotkeys('c', () => {
showMePeriod()
handleCopy()
})
const handleWindowFocus = () => {
setIsWindowFocus(true)
}
@@ -155,6 +142,19 @@ const WindowFooter: FC<FooterProps> = ({
setIsWindowFocus(false)
}
useEffect(() => {
window.addEventListener('focus', handleWindowFocus)
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('focus', handleWindowFocus)
window.removeEventListener('blur', handleWindowBlur)
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current)
}
}
}, [])
return (
<Container
onMouseEnter={() => setIsContainerHovered(true)}

View File

@@ -204,31 +204,6 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
}
}, [setTimeoutTimer])
const handleAction = useCallback(
(action: ActionItem) => {
if (demo) return
/** avoid mutating the original action, it will cause syncing issue */
const newAction = { ...action, selectedText: selectedText.current }
switch (action.id) {
case 'copy':
handleCopy()
break
case 'search':
handleSearch(newAction)
break
case 'quote':
handleQuote(newAction)
break
default:
handleDefaultAction(newAction)
break
}
},
[demo, handleCopy]
)
const handleSearch = (action: ActionItem) => {
if (!action.searchEngine) return
@@ -256,6 +231,31 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => {
window.api?.selection.hideToolbar()
}
const handleAction = useCallback(
(action: ActionItem) => {
if (demo) return
/** avoid mutating the original action, it will cause syncing issue */
const newAction = { ...action, selectedText: selectedText.current }
switch (action.id) {
case 'copy':
handleCopy()
break
case 'search':
handleSearch(newAction)
break
case 'quote':
handleQuote(newAction)
break
default:
handleDefaultAction(newAction)
break
}
},
[demo, handleCopy]
)
return (
<Container>
<LogoWrapper $draggable={!demo}>

146
yarn.lock
View File

@@ -2098,6 +2098,29 @@ __metadata:
languageName: node
linkType: hard
"@babel/core@npm:^7.24.4":
version: 7.28.4
resolution: "@babel/core@npm:7.28.4"
dependencies:
"@babel/code-frame": "npm:^7.27.1"
"@babel/generator": "npm:^7.28.3"
"@babel/helper-compilation-targets": "npm:^7.27.2"
"@babel/helper-module-transforms": "npm:^7.28.3"
"@babel/helpers": "npm:^7.28.4"
"@babel/parser": "npm:^7.28.4"
"@babel/template": "npm:^7.27.2"
"@babel/traverse": "npm:^7.28.4"
"@babel/types": "npm:^7.28.4"
"@jridgewell/remapping": "npm:^2.3.5"
convert-source-map: "npm:^2.0.0"
debug: "npm:^4.1.0"
gensync: "npm:^1.0.0-beta.2"
json5: "npm:^2.2.3"
semver: "npm:^6.3.1"
checksum: 10c0/ef5a6c3c6bf40d3589b5593f8118cfe2602ce737412629fb6e26d595be2fcbaae0807b43027a5c42ec4fba5b895ff65891f2503b5918c8a3ea3542ab44d4c278
languageName: node
linkType: hard
"@babel/core@npm:^7.27.7":
version: 7.28.0
resolution: "@babel/core@npm:7.28.0"
@@ -2134,6 +2157,19 @@ __metadata:
languageName: node
linkType: hard
"@babel/generator@npm:^7.28.3":
version: 7.28.3
resolution: "@babel/generator@npm:7.28.3"
dependencies:
"@babel/parser": "npm:^7.28.3"
"@babel/types": "npm:^7.28.2"
"@jridgewell/gen-mapping": "npm:^0.3.12"
"@jridgewell/trace-mapping": "npm:^0.3.28"
jsesc: "npm:^3.0.2"
checksum: 10c0/0ff58bcf04f8803dcc29479b547b43b9b0b828ec1ee0668e92d79f9e90f388c28589056637c5ff2fd7bcf8d153c990d29c448d449d852bf9d1bc64753ca462bc
languageName: node
linkType: hard
"@babel/helper-compilation-targets@npm:^7.27.2":
version: 7.27.2
resolution: "@babel/helper-compilation-targets@npm:7.27.2"
@@ -2177,6 +2213,19 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-module-transforms@npm:^7.28.3":
version: 7.28.3
resolution: "@babel/helper-module-transforms@npm:7.28.3"
dependencies:
"@babel/helper-module-imports": "npm:^7.27.1"
"@babel/helper-validator-identifier": "npm:^7.27.1"
"@babel/traverse": "npm:^7.28.3"
peerDependencies:
"@babel/core": ^7.0.0
checksum: 10c0/549be62515a6d50cd4cfefcab1b005c47f89bd9135a22d602ee6a5e3a01f27571868ada10b75b033569f24dc4a2bb8d04bfa05ee75c16da7ade2d0db1437fcdb
languageName: node
linkType: hard
"@babel/helper-plugin-utils@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-plugin-utils@npm:7.27.1"
@@ -2215,6 +2264,27 @@ __metadata:
languageName: node
linkType: hard
"@babel/helpers@npm:^7.28.4":
version: 7.28.4
resolution: "@babel/helpers@npm:7.28.4"
dependencies:
"@babel/template": "npm:^7.27.2"
"@babel/types": "npm:^7.28.4"
checksum: 10c0/aaa5fb8098926dfed5f223adf2c5e4c7fbba4b911b73dfec2d7d3083f8ba694d201a206db673da2d9b3ae8c01793e795767654558c450c8c14b4c2175b4fcb44
languageName: node
linkType: hard
"@babel/parser@npm:^7.24.4, @babel/parser@npm:^7.28.3, @babel/parser@npm:^7.28.4":
version: 7.28.4
resolution: "@babel/parser@npm:7.28.4"
dependencies:
"@babel/types": "npm:^7.28.4"
bin:
parser: ./bin/babel-parser.js
checksum: 10c0/58b239a5b1477ac7ed7e29d86d675cc81075ca055424eba6485872626db2dc556ce63c45043e5a679cd925e999471dba8a3ed4864e7ab1dbf64306ab72c52707
languageName: node
linkType: hard
"@babel/parser@npm:^7.25.4, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.5, @babel/parser@npm:^7.27.7, @babel/parser@npm:^7.28.0":
version: 7.28.0
resolution: "@babel/parser@npm:7.28.0"
@@ -2284,6 +2354,21 @@ __metadata:
languageName: node
linkType: hard
"@babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.4":
version: 7.28.4
resolution: "@babel/traverse@npm:7.28.4"
dependencies:
"@babel/code-frame": "npm:^7.27.1"
"@babel/generator": "npm:^7.28.3"
"@babel/helper-globals": "npm:^7.28.0"
"@babel/parser": "npm:^7.28.4"
"@babel/template": "npm:^7.27.2"
"@babel/types": "npm:^7.28.4"
debug: "npm:^4.3.1"
checksum: 10c0/ee678fdd49c9f54a32e07e8455242390d43ce44887cea6567b233fe13907b89240c377e7633478a32c6cf1be0e17c2f7f3b0c59f0666e39c5074cc47b968489c
languageName: node
linkType: hard
"@babel/types@npm:^7.25.4, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.6, @babel/types@npm:^7.28.0":
version: 7.28.1
resolution: "@babel/types@npm:7.28.1"
@@ -2304,6 +2389,16 @@ __metadata:
languageName: node
linkType: hard
"@babel/types@npm:^7.28.4":
version: 7.28.4
resolution: "@babel/types@npm:7.28.4"
dependencies:
"@babel/helper-string-parser": "npm:^7.27.1"
"@babel/helper-validator-identifier": "npm:^7.27.1"
checksum: 10c0/ac6f909d6191319e08c80efbfac7bd9a25f80cc83b43cd6d82e7233f7a6b9d6e7b90236f3af7400a3f83b576895bcab9188a22b584eb0f224e80e6d4e95f4517
languageName: node
linkType: hard
"@bcoe/v8-coverage@npm:^1.0.2":
version: 1.0.2
resolution: "@bcoe/v8-coverage@npm:1.0.2"
@@ -5998,7 +6093,7 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/remapping@npm:^2.3.4":
"@jridgewell/remapping@npm:^2.3.4, @jridgewell/remapping@npm:^2.3.5":
version: 2.3.5
resolution: "@jridgewell/remapping@npm:2.3.5"
dependencies:
@@ -14113,7 +14208,7 @@ __metadata:
eslint: "npm:^9.22.0"
eslint-plugin-import-zod: "npm:^1.2.0"
eslint-plugin-oxlint: "npm:^1.15.0"
eslint-plugin-react-hooks: "npm:^5.2.0"
eslint-plugin-react-hooks: "npm:^7.0.0"
eslint-plugin-simple-import-sort: "npm:^12.1.1"
eslint-plugin-unused-imports: "npm:^4.1.4"
express: "npm:^5.1.0"
@@ -18225,6 +18320,21 @@ __metadata:
languageName: node
linkType: hard
"eslint-plugin-react-hooks@npm:^7.0.0":
version: 7.0.0
resolution: "eslint-plugin-react-hooks@npm:7.0.0"
dependencies:
"@babel/core": "npm:^7.24.4"
"@babel/parser": "npm:^7.24.4"
hermes-parser: "npm:^0.25.1"
zod: "npm:^3.22.4 || ^4.0.0"
zod-validation-error: "npm:^3.0.3 || ^4.0.0"
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
checksum: 10c0/911c9efdd9b102ce2eabac247dff8c217ecb8d6972aaf3b7eecfb1cfc293d4d902766355993ff7a37a33c0abde3e76971f43bc1c8ff36d6c123310e5680d0423
languageName: node
linkType: hard
"eslint-plugin-react-naming-convention@npm:1.48.1":
version: 1.48.1
resolution: "eslint-plugin-react-naming-convention@npm:1.48.1"
@@ -20005,6 +20115,22 @@ __metadata:
languageName: node
linkType: hard
"hermes-estree@npm:0.25.1":
version: 0.25.1
resolution: "hermes-estree@npm:0.25.1"
checksum: 10c0/48be3b2fa37a0cbc77a112a89096fa212f25d06de92781b163d67853d210a8a5c3784fac23d7d48335058f7ed283115c87b4332c2a2abaaccc76d0ead1a282ac
languageName: node
linkType: hard
"hermes-parser@npm:^0.25.1":
version: 0.25.1
resolution: "hermes-parser@npm:0.25.1"
dependencies:
hermes-estree: "npm:0.25.1"
checksum: 10c0/3abaa4c6f1bcc25273f267297a89a4904963ea29af19b8e4f6eabe04f1c2c7e9abd7bfc4730ddb1d58f2ea04b6fee74053d8bddb5656ec6ebf6c79cc8d14202c
languageName: node
linkType: hard
"hls-video-element@npm:^1.5.6":
version: 1.5.7
resolution: "hls-video-element@npm:1.5.7"
@@ -30251,6 +30377,15 @@ __metadata:
languageName: node
linkType: hard
"zod-validation-error@npm:^3.0.3 || ^4.0.0":
version: 4.0.2
resolution: "zod-validation-error@npm:4.0.2"
peerDependencies:
zod: ^3.25.0 || ^4.0.0
checksum: 10c0/0ccfec48c46de1be440b719cd02044d4abb89ed0e14c13e637cd55bf29102f67ccdba373f25def0fc7130e5f15025be4d557a7edcc95d5a3811599aade689e1b
languageName: node
linkType: hard
"zod-validation-error@npm:^3.4.0":
version: 3.4.0
resolution: "zod-validation-error@npm:3.4.0"
@@ -30260,6 +30395,13 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.22.4 || ^4.0.0":
version: 4.1.12
resolution: "zod@npm:4.1.12"
checksum: 10c0/b64c1feb19e99d77075261eaf613e0b2be4dfcd3551eff65ad8b4f2a079b61e379854d066f7d447491fcf193f45babd8095551a9d47973d30b46b6d8e2c46774
languageName: node
linkType: hard
"zod@npm:^3.22.4, zod@npm:^3.23.8, zod@npm:^3.24.1":
version: 3.25.56
resolution: "zod@npm:3.25.56"