Compare commits

...

28 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
kangfenmao
9d45991181 chore: update release notes for v1.7.0-beta.2
- Added new features including session settings management, full-text search for notes, and integration with DiDi MCP server.
- Improved agent model selection and added support for Mistral AI and NewAPI providers.
- Enhanced UI/UX with navbar layout consistency and chat component responsiveness.
- Fixed various bugs related to assistant creation, streaming issues, and message layout.
2025-10-18 11:14:16 +08:00
Phantom
cf2f2fd707 fix: agent default model (#10774)
* refactor(AgentModal): simplify trigger prop structure and mark Agents as deprecated

- Replace trigger prop with children for simpler component usage
- Add deprecation notice to Agents component

* fix(AgentModal): set empty default model instead of 'claude-4-sonnet'

The default model should be empty to require explicit selection rather than defaulting to a specific model

* fix(AgentModal): wrap children in conditional check to prevent undefined errors

* refactor(agent-modal): simplify modal control by removing trigger props

Remove the trigger props pattern from AgentModal component and use explicit isOpen/onClose control. This makes the component's behavior more predictable and aligns with the usage pattern in the Agents page where modal state is managed externally.

Also remove unused ReactNode import and clean up related comments.

* refactor(UnifiedAddButton): replace useState with useDisclosure for agent modal

Use useDisclosure hook for better state management of agent modal
2025-10-18 04:07:44 +08:00
kangfenmao
d4b1db0407 fix: adjust Navbar and Chat components for better layout and responsiveness
- Updated Navbar styles to improve margin handling for macOS.
- Refactored Chat component to streamline layout and enhance responsiveness, including adjustments to main height calculations and navbar integration.
- Cleaned up commented code and improved the structure of the ChatNavbar for better clarity and maintainability.
- Enhanced styling in various components for consistent appearance and behavior across different screen sizes.
2025-10-18 00:12:38 +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
45 changed files with 857 additions and 778 deletions

View File

@@ -126,112 +126,60 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
What's New in v1.7.0-beta.1
What's New in v1.7.0-beta.2
Major Features:
- Agent System: Introducing intelligent Agent capabilities alongside Assistants. Agents can autonomously solve complex problems using Claude Code SDK with tool calling, file operations, and multi-turn reasoning
- Agent Management: Create, configure, and manage agents with custom settings including model selection, tool permissions, accessible paths, and MCP server integrations
- Agent Sessions: Dedicated session management for agent interactions with persistent message history and context tracking
- Unified UI: Streamlined interface combining Assistants and Agents tabs with improved navigation and settings management
New Features:
- Session Settings: Manage session-specific settings and model configurations independently
- Notes Full-Text Search: Search across all notes with match highlighting
- Built-in DiDi MCP Server: Integration with DiDi ride-hailing services (China only)
- Intel OV OCR: Hardware-accelerated OCR using Intel NPU
- Auto-start API Server: Automatically starts when agents exist
Agent Features:
- Tool Support: Web search, file operations, bash commands, and custom MCP tools
- Advanced Configuration: Max turns, temperature, token limits
- Permission Control: Configurable tool approval modes (manual, automatic, none)
- Session Persistence: Automatic message saving with optimized streaming and database integration
- Model Selection: API-based model filtering with provider-specific support
UI/UX Improvements:
- Unified assistant/agent tabs with smooth animations
- In-place session name editing
- Virtual list rendering for improved performance
- Session count indicators for active agents
- Enhanced settings popup with tabbed interface
- Webview keyboard shortcut interception for search functionality
API & Infrastructure:
- RESTful API for agent and session management
- Drizzle ORM integration for agent database
- OAuth support for Claude Code authentication
- Express validator for request validation
- Comprehensive error handling with Zod schemas
Model Updates:
- Gemini 2.5 Image Flash support
- Grok 4 Fast with reasoning capabilities
- Qwen3-omni and Qwen3-vl thinking models
- DeepSeek, Claude 4.5, GLM 4.6 support
- GitHub Copilot CLI integration with gpt-5-codex
Improvements:
- Agent model selection now requires explicit user choice
- Added Mistral AI provider support
- Added NewAPI generic provider support
- Improved navbar layout consistency across different modes
- Enhanced chat component responsiveness
- Better code block display on small screens
- Updated OVMS to 2025.3 official release
- Added Greek language support
Bug Fixes:
- Fix Swagger UI accessibility issues
- Fix AI SDK error display with syntax highlighting
- Fix webview search shortcut handling
- Fix agent model visibility for CherryIn provider
- Fix session message ordering and persistence
- Fix anthropic model visibility in agent configuration
- Fix knowledge base deletion and web search RAG errors
- Fix migration for missing providers
Technical Updates:
- React 19.2.0 upgrade
- Enhanced Claude Code service with streaming support
- Improved message transformation and streaming lifecycle
- Database migration system with automatic schema sync
- Optimized bundle size and dependency management
- Fixed GitHub Copilot gpt-5-codex streaming issues
- Fixed assistant creation failures
- Fixed translate auto-copy functionality
- Fixed miniapps external link opening
- Fixed message layout and overflow issues
- Fixed API key parsing to preserve spaces
- Fixed agent display in different navbar layouts
<!--LANG:zh-CN-->
v1.7.0-beta.1 新特性
v1.7.0-beta.2 新特性
核心功能:
- Agent 系统:引入智能 Agent 能力,与助手(Assistant)并存。Agent 基于 Claude Code SDK 构建,具备工具调用、文件操作和多轮推理能力,可自主解决复杂问题
- Agent 管理:创建、配置和管理 Agent,支持自定义模型选择、工具权限、可访问路径和 MCP 服务器集成
- Agent 会话:专属会话管理系统,支持持久化消息历史和上下文追踪
- 统一界面:精简的助手和 Agent 标签页界面,改进导航和设置管理体验
功能:
- 会话设置:独立管理会话特定的设置和模型配置
- 笔记全文搜索:跨所有笔记搜索并高亮匹配内容
- 内置滴滴 MCP 服务器:集成滴滴打车服务(仅限中国地区)
- Intel OV OCR使用 Intel NPU 的硬件加速 OCR
- 自动启动 API 服务器:当存在 Agent 时自动启动
Agent 功能特性:
- 工具支持网页搜索、文件操作、Bash 命令执行和自定义 MCP 工具
- 高级配置最大轮次、温度、Token 限制
- 权限控制:可配置的工具批准模式(手动、自动、无需批准)
- 会话持久化:自动消息保存,优化的流式传输和数据库集成
- 模型选择:基于 API 的模型过滤,支持特定提供商
界面与交互优化:
- 统一的助手/Agent 标签页,带有流畅动画效果
- 会话名称原地编辑功能
- 虚拟列表渲染,提升性能表现
- 活跃 Agent 的会话计数指示器
- 增强的设置弹窗,采用标签页界面
- Webview 键盘快捷键拦截,支持搜索功能
API 与基础设施:
- RESTful API 用于 Agent 和会话管理
- 集成 Drizzle ORM 管理 Agent 数据库
- Claude Code OAuth 认证支持
- Express validator 请求验证
- 基于 Zod 模式的完善错误处理
模型更新:
- 支持 Gemini 2.5 Image Flash
- Grok 4 Fast 推理能力
- Qwen3-omni 和 Qwen3-vl 思考模型
- DeepSeek、Claude 4.5、GLM 4.6 支持
- GitHub Copilot CLI 集成 gpt-5-codex
改进:
- Agent 模型选择现在需要用户显式选择
- 添加 Mistral AI 提供商支持
- 添加 NewAPI 通用提供商支持
- 改进不同模式下的导航栏布局一致性
- 增强聊天组件响应式设计
- 优化小屏幕代码块显示
- 更新 OVMS 至 2025.3 正式版
- 添加希腊语支持
问题修复:
- 修复 Swagger UI 无法打开
- 修复 AI SDK 错误显示,添加语法高亮
- 修复 Webview 搜索快捷键处理
- 修复 CherryIn 提供商的 Agent 模型可见性
- 修复会话消息排序和持久化
- 修复 Anthropic 模型在 Agent 配置中的可见性
- 修复知识库删除和网页搜索 RAG 错误
- 修复缺失提供商的迁移问题
技术更新:
- 升级至 React 19.2.0
- 增强 Claude Code 服务流式传输支持
- 改进消息转换和流式生命周期
- 数据库迁移系统,支持自动模式同步
- 优化打包大小和依赖管理
- 修复 GitHub Copilot gpt-5-codex 流式传输问题
- 修复助手创建失败
- 修复翻译自动复制功能
- 修复小程序外部链接打开
- 修复消息布局和溢出问题
- 修复 API 密钥解析以保留空格
- 修复不同导航栏布局中的 Agent 显示
<!--LANG:END-->

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

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.7.0-beta.1",
"version": "1.7.0-beta.2",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -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

@@ -1,6 +1,5 @@
import {
Button,
cn,
Form,
Input,
Modal,
@@ -34,7 +33,7 @@ import {
UpdateAgentForm
} from '@renderer/types'
import { AlertTriangleIcon } from 'lucide-react'
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ErrorBoundary } from '../../ErrorBoundary'
@@ -57,43 +56,30 @@ const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
name: existing?.name ?? 'Claude Code',
description: existing?.description,
instructions: existing?.instructions,
model: existing?.model ?? 'claude-4-sonnet',
model: existing?.model ?? '',
accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : [],
allowed_tools: existing?.allowed_tools ? [...existing.allowed_tools] : [],
mcps: existing?.mcps ? [...existing.mcps] : [],
configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {})
})
interface BaseProps {
type Props = {
agent?: AgentWithTools
}
interface TriggerProps extends BaseProps {
trigger: { content: ReactNode; className?: string }
isOpen?: never
onClose?: never
}
interface StateProps extends BaseProps {
trigger?: never
isOpen: boolean
onClose: () => void
}
type Props = TriggerProps | StateProps
/**
* Modal component for creating or editing an agent.
*
* Either trigger or isOpen and onClose is given.
* @param agent - Optional agent entity for editing mode.
* @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal.
* @param isOpen - Optional controlled modal open state. From useDisclosure.
* @param onClose - Optional callback when modal closes. From useDisclosure.
* @returns Modal component for agent creation/editing
*/
export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, onClose: _onClose }) => {
const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _onClose }) => {
const { isOpen, onClose } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
const { t } = useTranslation()
const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer()
@@ -359,23 +345,6 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
return (
<ErrorBoundary>
{/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single
encapsulated component. This is because the Modal component needs to bind the onOpen
event handler to the Button for proper focus management.
Or just use external isOpen/onOpen/onClose to control modal state.
*/}
{trigger && (
<div
onClick={(e) => {
e.stopPropagation()
onOpen()
}}
className={cn('w-full', trigger.className)}>
{trigger.content}
</div>
)}
<Modal
isOpen={isOpen}
onClose={onClose}

View File

@@ -67,14 +67,14 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
flex-direction: row;
min-height: ${isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)'};
max-height: var(--navbar-height);
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1 + 2px)' : 0};
padding-left: ${({ $isFullScreen }) =>
isMac ? ($isFullScreen ? 'var(--sidebar-width)' : 'env(titlebar-area-x)') : 0};
-webkit-app-region: drag;
`
const NavbarLeftContainer = styled.div`
min-width: ${isMac ? 'calc(var(--assistants-width) - 20px)' : 'var(--assistants-width)'};
/* min-width: ${isMac ? 'calc(var(--assistants-width) - 20px)' : 'var(--assistants-width)'}; */
padding: 0 10px;
display: flex;
flex-direction: row;

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

@@ -140,9 +140,7 @@ const Chat: FC<Props> = (props) => {
firstUpdateOrNoFirstUpdateHandler()
}
const mainHeight = isTopNavbar
? 'calc(100vh - var(--navbar-height) - var(--navbar-height) - 12px)'
: 'calc(100vh - var(--navbar-height))'
const mainHeight = isTopNavbar ? 'calc(100vh - var(--navbar-height) - 6px)' : 'calc(100vh - var(--navbar-height))'
const SessionMessages = useMemo(() => {
if (activeAgentId === null) {
@@ -192,66 +190,84 @@ const Chat: FC<Props> = (props) => {
</div>
)
}, [])
return (
<Container id="chat" className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}>
{isTopNavbar && (
<ChatNavbar
activeAssistant={props.assistant}
activeTopic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
setActiveAssistant={props.setActiveAssistant}
position="left"
/>
)}
<HStack>
<Main
ref={mainRef}
id="chat-main"
vertical
flex={1}
justify="space-between"
style={{ maxWidth: chatMaxWidth, height: mainHeight }}>
<QuickPanelProvider>
{activeTopicOrSession === 'topic' && (
<>
<Messages
key={props.activeTopic.id}
assistant={assistant}
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
onComponentUpdate={messagesComponentUpdateHandler}
onFirstUpdate={messagesComponentFirstUpdateHandler}
/>
<ContentSearch
ref={contentSearchRef}
searchTarget={mainRef as React.RefObject<HTMLElement>}
filter={contentSearchFilter}
includeUser={filterIncludeUser}
onIncludeUserChange={userOutlinedItemClickHandler}
/>
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
</>
)}
{activeTopicOrSession === 'session' && !activeAgentId && <AgentInvalid />}
{activeTopicOrSession === 'session' && activeAgentId && !activeSessionId && <SessionInvalid />}
{activeTopicOrSession === 'session' && activeAgentId && activeSessionId && (
<>
<SessionMessages />
<SessionInputBar />
</>
)}
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
</QuickPanelProvider>
</Main>
<motion.div
animate={{
marginRight: topicPosition === 'right' && showTopics ? 'var(--assistants-width)' : 0
}}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ flex: 1, display: 'flex', minWidth: 0 }}>
<Main
ref={mainRef}
id="chat-main"
vertical
flex={1}
justify="space-between"
style={{ maxWidth: chatMaxWidth, height: mainHeight }}>
<QuickPanelProvider>
<ChatNavbar
activeAssistant={props.assistant}
activeTopic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
setActiveAssistant={props.setActiveAssistant}
position="left"
/>
<div
className="flex flex-1 flex-col justify-between"
style={{ height: `calc(${mainHeight} - var(--navbar-height))` }}>
{activeTopicOrSession === 'topic' && (
<>
<Messages
key={props.activeTopic.id}
assistant={assistant}
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
onComponentUpdate={messagesComponentUpdateHandler}
onFirstUpdate={messagesComponentFirstUpdateHandler}
/>
<ContentSearch
ref={contentSearchRef}
searchTarget={mainRef as React.RefObject<HTMLElement>}
filter={contentSearchFilter}
includeUser={filterIncludeUser}
onIncludeUserChange={userOutlinedItemClickHandler}
/>
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
</>
)}
{activeTopicOrSession === 'session' && !activeAgentId && <AgentInvalid />}
{activeTopicOrSession === 'session' && activeAgentId && !activeSessionId && <SessionInvalid />}
{activeTopicOrSession === 'session' && activeAgentId && activeSessionId && (
<>
<SessionMessages />
<SessionInputBar />
</>
)}
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
</div>
</QuickPanelProvider>
</Main>
</motion.div>
<AnimatePresence initial={false}>
{topicPosition === 'right' && showTopics && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
key="right-tabs"
initial={{ x: 'var(--assistants-width)' }}
animate={{ x: 0 }}
exit={{ x: 'var(--assistants-width)' }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}>
style={{
position: 'absolute',
right: 0,
top: isTopNavbar ? 0 : 'calc(var(--navbar-height) + 1px)',
width: 'var(--assistants-width)',
height: '100%',
zIndex: 10
}}>
<Tabs
activeAssistant={assistant}
activeTopic={props.activeTopic}
@@ -269,13 +285,14 @@ const Chat: FC<Props> = (props) => {
export const useChatMaxWidth = () => {
const { showTopics, topicPosition } = useSettings()
const { isLeftNavbar } = useNavbarPosition()
const { isLeftNavbar, isTopNavbar } = useNavbarPosition()
const { showAssistants } = useShowAssistants()
const showRightTopics = showTopics && topicPosition === 'right'
const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : ''
const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : ''
const minusBorderWidth = isTopNavbar ? (showTopics ? '- 12px' : '- 6px') : ''
const sidebarWidth = isLeftNavbar ? '- var(--sidebar-width)' : ''
return `calc(100vw ${sidebarWidth} ${minusAssistantsWidth} ${minusRightTopicsWidth})`
return `calc(100vw ${sidebarWidth} ${minusAssistantsWidth} ${minusRightTopicsWidth} ${minusBorderWidth})`
}
const Container = styled.div`

View File

@@ -3,7 +3,7 @@ import { HStack } from '@renderer/components/Layout'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { modelGenerating } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
@@ -34,6 +34,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { topicPosition, narrowMode } = useSettings()
const { showTopics, toggleShowTopics } = useShowTopics()
const { isTopNavbar } = useNavbarPosition()
const dispatch = useAppDispatch()
useShortcut('toggle_show_assistants', toggleShowAssistants)
@@ -73,16 +74,16 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
// )
return (
<NavbarHeader className="home-navbar">
<div className="flex min-w-0 flex-1 shrink items-center overflow-auto">
{showAssistants && (
<NavbarHeader className="home-navbar" style={{ height: 'var(--navbar-height)' }}>
<div className="flex h-full min-w-0 flex-1 shrink items-center overflow-auto">
{isTopNavbar && showAssistants && (
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={toggleShowAssistants}>
<PanelLeftClose size={18} />
</NavbarIcon>
</Tooltip>
)}
{!showAssistants && (
{isTopNavbar && !showAssistants && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={() => toggleShowAssistants()} style={{ marginRight: 8 }}>
<PanelRightClose size={18} />
@@ -90,13 +91,13 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
</Tooltip>
)}
<AnimatePresence initial={false}>
{!showAssistants && (
{!showAssistants && isTopNavbar && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}>
<NavbarIcon onClick={onShowAssistantsDrawer} style={{ marginRight: 8 }}>
<NavbarIcon onClick={onShowAssistantsDrawer} style={{ marginRight: 5 }}>
<Menu size={18} />
</NavbarIcon>
</motion.div>
@@ -105,25 +106,29 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
<ChatNavbarContent assistant={assistant} />
</div>
<HStack alignItems="center" gap={8}>
<UpdateAppButton />
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={handleNarrowModeToggle}>
<i className="iconfont icon-icon-adaptive-width"></i>
</NarrowIcon>
</Tooltip>
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={() => SearchPopup.show()}>
<Search size={18} />
</NavbarIcon>
</Tooltip>
{topicPosition === 'right' && !showTopics && (
{isTopNavbar && <UpdateAppButton />}
{isTopNavbar && (
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={handleNarrowModeToggle}>
<i className="iconfont icon-icon-adaptive-width"></i>
</NarrowIcon>
</Tooltip>
)}
{isTopNavbar && (
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={() => SearchPopup.show()}>
<Search size={18} />
</NavbarIcon>
</Tooltip>
)}
{isTopNavbar && topicPosition === 'right' && !showTopics && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}>
<NavbarIcon onClick={toggleShowTopics}>
<PanelLeftClose size={18} />
</NavbarIcon>
</Tooltip>
)}
{topicPosition === 'right' && showTopics && (
{isTopNavbar && topicPosition === 'right' && showTopics && (
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={2}>
<NavbarIcon onClick={toggleShowTopics}>
<PanelRightClose size={18} />

View File

@@ -1,14 +1,11 @@
import { Navbar, NavbarCenter, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { isLinux, isWin } from '@renderer/config/constant'
import { modelGenerating } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { useChatMaxWidth } from '@renderer/pages/home/Chat'
import ChatNavbarContent from '@renderer/pages/home/components/ChatNavbarContent'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { useAppDispatch } from '@renderer/store'
import { setNarrowMode } from '@renderer/store/settings'
@@ -17,11 +14,10 @@ import { Tooltip } from 'antd'
import { t } from 'i18next'
import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import React, { FC } from 'react'
import { FC } from 'react'
import styled from 'styled-components'
import AssistantsDrawer from './components/AssistantsDrawer'
import SelectModelButton from './components/SelectModelButton'
import UpdateAppButton from './components/UpdateAppButton'
interface Props {
@@ -40,11 +36,9 @@ const HeaderNavbar: FC<Props> = ({
setActiveTopic,
activeTopicOrSession
}) => {
const { assistant } = useAssistant(activeAssistant.id)
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { topicPosition, narrowMode } = useSettings()
const { showTopics, toggleShowTopics } = useShowTopics()
const chatMaxWidth = useChatMaxWidth()
const dispatch = useAppDispatch()
useShortcut('toggle_show_assistants', toggleShowAssistants)
@@ -101,7 +95,7 @@ const HeaderNavbar: FC<Props> = ({
justifyContent: 'flex-start',
borderRight: 'none',
paddingLeft: 0,
paddingRight: 10,
paddingRight: 0,
minWidth: 'auto'
}}>
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
@@ -123,22 +117,7 @@ const HeaderNavbar: FC<Props> = ({
</AnimatePresence>
</NavbarLeft>
)}
<NavbarCenter>
{activeTopicOrSession === 'topic' ? (
<HStack alignItems="center" gap={6} ml={!isMac ? 16 : 0}>
<SelectModelButton assistant={assistant} />
</HStack>
) : (
<ChatNavbarContainer
style={{
maxWidth: chatMaxWidth,
marginLeft: !isMac ? 16 : 0
}}>
<ChatNavbarContent assistant={assistant} />
</ChatNavbarContainer>
)}
</NavbarCenter>
<NavbarCenter></NavbarCenter>
<NavbarRight
style={{
justifyContent: 'flex-end',
@@ -220,15 +199,4 @@ const NarrowIcon = styled(NavbarIcon)`
}
`
const ChatNavbarContainer: React.FC<{ children: React.ReactNode; style?: React.CSSProperties }> = ({
children,
style
}) => {
return (
<div className="nodrag flex min-w-0 flex-1 items-center justify-start gap-1.5 overflow-hidden" style={style}>
{children}
</div>
)
}
export default HeaderNavbar

View File

@@ -180,7 +180,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const Container = styled(Scrollbar)`
display: flex;
flex-direction: column;
padding: 10px;
padding: 12px 10px;
`
export default AssistantsTab

View File

@@ -30,7 +30,7 @@ const SessionSettingsTab: FC<Props> = ({ session, update }) => {
return (
<div className="w-[var(--assistants-width)] p-2 px-3 pt-4">
<EssentialSettings agentBase={session} update={update} />
<EssentialSettings agentBase={session} update={update} showModelSetting={false} />
<AdvancedSettings agentBase={session} update={update} />
<Divider className="my-2" />
<Button size="sm" fullWidth onPress={onMoreSetting}>

View File

@@ -14,7 +14,6 @@ const SessionsTab: FC<SessionsTabProps> = () => {
const { activeAgentId } = chat
const { t } = useTranslation()
const { apiServer } = useSettings()
const { topicPosition, navbarPosition } = useSettings()
if (!apiServer.enabled) {
return (
@@ -34,15 +33,7 @@ const SessionsTab: FC<SessionsTabProps> = () => {
return (
<AnimatePresence mode="wait">
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'var(--assistants-width)', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
className={cn(
'overflow-hidden',
topicPosition === 'right' && navbarPosition === 'top' ? 'rounded-l-2xl border-t border-b border-l' : undefined
)}>
<motion.div className={cn('overflow-hidden', 'h-full')}>
<Sessions agentId={activeAgentId} />
</motion.div>
</AnimatePresence>

View File

@@ -44,9 +44,16 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
<AgentLabel agent={agent} />
</AgentNameWrapper>
</AssistantNameRow>
<MenuButton>
{isActive ? <SessionCount>{sessions.length}</SessionCount> : <Bot size={14} className="text-primary" />}
</MenuButton>
{isActive && (
<MenuButton>
<SessionCount>{sessions.length}</SessionCount>
</MenuButton>
)}
{!isActive && (
<BotIcon>
<Bot size={16} className="text-primary" />
</BotIcon>
)}
</Container>
</ContextMenuTrigger>
<ContextMenuContent>
@@ -110,6 +117,16 @@ export const MenuButton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ cla
/>
)
export const BotIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'absolute top-[8px] right-[12px] flex flex-row items-center justify-center rounded-full text-[14px] text-[var(--color-text)]',
className
)}
{...props}
/>
)
export const SessionCount: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(

View File

@@ -1,4 +1,3 @@
import { Button, cn, Input, Tooltip } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { isMac } from '@renderer/config/constant'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
@@ -11,9 +10,19 @@ import { SessionLabel } from '@renderer/pages/settings/AgentSettings/shared'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { newMessagesActions } from '@renderer/store/newMessage'
import { AgentSessionEntity } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger
} from '@renderer/ui/context-menu'
import { classNames } from '@renderer/utils'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { XIcon } from 'lucide-react'
import { Tooltip } from 'antd'
import { MenuIcon, XIcon } from 'lucide-react'
import React, { FC, memo, startTransition, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -24,13 +33,11 @@ interface SessionItemProps {
session: AgentSessionEntity
// use external agentId as SSOT, instead of session.agent_id
agentId: string
isDisabled?: boolean
isLoading?: boolean
onDelete: () => void
onPress: () => void
}
const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoading, onDelete, onPress }) => {
const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress }) => {
const { t } = useTranslation()
const { chat } = useRuntime()
const { updateSession } = useUpdateSession(agentId)
@@ -50,16 +57,16 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
const DeleteButton = () => {
return (
<Tooltip
content={t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
classNames={{ content: 'text-xs' }}
delay={500}
closeDelay={0}>
<div
role="button"
className={cn(
'mr-2 flex aspect-square h-6 w-6 items-center justify-center rounded-2xl',
isConfirmingDeletion ? 'hover:bg-danger-100' : 'hover:bg-foreground-300'
)}
placement="bottom"
mouseEnterDelay={0.7}
mouseLeaveDelay={0}
title={
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
}>
<MenuButton
className="menu"
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
if (isConfirmingDeletion || e.ctrlKey || e.metaKey) {
@@ -78,17 +85,11 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
}
}}>
{isConfirmingDeletion ? (
<DeleteIcon
size={14}
className="opacity-0 transition-colors-opacity group-hover:text-danger group-hover:opacity-100"
/>
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
) : (
<XIcon
size={14}
className={cn(isActive ? 'opacity-100' : 'opacity-0', 'group-hover:opacity-100', 'transition-opacity')}
/>
<XIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />
)}
</div>
</MenuButton>
</Tooltip>
)
}
@@ -106,44 +107,44 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
}
}, [activeSessionId, dispatch, isFulfilled, session.id, sessionTopicId])
const { topicPosition, setTopicPosition } = useSettings()
const singlealone = topicPosition === 'right'
return (
<>
<ContextMenu modal={false}>
<ContextMenuTrigger>
<ButtonContainer
isDisabled={isDisabled}
isLoading={isLoading}
onPress={onPress}
isActive={isActive}
<SessionListItem
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
onClick={isEditing ? undefined : onPress}
onDoubleClick={() => startEdit(session.name ?? '')}
className="group">
<SessionLabelContainer className="name h-full w-full pl-1" title={session.name ?? session.id}>
{isPending && !isActive && <PendingIndicator />}
{isFulfilled && !isActive && <FulfilledIndicator />}
{isEditing && (
<Input
title={session.name ?? session.id}
style={{
borderRadius: 'var(--list-item-border-radius)',
cursor: isEditing ? 'default' : 'pointer'
}}>
{isPending && !isActive && <PendingIndicator />}
{isFulfilled && !isActive && <FulfilledIndicator />}
<SessionNameContainer>
{isEditing ? (
<SessionEditInput
ref={inputRef}
variant="bordered"
value={editValue}
onValueChange={handleValueChange}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleValueChange(e.target.value)}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
classNames={{
base: 'h-full',
mainWrapper: 'h-full',
inputWrapper: 'h-full min-h-0 px-1.5',
input: isSaving ? 'brightness-50' : undefined
}}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
style={{ opacity: isSaving ? 0.5 : 1 }}
/>
)}
{!isEditing && (
<div className="flex w-full items-center justify-between">
<SessionLabel session={session} />
) : (
<>
<SessionName>
<SessionLabel session={session} />
</SessionName>
<DeleteButton />
</div>
</>
)}
</SessionLabelContainer>
</ButtonContainer>
</SessionNameContainer>
</SessionListItem>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
@@ -157,6 +158,20 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
<EditIcon size={14} />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger className="gap-2">
<MenuIcon size={14} />
{t('settings.topic.position.label')}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem key="left" onClick={() => setTopicPosition('left')}>
{t('settings.topic.position.left')}
</ContextMenuItem>
<ContextMenuItem key="right" onClick={() => setTopicPosition('right')}>
{t('settings.topic.position.right')}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem
key="delete"
className="text-danger"
@@ -172,38 +187,96 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
)
}
const ButtonContainer: React.FC<React.ComponentProps<typeof Button> & { isActive?: boolean }> = ({
isActive,
className,
children,
...props
}) => {
const { topicPosition } = useSettings()
const activeBg = topicPosition === 'left' ? 'bg-[var(--color-list-item)]' : 'bg-foreground-100'
return (
<Button
{...props}
variant="light"
className={cn(
'relative mb-2 flex h-9 flex-row justify-between p-0',
'rounded-[var(--list-item-border-radius)]',
'border-[0.5px] border-transparent',
'w-[calc(var(--assistants-width)_-_20px)]',
'cursor-pointer',
isActive ? cn(activeBg, 'shadow-sm') : undefined,
className
)}>
{children}
</Button>
)
}
const SessionListItem = styled.div`
padding: 7px 12px;
border-radius: var(--list-item-border-radius);
font-size: 13px;
display: flex;
flex-direction: column;
justify-content: space-between;
cursor: pointer;
width: calc(var(--assistants-width) - 20px);
margin-bottom: 8px;
const SessionLabelContainer: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
{...props}
className={cn('text-[13px] text-[var(--color-text)]', 'flex flex-row items-center gap-2', className)}
/>
)
.menu {
opacity: 0;
color: var(--color-text-3);
}
&:hover {
background-color: var(--color-list-item-hover);
transition: background-color 0.1s;
.menu {
opacity: 1;
}
}
&.active {
background-color: var(--color-list-item);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
.menu {
opacity: 1;
&:hover {
color: var(--color-text-2);
}
}
}
&.singlealone {
border-radius: 0 !important;
&:hover {
background-color: var(--color-background-soft);
}
&.active {
border-left: 2px solid var(--color-primary);
box-shadow: none;
}
}
`
const SessionNameContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
height: 20px;
justify-content: space-between;
`
const SessionName = styled.div`
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 13px;
position: relative;
`
const SessionEditInput = styled.input`
background: var(--color-background);
border: none;
color: var(--color-text-1);
font-size: 13px;
font-family: inherit;
padding: 2px 6px;
width: 100%;
outline: none;
padding: 0;
`
const MenuButton = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
min-width: 20px;
min-height: 20px;
.anticon {
font-size: 12px;
}
`
const PendingIndicator = styled.div.attrs({
className: 'animation-pulse'

View File

@@ -12,7 +12,7 @@ import {
} from '@renderer/store/runtime'
import { CreateSessionForm } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { AnimatePresence, motion } from 'framer-motion'
import { motion } from 'framer-motion'
import { memo, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
@@ -30,7 +30,7 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
const { agent } = useAgent(agentId)
const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId)
const { chat } = useRuntime()
const { activeSessionIdMap, sessionWaiting } = chat
const { activeSessionIdMap } = chat
const dispatch = useAppDispatch()
const setActiveSessionId = useCallback(
@@ -109,45 +109,30 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
if (error) return <Alert color="danger" content={t('agent.session.get.error.failed')} />
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="sessions-tab flex h-full w-full flex-col p-2">
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
<AddButton onPress={handleCreateSession} className="mb-2">
{t('agent.session.add.title')}
</AddButton>
</motion.div>
<AnimatePresence>
{/* h-9 */}
<DynamicVirtualList
list={sessions}
estimateSize={() => 9 * 4}
scrollerStyle={{
// FIXME: This component only supports CSSProperties
overflowX: 'hidden'
}}
autoHideScrollbar>
{(session) => (
<motion.div
key={session.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}>
<SessionItem
session={session}
agentId={agentId}
isDisabled={sessionWaiting[session.id]}
isLoading={sessionWaiting[session.id]}
onDelete={() => handleDeleteSession(session.id)}
onPress={() => setActiveSessionId(agentId, session.id)}
/>
</motion.div>
)}
</DynamicVirtualList>
</AnimatePresence>
</motion.div>
<div className="sessions-tab flex h-full w-full flex-col p-2">
<AddButton onPress={handleCreateSession} className="mb-2">
{t('agent.session.add.title')}
</AddButton>
{/* h-9 */}
<DynamicVirtualList
list={sessions}
estimateSize={() => 9 * 4}
scrollerStyle={{
// FIXME: This component only supports CSSProperties
overflowX: 'hidden'
}}
autoHideScrollbar>
{(session) => (
<SessionItem
key={session.id}
session={session}
agentId={agentId}
onDelete={() => handleDeleteSession(session.id)}
onPress={() => setActiveSessionId(agentId, session.id)}
/>
)}
</DynamicVirtualList>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react'
import { Button, Popover, PopoverContent, PopoverTrigger, useDisclosure } from '@heroui/react'
import { AgentModal } from '@renderer/components/Popups/agent/AgentModal'
import { Bot, MessageSquare } from 'lucide-react'
import { FC, useState } from 'react'
@@ -13,7 +13,7 @@ interface UnifiedAddButtonProps {
const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => {
const { t } = useTranslation()
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const [isAgentModalOpen, setIsAgentModalOpen] = useState(false)
const { isOpen: isAgentModalOpen, onOpen: onAgentModalOpen, onClose: onAgentModalClose } = useDisclosure()
const handleAddAssistant = () => {
setIsPopoverOpen(false)
@@ -22,7 +22,7 @@ const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => {
const handleAddAgent = () => {
setIsPopoverOpen(false)
setIsAgentModalOpen(true)
onAgentModalOpen()
}
return (
@@ -53,7 +53,7 @@ const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => {
</PopoverContent>
</Popover>
<AgentModal isOpen={isAgentModalOpen} onClose={() => setIsAgentModalOpen(false)} />
<AgentModal isOpen={isAgentModalOpen} onClose={onAgentModalClose} />
</div>
)
}

View File

@@ -189,10 +189,7 @@ const Container = styled.div`
background-color: var(--color-background);
}
[navbar-position='top'] & {
height: calc(100vh - var(--navbar-height) - 12px);
&.right {
height: calc(100vh - var(--navbar-height) - var(--navbar-height) - 12px);
}
height: calc(100vh - var(--navbar-height));
}
overflow: hidden;
.collapsed {

View File

@@ -1,13 +1,13 @@
import { BreadcrumbItem, Breadcrumbs, Chip, cn } from '@heroui/react'
import { BreadcrumbItem, Breadcrumbs, cn } from '@heroui/react'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent'
import { useActiveSession } from '@renderer/hooks/agents/useActiveSession'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { AgentEntity, AgentSessionEntity, ApiModel, Assistant, PermissionMode } from '@renderer/types'
import { AgentEntity, AgentSessionEntity, ApiModel, Assistant } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { t } from 'i18next'
import { Folder } from 'lucide-react'
import { FC, ReactNode, useCallback } from 'react'
import { AgentSettingsPopup, SessionSettingsPopup } from '../../settings/AgentSettings'
@@ -38,24 +38,15 @@ const ChatNavbarContent: FC<Props> = ({ assistant }) => {
<>
{activeTopicOrSession === 'topic' && <SelectModelButton assistant={assistant} />}
{activeTopicOrSession === 'session' && activeAgent && (
<HorizontalScrollContainer>
<Breadcrumbs
classNames={{
base: 'flex',
list: 'flex-nowrap'
}}>
<HorizontalScrollContainer className="ml-2 flex-initial">
<Breadcrumbs classNames={{ base: 'flex', list: 'flex-nowrap' }}>
<BreadcrumbItem
onPress={() => AgentSettingsPopup.show({ agentId: activeAgent.id })}
classNames={{
base: 'self-stretch',
item: 'h-full'
}}>
<Chip size="md" variant="light" className="h-full transition-background hover:bg-foreground-100">
<AgentLabel
agent={activeAgent}
classNames={{ name: 'max-w-40 font-bold text-xs', avatar: 'h-4.5 w-4.5', container: 'gap-1.5' }}
/>
</Chip>
classNames={{ base: 'self-stretch', item: 'h-full' }}>
<AgentLabel
agent={activeAgent}
classNames={{ name: 'max-w-40 text-xs', avatar: 'h-4.5 w-4.5', container: 'gap-1.5' }}
/>
</BreadcrumbItem>
{activeSession && (
<BreadcrumbItem
@@ -65,13 +56,8 @@ const ChatNavbarContent: FC<Props> = ({ assistant }) => {
sessionId: activeSession.id
})
}
classNames={{
base: 'self-stretch',
item: 'h-full'
}}>
<Chip size="md" variant="light" className="h-full transition-background hover:bg-foreground-100">
<SessionLabel session={activeSession} className="max-w-40 font-bold text-xs" />
</Chip>
classNames={{ base: 'self-stretch', item: 'h-full' }}>
<SessionLabel session={activeSession} className="max-w-40 text-xs" />
</BreadcrumbItem>
)}
{activeSession && (
@@ -97,11 +83,11 @@ const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity
}
const firstAccessiblePath = session.accessible_paths?.[0]
const permissionMode = (session.configuration?.permission_mode ?? 'default') as PermissionMode
const permissionModeCard = permissionModeCards.find((card) => card.mode === permissionMode)
const permissionModeLabel = permissionModeCard
? t(permissionModeCard.titleKey, permissionModeCard.titleFallback)
: permissionMode
// const permissionMode = (session.configuration?.permission_mode ?? 'default') as PermissionMode
// const permissionModeCard = permissionModeCards.find((card) => card.mode === permissionMode)
// const permissionModeLabel = permissionModeCard
// ? t(permissionModeCard.titleKey, permissionModeCard.titleFallback)
// : permissionMode
const infoItems: ReactNode[] = []
@@ -117,12 +103,13 @@ const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity
}) => (
<div
className={cn(
'rounded-medium border border-default-200 px-2 py-1 text-foreground-500 text-xs dark:text-foreground-400',
'flex items-center gap-1.5 text-foreground-500 text-xs dark:text-foreground-400',
onClick !== undefined ? 'cursor-pointer' : undefined,
className
)}
title={text}
onClick={onClick}>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="block truncate">{text}</span>
</div>
)
@@ -148,7 +135,7 @@ const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity
)
}
infoItems.push(<InfoTag key="permission-mode" text={permissionModeLabel} className="max-w-50" />)
// infoItems.push(<InfoTag key="permission-mode" text={permissionModeLabel} className="max-w-50" />)
if (infoItems.length === 0) {
return null

View File

@@ -38,12 +38,12 @@ const SelectAgentBaseModelButton: FC<Props> = ({ agentBase: agent, onSelect, isD
<Button
size="sm"
variant="light"
className="nodrag rounded-2xl px-1 py-3"
className="nodrag h-[28px] rounded-2xl px-1"
onPress={onSelectModel}
isDisabled={isDisabled}>
<div className="flex items-center gap-1.5 overflow-x-hidden">
<ModelAvatar model={model ? apiModelAdapter(model) : undefined} size={20} />
<span className="truncate font-medium">
<span className="truncate text-[var(--color-text)]">
{model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''}
</span>
</div>

View File

@@ -87,6 +87,7 @@ const ButtonContent = styled.div`
const ModelName = styled.span`
font-weight: 500;
margin-right: -2px;
font-size: 12px;
`
export default SelectModelButton

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}
@@ -104,7 +71,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
afterClose={afterClose}
maskClosable={false}
footer={null}
title={<AgentLabel agent={agent} classNames={{ name: 'text-lg font-extrabold' }} />}
title={<AgentLabel agent={agent} />}
transitionName="animation-move-down"
styles={{
content: {
@@ -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

@@ -20,13 +20,15 @@ type EssentialSettingsProps =
| {
agentBase: GetAgentResponse | undefined | null
update: ReturnType<typeof useUpdateAgent>['updateAgent']
showModelSetting?: boolean
}
| {
agentBase: GetAgentSessionResponse | undefined | null
update: ReturnType<typeof useUpdateSession>['updateSession']
showModelSetting?: boolean
}
const EssentialSettings: FC<EssentialSettingsProps> = ({ agentBase, update }) => {
const EssentialSettings: FC<EssentialSettingsProps> = ({ agentBase, update, showModelSetting = true }) => {
const { t } = useTranslation()
if (!agentBase) return null
@@ -46,7 +48,7 @@ const EssentialSettings: FC<EssentialSettingsProps> = ({ agentBase, update }) =>
)}
{isAgent && <AvatarSetting agent={agentBase} update={update} />}
<NameSetting base={agentBase} update={update} />
<ModelSetting base={agentBase} update={update} />
{showModelSetting && <ModelSetting base={agentBase} update={update} />}
<AccessibleDirsSetting base={agentBase} update={update} />
<DescriptionSetting base={agentBase} update={update} />
</SettingsContainer>

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}
@@ -106,7 +73,7 @@ const SessionSettingPopupContainer: React.FC<SessionSettingPopupParams> = ({ tab
afterClose={afterClose}
maskClosable={false}
footer={null}
title={<SessionLabel session={session} className="font-extrabold text-lg" />}
title={<SessionLabel session={session} />}
transitionName="animation-move-down"
styles={{
content: {
@@ -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

@@ -36,7 +36,7 @@ export const AgentLabel: React.FC<AgentLabelProps> = ({ agent, classNames }) =>
return (
<div className={cn('flex w-full items-center gap-2 truncate', classNames?.container)}>
<EmojiIcon emoji={emoji || '⭐️'} className={classNames?.avatar} />
<span className={cn('truncate', classNames?.name)}>
<span className={cn('truncate', 'text-[var(--color-text)]', classNames?.name)}>
{agent?.name ?? (agent?.type ? getAgentTypeLabel(agent.type) : '')}
</span>
</div>
@@ -52,7 +52,7 @@ export const SessionLabel: React.FC<SessionLabelProps> = ({ session, className }
const displayName = session?.name ?? session?.id
return (
<>
<span className={cn('truncate px-2 text-sm', className)}>{displayName}</span>
<span className={cn('truncate text-[var(--color-text)] text-sm', className)}>{displayName}</span>
</>
)
}

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"