Compare commits
28 Commits
feat/sora2
...
fix/react-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca44133e90 | ||
|
|
b4810bb487 | ||
|
|
dc0f9c5f08 | ||
|
|
595fd878a6 | ||
|
|
9d45991181 | ||
|
|
cf2f2fd707 | ||
|
|
d4b1db0407 | ||
|
|
51dcdf94fb | ||
|
|
254051cf62 | ||
|
|
24d2e6e6ce | ||
|
|
cf9bfce43c | ||
|
|
76bf78b810 | ||
|
|
f4441e2a55 | ||
|
|
84f590ec7b | ||
|
|
a5865cfd01 | ||
|
|
4e7c714ea2 | ||
|
|
d2c4231458 | ||
|
|
b5004e2a51 | ||
|
|
e0c334b5ed | ||
|
|
d482e661fb | ||
|
|
3ac1caca69 | ||
|
|
94c112c066 | ||
|
|
2e694a87f8 | ||
|
|
4ae30db53a | ||
|
|
f4a6dd91cf | ||
|
|
c08a570c27 | ||
|
|
9c318c9526 | ||
|
|
4cee09870a |
BIN
.yarn/patches/openai-npm-5.12.2-30b075401c.patch
vendored
Normal file
BIN
.yarn/patches/openai-npm-5.12.2-30b075401c.patch
vendored
Normal file
Binary file not shown.
@@ -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-->
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
package.json
10
package.json
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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}}。这可能导致问题。"
|
||||
},
|
||||
|
||||
@@ -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}}。這可能導致問題。"
|
||||
},
|
||||
|
||||
@@ -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}}. Αυτό μπορεί να προκαλέσει προβλήματα."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 n’existe pas, retour au fournisseur par défaut {{provider}}. Cela peut entraîner des problèmes."
|
||||
},
|
||||
|
||||
@@ -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}} にロールバックされました。これにより問題が発生する可能性があります。"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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}}. Это может привести к проблемам."
|
||||
},
|
||||
|
||||
@@ -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} /> }
|
||||
]
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -87,6 +87,7 @@ const ButtonContent = styled.div`
|
||||
const ModelName = styled.span`
|
||||
font-weight: 500;
|
||||
margin-right: -2px;
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
export default SelectModelButton
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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)' }} />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 导出
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user