Merge remote-tracking branch 'origin/main' into feat/user-guide

This commit is contained in:
suyao 2026-01-26 09:59:37 +08:00
commit a3f0419d73
No known key found for this signature in database
54 changed files with 675 additions and 439 deletions

View File

@ -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:

View File

@ -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-->

View File

@ -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",

View File

@ -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)
}

View File

@ -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

View File

@ -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 = {

View File

@ -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

View File

@ -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) {

View File

@ -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) {

View File

@ -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
}

View File

@ -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']
}

View File

@ -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()
}))

View File

@ -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'])
})
})

View File

@ -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: ''
}

View File

@ -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

View File

@ -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',

View File

@ -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>
)

View File

@ -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://')
}

View File

@ -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

View File

@ -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 }

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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 ",

View File

@ -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": "翻译完成后自动复制",

View File

@ -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": "翻譯完成後自動複製",

View File

@ -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",

View File

@ -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": "Μετά τη μετάφραση, αντιγράφεται αυτόματα",

View File

@ -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",

View File

@ -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",

View File

@ -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": "翻訳完了後、自動的にコピー",

View File

@ -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",

View File

@ -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 ",

View File

@ -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": "Автоматически копировать после завершения перевода",

View File

@ -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}>

View File

@ -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);

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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;
`

View File

@ -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

View 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) {

View File

@ -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}>

View File

@ -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])

View File

@ -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'))

View File

@ -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://')

View File

@ -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)
}

View File

@ -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: []
}

View File

@ -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`

View File

@ -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()
}
})

View File

@ -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')
})
})
})

View File

@ -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)
}

View File

@ -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) {

View File

@ -1 +0,0 @@
export { mergeHeaders } from '@shared/utils'

View File

@ -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