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
119 changed files with 956 additions and 3635 deletions

Binary file not shown.

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-sora.3",
"version": "1.7.0-beta.2",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -126,7 +126,6 @@
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@cherrystudio/extension-table-plus": "workspace:^",
"@cherrystudio/openai": "6.3.0-fork.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -262,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",
@@ -297,6 +296,7 @@
"motion": "^12.10.5",
"notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"oxlint": "^1.22.0",
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
@@ -376,8 +376,8 @@
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"node-abi": "4.12.0",
"openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.3.0-fork.1",
"openai@npm:^4.87.3": "npm:@cherrystudio/openai@6.3.0-fork.1",
"openai@npm:^4.77.0": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"tar-fs": "^2.1.4",

View File

@@ -2,9 +2,9 @@
* 该脚本用于少量自动翻译所有baseLocale以外的文本。待翻译文案必须以[to be translated]开头
*
*/
import OpenAI from '@cherrystudio/openai'
import cliProgress from 'cli-progress'
import * as fs from 'fs'
import OpenAI from 'openai'
import * as path from 'path'
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')

View File

@@ -4,9 +4,9 @@
* API_KEY=sk-xxxx BASE_URL=xxxx MODEL=xxxx ts-node scripts/update-i18n.ts
*/
import OpenAI from '@cherrystudio/openai'
import cliProgress from 'cli-progress'
import fs from 'fs'
import OpenAI from 'openai'
type I18NValue = string | { [key: string]: I18NValue }
type I18N = { [key: string]: I18NValue }

View File

@@ -1,5 +1,5 @@
import { ChatCompletionCreateParams } from '@cherrystudio/openai/resources'
import express, { Request, Response } from 'express'
import { ChatCompletionCreateParams } from 'openai/resources'
import { loggerService } from '../../services/LoggerService'
import {

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

@@ -1,6 +1,6 @@
import OpenAI from '@cherrystudio/openai'
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from '@cherrystudio/openai/resources'
import { Provider } from '@types'
import OpenAI from 'openai'
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from 'openai/resources'
import { loggerService } from '../../services/LoggerService'
import { ModelValidationError, validateModelId } from '../utils'

View File

@@ -1,8 +1,8 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
import * as fs from 'fs'
import OpenAI from 'openai'
import { CacheService } from '../CacheService'
import { BaseFileService } from './BaseFileService'

View File

@@ -20,7 +20,6 @@ import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import AssistantPresetsPage from './pages/store/assistants/presets/AssistantPresetsPage'
import TranslatePage from './pages/translate/TranslatePage'
import { VideoPage } from './pages/video/VideoPage'
const Router: FC = () => {
const { navbarPosition } = useNavbarPosition()
@@ -41,7 +40,6 @@ const Router: FC = () => {
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
<Route path="/video" element={<VideoPage />} />
</Routes>
</ErrorBoundary>
)

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

@@ -12,23 +12,8 @@ import { loggerService } from '@logger'
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type {
Assistant,
DeleteVideoParams,
DeleteVideoResult,
GenerateImageParams,
Model,
Provider,
RetrieveVideoContentParams
} from '@renderer/types'
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import {
CreateVideoParams,
CreateVideoResult,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types/video'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
@@ -513,34 +498,6 @@ export default class ModernAiProvider {
return images
}
/**
* We manually implement this method before aisdk supports it well
*/
public async createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
return this.legacyProvider.createVideo(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
return this.legacyProvider.retrieveVideo(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
return this.legacyProvider.retrieveVideoContent(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
return this.legacyProvider.deleteVideo(params)
}
public getBaseURL(): string {
return this.legacyProvider.getBaseURL()
}

View File

@@ -1,6 +1,6 @@
import OpenAI from '@cherrystudio/openai'
import { Provider } from '@renderer/types'
import { OpenAISdkParams, OpenAISdkRawOutput } from '@renderer/types/sdk'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -1,9 +1,3 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import {
ChatCompletionContentPart,
ChatCompletionContentPartRefusal,
ChatCompletionTool
} from '@cherrystudio/openai/resources'
import { loggerService } from '@logger'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import {
@@ -84,6 +78,8 @@ import {
} from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
import { t } from 'i18next'
import OpenAI, { AzureOpenAI } from 'openai'
import { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatCompletionTool } from 'openai/resources'
import { GenericChunk } from '../../middleware/schemas'
import { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types'

View File

@@ -1,4 +1,3 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { COPILOT_DEFAULT_HEADERS } from '@renderer/aiCore/provider/constants'
import {
@@ -26,6 +25,7 @@ import {
ReasoningEffortOptionalParams
} from '@renderer/types/sdk'
import { formatApiHost } from '@renderer/utils/api'
import OpenAI, { AzureOpenAI } from 'openai'
import { BaseApiClient } from '../BaseApiClient'

View File

@@ -1,5 +1,3 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import { ResponseInput } from '@cherrystudio/openai/resources/responses/responses'
import { loggerService } from '@logger'
import { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas'
import { CompletionsContext } from '@renderer/aiCore/legacy/middleware/types'
@@ -36,12 +34,6 @@ import {
OpenAIResponseSdkTool,
OpenAIResponseSdkToolCall
} from '@renderer/types/sdk'
import {
CreateVideoParams,
DeleteVideoParams,
RetrieveVideoContentParams,
RetrieveVideoParams
} from '@renderer/types/video'
import { addImageFileToContents } from '@renderer/utils/formats'
import {
isSupportedToolUse,
@@ -53,6 +45,8 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi
import { MB } from '@shared/config/constant'
import { t } from 'i18next'
import { isEmpty } from 'lodash'
import OpenAI, { AzureOpenAI } from 'openai'
import { ResponseInput } from 'openai/resources/responses/responses'
import { RequestTransformer, ResponseChunkTransformer } from '../types'
import { OpenAIAPIClient } from './OpenAIApiClient'
@@ -158,26 +152,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
return await sdk.responses.create(payload, options)
}
public async createVideo(params: CreateVideoParams): Promise<OpenAI.Videos.Video> {
const sdk = await this.getSdkInstance()
return sdk.videos.create(params.params, params.options)
}
public async retrieveVideo(params: RetrieveVideoParams): Promise<OpenAI.Videos.Video> {
const sdk = await this.getSdkInstance()
return sdk.videos.retrieve(params.videoId, params.options)
}
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<Response> {
const sdk = await this.getSdkInstance()
return sdk.videos.downloadContent(params.videoId, params.query, params.options)
}
public async deleteVideo(params: DeleteVideoParams): Promise<OpenAI.Videos.VideoDeleteResponse> {
const sdk = await this.getSdkInstance()
return sdk.videos.delete(params.videoId, params.options)
}
private async handlePdfFile(file: FileMetadata): Promise<OpenAI.Responses.ResponseInputFile | undefined> {
if (file.size > 32 * MB) return undefined
try {
@@ -369,14 +343,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
switch (message.type) {
case 'function_call_output':
if (typeof message.output === 'string') {
sum += estimateTextTokens(message.output)
} else {
sum += message.output
.filter((item) => item.type === 'input_text')
.map((item) => estimateTextTokens(item.text))
.reduce((prev, cur) => prev + cur, 0)
}
sum += estimateTextTokens(message.output)
break
case 'function_call':
sum += estimateTextTokens(message.arguments)

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { isSupportedModel } from '@renderer/config/models'
import { objectKeys, Provider } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { isSupportedModel } from '@renderer/config/models'
import { Model, Provider } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -1,5 +1,4 @@
import Anthropic from '@anthropic-ai/sdk'
import OpenAI from '@cherrystudio/openai'
import { Assistant, MCPTool, MCPToolResponse, Model, ToolCallResponse } from '@renderer/types'
import { Provider } from '@renderer/types'
import {
@@ -14,6 +13,7 @@ import {
SdkTool,
SdkToolCall
} from '@renderer/types/sdk'
import OpenAI from 'openai'
import { CompletionsParams, GenericChunk } from '../middleware/schemas'
import { CompletionsContext } from '../middleware/types'

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { Provider } from '@renderer/types'
import { GenerateImageParams } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -5,22 +5,8 @@ import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@render
import { getProviderByModel } from '@renderer/services/AssistantService'
import { withSpanResult } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type {
DeleteVideoParams,
DeleteVideoResult,
GenerateImageParams,
Model,
Provider,
RetrieveVideoContentParams
} from '@renderer/types'
import type { GenerateImageParams, Model, Provider } from '@renderer/types'
import type { RequestOptions, SdkModel } from '@renderer/types/sdk'
import {
CreateVideoParams,
CreateVideoResult,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types/video'
import { isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { AihubmixAPIClient } from './clients/aihubmix/AihubmixAPIClient'
@@ -193,54 +179,6 @@ export default class AiProvider {
return this.apiClient.generateImage(params)
}
public async createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const video = await this.apiClient.createVideo(params)
return {
type: 'openai',
video
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const video = await this.apiClient.retrieveVideo(params)
return {
type: 'openai',
video
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const response = await this.apiClient.retrieveVideoContent(params)
return {
type: 'openai',
response
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const result = await this.apiClient.deleteVideo(params)
return {
type: 'openai',
result
}
} else {
throw new Error('Video deletion is not supported by this provider')
}
}
public getBaseURL(): string {
return this.apiClient.getBaseURL()
}

View File

@@ -1,10 +1,10 @@
import OpenAI from '@cherrystudio/openai'
import { toFile } from '@cherrystudio/openai/uploads'
import { isDedicatedImageGenerationModel } from '@renderer/config/models'
import FileManager from '@renderer/services/FileManager'
import { ChunkType } from '@renderer/types/chunk'
import { findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { defaultTimeout } from '@shared/config/constant'
import OpenAI from 'openai'
import { toFile } from 'openai/uploads'
import { BaseApiClient } from '../../clients/BaseApiClient'
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'

View File

@@ -3,7 +3,6 @@
* 处理文件内容提取、文件格式转换、文件上传等逻辑
*/
import type OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { getProviderByModel } from '@renderer/services/AssistantService'
import type { FileMetadata, Message, Model } from '@renderer/types'
@@ -11,6 +10,7 @@ import { FileTypes } from '@renderer/types'
import { FileMessageBlock } from '@renderer/types/newMessage'
import { findFileBlocks } from '@renderer/utils/messageUtils/find'
import type { FilePart, TextPart } from 'ai'
import type OpenAI from 'openai'
import { getAiSdkProviderId } from '../provider/factory'
import { getFileSizeLimit, supportsImageInput, supportsLargeFileUpload, supportsPdfInput } from './modelCapabilities'

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

@@ -32,7 +32,6 @@ import {
Sparkle,
Sun,
Terminal,
Video,
X
} from 'lucide-react'
import { useCallback, useEffect, useMemo } from 'react'
@@ -107,8 +106,6 @@ const getTabIcon = (
return <Settings size={14} />
case 'code':
return <Terminal size={14} />
case 'video':
return <Video size={14} />
default:
return null
}

View File

@@ -23,8 +23,7 @@ export const ToastPortal = () => {
timeout: 3000,
classNames: {
// This setting causes the 'hero-toast' class to be applied twice to the toast element. This is weird and I don't know why, but it works.
// `w-auto` would not overwrite default style, which set the width to a fixed value and causes text overflow.
base: 'hero-toast w-auto! max-w-[50vw]'
base: 'hero-toast'
}
}}
/>,

View File

@@ -1,6 +1,5 @@
import { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast'
import { RequireSome } from '@renderer/types'
import { t } from 'i18next'
type AddToastProps = Parameters<typeof addToast>[0]
type ToastPropsColored = Omit<AddToastProps, 'color'>
@@ -55,7 +54,7 @@ export const loading = (args: RequireSome<AddToastProps, 'promise'>) => {
if (args.timeout === undefined) {
args.timeout = 1
}
return addToast({ title: t('common.loading'), ...args })
return addToast(args)
}
export const getToastUtilities = () =>

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

@@ -26,8 +26,7 @@ import {
Palette,
Settings,
Sparkle,
Sun,
Video
Sun
} from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
@@ -140,8 +139,7 @@ const MainMenus: FC = () => {
knowledge: <FileSearch size={18} className="icon" />,
files: <Folder size={18} className="icon" />,
notes: <NotepadText size={18} className="icon" />,
code_tools: <Code size={18} className="icon" />,
video: <Video size={18} className="icon" />
code_tools: <Code size={18} className="icon" />
}
const pathMap = {
@@ -153,8 +151,7 @@ const MainMenus: FC = () => {
knowledge: '/knowledge',
files: '/files',
code_tools: '/code',
notes: '/notes',
video: '/video'
notes: '/notes'
}
return sidebarIcons.visible.map((icon) => {

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

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models/embedding'
import { Model } from '@renderer/types'
import { getLowerBaseModelName } from '@renderer/utils'
import OpenAI from 'openai'
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '../prompts'
import { getWebSearchTools } from '../tools'

View File

@@ -1,149 +0,0 @@
import { SystemProviderId, Video } from '@renderer/types'
// Hard-encoded for now. We may implement a function to filter video generation model from provider.models.
export const videoModelsMap = {
openai: ['sora-2', 'sora-2-pro'] as const
} as const satisfies Partial<Record<SystemProviderId, string[]>>
// Mock data for testing
export const mockVideos: Video[] = [
{
id: '1',
type: 'openai',
status: 'downloaded',
prompt: 'A beautiful sunset over the ocean with waves crashing',
thumbnail: 'https://picsum.photos/200/200?random=1',
fileId: 'file-001',
providerId: 'openai',
name: 'video-001',
metadata: {
id: 'video-001',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2',
progress: 100,
remixed_from_video_id: null,
seconds: '4',
size: '1280x720',
status: 'completed'
}
},
{
id: '2',
type: 'openai',
status: 'in_progress',
prompt: 'A cat playing with a ball of yarn in slow motion',
progress: 65,
providerId: 'openai',
name: 'video-002',
metadata: {
id: 'video-002',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: null,
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 65,
remixed_from_video_id: null,
seconds: '8',
size: '1792x1024',
status: 'in_progress'
}
},
{
id: '3',
type: 'openai',
status: 'queued',
prompt: 'Time-lapse of flowers blooming in a garden',
providerId: 'openai',
name: 'video-003',
metadata: {
id: 'video-003',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: null,
expires_at: null,
error: null,
model: 'sora-2',
progress: 0,
remixed_from_video_id: null,
seconds: '12',
size: '1280x720',
status: 'queued'
}
},
{
id: '4',
type: 'openai',
prompt: 'Birds flying in formation against blue sky',
status: 'downloading',
progress: 80,
thumbnail: 'https://picsum.photos/200/200?random=4',
providerId: 'openai',
name: 'video-004',
metadata: {
id: 'video-004',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 100,
remixed_from_video_id: null,
seconds: '8',
size: '1792x1024',
status: 'completed'
}
},
{
id: '5',
type: 'openai',
status: 'failed',
error: { code: '400', message: 'Video generation failed' },
prompt: 'Mountain landscape with snow peaks and forest',
providerId: 'openai',
name: 'video-005',
metadata: {
id: 'video-005',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: { code: '400', message: 'Video generation failed' },
model: 'sora-2',
progress: 0,
remixed_from_video_id: null,
seconds: '4',
size: '1280x720',
status: 'failed'
}
},
{
id: '6',
type: 'openai',
status: 'completed',
thumbnail: 'https://picsum.photos/200/200?random=6',
prompt: 'City street at night with neon lights reflecting on wet pavement',
providerId: 'openai',
name: 'video-006',
metadata: {
id: 'video-006',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 100,
remixed_from_video_id: null,
seconds: '12',
size: '1024x1792',
status: 'completed'
}
}
]

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

@@ -1,5 +1,5 @@
import { ChatCompletionTool } from '@cherrystudio/openai/resources'
import { Model } from '@renderer/types'
import { ChatCompletionTool } from 'openai/resources'
import { WEB_SEARCH_PROMPT_FOR_ZHIPU } from './prompts'

View File

@@ -1,17 +0,0 @@
import { useAppDispatch } from '@renderer/store'
import { setPendingAction } from '@renderer/store/runtime'
import { useCallback } from 'react'
import { useRuntime } from './useRuntime'
export const usePending = () => {
const { pendingMap } = useRuntime()
const dispatch = useAppDispatch()
const setPending = useCallback(
(id: string, value: boolean | undefined) => {
dispatch(setPendingAction({ id, value }))
},
[dispatch]
)
return { pendingMap, setPending }
}

View File

@@ -1,65 +0,0 @@
import OpenAI from '@cherrystudio/openai'
import { useCallback } from 'react'
import { useProviderVideos } from './useProviderVideos'
export const useAddOpenAIVideo = (providerId: string) => {
const { addVideo } = useProviderVideos(providerId)
const addOpenAIVideo = useCallback(
(video: OpenAI.Videos.Video, prompt: string) => {
switch (video.status) {
case 'queued':
addVideo({
id: video.id,
name: video.id,
providerId,
status: video.status,
type: 'openai',
metadata: video,
prompt
})
break
case 'in_progress':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'in_progress',
type: 'openai',
progress: video.progress,
metadata: video,
prompt
})
break
case 'completed':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'completed',
type: 'openai',
metadata: video,
prompt,
thumbnail: null
})
break
case 'failed':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'failed',
type: 'openai',
error: video.error,
metadata: video,
prompt
})
break
}
},
[addVideo, providerId]
)
return addOpenAIVideo
}

View File

@@ -1,47 +0,0 @@
import { retrieveVideo } from '@renderer/services/ApiService'
import useSWR, { SWRConfiguration, useSWRConfig } from 'swr'
import { useProvider } from '../useProvider'
import { useVideo } from './useVideo'
export const useOpenAIVideo = (providerId: string, id: string) => {
const { provider } = useProvider(providerId)
const fetcher = async () => {
switch (provider.type) {
case 'openai-response':
return retrieveVideo({
type: 'openai',
videoId: id,
provider
})
default:
throw new Error(`Unsupported provider type: ${provider.type}`)
}
}
const video = useVideo(providerId, id)
let options: SWRConfiguration = {}
switch (video?.status) {
case 'queued':
case 'in_progress':
options = {
refreshInterval: 3000
}
break
default:
options = {
revalidateOnFocus: false,
revalidateOnMount: true
}
}
const { data, isLoading, error } = useSWR(`video/openai/${id}`, fetcher, options)
const { mutate } = useSWRConfig()
const revalidate = () => mutate(`video/openai/${id}`)
return {
video: data,
isLoading,
error,
revalidate
}
}

View File

@@ -1,174 +0,0 @@
import { loggerService } from '@logger'
import { retrieveVideo } from '@renderer/services/ApiService'
import { getProviderById } from '@renderer/services/ProviderService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { addVideoAction, setVideoAction, setVideosAction, updateVideoAction } from '@renderer/store/video'
import { Video } from '@renderer/types/video'
import { getErrorMessage } from '@renderer/utils'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useVideos } from './useVideos'
import { useVideoThumbnail } from './useVideoThumbnail'
const logger = loggerService.withContext('useVideo')
export const useProviderVideos = (providerId: string) => {
const { removeVideo } = useVideos()
const videos = useAppSelector((state) => state.video.videoMap[providerId])
const videosRef = useRef(videos)
const dispatch = useAppDispatch()
const { t } = useTranslation()
useEffect(() => {
videosRef.current = videos
}, [videos])
const getVideo = useCallback(
(id: string) => {
return videos?.find((v) => v.id === id)
},
[videos]
)
const addVideo = useCallback(
(video: Video) => {
if (videos && videos.every((v) => v.id !== video.id)) {
dispatch(addVideoAction({ providerId, video }))
}
},
[dispatch, providerId, videos]
)
const updateVideo = useCallback(
(update: Partial<Omit<Video, 'status'>> & { id: string }) => {
dispatch(updateVideoAction({ providerId, update }))
},
[dispatch, providerId]
)
const setVideo = useCallback(
(video: Video) => {
dispatch(setVideoAction({ providerId, video }))
},
[dispatch, providerId]
)
const setVideos = useCallback(
(newVideos: Video[]) => {
dispatch(setVideosAction({ providerId, videos: newVideos }))
},
[dispatch, providerId]
)
const removeProviderVideo = useCallback(
(videoId: string) => {
removeVideo(videoId, providerId)
},
[providerId, removeVideo]
)
useEffect(() => {
if (!videos) {
setVideos([])
}
}, [setVideos, videos])
// update videos from api
// NOTE: This provider should support openai videos endpoint. No runtime check here.
const provider = getProviderById(providerId)
const fetcher = async () => {
if (!videos || !provider) return []
if (provider.type === 'openai-response') {
const openaiVideos = videos
.filter((v) => v.type === 'openai')
.filter((v) => v.status === 'queued' || v.status === 'in_progress')
const jobs = openaiVideos.map((v) => retrieveVideo({ type: 'openai', videoId: v.id, provider }))
const result = await Promise.allSettled(jobs)
return result.filter((p) => p.status === 'fulfilled').map((p) => p.value)
} else {
throw new Error(`Provider type ${provider.type} is not supported for video status polling`)
}
}
const { data, error } = useSWR('video/openai/videos', fetcher, { refreshInterval: 3000 })
const { retrieveThumbnail } = useVideoThumbnail()
useEffect(() => {
if (error) {
logger.error('Failed to fetch video status updates', error)
return
}
if (!provider) {
logger.warn(`Provider ${providerId} not found.`)
return
}
const videos = videosRef.current
if (!data || !videos) return
data.forEach((v) => {
const retrievedVideo = v.video
const storeVideo = videos.find((v) => v.id === retrievedVideo.id)
if (!storeVideo) {
logger.warn(`Try to update video ${retrievedVideo.id}, but it's not in the store.`)
return
}
switch (retrievedVideo.status) {
case 'in_progress':
if (storeVideo.status === 'queued' || storeVideo.status === 'in_progress') {
setVideo({
...storeVideo,
status: 'in_progress',
progress: retrievedVideo.progress,
metadata: retrievedVideo
})
}
break
case 'completed': {
if (storeVideo.status === 'in_progress' || storeVideo.status === 'queued') {
const newVideo = { ...storeVideo, status: 'completed', thumbnail: null, metadata: retrievedVideo } as const
setVideo(newVideo)
// Try to get thumbnail
retrieveThumbnail(newVideo)
.then((thumbnail) => {
const latestVideo = videosRef.current?.find((v) => v.id === newVideo.id)
if (
thumbnail !== null &&
latestVideo &&
latestVideo.status !== 'queued' &&
latestVideo.status !== 'in_progress' &&
latestVideo.status !== 'failed'
) {
setVideo({
...latestVideo,
thumbnail
})
}
})
.catch((e) => {
logger.error('Failed to get thumbnail', e as Error)
window.toast.error({ title: t('video.thumbnail.error.get'), description: getErrorMessage(e) })
})
}
break
}
case 'failed':
setVideo({
...storeVideo,
status: 'failed',
error: retrievedVideo.error,
metadata: retrievedVideo
})
}
})
}, [data, error, provider, providerId, retrieveThumbnail, setVideo, t])
return {
videos: videos ?? [],
getVideo,
addVideo,
updateVideo,
setVideos,
setVideo,
removeVideo: removeProviderVideo
}
}

View File

@@ -1,7 +0,0 @@
import { useProviderVideos } from './useProviderVideos'
export const useVideo = (providerId: string, id: string) => {
const { videos } = useProviderVideos(providerId)
const video = videos.find((v) => v.id === id)
return video
}

View File

@@ -1,86 +0,0 @@
import { loggerService } from '@logger'
import { retrieveVideoContent } from '@renderer/services/ApiService'
import ImageStorage from '@renderer/services/ImageStorage'
import { getProviderById } from '@renderer/services/ProviderService'
import { Video } from '@renderer/types'
import { useCallback } from 'react'
const logger = loggerService.withContext('useRetrieveThumbnail')
const pendingSet = new Set<string>()
export const useVideoThumbnail = () => {
const getThumbnailKey = useCallback((id: string) => {
return `video-thumbnail-${id}`
}, [])
const retrieveThumbnail = useCallback(
async (video: Video): Promise<string> => {
const provider = getProviderById(video.providerId)
if (!provider) {
throw new Error(`Provider not found for id ${video.providerId}`)
}
const thumbnailKey = getThumbnailKey(video.id)
if (pendingSet.has(thumbnailKey)) {
throw new Error('Thumbnail retrieval already pending')
}
pendingSet.add(thumbnailKey)
try {
const cachedThumbnail = await ImageStorage.get(thumbnailKey)
if (cachedThumbnail) {
return cachedThumbnail
}
const result = await retrieveVideoContent({
type: 'openai',
provider,
videoId: video.id,
query: { variant: 'thumbnail' }
})
const { response } = result
if (!response.ok) {
throw new Error(`Unexpected thumbnail status: ${response.status}`)
}
const blob = await response.blob()
if (!blob || blob.size === 0) {
throw new Error('Thumbnail response body is empty')
}
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Failed to convert thumbnail to base64'))
}
}
reader.onerror = () => reject(reader.error ?? new Error('Failed to read thumbnail blob'))
reader.readAsDataURL(blob)
})
await ImageStorage.set(thumbnailKey, base64)
return base64
} catch (e) {
logger.error(`Failed to get thumbnail for video ${video.id}`, e as Error)
throw e
} finally {
pendingSet.delete(thumbnailKey)
}
},
[getThumbnailKey]
)
const removeThumbnail = useCallback(
async (id: string) => {
const key = getThumbnailKey(id)
return ImageStorage.remove(key)
},
[getThumbnailKey]
)
return { getThumbnailKey, retrieveThumbnail, removeThumbnail }
}

View File

@@ -1,48 +0,0 @@
import FileManager from '@renderer/services/FileManager'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { removeVideoAction } from '@renderer/store/video'
import { objectValues } from '@renderer/types'
import { useCallback } from 'react'
import { useVideoThumbnail } from './useVideoThumbnail'
export const useVideos = () => {
const videoMap = useAppSelector((state) => state.video.videoMap)
const dispatch = useAppDispatch()
const { removeThumbnail } = useVideoThumbnail()
const videos = objectValues(videoMap)
.flat()
.filter((v) => v !== undefined)
const getVideo = useCallback(
(videoId: string) => {
return videos.find((v) => v.id === videoId)
},
[videos]
)
const removeVideo = useCallback(
(videoId: string, providerId?: string) => {
const video = getVideo(videoId)
if (!video) {
return
}
if (!providerId) {
providerId = video.providerId
}
// should delete from redux state, and related thumbnail image, video file
if (video.thumbnail) {
removeThumbnail(videoId)
}
if (video.fileId) {
FileManager.deleteFile(video.fileId)
}
dispatch(removeVideoAction({ providerId, videoId }))
},
[dispatch, getVideo, removeThumbnail]
)
return { videos, getVideo, removeVideo }
}

View File

@@ -146,8 +146,7 @@ const titleKeyMap = {
notes: 'title.notes',
paintings: 'title.paintings',
settings: 'title.settings',
translate: 'title.translate',
video: 'title.video'
translate: 'title.translate'
} as const
export const getTitleLabel = (key: string): string => {
@@ -173,8 +172,7 @@ const sidebarIconKeyMap = {
knowledge: 'knowledge.title',
files: 'files.title',
code_tools: 'code.title',
notes: 'notes.title',
video: 'video.title'
notes: 'notes.title'
} as const
export const getSidebarIconLabel = (key: string): string => {

View File

@@ -978,7 +978,6 @@
"delete_confirm": "Are you sure you want to delete?",
"delete_failed": "Failed to delete",
"delete_success": "Deleted successfully",
"deleting": "Deleting...",
"description": "Description",
"detail": "Detail",
"disabled": "Disabled",
@@ -1023,12 +1022,10 @@
"prompt": "Prompt",
"provider": "Provider",
"reasoning_content": "Deep reasoning",
"redownload": "Redownload",
"refresh": "Refresh",
"regenerate": "Regenerate",
"rename": "Rename",
"reset": "Reset",
"retry": "Retry",
"save": "Save",
"saved": "Saved",
"search": "Search",
@@ -1036,7 +1033,6 @@
"selected": "Selected",
"selectedItems": "Selected {{count}} items",
"selectedMessages": "Selected {{count}} messages",
"send": "Send",
"settings": "Settings",
"sort": {
"pinyin": {
@@ -1096,9 +1092,6 @@
},
"content": "Content",
"data": "Data",
"delete": {
"failed": "Failed to delete."
},
"detail": "Error Details",
"details": "Details",
"errors": "Errors",
@@ -1206,8 +1199,7 @@
"size": "Size",
"text": "Text",
"title": "Files",
"type": "Type",
"video": "Video"
"type": "Type"
},
"gpustack": {
"keep_alive_time": {
@@ -4514,8 +4506,7 @@
"paintings": "Paintings",
"settings": "Settings",
"store": "Assistant Library",
"translate": "Translate",
"video": "Video"
"translate": "Translate"
},
"trace": {
"backList": "Back To List",
@@ -4673,56 +4664,6 @@
"saveDataError": "Failed to save data, please try again.",
"title": "Update"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "The video was not found remotely. It will only be deleted locally.",
"title": "Video not found."
}
}
},
"error": {
"create": "Failed to create video",
"download": "Failed to download video.",
"invalid": "Invalid video",
"load": {
"message": "Failed to load the video",
"reason": "The file may be corrupted or has been deleted externally."
}
},
"expired": "Expired",
"input_reference": {
"add": {
"error": {
"format": "Not a image",
"size": "This image is too large. It should be under 5MB."
},
"tooltip": "Add image reference"
}
},
"prompt": {
"placeholder": "describes the video to generate"
},
"seconds": "Seconds",
"size": "Size",
"status": {
"completed": "Generation Completed",
"downloading": "Downloading",
"failed": "Generation Failed",
"in_progress": "Generating",
"queued": "Queued"
},
"thumbnail": {
"error": {
"get": "Failed to get thumbnail"
},
"get": "Get thumbnail",
"placeholder": "No thumbnail"
},
"title": "Video",
"undefined": "No available video"
},
"warning": {
"missing_provider": "The supplier does not exist; reverted to the default supplier {{provider}}. This may cause issues."
},

View File

@@ -978,7 +978,6 @@
"delete_confirm": "确定要删除吗?",
"delete_failed": "删除失败",
"delete_success": "删除成功",
"deleting": "删除中...",
"description": "描述",
"detail": "详情",
"disabled": "已禁用",
@@ -1023,12 +1022,10 @@
"prompt": "提示词",
"provider": "提供商",
"reasoning_content": "已深度思考",
"redownload": "重新下载",
"refresh": "刷新",
"regenerate": "重新生成",
"rename": "重命名",
"reset": "重置",
"retry": "重试",
"save": "保存",
"saved": "已保存",
"search": "搜索",
@@ -1036,7 +1033,6 @@
"selected": "已选择",
"selectedItems": "已选择 {{count}} 项",
"selectedMessages": "选中 {{count}} 条消息",
"send": "发送",
"settings": "设置",
"sort": {
"pinyin": {
@@ -1096,9 +1092,6 @@
},
"content": "内容",
"data": "数据",
"delete": {
"failed": "删除失败"
},
"detail": "错误详情",
"details": "详细信息",
"errors": "错误",
@@ -1206,8 +1199,7 @@
"size": "大小",
"text": "文本",
"title": "文件",
"type": "类型",
"video": "视频"
"type": "类型"
},
"gpustack": {
"keep_alive_time": {
@@ -4514,8 +4506,7 @@
"paintings": "绘画",
"settings": "设置",
"store": "助手库",
"translate": "翻译",
"video": "视频"
"translate": "翻译"
},
"trace": {
"backList": "返回列表",
@@ -4673,56 +4664,6 @@
"saveDataError": "保存数据失败,请重试",
"title": "更新提示"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "远程未找到该视频,仅会删除本地记录。",
"title": "视频未找到"
}
}
},
"error": {
"create": "创建视频失败",
"download": "视频下载失败",
"invalid": "无效的视频",
"load": {
"message": "加载视频失败",
"reason": "文件可能已损坏或已被外部删除。"
}
},
"expired": "已过期",
"input_reference": {
"add": {
"error": {
"format": "需要上传图片格式的文件",
"size": "图片过大,应小于 5MB"
},
"tooltip": "添加图像参考"
}
},
"prompt": {
"placeholder": "描述要生成的视频"
},
"seconds": "秒数",
"size": "尺寸",
"status": {
"completed": "生成完成",
"downloading": "下载中",
"failed": "生成失败",
"in_progress": "生成中",
"queued": "排队中"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "无缩略图"
},
"title": "视频",
"undefined": "无可用视频"
},
"warning": {
"missing_provider": "供应商不存在,已回退到默认供应商 {{provider}}。这可能导致问题。"
},

View File

@@ -978,7 +978,6 @@
"delete_confirm": "確定要刪除嗎?",
"delete_failed": "刪除失敗",
"delete_success": "刪除成功",
"deleting": "[to be translated]:Deleting...",
"description": "描述",
"detail": "詳情",
"disabled": "已停用",
@@ -1023,12 +1022,10 @@
"prompt": "提示詞",
"provider": "供應商",
"reasoning_content": "已深度思考",
"redownload": "[to be translated]:Redownload",
"refresh": "重新整理",
"regenerate": "重新生成",
"rename": "重新命名",
"reset": "重設",
"retry": "[to be translated]:Retry",
"save": "儲存",
"saved": "已儲存",
"search": "搜尋",
@@ -1036,7 +1033,6 @@
"selected": "已選擇",
"selectedItems": "已選擇 {{count}} 項",
"selectedMessages": "選中 {{count}} 條訊息",
"send": "[to be translated]:Send",
"settings": "設定",
"sort": {
"pinyin": {
@@ -1096,9 +1092,6 @@
},
"content": "內容",
"data": "数据",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "錯誤詳情",
"details": "詳細信息",
"errors": "錯誤",
@@ -1206,8 +1199,7 @@
"size": "大小",
"text": "文字",
"title": "檔案",
"type": "類型",
"video": "[to be translated]:Video"
"type": "類型"
},
"gpustack": {
"keep_alive_time": {
@@ -4514,8 +4506,7 @@
"paintings": "繪畫",
"settings": "設定",
"store": "助手庫",
"translate": "翻譯",
"video": "[to be translated]:Video"
"translate": "翻譯"
},
"trace": {
"backList": "返回清單",
@@ -4673,56 +4664,6 @@
"saveDataError": "保存數據失敗,請重試",
"title": "更新提示"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "供應商不存在,已回退到預設供應商 {{provider}}。這可能導致問題。"
},

View File

@@ -978,7 +978,6 @@
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
"delete_failed": "Αποτυχία διαγραφής",
"delete_success": "Η διαγραφή ήταν επιτυχής",
"deleting": "[to be translated]:Deleting...",
"description": "Περιγραφή",
"detail": "Λεπτομέρειες",
"disabled": "Απενεργοποιημένο",
@@ -1023,12 +1022,10 @@
"prompt": "Ενδεικτικός ρήματος",
"provider": "Παρέχων",
"reasoning_content": "Έχει σκεφτεί πολύ καλά",
"redownload": "[to be translated]:Redownload",
"refresh": "Ανανέωση",
"regenerate": "Ξαναπαραγωγή",
"rename": "Μετονομασία",
"reset": "Επαναφορά",
"retry": "[to be translated]:Retry",
"save": "Αποθήκευση",
"saved": "Αποθηκεύτηκε",
"search": "Αναζήτηση",
@@ -1036,7 +1033,6 @@
"selected": "Επιλεγμένο",
"selectedItems": "Επιλέχθηκαν {{count}} αντικείμενα",
"selectedMessages": "Επιλέχθηκαν {{count}} μηνύματα",
"send": "[to be translated]:Send",
"settings": "Ρυθμίσεις",
"sort": {
"pinyin": {
@@ -1096,9 +1092,6 @@
},
"content": "Περιεχόμενο",
"data": "δεδομένα",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Λεπτομέρειες σφάλματος",
"details": "Λεπτομέρειες",
"errors": "Λάθος",
@@ -1206,8 +1199,7 @@
"size": "Μέγεθος",
"text": "Κείμενο",
"title": "Αρχεία",
"type": "Τύπος",
"video": "[to be translated]:Video"
"type": "Τύπος"
},
"gpustack": {
"keep_alive_time": {
@@ -4504,8 +4496,7 @@
"paintings": "Ζωγραφική",
"settings": "Ρυθμίσεις",
"store": "Βιβλιοθήκη βοηθών",
"translate": "Μετάφραση",
"video": "[to be translated]:Video"
"translate": "Μετάφραση"
},
"trace": {
"backList": "Επιστροφή στη λίστα",
@@ -4663,56 +4654,6 @@
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
"title": "Ενημέρωση"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "Ο προμηθευτής δεν υπάρχει, έγινε επαναφορά στον προεπιλεγμένο προμηθευτή {{provider}}. Αυτό μπορεί να προκαλέσει προβλήματα."
},

View File

@@ -978,7 +978,6 @@
"delete_confirm": "¿Está seguro de que desea eliminarlo?",
"delete_failed": "Error al eliminar",
"delete_success": "Eliminación exitosa",
"deleting": "[to be translated]:Deleting...",
"description": "Descripción",
"detail": "Detalles",
"disabled": "Desactivado",
@@ -1023,12 +1022,10 @@
"prompt": "Prompt",
"provider": "Proveedor",
"reasoning_content": "Pensamiento profundo",
"redownload": "[to be translated]:Redownload",
"refresh": "Actualizar",
"regenerate": "Regenerar",
"rename": "Renombrar",
"reset": "Restablecer",
"retry": "[to be translated]:Retry",
"save": "Guardar",
"saved": "Guardado",
"search": "Buscar",
@@ -1036,7 +1033,6 @@
"selected": "Seleccionado",
"selectedItems": "{{count}} elementos seleccionados",
"selectedMessages": "{{count}} mensajes seleccionados",
"send": "[to be translated]:Send",
"settings": "Configuración",
"sort": {
"pinyin": {
@@ -1096,9 +1092,6 @@
},
"content": "contenido",
"data": "datos",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Detalles del error",
"details": "Detalles",
"errors": "error",
@@ -1206,8 +1199,7 @@
"size": "Tamaño",
"text": "Texto",
"title": "Archivo",
"type": "Tipo",
"video": "[to be translated]:Video"
"type": "Tipo"
},
"gpustack": {
"keep_alive_time": {
@@ -4504,8 +4496,7 @@
"paintings": "Pinturas",
"settings": "Configuración",
"store": "Biblioteca de asistentes",
"translate": "Traducir",
"video": "[to be translated]:Video"
"translate": "Traducir"
},
"trace": {
"backList": "Volver a la lista",
@@ -4663,56 +4654,6 @@
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
"title": "Actualización"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "El proveedor no existe, se ha revertido al proveedor predeterminado {{provider}}. Esto podría causar problemas."
},

View File

@@ -978,7 +978,6 @@
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ?",
"delete_failed": "Échec de la suppression",
"delete_success": "Suppression réussie",
"deleting": "[to be translated]:Deleting...",
"description": "Description",
"detail": "détails",
"disabled": "Désactivé",
@@ -1023,12 +1022,10 @@
"prompt": "Prompt",
"provider": "Fournisseur",
"reasoning_content": "Réflexion approfondie",
"redownload": "[to be translated]:Redownload",
"refresh": "Actualiser",
"regenerate": "Regénérer",
"rename": "Renommer",
"reset": "Réinitialiser",
"retry": "[to be translated]:Retry",
"save": "Enregistrer",
"saved": "enregistré",
"search": "Rechercher",
@@ -1036,7 +1033,6 @@
"selected": "Sélectionné",
"selectedItems": "{{count}} éléments sélectionnés",
"selectedMessages": "{{count}} messages sélectionnés",
"send": "[to be translated]:Send",
"settings": "Paramètres",
"sort": {
"pinyin": {
@@ -1096,9 +1092,6 @@
},
"content": "suivre l'instruction du système",
"data": "données",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Détails de l'erreur",
"details": "Informations détaillées",
"errors": "erreur",
@@ -1206,8 +1199,7 @@
"size": "Taille",
"text": "Texte",
"title": "Fichier",
"type": "Type",
"video": "[to be translated]:Video"
"type": "Type"
},
"gpustack": {
"keep_alive_time": {
@@ -4504,8 +4496,7 @@
"paintings": "Peintures",
"settings": "Paramètres",
"store": "Bibliothèque d'assistants",
"translate": "Traduire",
"video": "[to be translated]:Video"
"translate": "Traduire"
},
"trace": {
"backList": "Retour à la liste",
@@ -4663,56 +4654,6 @@
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
"title": "Mise à jour"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "Le fournisseur nexiste pas, retour au fournisseur par défaut {{provider}}. Cela peut entraîner des problèmes."
},

View File

@@ -978,7 +978,6 @@
"delete_confirm": "削除してもよろしいですか?",
"delete_failed": "削除に失敗しました",
"delete_success": "削除に成功しました",
"deleting": "[to be translated]:Deleting...",
"description": "説明",
"detail": "詳細",
"disabled": "無効",
@@ -1023,12 +1022,10 @@
"prompt": "プロンプト",
"provider": "プロバイダー",
"reasoning_content": "深く考察済み",
"redownload": "[to be translated]:Redownload",
"refresh": "更新",
"regenerate": "再生成",
"rename": "名前を変更",
"reset": "リセット",
"retry": "[to be translated]:Retry",
"save": "保存",
"saved": "保存されました",
"search": "検索",
@@ -1036,7 +1033,6 @@
"selected": "選択済み",
"selectedItems": "{{count}}件の項目を選択しました",
"selectedMessages": "{{count}}件のメッセージを選択しました",
"send": "[to be translated]:Send",
"settings": "設定",
"sort": {
"pinyin": {
@@ -1096,9 +1092,6 @@
},
"content": "内容",
"data": "データ",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "エラーの詳細",
"details": "詳細",
"errors": "エラー",
@@ -1206,8 +1199,7 @@
"size": "サイズ",
"text": "テキスト",
"title": "ファイル",
"type": "タイプ",
"video": "[to be translated]:Video"
"type": "タイプ"
},
"gpustack": {
"keep_alive_time": {
@@ -4504,8 +4496,7 @@
"paintings": "ペインティング",
"settings": "設定",
"store": "アシスタントライブラリ",
"translate": "翻訳",
"video": "[to be translated]:Video"
"translate": "翻訳"
},
"trace": {
"backList": "リストに戻る",
@@ -4663,56 +4654,6 @@
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
"title": "更新"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "サプライヤーが存在しないため、デフォルトのサプライヤー {{provider}} にロールバックされました。これにより問題が発生する可能性があります。"
},

View File

@@ -978,7 +978,6 @@
"delete_confirm": "Tem certeza de que deseja excluir?",
"delete_failed": "Falha ao excluir",
"delete_success": "Excluído com sucesso",
"deleting": "[to be translated]:Deleting...",
"description": "Descrição",
"detail": "detalhes",
"disabled": "Desativado",
@@ -1023,12 +1022,10 @@
"prompt": "Prompt",
"provider": "Fornecedor",
"reasoning_content": "Pensamento profundo concluído",
"redownload": "[to be translated]:Redownload",
"refresh": "Atualizar",
"regenerate": "Regenerar",
"rename": "Renomear",
"reset": "Redefinir",
"retry": "[to be translated]:Retry",
"save": "Salvar",
"saved": "Guardado",
"search": "Pesquisar",
@@ -1036,7 +1033,6 @@
"selected": "Selecionado",
"selectedItems": "{{count}} itens selecionados",
"selectedMessages": "{{count}} mensagens selecionadas",
"send": "[to be translated]:Send",
"settings": "Configurações",
"sort": {
"pinyin": {
@@ -1096,9 +1092,6 @@
},
"content": "conteúdo",
"data": "dados",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Detalhes do erro",
"details": "Detalhes",
"errors": "erro",
@@ -1206,8 +1199,7 @@
"size": "Tamanho",
"text": "Texto",
"title": "Arquivo",
"type": "Tipo",
"video": "[to be translated]:Video"
"type": "Tipo"
},
"gpustack": {
"keep_alive_time": {
@@ -4504,8 +4496,7 @@
"paintings": "Pinturas",
"settings": "Configurações",
"store": "Biblioteca de assistentes",
"translate": "Traduzir",
"video": "[to be translated]:Video"
"translate": "Traduzir"
},
"trace": {
"backList": "Voltar à lista",
@@ -4663,56 +4654,6 @@
"saveDataError": "Falha ao salvar os dados, tente novamente",
"title": "Atualização"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "O fornecedor não existe; foi revertido para o fornecedor predefinido {{provider}}. Isto pode causar problemas."
},

View File

@@ -978,7 +978,6 @@
"delete_confirm": "Вы уверены, что хотите удалить?",
"delete_failed": "Не удалось удалить",
"delete_success": "Удаление выполнено успешно",
"deleting": "[to be translated]:Deleting...",
"description": "Описание",
"detail": "Подробности",
"disabled": "Отключено",
@@ -1023,12 +1022,10 @@
"prompt": "Промпт",
"provider": "Провайдер",
"reasoning_content": "Глубокий анализ",
"redownload": "[to be translated]:Redownload",
"refresh": "Обновить",
"regenerate": "Пересоздать",
"rename": "Переименовать",
"reset": "Сбросить",
"retry": "[to be translated]:Retry",
"save": "Сохранить",
"saved": "Сохранено",
"search": "Поиск",
@@ -1036,7 +1033,6 @@
"selected": "Выбрано",
"selectedItems": "Выбрано {{count}} элементов",
"selectedMessages": "Выбрано {{count}} сообщений",
"send": "[to be translated]:Send",
"settings": "Настройки",
"sort": {
"pinyin": {
@@ -1096,9 +1092,6 @@
},
"content": "Содержание",
"data": "данные",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Детали ошибки",
"details": "Подробности",
"errors": "ошибка",
@@ -1206,8 +1199,7 @@
"size": "Размер",
"text": "Текст",
"title": "Файлы",
"type": "Тип",
"video": "[to be translated]:Video"
"type": "Тип"
},
"gpustack": {
"keep_alive_time": {
@@ -4504,8 +4496,7 @@
"paintings": "Рисунки",
"settings": "Настройки",
"store": "Библиотека помощников",
"translate": "Перевод",
"video": "[to be translated]:Video"
"translate": "Перевод"
},
"trace": {
"backList": "Вернуться к списку",
@@ -4663,56 +4654,6 @@
"saveDataError": "Ошибка сохранения данных, повторите попытку",
"title": "Обновление"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "Поставщик не существует, возвращение к поставщику по умолчанию {{provider}}. Это может привести к проблемам."
},

View File

@@ -19,8 +19,7 @@ import {
File as FileIcon,
FileImage,
FileText,
FileType as FileTypeIcon,
FileVideo
FileType as FileTypeIcon
} from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -139,7 +138,6 @@ const FilesPage: FC = () => {
{ key: FileTypes.DOCUMENT, label: t('files.document'), icon: <FileIcon size={16} /> },
{ key: FileTypes.IMAGE, label: t('files.image'), icon: <FileImage size={16} /> },
{ key: FileTypes.TEXT, label: t('files.text'), icon: <FileTypeIcon size={16} /> },
{ key: FileTypes.VIDEO, label: t('files.video'), icon: <FileVideo size={16} /> },
{ key: 'all', label: t('files.all'), icon: <FileText size={16} /> }
]

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

@@ -2,7 +2,7 @@ import App from '@renderer/components/MinApp/MinApp'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle, Video } from 'lucide-react'
import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle } from 'lucide-react'
import { FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
@@ -63,12 +63,6 @@ const LaunchpadPage: FC = () => {
text: t('title.notes'),
path: '/notes',
bgColor: 'linear-gradient(135deg, #F97316, #FB923C)' // 笔记:橙色,代表活力和清晰思路
},
{
icon: <Video size={32} className="icon" />,
text: t('title.video'),
path: '/video',
bgColor: 'linear-gradient(135deg, #7C3AED, #A78BFA)' // Video Generation: deep purple, representing creativity and dynamic media
}
]

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

@@ -21,8 +21,7 @@ import {
MessageSquareQuote,
NotepadText,
Palette,
Sparkle,
Video
Sparkle
} from 'lucide-react'
import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -128,8 +127,7 @@ const SidebarIconsManager: FC<SidebarIconsManagerProps> = ({
knowledge: <FileSearch size={16} />,
files: <Folder size={16} />,
notes: <NotepadText size={16} />,
code_tools: <Code size={16} />,
video: <Video size={16} />
code_tools: <Code size={16} />
}),
[]
)

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,35 +0,0 @@
import { Video } from '@renderer/types'
import { PlusIcon } from 'lucide-react'
import { VideoListItem } from './VideoListItem'
export type VideoListProps = {
videos: Video[]
activeVideoId?: string
setActiveVideoId: (id: string | undefined) => void
onDelete: (id: string) => void
onGetThumbnail: (id: string) => void
}
export const VideoList = ({ videos, activeVideoId, setActiveVideoId, onDelete, onGetThumbnail }: VideoListProps) => {
return (
<div className="flex w-40 flex-col gap-1 space-y-3 overflow-auto p-2">
<div
className="group relative flex aspect-square cursor-pointer items-center justify-center rounded-xl border-2 transition-all hover:scale-105 hover:shadow-lg"
onClick={() => setActiveVideoId(undefined)}>
<PlusIcon size={24} />
</div>
{/* {mockVideos.map((video) => ( */}
{videos.map((video) => (
<VideoListItem
key={video.id}
video={video}
isActive={activeVideoId === video.id}
onClick={() => setActiveVideoId(video.id)}
onDelete={() => onDelete(video.id)}
onGetThhumbnail={() => onGetThumbnail(video.id)}
/>
))}
</div>
)
}

View File

@@ -1,158 +0,0 @@
import { cn, Progress, Spinner } from '@heroui/react'
import { DeleteIcon } from '@renderer/components/Icons'
import { Video } from '@renderer/types/video'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import { CheckCircleIcon, CircleXIcon, ClockIcon, DownloadIcon, ImageDownIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export const VideoListItem = ({
video,
isActive,
onClick,
onDelete,
onGetThhumbnail
}: {
video: Video
isActive: boolean
onClick: () => void
onDelete: () => void
onGetThhumbnail: () => void
}) => {
const { t } = useTranslation()
const getStatusIcon = () => {
switch (video.status) {
case 'queued':
return <ClockIcon size={20} className="text-default-500" />
case 'in_progress':
return <Spinner size="sm" color="primary" />
case 'completed':
return <CheckCircleIcon size={20} className="text-success" />
case 'downloading':
return <DownloadIcon size={20} className="text-primary" />
case 'downloaded':
return null // No indicator for downloaded state
case 'failed':
return <CircleXIcon size={20} className="text-danger" />
default:
return null
}
}
const getStatusColor = () => {
switch (video.status) {
case 'queued':
return 'bg-default-100'
case 'in_progress':
return 'bg-primary-50'
case 'completed':
return 'bg-success-50'
case 'downloading':
return 'bg-primary-50'
case 'downloaded':
return 'bg-success-50'
case 'failed':
return 'bg-danger-50'
default:
return 'bg-default-50'
}
}
const getStatusLabel = () => {
switch (video.status) {
case 'queued':
return t('video.status.queued')
case 'in_progress':
return t('video.status.in_progress')
case 'completed':
return t('video.status.completed')
case 'downloading':
return t('video.status.downloading')
case 'downloaded':
return ''
case 'failed':
return t('video.status.failed')
default:
return ''
}
}
const showProgress = video.status === 'in_progress' || video.status === 'downloading'
const showThumbnail =
(video.status === 'completed' || video.status === 'downloading' || video.status === 'downloaded') &&
video.thumbnail !== null
return (
<ContextMenu>
<ContextMenuTrigger>
<div
className={cn(
`group relative aspect-square cursor-pointer overflow-hidden rounded-xl border-2 transition-all hover:scale-105 hover:shadow-lg ${getStatusColor()}`,
isActive ? 'border-primary' : undefined
)}
onClick={onClick}>
{/* Thumbnail placeholder */}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-default-100 to-default-200">
{showThumbnail ? (
<img src={video.thumbnail ?? ''} alt="Video thumbnail" className="h-full w-full object-cover" />
) : (
<div className="flex flex-col items-center gap-2 text-default-400">
<div className="text-2xl">🎬</div>
</div>
)}
</div>
{/* Status overlay */}
<div className="absolute inset-0 bg-black/20 opacity-0 transition-opacity group-hover:opacity-100" />
{/* Status indicator */}
{getStatusIcon() && (
<div className="absolute top-2 right-2 flex items-center gap-1 rounded-full bg-white/90 px-2 py-1 backdrop-blur-sm">
{getStatusIcon()}
<span className="font-medium text-black text-xs">{getStatusLabel()}</span>
</div>
)}
{/* Progress bar for in_progress and downloading states */}
{showProgress && (
<div className="absolute right-0 bottom-0 left-0 p-2">
<Progress
aria-label="progress bar"
size="sm"
value={video.progress}
color={video.status === 'downloading' ? 'primary' : 'primary'}
className="w-full"
showValueLabel={false}
/>
</div>
)}
{/* Video info overlay */}
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/60 to-transparent p-3 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
<div className="text-white">
<p className="truncate font-medium text-sm">{video.metadata.id}</p>
{video.prompt && <p className="mt-1 line-clamp-2 text-xs opacity-80">{video.prompt}</p>}
</div>
</div>
{/* Failed state overlay */}
{video.status === 'failed' && (
<div className="absolute inset-0 flex items-center justify-center bg-danger/10"></div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{video.thumbnail === null && (
<ContextMenuItem onSelect={onGetThhumbnail}>
<ImageDownIcon />
<span>{t('video.thumbnail.get')}</span>
</ContextMenuItem>
)}
<ContextMenuItem onSelect={onDelete}>
<DeleteIcon className="text-danger" />
<span className="text-danger">{t('common.delete')}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}

View File

@@ -1,160 +0,0 @@
// interface VideoPageProps {}
import { Divider } from '@heroui/react'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { usePending } from '@renderer/hooks/usePending'
import { useProvider } from '@renderer/hooks/useProvider'
import { useProviderVideos } from '@renderer/hooks/video/useProviderVideos'
import { useVideoThumbnail } from '@renderer/hooks/video/useVideoThumbnail'
import { deleteVideo } from '@renderer/services/ApiService'
import { SystemProviderIds } from '@renderer/types'
import { CreateVideoParams } from '@renderer/types/video'
import { getErrorMessage } from '@renderer/utils'
import { deepUpdate } from '@renderer/utils/deepUpdate'
import { isVideoModel } from '@renderer/utils/model/video'
import { DeepPartial } from 'ai'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ModelSetting } from './settings/ModelSetting'
import { OpenAIParamSettings } from './settings/OpenAIParamSettings'
import { ProviderSetting } from './settings/ProviderSetting'
import { SettingsGroup } from './settings/shared'
import { VideoList } from './VideoList'
import { VideoPanel } from './VideoPanel'
export const VideoPage = () => {
const { t } = useTranslation()
const [providerId, setProviderId] = useState<string>(SystemProviderIds.openai)
const { provider } = useProvider(providerId)
const [params, setParams] = useState<CreateVideoParams>({
type: 'openai',
provider,
params: {
model: 'sora-2',
prompt: ''
},
options: {}
})
const { videos, removeVideo, getVideo, updateVideo } = useProviderVideos(providerId)
// const activeVideo = useMemo(() => mockVideos.find((v) => v.id === activeVideoId), [activeVideoId])
const [activeVideoId, setActiveVideoId] = useState<string>()
const activeVideo = useMemo(() => videos.find((v) => v.id === activeVideoId), [activeVideoId, videos])
const { setPending } = usePending()
const { removeThumbnail, retrieveThumbnail } = useVideoThumbnail()
const updateParams = useCallback((update: DeepPartial<Omit<CreateVideoParams, 'type'>>) => {
setParams((prev) => deepUpdate<CreateVideoParams>(prev, update))
}, [])
const updateModelId = useCallback(
(id: string) => {
if (isVideoModel(id)) {
updateParams({ params: { model: id } })
}
},
[updateParams]
)
const afterDeleteVideo = useCallback(
(id: string) => {
removeVideo(id)
removeThumbnail(id)
},
[removeThumbnail, removeVideo]
)
const handleDeleteVideo = useCallback(
async (id: string) => {
switch (provider.type) {
case 'openai-response':
try {
setPending(id, true)
const promise = deleteVideo({
type: 'openai',
videoId: id,
provider
})
window.toast.loading({
title: t('common.deleting'),
promise
})
const result = await promise
if (result.result.deleted) {
afterDeleteVideo(id)
} else {
window.toast.error(t('error.delete.failed'))
}
} catch (e) {
if (e instanceof Error && e.message.includes('404')) {
window.toast.warning({
title: t('video.delete.error.not_found.title'),
description: t('video.delete.error.not_found.description')
})
afterDeleteVideo(id)
} else {
window.toast.error({ title: t('error.delete.failed'), description: getErrorMessage(e) })
}
} finally {
setPending(id, undefined)
}
break
default:
throw new Error(`Provider type "${provider.type}" is not supported for video deletion`)
}
},
[afterDeleteVideo, provider, setPending, t]
)
const handleGetThumbnail = useCallback(
async (id: string) => {
const video = getVideo(id)
if (video && video.thumbnail === null) {
try {
const promise = retrieveThumbnail(video)
window.toast.loading({ title: t('video.thumbnail.get'), promise })
const thumbnail = await promise
if (thumbnail) {
updateVideo({ id: video.id, thumbnail })
}
} catch (e) {
window.toast.error({ title: t('video.thumbnail.error.get'), description: getErrorMessage(e) })
}
}
},
[getVideo, retrieveThumbnail, t, updateVideo]
)
return (
<div className="flex flex-1 flex-col">
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('video.title')}</NavbarCenter>
</Navbar>
<div id="content-container" className="flex max-h-full flex-1">
{/* Settings */}
<div className="flex w-70 flex-col p-2">
<SettingsGroup>
<ProviderSetting providerId={providerId} setProviderId={setProviderId} />
<ModelSetting
providerId={providerId}
modelId={params.params.model ?? 'sora-2'}
setModelId={updateModelId}
/>
</SettingsGroup>
{provider.type === 'openai-response' && <OpenAIParamSettings params={params} updateParams={updateParams} />}
</div>
<Divider orientation="vertical" />
<VideoPanel provider={provider} params={params} updateParams={updateParams} video={activeVideo} />
<Divider orientation="vertical" />
{/* Video list */}
<VideoList
videos={videos}
activeVideoId={activeVideoId}
setActiveVideoId={setActiveVideoId}
onDelete={handleDeleteVideo}
onGetThumbnail={handleGetThumbnail}
/>
</div>
</div>
)
}

View File

@@ -1,298 +0,0 @@
import { Button, cn, Image, Skeleton, Textarea, Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import { usePending } from '@renderer/hooks/usePending'
import { useAddOpenAIVideo } from '@renderer/hooks/video/useAddOpenAIVideo'
import { useProviderVideos } from '@renderer/hooks/video/useProviderVideos'
import { createVideo, retrieveVideoContent } from '@renderer/services/ApiService'
import FileManager from '@renderer/services/FileManager'
import { FileTypes, Provider, VideoFileMetadata } from '@renderer/types'
import { CreateVideoParams, Video } from '@renderer/types/video'
import { getErrorMessage } from '@renderer/utils'
import { MB } from '@shared/config/constant'
import { DeepPartial } from 'ai'
import dayjs from 'dayjs'
import { isEmpty } from 'lodash'
import { ArrowUp, CircleXIcon, ImageIcon } from 'lucide-react'
import mime from 'mime-types'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { VideoViewer } from './VideoViewer'
export type VideoPanelProps = {
provider: Provider
video?: Video
params: CreateVideoParams
updateParams: (upadte: DeepPartial<Omit<CreateVideoParams, 'type'>>) => void
}
const logger = loggerService.withContext('VideoPanel')
export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanelProps) => {
const { t } = useTranslation()
const addOpenAIVideo = useAddOpenAIVideo(provider.id)
const { setVideo } = useProviderVideos(provider.id)
const { pendingMap, setPending: setPendingById } = usePending()
const fileInputRef = useRef<HTMLInputElement>(null)
const inputReference = params.params.input_reference
const couldCreateVideo = useMemo(
() =>
!isEmpty(params.params.prompt) &&
video?.status !== 'queued' &&
video?.status !== 'downloading' &&
video?.status !== 'in_progress' &&
(video === undefined || pendingMap[video.id] !== true),
[params.params.prompt, pendingMap, video]
)
useEffect(() => {
if (video) {
updateParams({ params: { prompt: video.prompt } })
} else {
updateParams({ params: { prompt: '' } })
}
}, [updateParams, video])
const isPending = video ? pendingMap[video.id] : false
const setPending = useCallback(
(value: boolean) => {
if (video) {
setPendingById(video.id, value ? value : undefined)
}
},
[setPendingById, video]
)
const handleCreateVideo = useCallback(async () => {
if (!couldCreateVideo) return
setPending(true)
try {
if (video === undefined) {
const result = await createVideo(params)
const video = result.video
switch (result.type) {
case 'openai':
addOpenAIVideo(video, params.params.prompt)
break
default:
logger.error(`Invalid video type ${result.type}.`)
}
} else {
// TODO: remix video
window.toast.info('Remix video is not implemented.')
}
} catch (e) {
window.toast.error({ title: t('video.error.create'), description: getErrorMessage(e), timeout: 5000 })
} finally {
setPending(false)
}
}, [addOpenAIVideo, couldCreateVideo, params, setPending, t, video])
const handleRegenerateVideo = useCallback(() => {
window.toast.info('Not implemented')
}, [])
const handleDownloadVideo = useCallback(async () => {
if (!video) return
if (video.status !== 'completed' && video.status !== 'downloaded') return
const baseVideo: Video = {
...video,
status: 'downloading',
progress: 0,
thumbnail: video.thumbnail
}
setVideo(baseVideo)
try {
const { response } = await retrieveVideoContent({ type: 'openai', videoId: video.id, provider })
if (!response.body) {
throw new Error('Video response body is empty')
}
const reader = response.body.getReader()
const contentLengthHeader = response.headers.get('content-length')
const totalSize = contentLengthHeader ? Number(contentLengthHeader) : undefined
const chunks: Uint8Array[] = []
let receivedLength = 0
let progressValue = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value) continue
chunks.push(value)
receivedLength += value.length
if (totalSize && Number.isFinite(totalSize) && totalSize > 0) {
progressValue = Math.floor((receivedLength / totalSize) * 100)
} else {
progressValue = Math.min(progressValue + 1, 99)
}
setVideo({
...baseVideo,
progress: Math.min(progressValue, 99)
})
}
const fileData = new Uint8Array(receivedLength)
let offset = 0
for (const chunk of chunks) {
fileData.set(chunk, offset)
offset += chunk.length
}
const contentType = response.headers.get('content-type') ?? 'video/mp4'
const normalizedContentType = contentType.split(';')[0]?.trim() || 'video/mp4'
const extension = (() => {
const ext = mime.extension(normalizedContentType)
return ext ? `.${ext}` : '.mp4'
})()
const fileName = `${video.id}${extension}`.toLowerCase()
const tempFilePath = await window.api.file.createTempFile(fileName)
await window.api.file.write(tempFilePath, fileData)
const tempFileMetadata = {
id: crypto.randomUUID(),
name: fileName,
origin_name: fileName,
path: tempFilePath,
size: receivedLength,
ext: extension,
type: FileTypes.VIDEO,
created_at: dayjs().toISOString(),
count: 1
} satisfies VideoFileMetadata
const uploadedFile = await FileManager.uploadFile(tempFileMetadata)
setVideo({
...video,
status: 'downloaded',
thumbnail: video.thumbnail,
fileId: uploadedFile.id,
name: uploadedFile.origin_name
})
} catch (error) {
logger.error(`Failed to download video ${video.id}.`, error as Error)
window.toast.error(t('video.error.download'))
setVideo(video)
}
}, [provider, setVideo, t, video])
const handleUploadFile = useCallback(() => {
fileInputRef.current?.click()
}, [])
const setPrompt = useCallback((value: string) => updateParams({ params: { prompt: value } }), [updateParams])
const UploadImageReferenceButton = useCallback(() => {
const content = inputReference ? (
<div className="group">
<Image
className="aspect-square max-h-50 max-w-50 object-contain"
src={URL.createObjectURL(inputReference as File)}
/>
<Button
variant="light"
color="danger"
className="absolute top-1 right-1 z-100 h-6 w-6 min-w-0 opacity-0 group-hover:opacity-100"
isIconOnly
startContent={<CircleXIcon size={16} className="text-danger" />}
onPress={() => updateParams({ params: { input_reference: undefined } })}
/>
</div>
) : (
t('video.input_reference.add.tooltip')
)
return (
<>
<Tooltip content={content} closeDelay={0}>
<Button
variant="light"
startContent={<ImageIcon size={16} className={cn(inputReference ? 'text-primary' : undefined)} />}
isIconOnly
className="h-6 w-6 min-w-0"
isDisabled={isPending}
onPress={handleUploadFile}
/>
</Tooltip>
</>
)
}, [handleUploadFile, inputReference, isPending, t, updateParams])
return (
<div className="flex flex-1 flex-col p-2">
<div className="m-8 flex-1 overflow-hidden">
<Skeleton className="h-full w-full rounded-2xl" classNames={{ content: 'h-full w-full' }} isLoaded={true}>
{video && <VideoViewer video={video} onDownload={handleDownloadVideo} onRegenerate={handleRegenerateVideo} />}
{!video && <VideoViewer video={video} />}
</Skeleton>
</div>
<div className="relative">
<Textarea
label={t('common.prompt')}
placeholder={t('video.prompt.placeholder')}
value={params.params.prompt}
onValueChange={setPrompt}
isClearable
isDisabled={isPending}
classNames={{ inputWrapper: 'pb-8' }}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleCreateVideo()
}
}}
/>
<div className="absolute bottom-0 flex w-full items-end justify-between p-2">
<div className="flex">
<UploadImageReferenceButton />
<input
ref={fileInputRef}
type="file"
hidden
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) {
const file = files[0]
if (!file.type.startsWith('image/')) {
window.toast.error(t('video.input_reference.add.error.format'))
return
}
const maxSize = 5 * MB
if (file.size > maxSize) {
window.toast.error(t('video.input_reference.add.error.size'))
return
}
updateParams({ params: { input_reference: file } })
} else {
updateParams({ params: { input_reference: undefined } })
}
}}
/>
</div>
<Tooltip content={t('common.send')} closeDelay={0}>
<Button
color="primary"
radius="full"
isIconOnly
isDisabled={!couldCreateVideo}
isLoading={isPending}
className="h-6 w-6 min-w-0"
onPress={handleCreateVideo}>
<ArrowUp size={16} className="text-primary-foreground" />
</Button>
</Tooltip>
</div>
</div>
</div>
)
}

View File

@@ -1,233 +0,0 @@
import {
Alert,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
Progress,
Skeleton,
Spinner,
useDisclosure
} from '@heroui/react'
import { usePending } from '@renderer/hooks/usePending'
import FileManager from '@renderer/services/FileManager'
import { Video, VideoDownloaded, VideoFailed } from '@renderer/types/video'
import dayjs from 'dayjs'
import { CheckCircleIcon, CircleXIcon, Clock9Icon } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable'
export type VideoViewerProps =
| {
video: undefined
onDownload?: never
onRegenerate?: never
}
| {
video: Video
onDownload: () => void
onRegenerate: () => void
}
export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProps) => {
const { t } = useTranslation()
const [loadSuccess, setLoadSuccess] = useState<boolean | undefined>(undefined)
const { pendingMap } = usePending()
const isPending = video ? pendingMap[video.id] : false
useEffect(() => {
setLoadSuccess(undefined)
}, [video?.id])
return (
<>
<div className="flex h-full max-h-full w-full items-center justify-center rounded-2xl bg-foreground-200">
{video === undefined && t('video.undefined')}
{video && video.status === 'queued' && <QueuedVideo />}
{video && video.status === 'in_progress' && <InProgressVideo progress={video.progress} />}
{video && video.status === 'completed' && (
<CompletedVideo video={video} isDisabled={isPending} onDownload={onDownload} onRegenerate={onRegenerate} />
)}
{video && video.status === 'downloading' && <DownloadingVideo progress={video.progress} />}
{video && video.status === 'downloaded' && loadSuccess !== false && (
<VideoPlayer video={video} setLoadSuccess={setLoadSuccess} />
)}
{video && video.status === 'failed' && <FailedVideo error={video.error} />}
{video && video.status === 'downloaded' && loadSuccess === false && (
<LoadFailedVideo isDisabled={isPending} onRedownload={onDownload} />
)}
</div>
</>
)
}
const QueuedVideo = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl">
<Spinner variant="dots" />
<span>{t('video.status.queued')}</span>
</div>
)
}
const InProgressVideo = ({ progress }: { progress: number }) => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl">
<Progress
label={t('video.status.in_progress')}
aria-label={t('video.status.in_progress')}
className="max-w-md"
color="primary"
showValueLabel={true}
size="md"
value={progress}
/>
</div>
)
}
const CompletedVideo = ({
video,
isDisabled,
onDownload,
onRegenerate
}: {
video: Video
isDisabled?: boolean
onDownload: () => void
onRegenerate: () => void
}) => {
const { t } = useTranslation()
const isExpired = video.metadata.expires_at !== null && video.metadata.expires_at < dayjs().unix()
if (isExpired) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 rounded-2xl bg-warning-200">
<Clock9Icon size={64} className="text-warning" />
<span className="font-bold text-2xl">{t('video.expired')}</span>
<Button onPress={onRegenerate} isDisabled={isDisabled}>
{t('common.regenerate')}
</Button>
</div>
)
}
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 rounded-2xl bg-success-200">
<CheckCircleIcon size={64} className="text-success" />
<span className="font-bold text-2xl">{t('video.status.completed')}</span>
<Button onPress={onDownload} isDisabled={isDisabled}>
{t('common.download')}
</Button>
</div>
)
}
const DownloadingVideo = ({ progress }: { progress?: number }) => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl">
<Progress
label={t('video.status.downloading')}
aria-label={t('video.status.downloading')}
className="max-w-md"
color="primary"
showValueLabel={true}
size="md"
value={progress}
/>
</div>
)
}
const FailedVideo = ({ error }: { error: VideoFailed['error'] }) => {
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
const alert = useMemo(() => {
if (error === null) {
return <Alert color="danger" title={t('error.unknown')} />
} else {
return <Alert color="danger" title={error.code} description={error.message} />
}
}, [error, t])
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl bg-danger-200">
<CircleXIcon size={64} className="fill-danger text-danger-200" />
<span className="font-bold text-2xl">{t('video.status.failed')}</span>
<div className="my-2 flex justify-between gap-2">
<Button onPress={onOpen}>{t('common.detail')}</Button>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalBody>
<ModalContent>
<div className="p-4">{alert}</div>
</ModalContent>
</ModalBody>
<ModalFooter></ModalFooter>
</Modal>
<Button onPress={() => window.toast.info('Not implemented')}>{t('common.retry')}</Button>
</div>
</div>
)
}
const LoadFailedVideo = ({ isDisabled, onRedownload }: { isDisabled?: boolean; onRedownload: () => void }) => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl bg-danger-200">
<CircleXIcon size={64} className="fill-danger text-danger-200" />
<span className="font-bold text-2xl">{t('video.error.load.message')}</span>
<span>{t('video.error.load.reason')}</span>
<div className="my-2 flex justify-between gap-2">
<Button onPress={onRedownload} isDisabled={isDisabled}>
{t('common.redownload')}
</Button>
</div>
</div>
)
}
const VideoPlayer = ({
video,
setLoadSuccess
}: {
video: VideoDownloaded
setLoadSuccess: (value: boolean) => void
}) => {
const videoRef = useRef<HTMLVideoElement>(null)
const fetcher = async () => {
const file = await FileManager.getFile(video.fileId)
if (!file) {
throw new Error(`Video file ${video.fileId} not exist.`)
}
return FileManager.getFilePath(file)
}
const { data: src, isLoading, error } = useSWRImmutable(`video/file/${video.id}`, fetcher)
useEffect(() => {
const videoElement = videoRef.current
if (videoElement) {
videoElement.load()
}
}, [video?.id])
if (error) {
setLoadSuccess(false)
}
if (isLoading) {
return <Skeleton />
}
return (
<video
ref={videoRef}
controls
className="h-full w-full rounded-2xl bg-content2 object-contain dark:bg-background"
onLoadedData={() => setLoadSuccess(true)}
onError={() => setLoadSuccess(false)}>
<source src={`file://${src}`} type="video/mp4" />
</video>
)
}

View File

@@ -1,44 +0,0 @@
import { Select, SelectItem } from '@heroui/react'
import { videoModelsMap } from '@renderer/config/models/video'
import { Model } from '@renderer/types'
import { useTranslation } from 'react-i18next'
import { SettingItem } from './shared'
export interface ModelSettingProps {
providerId: string
modelId: string
setModelId: (id: string) => void
}
interface ModelSelectItem extends Model {
key: string
label: string
}
export const ModelSetting = ({ providerId, modelId, setModelId }: ModelSettingProps) => {
const { t } = useTranslation()
const items: ModelSelectItem[] = videoModelsMap[providerId]?.map((m: string) => ({ key: m, label: m })) ?? []
return (
<SettingItem>
<Select
label={t('common.model')}
labelPlacement="outside"
selectionMode="single"
items={items}
defaultSelectedKeys={[modelId]}
disallowEmptySelection
onSelectionChange={(keys) => {
if (keys.currentKey) setModelId(keys.currentKey)
}}>
{(model) => (
<SelectItem textValue={model.label}>
<span>{model.label}</span>
</SelectItem>
)}
</Select>
</SettingItem>
)
}

View File

@@ -1,80 +0,0 @@
import { VideoSeconds, VideoSize } from '@cherrystudio/openai/resources'
import { Select, SelectItem } from '@heroui/react'
import { OpenAICreateVideoParams } from '@renderer/types/video'
import { DeepPartial } from 'ai'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingItem, SettingsGroup } from './shared'
export type OpenAIParamSettingsProps = {
params: OpenAICreateVideoParams
updateParams: (update: DeepPartial<Omit<OpenAICreateVideoParams, 'type'>>) => void
}
export const OpenAIParamSettings = ({ params, updateParams }: OpenAIParamSettingsProps) => {
const { t } = useTranslation()
const secondItems = [{ key: '4' }, { key: '8' }, { key: '12' }] as const satisfies { key: VideoSeconds }[]
const sizeItems = [
{ key: '720x1280' },
{ key: '1280x720' },
{ key: '1024x1792' },
{ key: '1792x1024' }
] as const satisfies { key: VideoSize }[]
const updateSeconds = useCallback(
(seconds: VideoSeconds) => {
updateParams({ params: { seconds } })
},
[updateParams]
)
const updateSize = useCallback(
(size: VideoSize) => {
updateParams({ params: { size } })
},
[updateParams]
)
return (
<SettingsGroup>
<SettingItem>
<Select
label={t('video.seconds')}
labelPlacement="outside"
selectedKeys={[params.params.seconds ?? '4']}
onSelectionChange={(keys) => {
if (keys.currentKey) updateSeconds(keys.currentKey as VideoSeconds)
}}
items={secondItems}
selectionMode="single"
disallowEmptySelection>
{(item) => (
<SelectItem key={item.key} textValue={item.key}>
<span>{item.key}</span>
</SelectItem>
)}
</Select>
</SettingItem>
<SettingItem>
<Select
label={t('video.size')}
labelPlacement="outside"
selectedKeys={[params.params.size ?? '720x1280']}
onSelectionChange={(keys) => {
if (keys.currentKey) updateSize(keys.currentKey as VideoSize)
}}
items={sizeItems}
selectionMode="single"
disallowEmptySelection>
{(item) => (
<SelectItem key={item.key} textValue={item.key}>
<span>{item.key}</span>
</SelectItem>
)}
</Select>
</SettingItem>
</SettingsGroup>
)
}

View File

@@ -1,60 +0,0 @@
import { Select, SelectItem } from '@heroui/react'
import { ProviderAvatar } from '@renderer/components/ProviderAvatar'
import { useProviders } from '@renderer/hooks/useProvider'
import { Provider, SystemProviderId } from '@renderer/types'
import { getFancyProviderName } from '@renderer/utils'
import { Dispatch, SetStateAction } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingItem } from './shared'
export interface ProviderSettingProps {
providerId: string
setProviderId: Dispatch<SetStateAction<string>>
}
interface ProviderSelectItem extends Provider {
key: string
label: string
}
export const ProviderSetting = ({ providerId, setProviderId }: ProviderSettingProps) => {
const { t } = useTranslation()
// Support limited providers.
const supportedProviderIds = ['openai'] satisfies SystemProviderId[]
const { providers } = useProviders()
const items: ProviderSelectItem[] = providers
.filter((p) => supportedProviderIds.some((id) => id === p.id))
.map((p) => ({ ...p, key: p.id, label: getFancyProviderName(p) }))
return (
<SettingItem>
<Select
label={t('common.provider')}
labelPlacement="outside"
selectionMode="single"
items={items}
defaultSelectedKeys={[providerId]}
disallowEmptySelection
onSelectionChange={(keys) => {
if (keys.currentKey) setProviderId(keys.currentKey)
}}
renderValue={(items) => {
const provider = items[0].data
if (!provider) return null
return (
<div className="flex items-center gap-2">
<ProviderAvatar provider={provider} size={16} />
<span>{provider.label}</span>
</div>
)
}}>
{(provider) => (
<SelectItem textValue={provider.label} startContent={<ProviderAvatar provider={provider} size={16} />}>
<span>{provider.label}</span>
</SelectItem>
)}
</Select>
</SettingItem>
)
}

View File

@@ -1,15 +0,0 @@
import { Divider } from '@heroui/react'
import { PropsWithChildren } from 'react'
export const SettingsGroup = ({ children }: PropsWithChildren) => {
return <div className="mb-4 flex flex-col rounded-2xl border border-foreground-200 p-3">{children}</div>
}
export const SettingItem = ({ children, divider = false }: PropsWithChildren<{ divider?: boolean }>) => {
return (
<>
<div className="mb-2">{children}</div>
{divider && <Divider className="my-2" />}
</>
)
}

View File

@@ -10,24 +10,12 @@ import { isDedicatedImageGenerationModel, isEmbeddingModel } from '@renderer/con
import { getStoreSetting } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import type {
DeleteVideoParams,
DeleteVideoResult,
FetchChatCompletionParams,
RetrieveVideoContentParams
} from '@renderer/types'
import type { FetchChatCompletionParams } from '@renderer/types'
import { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { type Chunk, ChunkType } from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import { SdkModel } from '@renderer/types/sdk'
import {
CreateVideoParams,
CreateVideoResult,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types/video'
import { removeSpecialCharactersForTopicName, uuid } from '@renderer/utils'
import { abortCompletion, readyToAbort } from '@renderer/utils/abortController'
import { isAbortError } from '@renderer/utils/error'
@@ -409,26 +397,6 @@ export async function fetchGenerate({
}
}
export async function createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
const ai = new AiProviderNew(params.provider)
return ai.createVideo(params)
}
export async function retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
const ai = new AiProviderNew(params.provider)
return ai.retrieveVideo(params)
}
export async function retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
const ai = new AiProviderNew(params.provider)
return ai.retrieveVideoContent(params)
}
export async function deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
const ai = new AiProviderNew(params.provider)
return ai.deleteVideo(params)
}
export function hasApiKey(provider: Provider) {
if (!provider) return false
if (['ollama', 'lmstudio', 'vertexai', 'cherryai'].includes(provider.id)) return true

View File

@@ -1,6 +1,6 @@
import { ChatCompletionContentPart, ChatCompletionMessageParam } from '@cherrystudio/openai/resources'
import { Model } from '@renderer/types'
import { findLast } from 'lodash'
import { ChatCompletionContentPart, ChatCompletionMessageParam } from 'openai/resources'
export function processReqMessages(
model: Model,

View File

@@ -1,5 +1,4 @@
import { MessageStream } from '@anthropic-ai/sdk/resources/messages/messages'
import { Stream } from '@cherrystudio/openai/streaming'
import { loggerService } from '@logger'
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { cleanContext, endContext, getContext, startContext } from '@mcp-trace/trace-web'
@@ -17,6 +16,7 @@ import { Model, Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { MessageBlockType } from '@renderer/types/newMessage'
import { SdkRawChunk } from '@renderer/types/sdk'
import { Stream } from 'openai/streaming'
const logger = loggerService.withContext('SpanManagerService')

View File

@@ -6,8 +6,6 @@ import {
WebSearchResultBlock,
WebSearchToolResultError
} from '@anthropic-ai/sdk/resources/messages'
import OpenAI from '@cherrystudio/openai'
import { ChatCompletionChunk } from '@cherrystudio/openai/resources'
import { FinishReason, MediaModality } from '@google/genai'
import { FunctionCall } from '@google/genai'
import AiProvider from '@renderer/aiCore'
@@ -40,6 +38,8 @@ import {
import { mcpToolCallResponseToGeminiMessage } from '@renderer/utils/mcp-tools'
import * as McpToolsModule from '@renderer/utils/mcp-tools'
import { cloneDeep } from 'lodash'
import OpenAI from 'openai'
import { ChatCompletionChunk } from 'openai/resources'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock the ApiClientFactory
vi.mock('@renderer/aiCore/legacy/clients/ApiClientFactory', () => ({

View File

@@ -1,5 +1,5 @@
import { ChatCompletionMessageParam } from '@cherrystudio/openai/resources'
import type { Model } from '@renderer/types'
import { ChatCompletionMessageParam } from 'openai/resources'
import { describe, expect, it } from 'vitest'
import { processReqMessages } from '../ModelMessageService'

View File

@@ -30,7 +30,6 @@ import settings from './settings'
import shortcuts from './shortcuts'
import tabs from './tabs'
import translate from './translate'
import video from './video'
import websearch from './websearch'
const logger = loggerService.withContext('Store')
@@ -59,15 +58,14 @@ const rootReducer = combineReducers({
inputTools: inputToolsReducer,
translate,
ocr,
note,
video
note
})
const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 164,
version: 163,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@@ -1,10 +1,10 @@
import { WebSearchResultBlock } from '@anthropic-ai/sdk/resources'
import type OpenAI from '@cherrystudio/openai'
import type { GroundingMetadata } from '@google/genai'
import { createEntityAdapter, createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { AISDKWebSearchResult, Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types'
import type { CitationMessageBlock, MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockType } from '@renderer/types/newMessage'
import type OpenAI from 'openai'
import type { RootState } from './index' // 确认 RootState 从 store/index.ts 导出

View File

@@ -2681,20 +2681,6 @@ const migrateConfig = {
logger.error('migrate 163 error', error as Error)
return state
}
},
'164': (state: RootState) => {
try {
if (state.settings && state.settings.sidebarIcons) {
if (!state.settings.sidebarIcons.visible.includes('video')) {
state.settings.sidebarIcons.visible = [...state.settings.sidebarIcons.visible, 'video']
}
}
state.video.videoMap = {}
return state
} catch (error) {
logger.error('migrate 164 error', error as Error)
return state
}
}
}

View File

@@ -56,8 +56,6 @@ export interface RuntimeState {
chat: ChatState
websearch: WebSearchState
iknow: Record<string, boolean>
/** To indicate something is pending. */
pendingMap: Record<string, boolean | undefined>
}
export interface ExportState {
@@ -100,8 +98,7 @@ const initialState: RuntimeState = {
websearch: {
activeSearches: {}
},
iknow: {},
pendingMap: {}
iknow: {}
}
const runtimeSlice = createSlice({
@@ -194,14 +191,6 @@ const runtimeSlice = createSlice({
setSessionWaitingAction: (state, action: PayloadAction<{ id: string; value: boolean }>) => {
const { id, value } = action.payload
state.chat.sessionWaiting[id] = value
},
setPendingAction: (state, action: PayloadAction<{ id: string; value: boolean | undefined }>) => {
const { id, value } = action.payload
if (value) {
state.pendingMap[id] = value
} else {
delete state.pendingMap[id]
}
}
}
})
@@ -221,7 +210,6 @@ export const {
setUpdateState,
setExportState,
addIknowAction,
setPendingAction,
// Chat related actions
toggleMultiSelectMode,
setSelectedMessageIds,

View File

@@ -1,85 +0,0 @@
import { loggerService } from '@logger'
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { Video } from '@renderer/types/video'
const logger = loggerService.withContext('Store:video')
export interface VideoState {
/** Provider ID to videos */
videoMap: Record<string, Video[] | undefined>
}
const initialState: VideoState = {
videoMap: {}
}
const videoSlice = createSlice({
name: 'video',
initialState,
reducers: {
addVideo: (state: VideoState, action: PayloadAction<{ providerId: string; video: Video }>) => {
const { providerId, video } = action.payload
if (state.videoMap[providerId]) {
state.videoMap[providerId].unshift(video)
} else {
state.videoMap[providerId] = [video]
}
},
removeVideo: (state: VideoState, action: PayloadAction<{ providerId: string; videoId: string }>) => {
const { providerId, videoId } = action.payload
const videos = state.videoMap[providerId]
state.videoMap[providerId] = videos?.filter((c) => c.id !== videoId)
},
updateVideo: (
state: VideoState,
action: PayloadAction<{ providerId: string; update: Partial<Omit<Video, 'status'>> & { id: string } }>
) => {
const { providerId, update } = action.payload
const videos = state.videoMap[providerId]
if (videos) {
let video = videos.find((v) => v.id === update.id)
if (video) {
switch (video.status) {
case 'queued':
case 'in_progress':
video = { ...video, ...update, thumbnail: undefined }
break
default:
video = { ...video, ...update }
}
} else {
logger.error(`Video with id ${update.id} not found in ${providerId}`)
}
} else {
logger.error(`Videos with Provider ${providerId} is undefined.`)
}
},
setVideo: (state: VideoState, action: PayloadAction<{ providerId: string; video: Video }>) => {
const { providerId, video } = action.payload
if (state.videoMap[providerId]) {
const index = state.videoMap[providerId].findIndex((v) => v.id === video.id)
if (index !== -1) {
state.videoMap[providerId][index] = video
} else {
state.videoMap[providerId].push(video)
}
} else {
state.videoMap[providerId] = [video]
}
},
setVideos: (state: VideoState, action: PayloadAction<{ providerId: string; videos: Video[] }>) => {
const { providerId, videos } = action.payload
state.videoMap[providerId] = videos
}
}
})
export const {
addVideo: addVideoAction,
removeVideo: removeVideoAction,
updateVideo: updateVideoAction,
setVideo: setVideoAction,
setVideos: setVideosAction
} = videoSlice.actions
export default videoSlice.reducer

Some files were not shown because too many files have changed in this diff Show More