mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-02-19 00:54:46 +08:00
Merge remote-tracking branch 'origin/main' into feat/user-guide
This commit is contained in:
commit
a3f0419d73
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@ -7,6 +7,16 @@ on:
|
||||
description: "Release tag (e.g. v1.0.0)"
|
||||
required: true
|
||||
default: "v1.0.0"
|
||||
platform:
|
||||
description: "Build platform"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- mac
|
||||
- linux
|
||||
push:
|
||||
tags:
|
||||
- v*.*.*
|
||||
@ -20,7 +30,14 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||
os: ${{ fromJSON(
|
||||
github.event_name == 'push' && '["macos-latest", "windows-latest", "ubuntu-latest"]' ||
|
||||
github.event.inputs.platform == 'all' && '["macos-latest", "windows-latest", "ubuntu-latest"]' ||
|
||||
github.event.inputs.platform == 'linux' && '["ubuntu-latest"]' ||
|
||||
github.event.inputs.platform == 'windows' && '["windows-latest"]' ||
|
||||
github.event.inputs.platform == 'mac' && '["macos-latest"]' ||
|
||||
'["macos-latest", "windows-latest", "ubuntu-latest"]'
|
||||
) }}
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
|
||||
@ -121,7 +121,6 @@ linux:
|
||||
- target: AppImage
|
||||
- target: deb
|
||||
- target: rpm
|
||||
- target: pacman
|
||||
maintainer: electronjs.org
|
||||
category: Utility
|
||||
desktop:
|
||||
@ -146,30 +145,60 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
<!--LANG:en-->
|
||||
Cherry Studio 1.7.13 - Security & Bug Fixes
|
||||
Cherry Studio 1.7.14 - New Features & Improvements
|
||||
|
||||
🔒 Security
|
||||
- [Plugin] Fix security vulnerability in DXT plugin system on Windows
|
||||
✨ New Features
|
||||
- [Notes] Add export to Word document functionality
|
||||
- [Code Tools] Add Kimi CLI support with auto-configuration
|
||||
- [Code Tools] Support custom providers
|
||||
- [Settings] Support viewing detailed error messages when model detection fails
|
||||
- [Topics] Display year in topic timestamps (YYYY/MM/DD format)
|
||||
- [Linux] Add system title bar setting option
|
||||
- [Models] Add Baichuan m3/m3-plus models
|
||||
- [Models] Add Qwen text-embedding models
|
||||
|
||||
🎨 Improvements
|
||||
- [Translate] Simplify translation with single target language selector
|
||||
- [Topics] Unpinned topics now move to top with auto-scroll
|
||||
- [Minapps] Add locale-based filtering support
|
||||
- [i18n] Update Romanian localization
|
||||
|
||||
🐛 Bug Fixes
|
||||
- [Agent] Fix Agent not working when Node.js is not installed on system
|
||||
- [Chat] Fix app crash when opening certain agents
|
||||
- [Chat] Fix reasoning process not displaying correctly for some providers
|
||||
- [Chat] Fix memory leak issue during streaming conversations
|
||||
- [MCP] Fix timeout field not accepting string format in MCP configuration
|
||||
- [Settings] Add careers section in About page
|
||||
- [Linux] Fix icon display and deb/rpm installation issues
|
||||
- [Linux] Fix window not coming to front when clicking tray
|
||||
- [Linux] Add Alpine Linux (musl) support
|
||||
- [Code Tools] Fix Windows Terminal issues
|
||||
- [Azure] Fix API preview link for completion mode
|
||||
- [Images] Fix trailing slashes in API URLs for image generation
|
||||
- [OpenRouter] Fix MCP tools support
|
||||
- [Chat] Fix image enhancement model conversation history
|
||||
|
||||
<!--LANG:zh-CN-->
|
||||
Cherry Studio 1.7.13 - 安全与问题修复
|
||||
Cherry Studio 1.7.14 - 新功能与改进
|
||||
|
||||
🔒 安全修复
|
||||
- [插件] 修复 Windows 系统 DXT 插件的安全漏洞
|
||||
✨ 新功能
|
||||
- [笔记] 支持导出为 Word 文档
|
||||
- [代码工具] 新增 Kimi CLI 支持,自动配置环境
|
||||
- [代码工具] 支持自定义服务商
|
||||
- [设置] 模型检测失败时可查看详细错误信息
|
||||
- [话题] 时间戳显示年份(YYYY/MM/DD 格式)
|
||||
- [Linux] 新增系统标题栏设置选项
|
||||
- [模型] 新增百川 m3/m3-plus 模型
|
||||
- [模型] 新增通义 Embedding 模型
|
||||
|
||||
🎨 改进
|
||||
- [翻译] 简化翻译操作,使用单一目标语言选择
|
||||
- [话题] 取消置顶的话题移动到顶部并自动滚动
|
||||
- [小程序] 支持按语言筛选小程序
|
||||
- [国际化] 更新罗马尼亚语翻译
|
||||
|
||||
🐛 问题修复
|
||||
- [Agent] 修复系统未安装 Node.js 时 Agent 功能无法使用的问题
|
||||
- [对话] 修复打开某些智能体时应用崩溃的问题
|
||||
- [对话] 修复部分服务商推理过程无法正确显示的问题
|
||||
- [对话] 修复流式对话时的内存泄漏问题
|
||||
- [MCP] 修复 MCP 配置的 timeout 字段不支持字符串格式的问题
|
||||
- [设置] 关于页面新增招聘入口
|
||||
- [Linux] 修复图标显示和 deb/rpm 安装问题
|
||||
- [Linux] 修复点击托盘后窗口无法置顶的问题
|
||||
- [Linux] 新增 Alpine Linux (musl) 支持
|
||||
- [代码工具] 修复 Windows 终端问题
|
||||
- [Azure] 修复完成模式下 API 预览链接
|
||||
- [图片生成] 修复 API 地址结尾斜杠问题
|
||||
- [OpenRouter] 修复 MCP 工具支持
|
||||
- [对话] 修复图片增强模型对话历史丢失问题
|
||||
<!--LANG:END-->
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.7.13",
|
||||
"version": "1.7.15",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"desktopName": "CherryStudio.desktop",
|
||||
|
||||
@ -3,8 +3,6 @@
|
||||
* 提供类型安全的 Provider 配置构建器
|
||||
*/
|
||||
|
||||
import { normalizeHeaders } from '@ai-sdk/provider-utils'
|
||||
|
||||
import type { ProviderId, ProviderSettingsMap } from './types'
|
||||
|
||||
/**
|
||||
@ -81,7 +79,7 @@ export class ProviderConfigBuilder<T extends ProviderId = ProviderId> {
|
||||
*/
|
||||
withRequestConfig(options: { headers?: Record<string, string>; fetch?: typeof fetch }): this {
|
||||
if (options.headers) {
|
||||
this.config.headers = normalizeHeaders({ ...this.config.headers, ...options.headers })
|
||||
this.config.headers = { ...this.config.headers, ...options.headers }
|
||||
}
|
||||
if (options.fetch) {
|
||||
this.config.fetch = options.fetch
|
||||
@ -142,7 +140,6 @@ export class ProviderConfigFactory {
|
||||
provider: CompleteProviderConfig<T>,
|
||||
options?: {
|
||||
headers?: Record<string, string>
|
||||
fetch?: typeof globalThis.fetch
|
||||
[key: string]: any
|
||||
}
|
||||
): ProviderSettingsMap[T] {
|
||||
@ -158,10 +155,9 @@ export class ProviderConfigFactory {
|
||||
}
|
||||
|
||||
// 设置请求配置
|
||||
if (options?.headers || options?.fetch) {
|
||||
if (options?.headers) {
|
||||
builder.withRequestConfig({
|
||||
headers: options.headers,
|
||||
fetch: options.fetch
|
||||
headers: options.headers
|
||||
})
|
||||
}
|
||||
|
||||
@ -175,7 +171,6 @@ export class ProviderConfigFactory {
|
||||
if (options) {
|
||||
const customOptions = { ...options }
|
||||
delete customOptions.headers // 已经处理过了
|
||||
delete customOptions.fetch
|
||||
if (Object.keys(customOptions).length > 0) {
|
||||
builder.withCustomParams(customOptions)
|
||||
}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { normalizeHeaders, withUserAgentSuffix } from '@ai-sdk/provider-utils'
|
||||
|
||||
export const defaultAppHeaders = () => {
|
||||
return {
|
||||
'HTTP-Referer': 'https://cherry-ai.com',
|
||||
@ -7,43 +5,6 @@ export const defaultAppHeaders = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个 headers 对象,自动标准化大小写
|
||||
*
|
||||
* @param headerSets - headers 集合(按顺序合并,后者覆盖前者,但 User-Agent 会追加)
|
||||
* @returns 合并后的 headers(键全部小写)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = mergeHeaders(
|
||||
* { 'User-Agent': 'DefaultAgent', 'X-Custom': 'value1' },
|
||||
* { 'user-agent': 'CustomSuffix', 'X-Custom': 'value2' }
|
||||
* )
|
||||
* // result: { 'user-agent': 'DefaultAgent CustomSuffix', 'x-custom': 'value2' }
|
||||
* ```
|
||||
*/
|
||||
export function mergeHeaders(
|
||||
...headerSets: Array<HeadersInit | Record<string, string | undefined> | undefined>
|
||||
): Record<string, string> {
|
||||
const { result, userAgents } = headerSets.reduce(
|
||||
(acc, headerSet) => {
|
||||
const headers = new Headers(normalizeHeaders(headerSet))
|
||||
|
||||
const ua = headers.get('user-agent')
|
||||
if (ua) {
|
||||
acc.userAgents.push(ua)
|
||||
headers.delete('user-agent')
|
||||
}
|
||||
|
||||
headers.forEach((value, key) => acc.result.set(key, value))
|
||||
return acc
|
||||
},
|
||||
{ result: new Headers(), userAgents: [] as string[] }
|
||||
)
|
||||
|
||||
return userAgents.length > 0 ? withUserAgentSuffix(result, ...userAgents) : Object.fromEntries(result.entries())
|
||||
}
|
||||
|
||||
// Following two function are not being used for now.
|
||||
// I may use them in the future, so just keep them commented. - by eurfelux
|
||||
|
||||
|
||||
@ -1,14 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('os', () => ({
|
||||
default: {
|
||||
release: vi.fn(() => '10.0.0'),
|
||||
homedir: vi.fn(() => '/home/test')
|
||||
},
|
||||
release: vi.fn(() => '10.0.0'),
|
||||
homedir: vi.fn(() => '/home/test')
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
default: {
|
||||
existsSync: vi.fn(() => false),
|
||||
@ -104,8 +95,7 @@ vi.mock('electron', () => {
|
||||
return '/mock/unknown'
|
||||
}),
|
||||
getAppPath: vi.fn(() => '/mock/app'),
|
||||
setPath: vi.fn(),
|
||||
getVersion: vi.fn(() => '1.0.0')
|
||||
setPath: vi.fn()
|
||||
}
|
||||
|
||||
const nativeTheme = {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||
import type { BrowserView, BrowserWindow } from 'electron'
|
||||
|
||||
export const logger = loggerService.withContext('MCPBrowserCDP')
|
||||
export const userAgent = generateUserAgent()
|
||||
export const userAgent =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||
|
||||
export interface TabInfo {
|
||||
id: string
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
// port https://github.com/zcaceres/fetch-mcp/blob/main/src/index.ts
|
||||
|
||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { mergeHeaders } from '@shared/utils'
|
||||
import { net } from 'electron'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import TurndownService from 'turndown'
|
||||
@ -20,12 +18,11 @@ export class Fetcher {
|
||||
private static async _fetch({ url, headers }: RequestPayload): Promise<Response> {
|
||||
try {
|
||||
const response = await net.fetch(url, {
|
||||
headers: mergeHeaders(
|
||||
{
|
||||
'User-Agent': generateUserAgent()
|
||||
},
|
||||
headers
|
||||
)
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
...headers
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { mergeHeaders } from '@shared/utils'
|
||||
import { app, net, safeStorage } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
@ -71,10 +70,11 @@ class CopilotService {
|
||||
|
||||
constructor() {
|
||||
this.tokenFilePath = this.getTokenFilePath()
|
||||
this.headers = mergeHeaders(CONFIG.DEFAULT_HEADERS, {
|
||||
this.headers = {
|
||||
...CONFIG.DEFAULT_HEADERS,
|
||||
accept: 'application/json',
|
||||
'user-agent': 'Visual Studio Code (desktop)'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private getTokenFilePath = (): string => {
|
||||
@ -90,7 +90,7 @@ class CopilotService {
|
||||
*/
|
||||
private updateHeaders = (headers?: Record<string, string>): void => {
|
||||
if (headers && Object.keys(headers).length > 0) {
|
||||
this.headers = mergeHeaders(this.headers, headers)
|
||||
this.headers = { ...headers }
|
||||
}
|
||||
}
|
||||
|
||||
@ -139,9 +139,10 @@ class CopilotService {
|
||||
|
||||
const response = await net.fetch(CONFIG.API_URLS.GITHUB_DEVICE_CODE, {
|
||||
method: 'POST',
|
||||
headers: mergeHeaders(this.headers, {
|
||||
headers: {
|
||||
...this.headers,
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CONFIG.GITHUB_CLIENT_ID,
|
||||
scope: 'read:user'
|
||||
@ -177,9 +178,10 @@ class CopilotService {
|
||||
try {
|
||||
const response = await net.fetch(CONFIG.API_URLS.GITHUB_ACCESS_TOKEN, {
|
||||
method: 'POST',
|
||||
headers: mergeHeaders(this.headers, {
|
||||
headers: {
|
||||
...this.headers,
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CONFIG.GITHUB_CLIENT_ID,
|
||||
device_code,
|
||||
@ -245,9 +247,10 @@ class CopilotService {
|
||||
|
||||
const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, {
|
||||
method: 'GET',
|
||||
headers: mergeHeaders(this.headers, {
|
||||
headers: {
|
||||
...this.headers,
|
||||
authorization: `token ${access_token}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@ -36,7 +36,7 @@ import type { MCPProgressEvent } from '@shared/config/types'
|
||||
import type { MCPServerLogEntry } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { buildFunctionCallToolName } from '@shared/mcp'
|
||||
import { defaultAppHeaders, mergeHeaders } from '@shared/utils'
|
||||
import { defaultAppHeaders } from '@shared/utils'
|
||||
import {
|
||||
BuiltinMCPServerNames,
|
||||
type GetResourceResponse,
|
||||
@ -285,7 +285,10 @@ class McpService {
|
||||
}
|
||||
|
||||
const prepareHeaders = () => {
|
||||
return mergeHeaders(defaultAppHeaders(), server.headers)
|
||||
return {
|
||||
...defaultAppHeaders(),
|
||||
...server.headers
|
||||
}
|
||||
}
|
||||
|
||||
// Create a promise for the initialization process
|
||||
@ -317,9 +320,10 @@ class McpService {
|
||||
return net.fetch(typeof url === 'string' ? url : url.toString(), init)
|
||||
},
|
||||
requestInit: {
|
||||
headers: mergeHeaders(defaultAppHeaders(), {
|
||||
headers: {
|
||||
...defaultAppHeaders(),
|
||||
APP: 'Cherry Studio'
|
||||
})
|
||||
}
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
// just import the themeService to ensure the theme is initialized
|
||||
import './ThemeService'
|
||||
|
||||
import { normalizeHeaders, withUserAgentSuffix } from '@ai-sdk/provider-utils'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { loggerService } from '@logger'
|
||||
import { isDev, isLinux, isMac, isWin } from '@main/constant'
|
||||
import { getFilesDir } from '@main/utils/file'
|
||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { app, BrowserWindow, nativeImage, nativeTheme, screen, shell } from 'electron'
|
||||
@ -318,23 +316,7 @@ export class WindowService {
|
||||
}
|
||||
|
||||
private setupWebRequestHeaders(mainWindow: BrowserWindow) {
|
||||
const webSession = mainWindow.webContents.session
|
||||
|
||||
// 拦截请求头,将 x-custom-user-agent 转换为 User-Agent
|
||||
// 这是因为 User-Agent 在 renderer 进程的 Fetch API 中是 forbidden header,无法直接设置
|
||||
webSession.webRequest.onBeforeSendHeaders({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
const requestHeaders = normalizeHeaders(details.requestHeaders)
|
||||
|
||||
const customUA = requestHeaders['x-custom-user-agent']
|
||||
if (customUA) {
|
||||
requestHeaders['User-Agent'] = customUA
|
||||
delete requestHeaders['x-custom-user-agent']
|
||||
}
|
||||
|
||||
callback({ requestHeaders: withUserAgentSuffix(requestHeaders, generateUserAgent()) })
|
||||
})
|
||||
|
||||
webSession.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
|
||||
if (details.responseHeaders?.['X-Frame-Options']) {
|
||||
delete details.responseHeaders['X-Frame-Options']
|
||||
}
|
||||
|
||||
@ -1,15 +1,6 @@
|
||||
import type { MCPServer, MCPTool } from '@types'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('os', () => ({
|
||||
default: {
|
||||
release: vi.fn(() => '10.0.0'),
|
||||
homedir: vi.fn(() => '/home/test')
|
||||
},
|
||||
release: vi.fn(() => '10.0.0'),
|
||||
homedir: vi.fn(() => '/home/test')
|
||||
}))
|
||||
|
||||
vi.mock('@main/apiServer/utils/mcp', () => ({
|
||||
getMCPServersFromRedux: vi.fn()
|
||||
}))
|
||||
|
||||
@ -174,9 +174,8 @@ describe('Copilot responses routing', () => {
|
||||
const config = providerToAiSdkConfig(provider, createModel('gpt-5-codex', 'GPT-5-CODEX'))
|
||||
|
||||
expect(config.providerId).toBe('github-copilot-openai-compatible')
|
||||
// Headers are normalized to lowercase by mergeHeaders
|
||||
expect(config.options.headers?.['editor-version']).toBe(COPILOT_EDITOR_VERSION)
|
||||
expect(config.options.headers?.['copilot-integration-id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
|
||||
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_EDITOR_VERSION)
|
||||
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
|
||||
expect(config.options.headers?.['copilot-vision-request']).toBe('true')
|
||||
})
|
||||
|
||||
@ -185,9 +184,8 @@ describe('Copilot responses routing', () => {
|
||||
const config = providerToAiSdkConfig(provider, createModel('gpt-4'))
|
||||
|
||||
expect(config.providerId).toBe('github-copilot-openai-compatible')
|
||||
// Headers are normalized to lowercase by mergeHeaders
|
||||
expect(config.options.headers?.['editor-version']).toBe(COPILOT_DEFAULT_HEADERS['Editor-Version'])
|
||||
expect(config.options.headers?.['copilot-integration-id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
|
||||
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_DEFAULT_HEADERS['Editor-Version'])
|
||||
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -33,10 +33,9 @@ import {
|
||||
isSupportStreamOptionsProvider,
|
||||
isVertexProvider
|
||||
} from '@renderer/utils/provider'
|
||||
import { defaultAppHeaders } from '@shared/utils'
|
||||
import { cloneDeep, isEmpty } from 'lodash'
|
||||
|
||||
import { customFetch } from '../../utils/customFetch'
|
||||
import { mergeHeaders } from '../../utils/headers'
|
||||
import type { AiSdkConfig } from '../types'
|
||||
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
|
||||
import { azureAnthropicProviderCreator } from './config/azure-anthropic'
|
||||
@ -163,8 +162,11 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
|
||||
if (isCopilotProvider) {
|
||||
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
|
||||
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
|
||||
headers: mergeHeaders(COPILOT_DEFAULT_HEADERS, storedHeaders, actualProvider.extra_headers),
|
||||
fetch: customFetch,
|
||||
headers: {
|
||||
...COPILOT_DEFAULT_HEADERS,
|
||||
...storedHeaders,
|
||||
...actualProvider.extra_headers
|
||||
},
|
||||
name: actualProvider.id,
|
||||
includeUsage
|
||||
})
|
||||
@ -180,18 +182,16 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
|
||||
providerId: 'ollama',
|
||||
options: {
|
||||
...baseConfig,
|
||||
fetch: customFetch,
|
||||
headers: mergeHeaders(actualProvider.extra_headers ?? {}, {
|
||||
headers: {
|
||||
...actualProvider.extra_headers,
|
||||
Authorization: !isEmpty(baseConfig.apiKey) ? `Bearer ${baseConfig.apiKey}` : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理OpenAI模式
|
||||
const extraOptions: any = {
|
||||
fetch: customFetch // 使用自定义 fetch 以支持 User-Agent header
|
||||
}
|
||||
const extraOptions: any = {}
|
||||
extraOptions.endpoint = endpoint
|
||||
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
extraOptions.mode = 'responses'
|
||||
@ -199,8 +199,10 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
|
||||
extraOptions.mode = 'chat'
|
||||
}
|
||||
|
||||
// NOTE: default app header在streamText就添加好了
|
||||
extraOptions.headers = actualProvider.extra_headers
|
||||
extraOptions.headers = {
|
||||
...defaultAppHeaders(),
|
||||
...actualProvider.extra_headers
|
||||
}
|
||||
|
||||
if (aiSdkProviderId === 'openai') {
|
||||
extraOptions.headers['X-Api-Key'] = baseConfig.apiKey
|
||||
@ -357,24 +359,33 @@ export async function prepareSpecialProviderConfig(
|
||||
switch (provider.id) {
|
||||
case 'copilot': {
|
||||
const defaultHeaders = store.getState().copilot.defaultHeaders ?? {}
|
||||
const headers = mergeHeaders(COPILOT_DEFAULT_HEADERS, defaultHeaders)
|
||||
const headers = {
|
||||
...COPILOT_DEFAULT_HEADERS,
|
||||
...defaultHeaders
|
||||
}
|
||||
const { token } = await window.api.copilot.getToken(headers)
|
||||
config.options.apiKey = token
|
||||
config.options.headers = mergeHeaders(headers, config.options.headers)
|
||||
config.options.headers = {
|
||||
...headers,
|
||||
...config.options.headers
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'cherryai': {
|
||||
config.options.fetch = async (url: RequestInfo | URL, options: RequestInit) => {
|
||||
config.options.fetch = async (url, options) => {
|
||||
// 在这里对最终参数进行签名
|
||||
const signature = await window.api.cherryai.generateSignature({
|
||||
method: 'POST',
|
||||
path: '/chat/completions',
|
||||
query: '',
|
||||
body: JSON.parse(options.body as string)
|
||||
body: JSON.parse(options.body)
|
||||
})
|
||||
return customFetch(url, {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: mergeHeaders((options.headers as Record<string, string>) ?? {}, signature)
|
||||
headers: {
|
||||
...options.headers,
|
||||
...signature
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
@ -384,11 +395,12 @@ export async function prepareSpecialProviderConfig(
|
||||
const oauthToken = await window.api.anthropic_oauth.getAccessToken()
|
||||
config.options = {
|
||||
...config.options,
|
||||
headers: mergeHeaders(config.options.headers ?? {}, {
|
||||
headers: {
|
||||
...(config.options.headers ? config.options.headers : {}),
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
Authorization: `Bearer ${oauthToken}`
|
||||
}),
|
||||
},
|
||||
baseURL: 'https://api.anthropic.com/v1',
|
||||
apiKey: ''
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { allMinApps } from '@renderer/config/minapps'
|
||||
import type { MinAppType } from '@renderer/types'
|
||||
import type { FC } from 'react'
|
||||
|
||||
@ -10,10 +10,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => {
|
||||
// First try to find in DEFAULT_MIN_APPS for predefined styling
|
||||
const _app = DEFAULT_MIN_APPS.find((item) => item.id === app.id)
|
||||
// First try to find in allMinApps for predefined styling
|
||||
const _app = allMinApps.find((item) => item.id === app.id)
|
||||
|
||||
// If found in DEFAULT_MIN_APPS, use predefined styling
|
||||
// If found in allMinApps, use predefined styling
|
||||
if (_app) {
|
||||
return (
|
||||
<img
|
||||
@ -34,7 +34,7 @@ const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// If not found in DEFAULT_MIN_APPS but app has logo, use it (for temporary apps)
|
||||
// If not found in allMinApps but app has logo, use it (for temporary apps)
|
||||
if (app.logo) {
|
||||
return (
|
||||
<img
|
||||
|
||||
@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import MinAppIcon from '../MinAppIcon'
|
||||
|
||||
vi.mock('@renderer/config/minapps', () => ({
|
||||
DEFAULT_MIN_APPS: [
|
||||
allMinApps: [
|
||||
{
|
||||
id: 'test-app-1',
|
||||
name: 'Test App 1',
|
||||
@ -52,7 +52,7 @@ describe('MinAppIcon', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when app is not found in DEFAULT_MIN_APPS', () => {
|
||||
it('should return null when app is not found in allMinApps', () => {
|
||||
const unknownApp = {
|
||||
id: 'unknown-app',
|
||||
name: 'Unknown App',
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import MinAppIcon from '@renderer/components/Icons/MinAppIcon'
|
||||
import IndicatorLight from '@renderer/components/IndicatorLight'
|
||||
import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps } from '@renderer/config/minapps'
|
||||
import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateAllMinApps } from '@renderer/config/minapps'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
@ -93,7 +93,7 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
|
||||
await window.api.file.writeWithId('custom-minapps.json', JSON.stringify(updatedApps, null, 2))
|
||||
window.toast.success(t('settings.miniapps.custom.remove_success'))
|
||||
const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
|
||||
updateDefaultMinApps(reloadedApps)
|
||||
updateAllMinApps(reloadedApps)
|
||||
updateMinapps(minapps.filter((item) => item.id !== app.id))
|
||||
updatePinnedMinapps(pinned.filter((item) => item.id !== app.id))
|
||||
updateDisabledMinapps(disabled.filter((item) => item.id !== app.id))
|
||||
@ -122,7 +122,7 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
|
||||
</StyledIndicator>
|
||||
)}
|
||||
</IconContainer>
|
||||
<AppTitle>{isLast ? t('settings.miniapps.custom.title') : app.name}</AppTitle>
|
||||
<AppTitle>{isLast ? t('settings.miniapps.custom.title') : app.nameKey ? t(app.nameKey) : app.name}</AppTitle>
|
||||
</Container>
|
||||
</Dropdown>
|
||||
)
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
import { loggerService } from '@logger'
|
||||
import WindowControls from '@renderer/components/WindowControls'
|
||||
import { isDev, isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { allMinApps } from '@renderer/config/minapps'
|
||||
import { useBridge } from '@renderer/hooks/useBridge'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
@ -246,7 +246,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
(acc, app) => ({
|
||||
...acc,
|
||||
[app.id]: {
|
||||
canPinned: DEFAULT_MIN_APPS.some((item) => item.id === app.id),
|
||||
canPinned: allMinApps.some((item) => item.id === app.id),
|
||||
isPinned: pinned.some((item) => item.id === app.id),
|
||||
canOpenExternalLink: app.url.startsWith('http://') || app.url.startsWith('https://')
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
||||
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
|
||||
import { ChecklistContent } from '@renderer/components/UserGuide/UserGuideChecklist'
|
||||
import { isLinux, isMac } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { allMinApps } from '@renderer/config/minapps'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
@ -61,7 +61,7 @@ const getTabIcon = (
|
||||
// Check if it's a minapp tab (format: apps:appId)
|
||||
if (tabId.startsWith('apps:')) {
|
||||
const appId = tabId.replace('apps:', '')
|
||||
let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
let app = [...allMinApps, ...minapps].find((app) => app.id === appId)
|
||||
|
||||
// If not found in permanent apps, search in temporary apps cache
|
||||
// The cache stores apps opened via openSmartMinapp() for top navbar mode
|
||||
@ -157,7 +157,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
// Check if it's a minapp tab
|
||||
if (tabId.startsWith('apps:')) {
|
||||
const appId = tabId.replace('apps:', '')
|
||||
let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
let app = [...allMinApps, ...minapps].find((app) => app.id === appId)
|
||||
|
||||
// If not found in permanent apps, search in temporary apps cache
|
||||
// This ensures temporary MinApps display proper titles while being used
|
||||
|
||||
@ -56,7 +56,6 @@ import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url'
|
||||
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url'
|
||||
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url'
|
||||
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png?url'
|
||||
import i18n from '@renderer/i18n'
|
||||
import type { MinAppType } from '@renderer/types'
|
||||
|
||||
const logger = loggerService.withContext('Config:minapps')
|
||||
@ -118,14 +117,18 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
},
|
||||
{
|
||||
id: 'yi',
|
||||
name: i18n.t('minapps.wanzhi'),
|
||||
name: 'Wanzhi',
|
||||
nameKey: 'minapps.wanzhi',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
url: 'https://www.wanzhi.com/',
|
||||
logo: WanZhiAppLogo,
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'zhipu',
|
||||
name: i18n.t('minapps.chatglm'),
|
||||
name: 'ChatGLM',
|
||||
nameKey: 'minapps.chatglm',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
url: 'https://chatglm.cn/main/alltoolsdetail',
|
||||
logo: ZhipuProviderLogo,
|
||||
bodered: true
|
||||
@ -133,31 +136,40 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
{
|
||||
id: 'moonshot',
|
||||
name: 'Kimi',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
url: 'https://kimi.moonshot.cn/',
|
||||
logo: KimiAppLogo
|
||||
},
|
||||
{
|
||||
id: 'baichuan',
|
||||
name: i18n.t('minapps.baichuan'),
|
||||
name: 'Baichuan',
|
||||
nameKey: 'minapps.baichuan',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
url: 'https://ying.baichuan-ai.com/chat',
|
||||
logo: BaicuanAppLogo
|
||||
},
|
||||
{
|
||||
id: 'dashscope',
|
||||
name: i18n.t('minapps.qwen'),
|
||||
name: 'Qwen',
|
||||
nameKey: 'minapps.qwen',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
url: 'https://www.qianwen.com',
|
||||
logo: QwenModelLogo
|
||||
},
|
||||
{
|
||||
id: 'stepfun',
|
||||
name: i18n.t('minapps.stepfun'),
|
||||
name: 'Stepfun',
|
||||
nameKey: 'minapps.stepfun',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
url: 'https://stepfun.com',
|
||||
logo: StepfunAppLogo,
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'doubao',
|
||||
name: i18n.t('minapps.doubao'),
|
||||
name: 'Doubao',
|
||||
nameKey: 'minapps.doubao',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
url: 'https://www.doubao.com/chat/',
|
||||
logo: DoubaoAppLogo
|
||||
},
|
||||
@ -169,7 +181,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
},
|
||||
{
|
||||
id: 'minimax',
|
||||
name: i18n.t('minapps.hailuo'),
|
||||
name: 'Hailuo',
|
||||
nameKey: 'minapps.hailuo',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
url: 'https://chat.minimaxi.com/',
|
||||
logo: HailuoModelLogo,
|
||||
bodered: true
|
||||
@ -198,13 +212,17 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
},
|
||||
{
|
||||
id: 'baidu-ai-chat',
|
||||
name: i18n.t('minapps.wenxin'),
|
||||
name: 'Wenxin',
|
||||
nameKey: 'minapps.wenxin',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: BaiduAiAppLogo,
|
||||
url: 'https://yiyan.baidu.com/'
|
||||
},
|
||||
{
|
||||
id: 'baidu-ai-search',
|
||||
name: i18n.t('minapps.baidu-ai-search'),
|
||||
name: 'Baidu AI Search',
|
||||
nameKey: 'minapps.baidu-ai-search',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: BaiduAiSearchLogo,
|
||||
url: 'https://chat.baidu.com/',
|
||||
bodered: true,
|
||||
@ -214,14 +232,18 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
},
|
||||
{
|
||||
id: 'tencent-yuanbao',
|
||||
name: i18n.t('minapps.tencent-yuanbao'),
|
||||
name: 'Tencent Yuanbao',
|
||||
nameKey: 'minapps.tencent-yuanbao',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: TencentYuanbaoAppLogo,
|
||||
url: 'https://yuanbao.tencent.com/chat',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'sensetime-chat',
|
||||
name: i18n.t('minapps.sensechat'),
|
||||
name: 'Sensechat',
|
||||
nameKey: 'minapps.sensechat',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: SensetimeAppLogo,
|
||||
url: 'https://chat.sensetime.com/wb/chat',
|
||||
bodered: true
|
||||
@ -229,12 +251,15 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
{
|
||||
id: 'spark-desk',
|
||||
name: 'SparkDesk',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: SparkDeskAppLogo,
|
||||
url: 'https://xinghuo.xfyun.cn/desk'
|
||||
},
|
||||
{
|
||||
id: 'metaso',
|
||||
name: i18n.t('minapps.metaso'),
|
||||
name: 'Metaso',
|
||||
nameKey: 'minapps.metaso',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: MetasoAppLogo,
|
||||
url: 'https://metaso.cn/'
|
||||
},
|
||||
@ -258,7 +283,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
},
|
||||
{
|
||||
id: 'tiangong-ai',
|
||||
name: i18n.t('minapps.tiangong-ai'),
|
||||
name: 'Tiangong AI',
|
||||
nameKey: 'minapps.tiangong-ai',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: TiangongAiLogo,
|
||||
url: 'https://www.tiangong.cn/',
|
||||
bodered: true
|
||||
@ -285,7 +312,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
},
|
||||
{
|
||||
id: 'nm',
|
||||
name: i18n.t('minapps.nami-ai'),
|
||||
name: 'Nami AI',
|
||||
nameKey: 'minapps.nami-ai',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: NamiAiLogo,
|
||||
url: 'https://bot.n.cn/',
|
||||
bodered: true
|
||||
@ -329,6 +358,7 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
{
|
||||
id: 'qwenlm',
|
||||
name: 'QwenChat',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: QwenlmAppLogo,
|
||||
url: 'https://chat.qwen.ai'
|
||||
},
|
||||
@ -354,7 +384,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
},
|
||||
{
|
||||
id: 'xiaoyi',
|
||||
name: i18n.t('minapps.xiaoyi'),
|
||||
name: 'Xiaoyi',
|
||||
nameKey: 'minapps.xiaoyi',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: XiaoYiAppLogo,
|
||||
url: 'https://xiaoyi.huawei.com/chat/',
|
||||
bodered: true
|
||||
@ -384,7 +416,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
},
|
||||
{
|
||||
id: 'wpslingxi',
|
||||
name: i18n.t('minapps.wps-copilot'),
|
||||
name: 'WPS AI',
|
||||
nameKey: 'minapps.wps-copilot',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: WPSLingXiLogo,
|
||||
url: 'https://copilot.wps.cn/',
|
||||
bodered: true
|
||||
@ -425,14 +459,18 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
},
|
||||
{
|
||||
id: 'zhihu',
|
||||
name: i18n.t('minapps.zhihu'),
|
||||
name: 'Zhihu Zhida',
|
||||
nameKey: 'minapps.zhihu',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: ZhihuAppLogo,
|
||||
url: 'https://zhida.zhihu.com/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'dangbei',
|
||||
name: i18n.t('minapps.dangbei'),
|
||||
name: 'Dangbei AI',
|
||||
nameKey: 'minapps.dangbei',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: DangbeiLogo,
|
||||
url: 'https://ai.dangbei.com/',
|
||||
bodered: true
|
||||
@ -460,13 +498,16 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
{
|
||||
id: 'longcat',
|
||||
name: 'LongCat',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
logo: LongCatAppLogo,
|
||||
url: 'https://longcat.chat/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'ling',
|
||||
name: i18n.t('minapps.ant-ling'),
|
||||
name: 'Ant Ling',
|
||||
nameKey: 'minapps.ant-ling',
|
||||
locales: ['zh-CN', 'zh-TW'],
|
||||
url: 'https://ling.tbox.cn/chat',
|
||||
logo: LingAppLogo,
|
||||
bodered: true,
|
||||
@ -486,11 +527,11 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
}
|
||||
]
|
||||
|
||||
// 加载自定义小应用并合并到默认应用中
|
||||
let DEFAULT_MIN_APPS = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
|
||||
// All mini apps: built-in defaults + custom apps loaded from user config
|
||||
let allMinApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
|
||||
|
||||
function updateDefaultMinApps(param) {
|
||||
DEFAULT_MIN_APPS = param
|
||||
function updateAllMinApps(apps: MinAppType[]) {
|
||||
allMinApps = apps
|
||||
}
|
||||
|
||||
export { DEFAULT_MIN_APPS, loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps }
|
||||
export { allMinApps, loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateAllMinApps }
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { allMinApps } from '@renderer/config/minapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
@ -120,10 +120,10 @@ export const useMinappPopup = () => {
|
||||
[openMinapp]
|
||||
)
|
||||
|
||||
/** Open a minapp by id (look up the minapp in DEFAULT_MIN_APPS) */
|
||||
/** Open a minapp by id (look up the minapp in allMinApps) */
|
||||
const openMinappById = useCallback(
|
||||
(id: string, keepAlive: boolean = false) => {
|
||||
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
|
||||
const app = allMinApps.find((app) => app?.id === id)
|
||||
if (app) {
|
||||
openMinapp(app, keepAlive)
|
||||
}
|
||||
|
||||
@ -1,25 +1,129 @@
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { allMinApps } from '@renderer/config/minapps'
|
||||
import type { RootState } from '@renderer/store'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setDisabledMinApps, setMinApps, setPinnedMinApps } from '@renderer/store/minapps'
|
||||
import type { MinAppType } from '@renderer/types'
|
||||
import type { LanguageVarious, MinAppType } from '@renderer/types'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Data Flow Design:
|
||||
*
|
||||
* PRINCIPLE: Locale filtering is a VIEW concern, not a DATA concern.
|
||||
*
|
||||
* - Redux stores ALL apps (including locale-restricted ones) to preserve user preferences
|
||||
* - allMinApps is the template data source containing locale definitions
|
||||
* - This hook applies locale filtering only when READING for UI display
|
||||
* - When WRITING, locale-hidden apps are merged back to prevent data loss
|
||||
*/
|
||||
|
||||
// Check if app should be visible for the given locale
|
||||
const isVisibleForLocale = (app: MinAppType, language: LanguageVarious): boolean => {
|
||||
if (!app.locales) return true
|
||||
return app.locales.includes(language)
|
||||
}
|
||||
|
||||
// Filter apps by locale - only show apps that match current language
|
||||
const filterByLocale = (apps: MinAppType[], language: LanguageVarious): MinAppType[] => {
|
||||
return apps.filter((app) => isVisibleForLocale(app, language))
|
||||
}
|
||||
|
||||
// Get locale-hidden apps from allMinApps for the current language
|
||||
// This uses allMinApps as source of truth for locale definitions
|
||||
const getLocaleHiddenApps = (language: LanguageVarious): MinAppType[] => {
|
||||
return allMinApps.filter((app) => !isVisibleForLocale(app, language))
|
||||
}
|
||||
|
||||
export const useMinapps = () => {
|
||||
const { enabled, disabled, pinned } = useAppSelector((state: RootState) => state.minapps)
|
||||
const language = useAppSelector((state: RootState) => state.settings.language)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const mapApps = useCallback(
|
||||
(apps: MinAppType[]) => apps.map((app) => allMinApps.find((item) => item.id === app.id) || app),
|
||||
[]
|
||||
)
|
||||
|
||||
const getAllApps = useCallback(
|
||||
(apps: MinAppType[], disabledApps: MinAppType[]) => {
|
||||
const mappedApps = mapApps(apps)
|
||||
const existingIds = new Set(mappedApps.map((app) => app.id))
|
||||
const disabledIds = new Set(disabledApps.map((app) => app.id))
|
||||
const missingApps = allMinApps.filter((app) => !existingIds.has(app.id) && !disabledIds.has(app.id))
|
||||
return [...mappedApps, ...missingApps]
|
||||
},
|
||||
[mapApps]
|
||||
)
|
||||
|
||||
// READ: Get apps filtered by locale for UI display
|
||||
const minapps = useMemo(() => {
|
||||
const allApps = getAllApps(enabled, disabled)
|
||||
const disabledIds = new Set(disabled.map((app) => app.id))
|
||||
const withoutDisabled = allApps.filter((app) => !disabledIds.has(app.id))
|
||||
return filterByLocale(withoutDisabled, language)
|
||||
}, [enabled, disabled, language, getAllApps])
|
||||
|
||||
const disabledApps = useMemo(() => filterByLocale(mapApps(disabled), language), [disabled, language, mapApps])
|
||||
const pinnedApps = useMemo(() => filterByLocale(mapApps(pinned), language), [pinned, language, mapApps])
|
||||
|
||||
const updateMinapps = useCallback(
|
||||
(visibleApps: MinAppType[]) => {
|
||||
const disabledIds = new Set(disabled.map((app) => app.id))
|
||||
|
||||
const withoutDisabled = visibleApps.filter((app) => !disabledIds.has(app.id))
|
||||
|
||||
const localeHiddenApps = getLocaleHiddenApps(language)
|
||||
|
||||
const localeHiddenIds = new Set(localeHiddenApps.map((app) => app.id))
|
||||
const preservedLocaleHidden = enabled.filter((app) => localeHiddenIds.has(app.id) && !disabledIds.has(app.id))
|
||||
|
||||
const visibleIds = new Set(withoutDisabled.map((app) => app.id))
|
||||
const toAppend = preservedLocaleHidden.filter((app) => !visibleIds.has(app.id))
|
||||
const merged = [...withoutDisabled, ...toAppend]
|
||||
|
||||
const existingIds = new Set(merged.map((app) => app.id))
|
||||
const missingApps = allMinApps.filter((app) => !existingIds.has(app.id) && !disabledIds.has(app.id))
|
||||
|
||||
dispatch(setMinApps([...merged, ...missingApps]))
|
||||
},
|
||||
[dispatch, enabled, disabled, language]
|
||||
)
|
||||
|
||||
// WRITE: Update disabled apps, preserving locale-hidden disabled apps
|
||||
const updateDisabledMinapps = useCallback(
|
||||
(visibleDisabledApps: MinAppType[]) => {
|
||||
const localeHiddenApps = getLocaleHiddenApps(language)
|
||||
const localeHiddenIds = new Set(localeHiddenApps.map((app) => app.id))
|
||||
const preservedLocaleHidden = disabled.filter((app) => localeHiddenIds.has(app.id))
|
||||
|
||||
const visibleIds = new Set(visibleDisabledApps.map((app) => app.id))
|
||||
const toAppend = preservedLocaleHidden.filter((app) => !visibleIds.has(app.id))
|
||||
|
||||
dispatch(setDisabledMinApps([...visibleDisabledApps, ...toAppend]))
|
||||
},
|
||||
[dispatch, disabled, language]
|
||||
)
|
||||
|
||||
// WRITE: Update pinned apps, preserving locale-hidden pinned apps
|
||||
const updatePinnedMinapps = useCallback(
|
||||
(visiblePinnedApps: MinAppType[]) => {
|
||||
const localeHiddenApps = getLocaleHiddenApps(language)
|
||||
const localeHiddenIds = new Set(localeHiddenApps.map((app) => app.id))
|
||||
const preservedLocaleHidden = pinned.filter((app) => localeHiddenIds.has(app.id))
|
||||
|
||||
const visibleIds = new Set(visiblePinnedApps.map((app) => app.id))
|
||||
const toAppend = preservedLocaleHidden.filter((app) => !visibleIds.has(app.id))
|
||||
|
||||
dispatch(setPinnedMinApps([...visiblePinnedApps, ...toAppend]))
|
||||
},
|
||||
[dispatch, pinned, language]
|
||||
)
|
||||
|
||||
return {
|
||||
minapps: enabled.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
|
||||
disabled: disabled.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
|
||||
pinned: pinned.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
|
||||
updateMinapps: (minapps: MinAppType[]) => {
|
||||
dispatch(setMinApps(minapps))
|
||||
},
|
||||
updateDisabledMinapps: (minapps: MinAppType[]) => {
|
||||
dispatch(setDisabledMinApps(minapps))
|
||||
},
|
||||
updatePinnedMinapps: (minapps: MinAppType[]) => {
|
||||
dispatch(setPinnedMinApps(minapps))
|
||||
}
|
||||
minapps,
|
||||
disabled: disabledApps,
|
||||
pinned: pinnedApps,
|
||||
updateMinapps,
|
||||
updateDisabledMinapps,
|
||||
updatePinnedMinapps
|
||||
}
|
||||
}
|
||||
|
||||
@ -5253,6 +5253,8 @@
|
||||
"detected": {
|
||||
"language": "Auto Detect"
|
||||
},
|
||||
"detected_source": "Detected",
|
||||
"detecting": "Detecting...",
|
||||
"empty": "Translation content is empty",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Qwen MT model cannot be used in chat. Please go to the translation page.",
|
||||
@ -5305,6 +5307,7 @@
|
||||
"not_pair": "Source language is different from the set language",
|
||||
"same": "Source and target languages are the same"
|
||||
},
|
||||
"language_settings": "Language Settings",
|
||||
"menu": {
|
||||
"description": "Translate the content of the current input box"
|
||||
},
|
||||
@ -5314,6 +5317,7 @@
|
||||
"output": {
|
||||
"placeholder": "Translation"
|
||||
},
|
||||
"preferred_target": "Preferred Target",
|
||||
"processing": "Translation in progress...",
|
||||
"settings": {
|
||||
"autoCopy": "Copy after translation ",
|
||||
|
||||
@ -5253,6 +5253,8 @@
|
||||
"detected": {
|
||||
"language": "自动检测"
|
||||
},
|
||||
"detected_source": "检测到",
|
||||
"detecting": "检测中...",
|
||||
"empty": "翻译内容为空",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Qwen MT 模型不可在对话中使用,请转至翻译页面",
|
||||
@ -5305,6 +5307,7 @@
|
||||
"not_pair": "源语言与设置的语言不同",
|
||||
"same": "源语言和目标语言相同"
|
||||
},
|
||||
"language_settings": "语言设置",
|
||||
"menu": {
|
||||
"description": "对当前输入框内容进行翻译"
|
||||
},
|
||||
@ -5314,6 +5317,7 @@
|
||||
"output": {
|
||||
"placeholder": "翻译"
|
||||
},
|
||||
"preferred_target": "首选目标",
|
||||
"processing": "翻译中...",
|
||||
"settings": {
|
||||
"autoCopy": "翻译完成后自动复制",
|
||||
|
||||
@ -5253,6 +5253,8 @@
|
||||
"detected": {
|
||||
"language": "自動偵測"
|
||||
},
|
||||
"detected_source": "偵測到",
|
||||
"detecting": "偵測中...",
|
||||
"empty": "翻譯內容為空",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Qwen MT 模型無法在對話中使用,請前往翻譯頁面",
|
||||
@ -5305,6 +5307,7 @@
|
||||
"not_pair": "來源語言與設定的語言不同",
|
||||
"same": "來源語言和目標語言相同"
|
||||
},
|
||||
"language_settings": "語言設定",
|
||||
"menu": {
|
||||
"description": "對目前輸入框內容進行翻譯"
|
||||
},
|
||||
@ -5314,6 +5317,7 @@
|
||||
"output": {
|
||||
"placeholder": "翻譯"
|
||||
},
|
||||
"preferred_target": "首選目標",
|
||||
"processing": "翻譯中...",
|
||||
"settings": {
|
||||
"autoCopy": "翻譯完成後自動複製",
|
||||
|
||||
@ -5253,6 +5253,8 @@
|
||||
"detected": {
|
||||
"language": "Automatische Erkennung"
|
||||
},
|
||||
"detected_source": "Erfasst",
|
||||
"detecting": "Erkenne...",
|
||||
"empty": "Übersetzungsinhalt leer",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Qwen MT-Modell kann nicht in der Konversation verwendet werden, bitte gehen Sie zur Übersetzungsseite",
|
||||
@ -5305,6 +5307,7 @@
|
||||
"not_pair": "Quellsprache unterscheidet sich von eingestellter Sprache",
|
||||
"same": "Quell- und Zielsprache sind identisch"
|
||||
},
|
||||
"language_settings": "Spracheinstellungen",
|
||||
"menu": {
|
||||
"description": "Inhalt des aktuellen Eingabefelds übersetzen"
|
||||
},
|
||||
@ -5314,6 +5317,7 @@
|
||||
"output": {
|
||||
"placeholder": "Übersetzen"
|
||||
},
|
||||
"preferred_target": "Bevorzugtes Ziel",
|
||||
"processing": "Wird übersetzt...",
|
||||
"settings": {
|
||||
"autoCopy": "Nach Übersetzung automatisch kopieren",
|
||||
|
||||
@ -5253,6 +5253,8 @@
|
||||
"detected": {
|
||||
"language": "Αυτόματη ανίχνευση"
|
||||
},
|
||||
"detected_source": "Εντοπίστηκε",
|
||||
"detecting": "Ανίχνευση...",
|
||||
"empty": "Το μεταφρασμένο κείμενο είναι κενό",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Τα μοντέλα Qwen MT δεν είναι διαθέσιμα για χρήση σε διαλόγους, παρακαλώ μεταβείτε στη σελίδα μετάφρασης",
|
||||
@ -5305,6 +5307,7 @@
|
||||
"not_pair": "Η γλώσσα πηγής διαφέρει από την οριζόμενη γλώσσα",
|
||||
"same": "Η γλώσσα πηγής και η γλώσσα προορισμού είναι ίδιες"
|
||||
},
|
||||
"language_settings": "Ρυθμίσεις Γλώσσας",
|
||||
"menu": {
|
||||
"description": "Μεταφράστε το περιεχόμενο του τρέχοντος πεδίου εισαγωγής"
|
||||
},
|
||||
@ -5314,6 +5317,7 @@
|
||||
"output": {
|
||||
"placeholder": "Μετάφραση"
|
||||
},
|
||||
"preferred_target": "Προτιμώμενος Στόχος",
|
||||
"processing": "Μεταφράζεται...",
|
||||
"settings": {
|
||||
"autoCopy": "Μετά τη μετάφραση, αντιγράφεται αυτόματα",
|
||||
|
||||
@ -5253,6 +5253,8 @@
|
||||
"detected": {
|
||||
"language": "Detección automática"
|
||||
},
|
||||
"detected_source": "Detectado",
|
||||
"detecting": "Detectando...",
|
||||
"empty": "El contenido de traducción está vacío",
|
||||
"error": {
|
||||
"chat_qwen_mt": "El modelo Qwen MT no está disponible para uso en conversaciones, por favor vaya a la página de traducción.",
|
||||
@ -5305,6 +5307,7 @@
|
||||
"not_pair": "El idioma de origen es diferente al idioma configurado",
|
||||
"same": "El idioma de origen y el idioma de destino son iguales"
|
||||
},
|
||||
"language_settings": "Configuración de idioma",
|
||||
"menu": {
|
||||
"description": "Traducir el contenido del campo de entrada actual"
|
||||
},
|
||||
@ -5314,6 +5317,7 @@
|
||||
"output": {
|
||||
"placeholder": "Traducción"
|
||||
},
|
||||
"preferred_target": "Objetivo Preferido",
|
||||
"processing": "Traduciendo...",
|
||||
"settings": {
|
||||
"autoCopy": "Copiar automáticamente después de completar la traducción",
|
||||
|
||||
@ -5253,6 +5253,8 @@
|
||||
"detected": {
|
||||
"language": "Détection automatique"
|
||||
},
|
||||
"detected_source": "Détecté",
|
||||
"detecting": "Détection...",
|
||||
"empty": "Le contenu à traduire est vide",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Les modèles Qwen MT ne peuvent pas être utilisés dans les conversations, veuillez vous rendre sur la page de traduction.",
|
||||
@ -5305,6 +5307,7 @@
|
||||
"not_pair": "La langue source est différente de la langue définie",
|
||||
"same": "La langue source et la langue cible sont identiques"
|
||||
},
|
||||
"language_settings": "Paramètres de langue",
|
||||
"menu": {
|
||||
"description": "Traduire le contenu de la zone de saisie actuelle"
|
||||
},
|
||||
@ -5314,6 +5317,7 @@
|
||||
"output": {
|
||||
"placeholder": "traduction"
|
||||
},
|
||||
"preferred_target": "Cible préférée",
|
||||
"processing": "en cours de traduction...",
|
||||
"settings": {
|
||||
"autoCopy": "Copié automatiquement après la traduction",
|
||||
|
||||
@ -5253,6 +5253,8 @@
|
||||
"detected": {
|
||||
"language": "自動検出"
|
||||
},
|
||||
"detected_source": "検出されました",
|
||||
"detecting": "検出中...",
|
||||
"empty": "翻訳内容が空です",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Qwen MT モデルは対話で使用できません。翻訳ページに移動してください",
|
||||
@ -5305,6 +5307,7 @@
|
||||
"not_pair": "ソース言語が設定された言語と異なります",
|
||||
"same": "ソース言語と目標言語が同じです"
|
||||
},
|
||||
"language_settings": "言語設定",
|
||||
"menu": {
|
||||
"description": "對當前輸入框內容進行翻譯"
|
||||
},
|
||||
@ -5314,6 +5317,7 @@
|
||||
"output": {
|
||||
"placeholder": "翻訳"
|
||||
},
|
||||
"preferred_target": "優先ターゲット",
|
||||
"processing": "翻訳中...",
|
||||
"settings": {
|
||||
"autoCopy": "翻訳完了後、自動的にコピー",
|
||||
|
||||
@ -5253,6 +5253,8 @@
|
||||
"detected": {
|
||||
"language": "Detecção automática"
|
||||
},
|
||||
"detected_source": "Detectado",
|
||||
"detecting": "Detectando...",
|
||||
"empty": "O conteúdo de tradução está vazio",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Modelos Qwen MT não estão disponíveis para uso em conversas. Por favor, vá para a página de tradução.",
|
||||
@ -5305,6 +5307,7 @@
|
||||
"not_pair": "O idioma de origem é diferente do idioma definido",
|
||||
"same": "O idioma de origem e o idioma de destino são iguais"
|
||||
},
|
||||
"language_settings": "Configurações de Idioma",
|
||||
"menu": {
|
||||
"description": "Traduzir o conteúdo da caixa de entrada atual"
|
||||
},
|
||||
@ -5314,6 +5317,7 @@
|
||||
"output": {
|
||||
"placeholder": "Tradução"
|
||||
},
|
||||
"preferred_target": "Alvo Preferencial",
|
||||
"processing": "Traduzindo...",
|
||||
"settings": {
|
||||
"autoCopy": "Cópia automática após a tradução",
|
||||
|
||||
@ -5253,6 +5253,8 @@
|
||||
"detected": {
|
||||
"language": "Detectare automată"
|
||||
},
|
||||
"detected_source": "Detectat",
|
||||
"detecting": "Se detectează...",
|
||||
"empty": "Conținutul traducerii este gol",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Modelul Qwen MT nu poate fi folosit în chat. Te rugăm să mergi la pagina de traducere.",
|
||||
@ -5305,6 +5307,7 @@
|
||||
"not_pair": "Limba sursă este diferită de limba setată",
|
||||
"same": "Limbile sursă și țintă sunt aceleași"
|
||||
},
|
||||
"language_settings": "Setări limbă",
|
||||
"menu": {
|
||||
"description": "Tradu conținutul casetei de intrare curente"
|
||||
},
|
||||
@ -5314,6 +5317,7 @@
|
||||
"output": {
|
||||
"placeholder": "Traducere"
|
||||
},
|
||||
"preferred_target": "Țintă Preferată",
|
||||
"processing": "Traducere în curs...",
|
||||
"settings": {
|
||||
"autoCopy": "Copiază după traducere ",
|
||||
|
||||
@ -5253,6 +5253,8 @@
|
||||
"detected": {
|
||||
"language": "Автоматическое обнаружение"
|
||||
},
|
||||
"detected_source": "Обнаружено",
|
||||
"detecting": "Обнаружение...",
|
||||
"empty": "Содержимое перевода пусто",
|
||||
"error": {
|
||||
"chat_qwen_mt": "Модель Qwen MT недоступна для использования в диалоге, перейдите на страницу перевода",
|
||||
@ -5305,6 +5307,7 @@
|
||||
"not_pair": "Исходный язык отличается от настроенного",
|
||||
"same": "Исходный и целевой языки совпадают"
|
||||
},
|
||||
"language_settings": "Языковые настройки",
|
||||
"menu": {
|
||||
"description": "Перевести содержимое текущего ввода"
|
||||
},
|
||||
@ -5314,6 +5317,7 @@
|
||||
"output": {
|
||||
"placeholder": "Перевод"
|
||||
},
|
||||
"preferred_target": "Предпочтительная цель",
|
||||
"processing": "Перевод в процессе...",
|
||||
"settings": {
|
||||
"autoCopy": "Автоматически копировать после завершения перевода",
|
||||
|
||||
@ -67,7 +67,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
}
|
||||
|
||||
return (
|
||||
<NavbarHeader className="home-navbar" style={{ height: 'var(--navbar-height)', paddingRight: 16 }}>
|
||||
<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}>
|
||||
|
||||
@ -6,6 +6,7 @@ import styled from 'styled-components'
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
width: calc(var(--assistants-width) - 20px);
|
||||
justify-content: flex-start;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
|
||||
@ -9,7 +9,7 @@ import type { MenuProps } from 'antd'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import { Bot, MoreVertical } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// const logger = loggerService.withContext('AgentItem')
|
||||
@ -24,6 +24,7 @@ interface AgentItemProps {
|
||||
const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) => {
|
||||
const { t } = useTranslation()
|
||||
const { clickAssistantToShowTopic, topicPosition, assistantIconType } = useSettings()
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
// Show session sidebar if setting is enabled (reusing the assistant setting for consistency)
|
||||
@ -35,13 +36,9 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
|
||||
onPress()
|
||||
}, [clickAssistantToShowTopic, topicPosition, onPress])
|
||||
|
||||
const handleMoreClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
agent.id && AgentSettingsPopup.show({ agentId: agent.id })
|
||||
},
|
||||
[agent.id]
|
||||
)
|
||||
const handleMenuButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const menuItems: MenuProps['items'] = useMemo(
|
||||
() => [
|
||||
@ -75,17 +72,26 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
|
||||
menu={{ items: menuItems }}
|
||||
trigger={['contextMenu']}
|
||||
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
|
||||
<Container onClick={handlePress} isActive={isActive}>
|
||||
<Container
|
||||
onClick={handlePress}
|
||||
isActive={isActive}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}>
|
||||
<AssistantNameRow className="name" title={agent.name ?? agent.id}>
|
||||
<AgentNameWrapper>
|
||||
<AgentLabel agent={agent} hideIcon={assistantIconType === 'none'} />
|
||||
</AgentNameWrapper>
|
||||
{isActive && (
|
||||
<MenuButton onClick={handleMoreClick}>
|
||||
<MoreVertical size={14} className="text-[var(--color-text-secondary)]" />
|
||||
</MenuButton>
|
||||
{(isActive || isHovered) && (
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
trigger={['click']}
|
||||
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
|
||||
<MenuButton onClick={handleMenuButtonClick}>
|
||||
<MoreVertical size={14} className="text-[var(--color-text-secondary)]" />
|
||||
</MenuButton>
|
||||
</Dropdown>
|
||||
)}
|
||||
{!isActive && assistantIconType !== 'none' && <BotIcon />}
|
||||
{!isActive && !isHovered && assistantIconType !== 'none' && <BotIcon />}
|
||||
</AssistantNameRow>
|
||||
</Container>
|
||||
</Dropdown>
|
||||
|
||||
@ -69,6 +69,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
|
||||
const { assistants, updateAssistants } = useAssistants()
|
||||
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
@ -148,20 +149,20 @@ const AssistantItem: FC<AssistantItemProps> = ({
|
||||
[assistant.emoji, assistantName]
|
||||
)
|
||||
|
||||
const handleMoreClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
AssistantSettingsPopup.show({ assistant })
|
||||
},
|
||||
[assistant]
|
||||
)
|
||||
const handleMenuButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
trigger={['contextMenu']}
|
||||
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
|
||||
<Container onClick={handleSwitch} isActive={isActive}>
|
||||
<Container
|
||||
onClick={handleSwitch}
|
||||
isActive={isActive}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}>
|
||||
<AssistantNameRow className="name" title={fullAssistantName}>
|
||||
<AssistantAvatar
|
||||
assistant={assistant}
|
||||
@ -170,10 +171,15 @@ const AssistantItem: FC<AssistantItemProps> = ({
|
||||
/>
|
||||
<AssistantName className="text-nowrap">{assistantName}</AssistantName>
|
||||
</AssistantNameRow>
|
||||
{isActive && (
|
||||
<MenuButton onClick={handleMoreClick}>
|
||||
<MoreVertical size={14} className="text-[var(--color-text-secondary)]" />
|
||||
</MenuButton>
|
||||
{(isActive || isHovered) && (
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
trigger={['click']}
|
||||
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
|
||||
<MenuButton onClick={handleMenuButtonClick}>
|
||||
<MoreVertical size={14} className="text-[var(--color-text-secondary)]" />
|
||||
</MenuButton>
|
||||
</Dropdown>
|
||||
)}
|
||||
</Container>
|
||||
</Dropdown>
|
||||
|
||||
@ -104,9 +104,11 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
||||
scrollerStyle={{ overflowX: 'hidden' }}
|
||||
autoHideScrollbar
|
||||
header={
|
||||
<AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]">
|
||||
{t('agent.session.add.title')}
|
||||
</AddButton>
|
||||
<div className="mt-[2px]">
|
||||
<AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]">
|
||||
{t('agent.session.add.title')}
|
||||
</AddButton>
|
||||
</div>
|
||||
}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
|
||||
@ -911,7 +911,7 @@ const HeaderRow = styled.div`
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-right: 10px;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
margin-top: 2px;
|
||||
`
|
||||
|
||||
|
||||
@ -130,14 +130,22 @@ const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity
|
||||
// ? t(permissionModeCard.titleKey, permissionModeCard.titleFallback)
|
||||
// : permissionMode
|
||||
|
||||
const getLastFolderName = (path: string): string => {
|
||||
const trimmedPath = path.replace(/[/\\]+$/, '')
|
||||
const parts = trimmedPath.split(/[/\\]/)
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
const infoItems: ReactNode[] = []
|
||||
|
||||
const InfoTag = ({
|
||||
text,
|
||||
tooltip,
|
||||
className,
|
||||
onClick
|
||||
}: {
|
||||
text: string
|
||||
tooltip?: string
|
||||
className?: string
|
||||
classNames?: {}
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
@ -148,7 +156,7 @@ const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity
|
||||
onClick !== undefined ? 'cursor-pointer' : undefined,
|
||||
className
|
||||
)}
|
||||
title={text}
|
||||
title={tooltip ?? text}
|
||||
onClick={onClick}>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="block truncate">{text}</span>
|
||||
@ -161,7 +169,8 @@ const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity
|
||||
infoItems.push(
|
||||
<InfoTag
|
||||
key="path"
|
||||
text={firstAccessiblePath}
|
||||
text={getLastFolderName(firstAccessiblePath)}
|
||||
tooltip={firstAccessiblePath}
|
||||
className="max-w-60 transition-colors hover:border-primary hover:text-primary"
|
||||
onClick={() => {
|
||||
window.api.file
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { allMinApps } from '@renderer/config/minapps'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useNavbarPosition } from '@renderer/hooks/useSettings'
|
||||
@ -51,7 +51,7 @@ const MinAppPage: FC = () => {
|
||||
if (!appId) return null
|
||||
|
||||
// First try to find in default and custom mini-apps
|
||||
let foundApp = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
|
||||
let foundApp = [...allMinApps, ...minapps].find((app) => app.id === appId)
|
||||
|
||||
// If not found and we have cache, try to find in cache (for temporary apps)
|
||||
if (!foundApp && minAppsCache) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { CloseOutlined } from '@ant-design/icons'
|
||||
import type { DraggableProvided, DroppableProvided, DropResult } from '@hello-pangea/dnd'
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { allMinApps } from '@renderer/config/minapps'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { getMiniappsStatusLabel } from '@renderer/i18n/label'
|
||||
import type { MinAppType } from '@renderer/types'
|
||||
@ -91,7 +91,9 @@ const MiniAppIconsManager: FC<MiniAppManagerProps> = ({
|
||||
)
|
||||
|
||||
const renderProgramItem = (program: MinAppType, provided: DraggableProvided, listType: ListType) => {
|
||||
const { name, logo } = DEFAULT_MIN_APPS.find((app) => app.id === program.id) || { name: program.name, logo: '' }
|
||||
const appData = allMinApps.find((app) => app.id === program.id)
|
||||
const name = appData?.nameKey ? t(appData.nameKey) : appData?.name || program.name
|
||||
const logo = appData?.logo || ''
|
||||
|
||||
return (
|
||||
<ProgramItem ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { UndoOutlined } from '@ant-design/icons' // 导入重置图标
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { allMinApps } from '@renderer/config/minapps'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { SettingDescription, SettingDivider, SettingRowTitle, SettingTitle } from '@renderer/pages/settings'
|
||||
@ -32,9 +32,9 @@ const MiniAppSettings: FC = () => {
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleResetMinApps = useCallback(() => {
|
||||
setVisibleMiniApps(DEFAULT_MIN_APPS)
|
||||
setVisibleMiniApps(allMinApps)
|
||||
setDisabledMiniApps([])
|
||||
updateMinapps(DEFAULT_MIN_APPS)
|
||||
updateMinapps(allMinApps)
|
||||
updateDisabledMinapps([])
|
||||
}, [updateDisabledMinapps, updateMinapps])
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { PlusOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps } from '@renderer/config/minapps'
|
||||
import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateAllMinApps } from '@renderer/config/minapps'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import type { MinAppType } from '@renderer/types'
|
||||
import { Button, Form, Input, Modal, Radio, Upload } from 'antd'
|
||||
@ -60,7 +60,7 @@ const NewAppButton: FC<Props> = ({ size = 60 }) => {
|
||||
form.resetFields()
|
||||
setFileList([])
|
||||
const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
|
||||
updateDefaultMinApps(reloadedApps)
|
||||
updateAllMinApps(reloadedApps)
|
||||
updateMinapps([...minapps, newApp])
|
||||
} catch (error) {
|
||||
window.toast.error(t('settings.miniapps.custom.save_error'))
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { isDev } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { allMinApps } from '@renderer/config/minapps'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
@ -50,7 +50,7 @@ const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOp
|
||||
const navigate = useNavigate()
|
||||
const [canGoBack, setCanGoBack] = useState(false)
|
||||
const [canGoForward, setCanGoForward] = useState(false)
|
||||
const canPinned = DEFAULT_MIN_APPS.some((item) => item.id === app.id)
|
||||
const canPinned = allMinApps.some((item) => item.id === app.id)
|
||||
const isPinned = pinned.some((item) => item.id === app.id)
|
||||
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ import {
|
||||
DEFAULT_TEMPERATURE,
|
||||
isMac
|
||||
} from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { allMinApps } from '@renderer/config/minapps'
|
||||
import {
|
||||
glm45FlashModel,
|
||||
isFunctionCallingModel,
|
||||
@ -99,7 +99,7 @@ function removeMiniAppFromState(state: RootState, id: string) {
|
||||
|
||||
function addMiniApp(state: RootState, id: string) {
|
||||
if (state.minapps) {
|
||||
const app = DEFAULT_MIN_APPS.find((app) => app.id === id)
|
||||
const app = allMinApps.find((app) => app.id === id)
|
||||
if (app) {
|
||||
if (!state.minapps.enabled.find((app) => app.id === id)) {
|
||||
state.minapps.enabled.push(app)
|
||||
@ -1076,7 +1076,7 @@ const migrateConfig = {
|
||||
|
||||
if (state.minapps) {
|
||||
appIds.forEach((id) => {
|
||||
const app = DEFAULT_MIN_APPS.find((app) => app.id === id)
|
||||
const app = allMinApps.find((app) => app.id === id)
|
||||
if (app) {
|
||||
state.minapps.enabled.push(app)
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
*/
|
||||
import type { PayloadAction } from '@reduxjs/toolkit'
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { allMinApps } from '@renderer/config/minapps'
|
||||
import type { MinAppType } from '@renderer/types'
|
||||
|
||||
export interface MinAppsState {
|
||||
@ -26,7 +26,7 @@ export interface MinAppsState {
|
||||
}
|
||||
|
||||
const initialState: MinAppsState = {
|
||||
enabled: DEFAULT_MIN_APPS,
|
||||
enabled: allMinApps,
|
||||
disabled: [],
|
||||
pinned: []
|
||||
}
|
||||
|
||||
@ -459,6 +459,10 @@ export interface PaintingsState {
|
||||
export type MinAppType = {
|
||||
id: string
|
||||
name: string
|
||||
/** i18n key for translatable names */
|
||||
nameKey?: string
|
||||
/** Locale codes where this app should be visible (e.g., ['zh-CN', 'zh-TW']) */
|
||||
locales?: LanguageVarious[]
|
||||
logo?: string
|
||||
url: string
|
||||
// FIXME: It should be `bordered`
|
||||
|
||||
@ -9,9 +9,9 @@ import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'
|
||||
vi.mock('@renderer/config/minapps', () => {
|
||||
return {
|
||||
ORIGIN_DEFAULT_MIN_APPS: [],
|
||||
DEFAULT_MIN_APPS: [],
|
||||
allMinApps: [],
|
||||
loadCustomMiniApp: async () => [],
|
||||
updateDefaultMinApps: vi.fn()
|
||||
updateAllMinApps: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mergeHeaders } from '../headers'
|
||||
|
||||
describe('mergeHeaders', () => {
|
||||
describe('basic merging', () => {
|
||||
it('should merge multiple header objects', () => {
|
||||
const result = mergeHeaders({ 'X-Custom': 'value1' }, { 'X-Other': 'value2' })
|
||||
|
||||
expect(result['x-custom']).toBe('value1')
|
||||
expect(result['x-other']).toBe('value2')
|
||||
})
|
||||
|
||||
it('should override headers with same key (case-insensitive)', () => {
|
||||
const result = mergeHeaders({ 'X-Custom': 'value1' }, { 'x-custom': 'value2' })
|
||||
|
||||
expect(result['x-custom']).toBe('value2')
|
||||
})
|
||||
|
||||
it('should normalize all keys to lowercase', () => {
|
||||
const result = mergeHeaders({ 'X-Custom': 'value1', 'Content-Type': 'application/json' })
|
||||
|
||||
expect(Object.keys(result)).toEqual(expect.arrayContaining(['x-custom', 'content-type']))
|
||||
expect(result['x-custom']).toBe('value1')
|
||||
expect(result['content-type']).toBe('application/json')
|
||||
})
|
||||
|
||||
it('should skip undefined and null header sets', () => {
|
||||
const result = mergeHeaders({ 'X-Custom': 'value1' }, undefined, null as any, { 'X-Other': 'value2' })
|
||||
|
||||
expect(result['x-custom']).toBe('value1')
|
||||
expect(result['x-other']).toBe('value2')
|
||||
})
|
||||
|
||||
it('should skip undefined values in headers', () => {
|
||||
const result = mergeHeaders({ 'X-Custom': 'value1', 'X-Undefined': undefined })
|
||||
|
||||
expect(result['x-custom']).toBe('value1')
|
||||
expect(result['x-undefined']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User-Agent special handling', () => {
|
||||
it('should concatenate User-Agent values with space', () => {
|
||||
const result = mergeHeaders({ 'User-Agent': 'DefaultAgent' }, { 'user-agent': 'CustomSuffix' })
|
||||
|
||||
expect(result['user-agent']).toBe('DefaultAgent CustomSuffix')
|
||||
})
|
||||
|
||||
it('should concatenate multiple User-Agent values in order', () => {
|
||||
const result = mergeHeaders({ 'User-Agent': 'Agent1' }, { 'user-agent': 'Agent2' }, { 'USER-AGENT': 'Agent3' })
|
||||
|
||||
expect(result['user-agent']).toBe('Agent1 Agent2 Agent3')
|
||||
})
|
||||
|
||||
it('should handle User-Agent with mixed case keys', () => {
|
||||
const result = mergeHeaders({ 'USER-AGENT': 'DefaultAgent' }, { 'User-Agent': 'CustomSuffix' })
|
||||
|
||||
expect(result['user-agent']).toBe('DefaultAgent CustomSuffix')
|
||||
})
|
||||
|
||||
it('should handle single User-Agent', () => {
|
||||
const result = mergeHeaders({ 'User-Agent': 'SingleAgent' })
|
||||
|
||||
expect(result['user-agent']).toBe('SingleAgent')
|
||||
})
|
||||
|
||||
it('should handle no User-Agent', () => {
|
||||
const result = mergeHeaders({ 'X-Custom': 'value' })
|
||||
|
||||
expect(result['user-agent']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('example from documentation', () => {
|
||||
it('should match the documented example', () => {
|
||||
const result = mergeHeaders(
|
||||
{ 'User-Agent': 'DefaultAgent', 'X-Custom': 'value1' },
|
||||
{ 'user-agent': 'CustomSuffix', 'X-Custom': 'value2' }
|
||||
)
|
||||
|
||||
expect(result['user-agent']).toBe('DefaultAgent CustomSuffix')
|
||||
expect(result['x-custom']).toBe('value2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty object when no headers provided', () => {
|
||||
const result = mergeHeaders()
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should handle empty header objects', () => {
|
||||
const result = mergeHeaders({}, { 'X-Custom': 'value' }, {})
|
||||
|
||||
expect(result['x-custom']).toBe('value')
|
||||
})
|
||||
|
||||
it('should handle HeadersInit types', () => {
|
||||
const headersInit = new Headers({ 'X-Custom': 'value1' })
|
||||
const result = mergeHeaders(headersInit, { 'X-Other': 'value2' })
|
||||
|
||||
expect(result['x-custom']).toBe('value1')
|
||||
expect(result['x-other']).toBe('value2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Custom fetch wrapper that preserves User-Agent header for Electron renderer process.
|
||||
*
|
||||
* In Electron's renderer process, User-Agent is a "forbidden header" that cannot be
|
||||
* modified via the Fetch API. This wrapper copies the user-agent header to a custom
|
||||
* x-custom-user-agent header, which is then converted back to User-Agent by the
|
||||
* main process's onBeforeSendHeaders interceptor.
|
||||
*/
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
export const customFetch: typeof fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.headers) {
|
||||
const headers = new Headers(init.headers as HeadersInit)
|
||||
const ua = headers.get('user-agent')
|
||||
if (ua) {
|
||||
headers.set('x-custom-user-agent', ua)
|
||||
}
|
||||
init = { ...init, headers }
|
||||
}
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
@ -73,6 +73,10 @@ export async function fetchWebContent(
|
||||
html = await Promise.race(promisesToRace)
|
||||
} else {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
},
|
||||
...httpOptions,
|
||||
signal: httpOptions?.signal
|
||||
? AbortSignal.any([httpOptions.signal, AbortSignal.timeout(30000)])
|
||||
@ -130,7 +134,11 @@ export async function fetchRedirectUrl(url: string) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
redirect: 'follow'
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
})
|
||||
return response.url
|
||||
} catch (e) {
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export { mergeHeaders } from '@shared/utils'
|
||||
@ -15,12 +15,12 @@ import { AssistantMessageStatus } from '@renderer/types/newMessage'
|
||||
import type { ActionItem } from '@renderer/types/selectionTypes'
|
||||
import { abortCompletion } from '@renderer/utils/abortController'
|
||||
import { detectLanguage } from '@renderer/utils/translate'
|
||||
import { Tooltip } from 'antd'
|
||||
import { ArrowRightFromLine, ArrowRightToLine, ChevronDown, CircleHelp, Globe } from 'lucide-react'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import { ArrowRight, ChevronDown, CircleHelp, Settings2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import styled, { createGlobalStyle } from 'styled-components'
|
||||
|
||||
import { processMessages } from './ActionUtils'
|
||||
import WindowFooter from './WindowFooter'
|
||||
@ -47,12 +47,15 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
})
|
||||
|
||||
const [alterLanguage, setAlterLanguage] = useState<TranslateLanguage>(LanguagesEnum.enUS)
|
||||
const [detectedLanguage, setDetectedLanguage] = useState<TranslateLanguage | null>(null)
|
||||
const [actualTargetLanguage, setActualTargetLanguage] = useState<TranslateLanguage>(targetLanguage)
|
||||
|
||||
const [error, setError] = useState('')
|
||||
const [showOriginal, setShowOriginal] = useState(false)
|
||||
const [status, setStatus] = useState<'preparing' | 'streaming' | 'finished'>('preparing')
|
||||
const [contentToCopy, setContentToCopy] = useState('')
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
// Use useRef for values that shouldn't trigger re-renders
|
||||
const assistantRef = useRef<Assistant | null>(null)
|
||||
@ -156,6 +159,10 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Set detected language for UI display
|
||||
const detectedLang = getLanguageByLangcode(sourceLanguageCode)
|
||||
setDetectedLanguage(detectedLang)
|
||||
|
||||
let translateLang: TranslateLanguage
|
||||
|
||||
if (sourceLanguageCode === UNKNOWN.langCode) {
|
||||
@ -170,11 +177,14 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Set actual target language for UI display
|
||||
setActualTargetLanguage(translateLang)
|
||||
|
||||
const assistant = getDefaultTranslateAssistant(translateLang, action.selectedText)
|
||||
assistantRef.current = assistant
|
||||
logger.debug('process once')
|
||||
processMessages(assistant, topicRef.current, assistant.content, setAskId, onStream, onFinish, onError)
|
||||
}, [action, targetLanguage, alterLanguage, scrollToBottom, initialized])
|
||||
}, [action, targetLanguage, alterLanguage, scrollToBottom, initialized, getLanguageByLangcode])
|
||||
|
||||
useEffect(() => {
|
||||
fetchResult()
|
||||
@ -213,16 +223,88 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
const isPreparing = status === 'preparing'
|
||||
const isStreaming = status === 'streaming'
|
||||
|
||||
const handleChangeLanguage = (targetLanguage: TranslateLanguage, alterLanguage: TranslateLanguage) => {
|
||||
if (!initialized) {
|
||||
return
|
||||
}
|
||||
setTargetLanguage(targetLanguage)
|
||||
targetLangRef.current = targetLanguage
|
||||
setAlterLanguage(alterLanguage)
|
||||
const handleChangeLanguage = useCallback(
|
||||
(newTargetLanguage: TranslateLanguage, newAlterLanguage: TranslateLanguage) => {
|
||||
if (!initialized) {
|
||||
return
|
||||
}
|
||||
setTargetLanguage(newTargetLanguage)
|
||||
targetLangRef.current = newTargetLanguage
|
||||
setAlterLanguage(newAlterLanguage)
|
||||
|
||||
db.settings.put({ id: 'translate:bidirectional:pair', value: [targetLanguage.langCode, alterLanguage.langCode] })
|
||||
}
|
||||
db.settings.put({
|
||||
id: 'translate:bidirectional:pair',
|
||||
value: [newTargetLanguage.langCode, newAlterLanguage.langCode]
|
||||
})
|
||||
},
|
||||
[initialized]
|
||||
)
|
||||
|
||||
// Handle direct target language change from the main dropdown
|
||||
const handleDirectTargetChange = useCallback(
|
||||
(langCode: TranslateLanguageCode) => {
|
||||
if (!initialized) return
|
||||
const newLang = getLanguageByLangcode(langCode)
|
||||
setActualTargetLanguage(newLang)
|
||||
|
||||
// Update settings: if new target equals current target, keep as is
|
||||
// Otherwise, swap if needed or just update target
|
||||
if (newLang.langCode !== targetLanguage.langCode && newLang.langCode !== alterLanguage.langCode) {
|
||||
// New language is different from both, update target
|
||||
setTargetLanguage(newLang)
|
||||
targetLangRef.current = newLang
|
||||
db.settings.put({ id: 'translate:bidirectional:pair', value: [newLang.langCode, alterLanguage.langCode] })
|
||||
}
|
||||
},
|
||||
[initialized, getLanguageByLangcode, targetLanguage.langCode, alterLanguage.langCode]
|
||||
)
|
||||
|
||||
// Settings dropdown menu items
|
||||
const settingsMenuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'preferred',
|
||||
label: (
|
||||
<SettingsMenuItem>
|
||||
<SettingsLabel>{t('translate.preferred_target')}</SettingsLabel>
|
||||
<LanguageSelect
|
||||
value={targetLanguage.langCode}
|
||||
style={{ width: '100%' }}
|
||||
listHeight={160}
|
||||
size="small"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(value) => {
|
||||
handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)
|
||||
setSettingsOpen(false)
|
||||
}}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</SettingsMenuItem>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'alter',
|
||||
label: (
|
||||
<SettingsMenuItem>
|
||||
<SettingsLabel>{t('translate.alter_language')}</SettingsLabel>
|
||||
<LanguageSelect
|
||||
value={alterLanguage.langCode}
|
||||
style={{ width: '100%' }}
|
||||
listHeight={160}
|
||||
size="small"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(value) => {
|
||||
handleChangeLanguage(targetLanguage, getLanguageByLangcode(value))
|
||||
setSettingsOpen(false)
|
||||
}}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</SettingsMenuItem>
|
||||
)
|
||||
}
|
||||
],
|
||||
[t, targetLanguage, alterLanguage, isStreaming, getLanguageByLangcode, handleChangeLanguage]
|
||||
)
|
||||
|
||||
const handlePause = () => {
|
||||
// FIXME: It doesn't work because abort signal is not set.
|
||||
@ -242,39 +324,58 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDropdownStyles />
|
||||
<Container>
|
||||
<MenuContainer>
|
||||
<Tooltip placement="bottom" title={t('translate.any.language')} arrow>
|
||||
<Globe size={16} style={{ flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
<ArrowRightToLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
|
||||
<Tooltip placement="bottom" title={t('translate.target_language')} arrow>
|
||||
<LeftGroup>
|
||||
{/* Detected language display (read-only) */}
|
||||
<DetectedLanguageTag>
|
||||
{isPreparing ? (
|
||||
<span>{t('translate.detecting')}</span>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ marginRight: 4 }}>{detectedLanguage?.emoji || '🌐'}</span>
|
||||
<span>{detectedLanguage?.label() || t('translate.detected_source')}</span>
|
||||
</>
|
||||
)}
|
||||
</DetectedLanguageTag>
|
||||
|
||||
<ArrowRight size={16} color="var(--color-text-3)" style={{ flexShrink: 0 }} />
|
||||
|
||||
{/* Target language selector */}
|
||||
<LanguageSelect
|
||||
value={targetLanguage.langCode}
|
||||
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
|
||||
value={actualTargetLanguage.langCode}
|
||||
style={{ minWidth: 100, maxWidth: 160 }}
|
||||
listHeight={160}
|
||||
title={t('translate.target_language')}
|
||||
size="small"
|
||||
optionFilterProp="label"
|
||||
onChange={(value) => handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)}
|
||||
onChange={handleDirectTargetChange}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ArrowRightFromLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
|
||||
<Tooltip placement="bottom" title={t('translate.alter_language')} arrow>
|
||||
<LanguageSelect
|
||||
value={alterLanguage.langCode}
|
||||
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
|
||||
listHeight={160}
|
||||
title={t('translate.alter_language')}
|
||||
optionFilterProp="label"
|
||||
onChange={(value) => handleChangeLanguage(targetLanguage, getLanguageByLangcode(value))}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" title={t('selection.action.translate.smart_translate_tips')} arrow>
|
||||
<QuestionIcon size={14} style={{ marginLeft: 4 }} />
|
||||
</Tooltip>
|
||||
<Spacer />
|
||||
|
||||
{/* Settings dropdown */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: settingsMenuItems,
|
||||
selectable: false,
|
||||
className: 'settings-dropdown-menu'
|
||||
}}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}>
|
||||
<Tooltip title={t('translate.language_settings')} placement="bottom">
|
||||
<SettingsButton>
|
||||
<Settings2 size={14} />
|
||||
</SettingsButton>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
|
||||
<Tooltip title={t('selection.action.translate.smart_translate_tips')} placement="bottom">
|
||||
<HelpIcon size={14} />
|
||||
</Tooltip>
|
||||
</LeftGroup>
|
||||
|
||||
<OriginalHeader onClick={() => setShowOriginal(!showOriginal)}>
|
||||
<span>
|
||||
{showOriginal ? t('selection.action.window.original_hide') : t('selection.action.window.original_show')}
|
||||
@ -390,12 +491,72 @@ const ErrorMsg = styled.div`
|
||||
word-break: break-all;
|
||||
`
|
||||
|
||||
const Spacer = styled.div`
|
||||
flex-grow: 0.5;
|
||||
const LeftGroup = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
`
|
||||
const QuestionIcon = styled(CircleHelp)`
|
||||
|
||||
const DetectedLanguageTag = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--color-background-soft);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const SettingsButton = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
`
|
||||
|
||||
const SettingsMenuItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
min-width: 180px;
|
||||
cursor: default;
|
||||
`
|
||||
|
||||
const SettingsLabel = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
`
|
||||
|
||||
const HelpIcon = styled(CircleHelp)`
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const SettingsDropdownStyles = createGlobalStyle`
|
||||
.settings-dropdown-menu {
|
||||
.ant-dropdown-menu-item {
|
||||
cursor: default !important;
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default ActionTranslate
|
||||
|
||||
Loading…
Reference in New Issue
Block a user