Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2

This commit is contained in:
fullex 2026-01-05 01:01:11 +08:00
commit 9b6c8f1f60
55 changed files with 7335 additions and 459 deletions

View File

@ -73,7 +73,7 @@ jobs:
- name: 🚀 Create Pull Request if changes exist
if: steps.git_status.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }} # Use the built-in GITHUB_TOKEN for bot actions
commit-message: "feat(bot): Weekly automated script run"

View File

@ -34,7 +34,7 @@
</a>
</h1>
<p align="center">English | <a href="./docs/zh/README.md">中文</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us">Documents</a> | <a href="./docs/en/guides/development.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
<p align="center">English | <a href="./docs/zh/README.md">中文</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/docs/en-us">Documents</a> | <a href="./docs/en/guides/development.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
<div align="center">
@ -242,12 +242,12 @@ The Enterprise Edition addresses core challenges in team collaboration by centra
## Version Comparison
| Feature | Community Edition | Enterprise Edition |
| :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| **Open Source** | ✅ Yes | ⭕️ Partially released to customers |
| Feature | Community Edition | Enterprise Edition |
| :---------------- | :----------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| **Open Source** | ✅ Yes | ⭕️ Partially released to customers |
| **Cost** | [AGPL-3.0 License](https://github.com/CherryHQ/cherry-studio?tab=AGPL-3.0-1-ov-file) | Buyout / Subscription Fee |
| **Admin Backend** | — | ● Centralized **Model** Access<br>**Employee** Management<br>● Shared **Knowledge Base**<br>**Access** Control<br>**Data** Backup |
| **Server** | — | ✅ Dedicated Private Deployment |
| **Admin Backend** | — | ● Centralized **Model** Access<br>**Employee** Management<br>● Shared **Knowledge Base**<br>**Access** Control<br>**Data** Backup |
| **Server** | — | ✅ Dedicated Private Deployment |
## Get the Enterprise Edition
@ -275,7 +275,7 @@ We believe the Enterprise Edition will become your team's AI productivity engine
# 📊 GitHub Stats
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg 'Repobeats analytics image')
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg "Repobeats analytics image")
# ⭐️ Star History

View File

@ -34,7 +34,7 @@
</a>
</h1>
<p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="https://cherry-ai.com">官方网站</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/zh-cn">文档</a> | <a href="./guides/development.md">开发</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">反馈</a><br>
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="https://cherry-ai.com">官方网站</a> | <a href="https://docs.cherry-ai.com">文档</a> | <a href="./guides/development.md">开发</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">反馈</a><br>
</p>
<!-- 题头徽章组合 -->
@ -281,7 +281,7 @@ https://docs.cherry-ai.com
# 📊 GitHub 统计
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg 'Repobeats analytics image')
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg "Repobeats analytics image")
# ⭐️ Star 记录

View File

@ -135,38 +135,44 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
Cherry Studio 1.7.8 - Bug Fixes & Performance Improvements
Cherry Studio 1.7.9 - New Features & Bug Fixes
This release focuses on bug fixes and performance optimizations.
⚡ Performance
- [ModelList] Improve model list loading performance
✨ New Features
- [Agent] Add 302.AI provider support
- [Browser] Browser data now persists and supports multiple tabs
- [Language] Add Romanian language support
- [Search] Add fuzzy search for file list
- [Models] Add latest Zhipu models
- [Image] Improve text-to-image functionality
🐛 Bug Fixes
- [Ollama] Fix new users unable to use Ollama models
- [Ollama] Improve reasoningEffort handling
- [Assistants] Prevent deleting last assistant and add error message
- [Shortcut] Fix shortcut icons sorting disorder
- [Memory] Fix global memory settings submit failure
- [Windows] Fix remember size not working for SelectionAction window
- [Anthropic] Fix API base URL handling
- [Files] Allow more file extensions
- [Mac] Fix mini window unexpected closing issue
- [Preview] Fix HTML preview controls not working in fullscreen
- [Translate] Fix translation duplicate execution issue
- [Zoom] Fix page zoom reset issue during navigation
- [Agent] Fix crash when switching between agent and assistant
- [Agent] Fix navigation in agent mode
- [Copy] Fix markdown copy button issue
- [Windows] Fix compatibility issues on non-Windows systems
<!--LANG:zh-CN-->
Cherry Studio 1.7.8 - 问题修复与性能优化
Cherry Studio 1.7.9 - 新功能与问题修复
本次更新专注于问题修复和性能优化。
⚡ 性能优化
- [模型列表] 提升模型列表加载性能
✨ 新功能
- [Agent] 新增 302.AI 服务商支持
- [浏览器] 浏览器数据现在可以保存,支持多标签页
- [语言] 新增罗马尼亚语支持
- [搜索] 文件列表新增模糊搜索功能
- [模型] 新增最新智谱模型
- [图片] 优化文生图功能
🐛 问题修复
- [Ollama] 修复新用户无法使用 Ollama 模型的问题
- [Ollama] 改进推理参数处理
- [助手] 防止删除最后一个助手并添加错误提示
- [快捷方式] 修复快捷方式图标排序混乱
- [记忆] 修复全局记忆设置提交失败
- [窗口] 修复 SelectionAction 窗口记住尺寸不生效
- [Anthropic] 修复 API 地址处理
- [文件] 允许更多文件扩展名
- [Mac] 修复迷你窗口意外关闭的问题
- [预览] 修复全屏模式下 HTML 预览控件无法使用的问题
- [翻译] 修复翻译重复执行的问题
- [缩放] 修复页面导航时缩放被重置的问题
- [智能体] 修复在智能体和助手间切换时崩溃的问题
- [智能体] 修复智能体模式下的导航问题
- [复制] 修复 Markdown 复制按钮问题
- [兼容性] 修复非 Windows 系统的兼容性问题
<!--LANG:END-->

View File

@ -55,14 +55,15 @@ export enum ThemeMode {
export type LanguageVarious =
| 'zh-CN'
| 'zh-TW'
| 'de-DE'
| 'el-GR'
| 'en-US'
| 'es-ES'
| 'fr-FR'
| 'ja-JP'
| 'pt-PT'
| 'ro-RO'
| 'ru-RU'
| 'de-DE'
export type WindowStyle = 'transparent' | 'opaque'

View File

@ -152,7 +152,8 @@ const languageMap = {
'es-es': 'Spanish',
'fr-fr': 'French',
'pt-pt': 'Portuguese',
'de-de': 'German'
'de-de': 'German',
'ro-ro': 'Romanian'
}
const PROMPT = `

View File

@ -1062,12 +1062,18 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)
} catch (error) {
const pluginError = extractPluginError(error)
if (pluginError) {
logger.error('Failed to list installed plugins', { agentId, error: pluginError })
logger.error('Failed to list installed plugins', {
agentId,
error: pluginError
})
return { success: false, error: pluginError }
}
const err = normalizeError(error)
logger.error('Failed to list installed plugins', { agentId, error: err })
logger.error('Failed to list installed plugins', {
agentId,
error: err
})
return {
success: false,
error: {

View File

@ -1,5 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('node:fs', () => ({
default: {
existsSync: vi.fn(() => false),
mkdirSync: vi.fn()
},
existsSync: vi.fn(() => false),
mkdirSync: vi.fn()
}))
vi.mock('electron', () => {
const sendCommand = vi.fn(async (command: string, params?: { expression?: string }) => {
if (command === 'Runtime.evaluate') {
@ -21,24 +30,31 @@ vi.mock('electron', () => {
sendCommand
}
const webContents = {
const createWebContents = () => ({
debugger: debuggerObj,
setUserAgent: vi.fn(),
getURL: vi.fn(() => 'https://example.com/'),
getTitle: vi.fn(async () => 'Example Title'),
loadURL: vi.fn(async () => {}),
once: vi.fn(),
removeListener: vi.fn(),
on: vi.fn()
}
const loadURL = vi.fn(async () => {})
on: vi.fn(),
isDestroyed: vi.fn(() => false),
canGoBack: vi.fn(() => false),
canGoForward: vi.fn(() => false),
goBack: vi.fn(),
goForward: vi.fn(),
reload: vi.fn(),
executeJavaScript: vi.fn(async () => null),
setWindowOpenHandler: vi.fn()
})
const windows: any[] = []
const views: any[] = []
class MockBrowserWindow {
private destroyed = false
public webContents = webContents
public loadURL = loadURL
public webContents = createWebContents()
public isDestroyed = vi.fn(() => this.destroyed)
public close = vi.fn(() => {
this.destroyed = true
@ -47,31 +63,58 @@ vi.mock('electron', () => {
this.destroyed = true
})
public on = vi.fn()
public setBrowserView = vi.fn()
public addBrowserView = vi.fn()
public removeBrowserView = vi.fn()
public getContentSize = vi.fn(() => [1200, 800])
public show = vi.fn()
constructor() {
windows.push(this)
}
}
class MockBrowserView {
public webContents = createWebContents()
public setBounds = vi.fn()
public setAutoResize = vi.fn()
public destroy = vi.fn()
constructor() {
views.push(this)
}
}
const app = {
isReady: vi.fn(() => true),
whenReady: vi.fn(async () => {}),
on: vi.fn()
on: vi.fn(),
getPath: vi.fn((key: string) => {
if (key === 'userData') return '/mock/userData'
if (key === 'temp') return '/tmp'
return '/mock/unknown'
}),
getAppPath: vi.fn(() => '/mock/app'),
setPath: vi.fn()
}
const nativeTheme = {
on: vi.fn(),
shouldUseDarkColors: false
}
return {
BrowserWindow: MockBrowserWindow as any,
BrowserView: MockBrowserView as any,
app,
nativeTheme,
__mockDebugger: debuggerObj,
__mockSendCommand: sendCommand,
__mockLoadURL: loadURL,
__mockWindows: windows
__mockWindows: windows,
__mockViews: views
}
})
import * as electron from 'electron'
const { __mockWindows } = electron as typeof electron & { __mockWindows: any[] }
import { CdpBrowserController } from '../browser'
describe('CdpBrowserController', () => {
@ -81,54 +124,249 @@ describe('CdpBrowserController', () => {
expect(result).toBe('ok')
})
it('opens a URL (hidden) and returns current page info', async () => {
it('opens a URL in normal mode and returns current page info', async () => {
const controller = new CdpBrowserController()
const result = await controller.open('https://foo.bar/', 5000, false)
expect(result.currentUrl).toBe('https://example.com/')
expect(result.title).toBe('Example Title')
})
it('opens a URL (visible) when show=true', async () => {
it('opens a URL in private mode', async () => {
const controller = new CdpBrowserController()
const result = await controller.open('https://foo.bar/', 5000, true, 'session-a')
const result = await controller.open('https://foo.bar/', 5000, true)
expect(result.currentUrl).toBe('https://example.com/')
expect(result.title).toBe('Example Title')
})
it('reuses session for execute and supports multiline', async () => {
const controller = new CdpBrowserController()
await controller.open('https://foo.bar/', 5000, false, 'session-b')
const result = await controller.execute('const a=1; const b=2; a+b;', 5000, 'session-b')
await controller.open('https://foo.bar/', 5000, false)
const result = await controller.execute('const a=1; const b=2; a+b;', 5000, false)
expect(result).toBe('ok')
})
it('evicts least recently used session when exceeding maxSessions', async () => {
const controller = new CdpBrowserController({ maxSessions: 2, idleTimeoutMs: 1000 * 60 })
await controller.open('https://foo.bar/', 5000, false, 's1')
await controller.open('https://foo.bar/', 5000, false, 's2')
await controller.open('https://foo.bar/', 5000, false, 's3')
const destroyedCount = __mockWindows.filter(
(w: any) => w.destroy.mock.calls.length > 0 || w.close.mock.calls.length > 0
).length
expect(destroyedCount).toBeGreaterThanOrEqual(1)
it('normal and private modes are isolated', async () => {
const controller = new CdpBrowserController()
await controller.open('https://foo.bar/', 5000, false)
await controller.open('https://foo.bar/', 5000, true)
const normalResult = await controller.execute('1+1', 5000, false)
const privateResult = await controller.execute('1+1', 5000, true)
expect(normalResult).toBe('ok')
expect(privateResult).toBe('ok')
})
it('fetches URL and returns html format', async () => {
it('fetches URL and returns html format with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'html')
expect(result).toBe('<html><body><h1>Test</h1><p>Content</p></body></html>')
expect(result.tabId).toBeDefined()
expect(result.content).toBe('<html><body><h1>Test</h1><p>Content</p></body></html>')
})
it('fetches URL and returns txt format', async () => {
it('fetches URL and returns txt format with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'txt')
expect(result).toBe('Test\nContent')
expect(result.tabId).toBeDefined()
expect(result.content).toBe('Test\nContent')
})
it('fetches URL and returns markdown format (default)', async () => {
it('fetches URL and returns markdown format (default) with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/')
expect(typeof result).toBe('string')
expect(result).toContain('Test')
expect(result.tabId).toBeDefined()
expect(typeof result.content).toBe('string')
expect(result.content).toContain('Test')
})
it('fetches URL in private mode with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'html', 10000, true)
expect(result.tabId).toBeDefined()
expect(result.content).toBe('<html><body><h1>Test</h1><p>Content</p></body></html>')
})
describe('Multi-tab support', () => {
it('creates new tab with newTab parameter', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
const result2 = await controller.open('https://site2.com/', 5000, false, true)
expect(result1.tabId).toBeDefined()
expect(result2.tabId).toBeDefined()
expect(result1.tabId).not.toBe(result2.tabId)
})
it('reuses same tab without newTab parameter', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false)
const result2 = await controller.open('https://site2.com/', 5000, false)
expect(result1.tabId).toBe(result2.tabId)
})
it('fetches in new tab with newTab parameter', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
const tabs = await controller.listTabs(false)
const initialTabCount = tabs.length
await controller.fetch('https://other.com/', 'html', 10000, false, true)
const tabsAfter = await controller.listTabs(false)
expect(tabsAfter.length).toBe(initialTabCount + 1)
})
})
describe('Tab management', () => {
it('lists tabs in a window', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
const tabs = await controller.listTabs(false)
expect(tabs.length).toBeGreaterThan(0)
expect(tabs[0].tabId).toBeDefined()
})
it('lists tabs separately for normal and private modes', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(normalTabs.length).toBe(1)
expect(privateTabs.length).toBe(1)
expect(normalTabs[0].tabId).not.toBe(privateTabs[0].tabId)
})
it('closes specific tab', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
await controller.open('https://site2.com/', 5000, false, true)
const tabsBefore = await controller.listTabs(false)
expect(tabsBefore.length).toBe(2)
await controller.closeTab(false, result1.tabId)
const tabsAfter = await controller.listTabs(false)
expect(tabsAfter.length).toBe(1)
expect(tabsAfter.find((t) => t.tabId === result1.tabId)).toBeUndefined()
})
it('switches active tab', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
const result2 = await controller.open('https://site2.com/', 5000, false, true)
await controller.switchTab(false, result1.tabId)
await controller.switchTab(false, result2.tabId)
})
it('throws error when switching to non-existent tab', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await expect(controller.switchTab(false, 'non-existent-tab')).rejects.toThrow('Tab non-existent-tab not found')
})
})
describe('Reset behavior', () => {
it('resets specific tab only', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
await controller.open('https://site2.com/', 5000, false, true)
await controller.reset(false, result1.tabId)
const tabs = await controller.listTabs(false)
expect(tabs.length).toBe(1)
})
it('resets specific window only', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
await controller.reset(false)
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(normalTabs.length).toBe(0)
expect(privateTabs.length).toBe(1)
})
it('resets all windows', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
await controller.reset()
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(normalTabs.length).toBe(0)
expect(privateTabs.length).toBe(0)
})
})
describe('showWindow parameter', () => {
it('passes showWindow parameter through open', async () => {
const controller = new CdpBrowserController()
const result = await controller.open('https://example.com/', 5000, false, false, true)
expect(result.currentUrl).toBe('https://example.com/')
expect(result.tabId).toBeDefined()
})
it('passes showWindow parameter through fetch', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'html', 10000, false, false, true)
expect(result.tabId).toBeDefined()
expect(result.content).toBe('<html><body><h1>Test</h1><p>Content</p></body></html>')
})
it('passes showWindow parameter through createTab', async () => {
const controller = new CdpBrowserController()
const { tabId, view } = await controller.createTab(false, true)
expect(tabId).toBeDefined()
expect(view).toBeDefined()
})
it('shows existing window when showWindow=true on subsequent calls', async () => {
const controller = new CdpBrowserController()
// First call creates window
await controller.open('https://example.com/', 5000, false, false, false)
// Second call with showWindow=true should show existing window
const result = await controller.open('https://example.com/', 5000, false, false, true)
expect(result.currentUrl).toBe('https://example.com/')
})
})
describe('Window limits and eviction', () => {
it('respects maxWindows limit', async () => {
const controller = new CdpBrowserController({ maxWindows: 1 })
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(privateTabs.length).toBe(1)
expect(normalTabs.length).toBe(0)
})
it('cleans up idle windows on next access', async () => {
const controller = new CdpBrowserController({ idleTimeoutMs: 1 })
await controller.open('https://example.com/', 5000, false)
await new Promise((r) => setTimeout(r, 10))
await controller.open('https://example.com/', 5000, true)
const normalTabs = await controller.listTabs(false)
expect(normalTabs.length).toBe(0)
})
})
})

View File

@ -0,0 +1,177 @@
# Browser MCP Server
A Model Context Protocol (MCP) server for controlling browser windows via Chrome DevTools Protocol (CDP).
## Features
### ✨ User Data Persistence
- **Normal mode (default)**: Cookies, localStorage, and sessionStorage persist across browser restarts
- **Private mode**: Ephemeral browsing - no data persists (like incognito mode)
### 🔄 Window Management
- Two browsing modes: normal (persistent) and private (ephemeral)
- Lazy idle timeout cleanup (cleaned on next window access)
- Maximum window limits to prevent resource exhaustion
> **Note**: Normal mode uses a global `persist:default` partition shared by all clients. This means login sessions and stored data are accessible to any code using the MCP server.
## Architecture
### How It Works
```
Normal Mode (BrowserWindow)
├─ Persistent Storage (partition: persist:default) ← Global, shared across all clients
└─ Tabs (BrowserView) ← created via newTab or automatically
Private Mode (BrowserWindow)
├─ Ephemeral Storage (partition: private) ← No disk persistence
└─ Tabs (BrowserView) ← created via newTab or automatically
```
- **One Window Per Mode**: Normal and private modes each have their own window
- **Multi-Tab Support**: Use `newTab: true` for parallel URL requests
- **Storage Isolation**: Normal and private modes have completely separate storage
## Available Tools
### `open`
Open a URL in a browser window. Optionally return page content.
```json
{
"url": "https://example.com",
"format": "markdown",
"timeout": 10000,
"privateMode": false,
"newTab": false,
"showWindow": false
}
```
- `format`: If set (`html`, `txt`, `markdown`, `json`), returns page content in that format along with tabId. If not set, just opens the page and returns navigation info.
- `newTab`: Set to `true` to open in a new tab (required for parallel requests)
- `showWindow`: Set to `true` to display the browser window (useful for debugging)
- Returns (without format): `{ currentUrl, title, tabId }`
- Returns (with format): `{ tabId, content }` where content is in the specified format
### `execute`
Execute JavaScript code in the page context.
```json
{
"code": "document.title",
"timeout": 5000,
"privateMode": false,
"tabId": "optional-tab-id"
}
```
- `tabId`: Target a specific tab (from `open` response)
### `reset`
Reset browser windows and tabs.
```json
{
"privateMode": false,
"tabId": "optional-tab-id"
}
```
- Omit all parameters to close all windows
- Set `privateMode` to close a specific window
- Set both `privateMode` and `tabId` to close a specific tab only
## Usage Examples
### Basic Navigation
```typescript
// Open a URL in normal mode (data persists)
await controller.open('https://example.com')
```
### Fetch Page Content
```typescript
// Open URL and get content as markdown
await open({ url: 'https://example.com', format: 'markdown' })
// Open URL and get raw HTML
await open({ url: 'https://example.com', format: 'html' })
```
### Multi-Tab / Parallel Requests
```typescript
// Open multiple URLs in parallel using newTab
const [page1, page2] = await Promise.all([
controller.open('https://site1.com', 10000, false, true), // newTab: true
controller.open('https://site2.com', 10000, false, true) // newTab: true
])
// Execute on specific tab
await controller.execute('document.title', 5000, false, page1.tabId)
// Close specific tab when done
await controller.reset(false, page1.tabId)
```
### Private Browsing
```typescript
// Open a URL in private mode (no data persistence)
await controller.open('https://example.com', 10000, true)
// Cookies and localStorage won't persist after reset
```
### Data Persistence (Normal Mode)
```typescript
// Set data
await controller.open('https://example.com', 10000, false)
await controller.execute('localStorage.setItem("key", "value")', 5000, false)
// Close window
await controller.reset(false)
// Reopen - data persists!
await controller.open('https://example.com', 10000, false)
const value = await controller.execute('localStorage.getItem("key")', 5000, false)
// Returns: "value"
```
### No Persistence (Private Mode)
```typescript
// Set data in private mode
await controller.open('https://example.com', 10000, true)
await controller.execute('localStorage.setItem("key", "value")', 5000, true)
// Close private window
await controller.reset(true)
// Reopen - data is gone!
await controller.open('https://example.com', 10000, true)
const value = await controller.execute('localStorage.getItem("key")', 5000, true)
// Returns: null
```
## Configuration
```typescript
const controller = new CdpBrowserController({
maxWindows: 5, // Maximum concurrent windows
idleTimeoutMs: 5 * 60 * 1000 // 5 minutes idle timeout (lazy cleanup)
})
```
> **Note on Idle Timeout**: Idle windows are cleaned up lazily when the next window is created or accessed, not on a background timer.
## Best Practices
1. **Use Normal Mode for Authentication**: When you need to stay logged in across sessions
2. **Use Private Mode for Sensitive Operations**: When you don't want data to persist
3. **Use `newTab: true` for Parallel Requests**: Avoid race conditions when fetching multiple URLs
4. **Resource Cleanup**: Call `reset()` when done, or `reset(privateMode, tabId)` to close specific tabs
5. **Error Handling**: All tool handlers return error responses on failure
6. **Timeout Configuration**: Adjust timeouts based on page complexity
## Technical Details
- **CDP Version**: 1.3
- **User Agent**: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0
- **Storage**:
- Normal mode: `persist:default` (disk-persisted, global)
- Private mode: `private` (memory only)
- **Window Size**: 1200x800 (default)
- **Visibility**: Windows hidden by default (use `showWindow: true` to display)

View File

@ -0,0 +1,3 @@
export const TAB_BAR_HEIGHT = 92 // Height for Chrome-style tab bar (42px) + address bar (50px)
export const SESSION_KEY_DEFAULT = 'default'
export const SESSION_KEY_PRIVATE = 'private'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,567 @@
export const TAB_BAR_HTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
user-select: none;
}
/* Light theme (default) */
:root {
--bg-tabrow: #dee1e6;
--bg-toolbar: #fff;
--bg-tab-hover: rgba(0,0,0,0.04);
--bg-tab-active: #fff;
--bg-url: #f1f3f4;
--bg-url-focus: #fff;
--bg-btn-hover: rgba(0,0,0,0.08);
--bg-favicon: #9aa0a6;
--color-text: #5f6368;
--color-text-active: #202124;
--color-separator: #c4c7cc;
--shadow-url-focus: 0 1px 6px rgba(32,33,36,0.28);
--window-close-hover: #e81123;
}
/* Dark theme */
body.theme-dark {
--bg-tabrow: #202124;
--bg-toolbar: #292a2d;
--bg-tab-hover: rgba(255,255,255,0.06);
--bg-tab-active: #292a2d;
--bg-url: #35363a;
--bg-url-focus: #202124;
--bg-btn-hover: rgba(255,255,255,0.1);
--bg-favicon: #5f6368;
--color-text: #9aa0a6;
--color-text-active: #e8eaed;
--color-separator: #3c3d41;
--shadow-url-focus: 0 1px 6px rgba(0,0,0,0.5);
--window-close-hover: #e81123;
}
body {
background: var(--bg-tabrow);
display: flex;
flex-direction: column;
position: relative;
}
body.platform-mac { --traffic-light-width: 70px; --window-controls-width: 0px; }
body.platform-win, body.platform-linux { --traffic-light-width: 0px; --window-controls-width: 138px; }
/* Chrome-style tab row */
#tab-row {
display: flex;
align-items: flex-end;
padding: 8px 8px 0 8px;
padding-left: calc(8px + var(--traffic-light-width, 0px));
padding-right: calc(8px + var(--window-controls-width, 0px));
height: 42px;
flex-shrink: 0;
-webkit-app-region: drag;
background: var(--bg-tabrow);
position: relative;
z-index: 1;
}
#tabs-container {
display: flex;
align-items: flex-end;
height: 34px;
flex: 1;
min-width: 0;
overflow: hidden;
}
/* New tab button - inside tabs container, right after last tab */
#new-tab-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
margin-left: 4px;
margin-bottom: 3px;
-webkit-app-region: no-drag;
flex-shrink: 0;
}
#new-tab-btn:hover { background: var(--bg-btn-hover); }
#new-tab-btn svg { width: 18px; height: 18px; fill: var(--color-text); }
/* Chrome-style tabs - shrink instead of scroll */
.tab {
position: relative;
display: flex;
align-items: center;
height: 34px;
min-width: 36px;
max-width: 240px;
flex: 1 1 240px;
padding: 0 6px;
background: transparent;
cursor: pointer;
-webkit-app-region: no-drag;
border-radius: 8px 8px 0 0;
transition: background 0.1s;
}
/* When tab is narrow, hide title, show favicon by default, show close on hover */
.tab.narrow .tab-title { display: none; }
.tab.narrow { justify-content: center; padding: 0; }
.tab.narrow .tab-favicon { margin-right: 0; }
.tab.narrow .tab-close { position: absolute; margin-left: 0; }
/* On narrow tab hover, hide favicon and show close button */
.tab.narrow:hover .tab-favicon { display: none; }
.tab.narrow:hover .tab-close { opacity: 1; }
/* Separator line using pseudo-element */
.tab::after {
content: '';
position: absolute;
right: 0;
top: 8px;
bottom: 8px;
width: 1px;
background: var(--color-separator);
pointer-events: none;
}
/* Hide separator for last tab */
.tab:last-of-type::after { display: none; }
/* Hide separator when tab is hovered (right side) */
.tab:hover::after { display: none; }
/* Hide separator on tab before hovered tab (left side of hovered) - managed by JS .before-hover class */
.tab.before-hover::after { display: none; }
/* Hide separator for active tab and its neighbors */
.tab.active::after { display: none; }
/* Hide separator on tab before active (left side of active) - managed by JS .before-active class */
.tab.before-active::after { display: none; }
.tab:hover { background: var(--bg-tab-hover); }
.tab.active {
background: var(--bg-tab-active);
z-index: 1;
}
/* Tab favicon placeholder */
.tab-favicon {
width: 16px;
height: 16px;
margin-right: 8px;
border-radius: 2px;
background: var(--bg-favicon);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.tab-favicon svg { width: 12px; height: 12px; fill: #fff; }
body.theme-dark .tab-favicon svg { fill: #9aa0a6; }
.tab-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
font-size: 12px;
font-weight: 400;
}
.tab.active .tab-title { color: var(--color-text-active); }
.tab-close {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-left: 4px;
opacity: 0;
transition: opacity 0.1s, background 0.1s;
flex-shrink: 0;
}
.tab:hover .tab-close { opacity: 1; }
.tab-close:hover { background: var(--bg-btn-hover); }
.tab-close svg { width: 16px; height: 16px; fill: var(--color-text); }
.tab-close:hover svg { fill: var(--color-text-active); }
/* Chrome-style address bar */
#address-bar {
display: flex;
align-items: center;
padding: 6px 16px 8px 8px;
gap: 4px;
background: var(--bg-toolbar);
-webkit-app-region: drag;
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
background: transparent;
border: none;
flex-shrink: 0;
-webkit-app-region: no-drag;
}
.nav-btn:hover { background: var(--bg-btn-hover); }
.nav-btn:disabled { opacity: 0.3; cursor: default; }
.nav-btn:disabled:hover { background: transparent; }
.nav-btn svg { width: 20px; height: 20px; fill: var(--color-text); }
#url-container {
flex: 1;
display: flex;
align-items: center;
background: var(--bg-url);
border-radius: 24px;
padding: 0 16px;
height: 36px;
-webkit-app-region: no-drag;
transition: background 0.2s, box-shadow 0.2s;
}
#url-container:focus-within {
background: var(--bg-url-focus);
box-shadow: var(--shadow-url-focus);
}
#url-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--color-text-active);
font-size: 14px;
font-family: inherit;
}
#url-input::placeholder { color: var(--color-text); }
#url-input::-webkit-input-placeholder { color: var(--color-text); }
/* Window controls for Windows/Linux - use inline-flex inside tab-row instead of fixed position */
#window-controls {
display: none;
height: 42px;
margin-left: auto;
margin-right: calc(-8px - var(--window-controls-width, 0px));
margin-top: -8px;
-webkit-app-region: no-drag;
}
body.platform-win #window-controls,
body.platform-linux #window-controls { display: flex; }
.window-control-btn {
width: 46px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
transition: background 0.1s;
-webkit-app-region: no-drag;
}
.window-control-btn:hover { background: var(--bg-btn-hover); }
.window-control-btn.close:hover { background: var(--window-close-hover); }
.window-control-btn svg { width: 10px; height: 10px; color: var(--color-text); fill: var(--color-text); stroke: var(--color-text); }
.window-control-btn:hover svg { color: var(--color-text-active); fill: var(--color-text-active); stroke: var(--color-text-active); }
.window-control-btn.close:hover svg { color: #fff; fill: #fff; stroke: #fff; }
</style>
</head>
<body>
<div id="tab-row">
<div id="tabs-container">
<div id="new-tab-btn" title="New tab">
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</div>
</div>
<!-- Window controls for Windows/Linux - inside tab-row to avoid drag region issues -->
<div id="window-controls">
<button class="window-control-btn" id="minimize-btn" title="Minimize">
<svg viewBox="0 0 10 1"><rect width="10" height="1"/></svg>
</button>
<button class="window-control-btn" id="maximize-btn" title="Maximize">
<svg viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
<button class="window-control-btn close" id="close-btn" title="Close">
<svg viewBox="0 0 10 10"><path d="M0 0L10 10M10 0L0 10" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
</div>
</div>
<div id="address-bar">
<button class="nav-btn" id="back-btn" title="Back" disabled>
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<button class="nav-btn" id="forward-btn" title="Forward" disabled>
<svg viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></svg>
</button>
<button class="nav-btn" id="refresh-btn" title="Refresh">
<svg viewBox="0 0 24 24"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
</button>
<div id="url-container">
<input type="text" id="url-input" placeholder="Search or enter URL" spellcheck="false" />
</div>
</div>
<script>
const tabsContainer = document.getElementById('tabs-container');
const urlInput = document.getElementById('url-input');
const backBtn = document.getElementById('back-btn');
const forwardBtn = document.getElementById('forward-btn');
const refreshBtn = document.getElementById('refresh-btn');
window.currentUrl = '';
window.canGoBack = false;
window.canGoForward = false;
// Helper function to update before-active class for separator hiding
function updateBeforeActiveClass() {
var tabs = tabsContainer.querySelectorAll('.tab');
tabs.forEach(function(tab, index) {
tab.classList.remove('before-active');
if (index < tabs.length - 1 && tabs[index + 1].classList.contains('active')) {
tab.classList.add('before-active');
}
});
}
// Helper function to update narrow class based on tab width
function updateNarrowClass() {
var tabs = tabsContainer.querySelectorAll('.tab');
tabs.forEach(function(tab) {
if (tab.offsetWidth < 72) {
tab.classList.add('narrow');
} else {
tab.classList.remove('narrow');
}
});
}
var newTabBtnHtml = '<div id="new-tab-btn" title="New tab"><svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></div>';
// Track if we're in "closing mode" where tab widths should be fixed
var closingModeTimeout = null;
var isInClosingMode = false;
function enterClosingMode() {
isInClosingMode = true;
// Clear any existing timeout
if (closingModeTimeout) {
clearTimeout(closingModeTimeout);
}
// Set timeout to exit closing mode after 1 second of no activity
closingModeTimeout = setTimeout(function() {
exitClosingMode();
}, 1000);
}
function exitClosingMode() {
isInClosingMode = false;
if (closingModeTimeout) {
clearTimeout(closingModeTimeout);
closingModeTimeout = null;
}
// Remove fixed widths from tabs
var tabs = tabsContainer.querySelectorAll('.tab');
tabs.forEach(function(tab) {
tab.style.flex = '';
tab.style.width = '';
});
}
// Exit closing mode when mouse leaves the tab row
document.getElementById('tab-row').addEventListener('mouseleave', function() {
if (isInClosingMode) {
exitClosingMode();
}
});
window.updateTabs = function(tabs, activeUrl, canGoBack, canGoForward) {
// Capture current tab widths before update if in closing mode
var previousWidths = {};
if (isInClosingMode) {
var existingTabs = tabsContainer.querySelectorAll('.tab');
existingTabs.forEach(function(tab) {
previousWidths[tab.dataset.id] = tab.offsetWidth;
});
}
if (!tabs || tabs.length === 0) {
// Window will be closed by main process when last tab is closed
// Just clear the UI in case this is called before window closes
tabsContainer.innerHTML = newTabBtnHtml;
urlInput.value = '';
document.getElementById('new-tab-btn').addEventListener('click', function() {
sendAction({ type: 'new' });
});
return;
}
tabsContainer.innerHTML = tabs.map(function(tab) {
var cls = 'tab' + (tab.isActive ? ' active' : '');
var title = (tab.title || 'New Tab').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
var url = (tab.url || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
return '<div class="' + cls + '" data-id="' + tab.id + '" title="' + url + '">' +
'<div class="tab-favicon"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg></div>' +
'<span class="tab-title">' + title + '</span>' +
'<div class="tab-close" data-id="' + tab.id + '">' +
'<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>' +
'</div>' +
'</div>';
}).join('') + newTabBtnHtml;
// Re-attach event listener for new tab button
document.getElementById('new-tab-btn').addEventListener('click', function() {
sendAction({ type: 'new' });
});
// If in closing mode, fix the widths of remaining tabs
if (isInClosingMode) {
var newTabs = tabsContainer.querySelectorAll('.tab');
newTabs.forEach(function(tab) {
var prevWidth = previousWidths[tab.dataset.id];
if (prevWidth) {
tab.style.flex = '0 0 ' + prevWidth + 'px';
tab.style.width = prevWidth + 'px';
}
});
}
// Update before-active class for proper separator hiding
updateBeforeActiveClass();
// Update narrow class based on tab width
updateNarrowClass();
if (activeUrl !== undefined) {
window.currentUrl = activeUrl || '';
if (document.activeElement !== urlInput) {
urlInput.value = window.currentUrl;
}
}
if (canGoBack !== undefined) {
window.canGoBack = canGoBack;
backBtn.disabled = !canGoBack;
}
if (canGoForward !== undefined) {
window.canGoForward = canGoForward;
forwardBtn.disabled = !canGoForward;
}
};
function sendAction(action) {
window.postMessage({ channel: 'tabbar-action', payload: action }, '*');
}
tabsContainer.addEventListener('click', function(e) {
var closeBtn = e.target.closest('.tab-close');
if (closeBtn) {
e.stopPropagation();
enterClosingMode();
sendAction({ type: 'close', tabId: closeBtn.dataset.id });
return;
}
var tab = e.target.closest('.tab');
if (tab) {
sendAction({ type: 'switch', tabId: tab.dataset.id });
}
});
tabsContainer.addEventListener('auxclick', function(e) {
if (e.button === 1) {
var tab = e.target.closest('.tab');
if (tab) {
enterClosingMode();
sendAction({ type: 'close', tabId: tab.dataset.id });
}
}
});
// Handle hover state for separator hiding (left side of hovered tab)
tabsContainer.addEventListener('mouseover', function(e) {
var tab = e.target.closest('.tab');
// Clear all before-hover classes first
tabsContainer.querySelectorAll('.before-hover').forEach(function(t) {
t.classList.remove('before-hover');
});
if (tab) {
var prev = tab.previousElementSibling;
if (prev && prev.classList.contains('tab')) {
prev.classList.add('before-hover');
}
}
});
tabsContainer.addEventListener('mouseleave', function() {
tabsContainer.querySelectorAll('.before-hover').forEach(function(t) {
t.classList.remove('before-hover');
});
});
urlInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
var url = urlInput.value.trim();
if (url) {
sendAction({ type: 'navigate', url: url });
}
}
});
urlInput.addEventListener('focus', function() {
urlInput.select();
});
backBtn.addEventListener('click', function() {
if (window.canGoBack) {
sendAction({ type: 'back' });
}
});
forwardBtn.addEventListener('click', function() {
if (window.canGoForward) {
sendAction({ type: 'forward' });
}
});
refreshBtn.addEventListener('click', function() {
sendAction({ type: 'refresh' });
});
// Window controls for Windows/Linux
document.getElementById('minimize-btn').addEventListener('click', function() {
sendAction({ type: 'window-minimize' });
});
document.getElementById('maximize-btn').addEventListener('click', function() {
sendAction({ type: 'window-maximize' });
});
document.getElementById('close-btn').addEventListener('click', function() {
sendAction({ type: 'window-close' });
});
// Platform initialization - called from main process
window.initPlatform = function(platform) {
document.body.classList.add('platform-' + platform);
};
// Theme initialization - called from main process
window.setTheme = function(isDark) {
if (isDark) {
document.body.classList.add('theme-dark');
} else {
document.body.classList.remove('theme-dark');
}
};
// Update narrow class on window resize
window.addEventListener('resize', function() {
updateNarrowClass();
});
</script>
</body>
</html>`

View File

@ -1,36 +1,39 @@
import * as z from 'zod'
import type { CdpBrowserController } from '../controller'
import { logger } from '../types'
import { errorResponse, successResponse } from './utils'
export const ExecuteSchema = z.object({
code: z
.string()
.describe(
'JavaScript evaluated via Chrome DevTools Runtime.evaluate. Keep it short; prefer one-line with semicolons for multiple statements.'
),
timeout: z.number().default(5000).describe('Timeout in milliseconds for code execution (default: 5000ms)'),
sessionId: z.string().optional().describe('Session identifier to target a specific page (default: default)')
code: z.string().describe('JavaScript code to run in page context'),
timeout: z.number().default(5000).describe('Execution timeout in ms (default: 5000)'),
privateMode: z.boolean().optional().describe('Target private session (default: false)'),
tabId: z.string().optional().describe('Target specific tab by ID')
})
export const executeToolDefinition = {
name: 'execute',
description:
'Run JavaScript in the current page via Runtime.evaluate. Prefer short, single-line snippets; use semicolons for multiple statements.',
'Run JavaScript in the currently open page. Use after open to: click elements, fill forms, extract content (document.body.innerText), or interact with the page. The page must be opened first with open or fetch.',
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'One-line JS to evaluate in page context'
description:
'JavaScript to evaluate. Examples: document.body.innerText (get text), document.querySelector("button").click() (click), document.title (get title)'
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds (default 5000)'
description: 'Execution timeout in ms (default: 5000)'
},
sessionId: {
privateMode: {
type: 'boolean',
description: 'Target private session (default: false)'
},
tabId: {
type: 'string',
description: 'Session identifier; targets a specific page (default: default)'
description: 'Target specific tab by ID (from open response)'
}
},
required: ['code']
@ -38,11 +41,12 @@ export const executeToolDefinition = {
}
export async function handleExecute(controller: CdpBrowserController, args: unknown) {
const { code, timeout, sessionId } = ExecuteSchema.parse(args)
const { code, timeout, privateMode, tabId } = ExecuteSchema.parse(args)
try {
const value = await controller.execute(code, timeout, sessionId ?? 'default')
const value = await controller.execute(code, timeout, privateMode ?? false, tabId)
return successResponse(typeof value === 'string' ? value : JSON.stringify(value))
} catch (error) {
logger.error('Execute failed', { error, code: code.slice(0, 100), privateMode, tabId })
return errorResponse(error as Error)
}
}

View File

@ -1,49 +0,0 @@
import * as z from 'zod'
import type { CdpBrowserController } from '../controller'
import { errorResponse, successResponse } from './utils'
export const FetchSchema = z.object({
url: z.url().describe('URL to fetch'),
format: z.enum(['html', 'txt', 'markdown', 'json']).default('markdown').describe('Output format (default: markdown)'),
timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'),
sessionId: z.string().optional().describe('Session identifier (default: default)')
})
export const fetchToolDefinition = {
name: 'fetch',
description: 'Fetch a URL using the browser and return content in specified format (html, txt, markdown, json)',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL to fetch'
},
format: {
type: 'string',
enum: ['html', 'txt', 'markdown', 'json'],
description: 'Output format (default: markdown)'
},
timeout: {
type: 'number',
description: 'Navigation timeout in milliseconds (default: 10000)'
},
sessionId: {
type: 'string',
description: 'Session identifier (default: default)'
}
},
required: ['url']
}
}
export async function handleFetch(controller: CdpBrowserController, args: unknown) {
const { url, format, timeout, sessionId } = FetchSchema.parse(args)
try {
const content = await controller.fetch(url, format, timeout ?? 10000, sessionId ?? 'default')
return successResponse(typeof content === 'string' ? content : JSON.stringify(content))
} catch (error) {
return errorResponse(error as Error)
}
}

View File

@ -1,15 +1,13 @@
export { ExecuteSchema, executeToolDefinition, handleExecute } from './execute'
export { FetchSchema, fetchToolDefinition, handleFetch } from './fetch'
export { handleOpen, OpenSchema, openToolDefinition } from './open'
export { handleReset, resetToolDefinition } from './reset'
import type { CdpBrowserController } from '../controller'
import { executeToolDefinition, handleExecute } from './execute'
import { fetchToolDefinition, handleFetch } from './fetch'
import { handleOpen, openToolDefinition } from './open'
import { handleReset, resetToolDefinition } from './reset'
export const toolDefinitions = [openToolDefinition, executeToolDefinition, resetToolDefinition, fetchToolDefinition]
export const toolDefinitions = [openToolDefinition, executeToolDefinition, resetToolDefinition]
export const toolHandlers: Record<
string,
@ -20,6 +18,5 @@ export const toolHandlers: Record<
> = {
open: handleOpen,
execute: handleExecute,
reset: handleReset,
fetch: handleFetch
reset: handleReset
}

View File

@ -1,39 +1,52 @@
import * as z from 'zod'
import type { CdpBrowserController } from '../controller'
import { successResponse } from './utils'
import { logger } from '../types'
import { errorResponse, successResponse } from './utils'
export const OpenSchema = z.object({
url: z.url().describe('URL to open in the controlled Electron window'),
timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'),
show: z.boolean().optional().describe('Whether to show the browser window (default: false)'),
sessionId: z
.string()
url: z.url().describe('URL to navigate to'),
format: z
.enum(['html', 'txt', 'markdown', 'json'])
.optional()
.describe('Session identifier; separate sessions keep separate pages (default: default)')
.describe('If set, return page content in this format. If not set, just open the page and return tabId.'),
timeout: z.number().optional().describe('Navigation timeout in ms (default: 10000)'),
privateMode: z.boolean().optional().describe('Use incognito mode, no data persisted (default: false)'),
newTab: z.boolean().optional().describe('Open in new tab, required for parallel requests (default: false)'),
showWindow: z.boolean().optional().default(true).describe('Show browser window (default: true)')
})
export const openToolDefinition = {
name: 'open',
description: 'Open a URL in a hidden Electron window controlled via Chrome DevTools Protocol',
description:
'Navigate to a URL in a browser window. If format is specified, returns { tabId, content } with page content in that format. Otherwise, returns { currentUrl, title, tabId } for subsequent operations with execute tool. Set newTab=true when opening multiple URLs in parallel.',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL to load'
description: 'URL to navigate to'
},
format: {
type: 'string',
enum: ['html', 'txt', 'markdown', 'json'],
description: 'If set, return page content in this format. If not set, just open the page and return tabId.'
},
timeout: {
type: 'number',
description: 'Navigation timeout in milliseconds (default 10000)'
description: 'Navigation timeout in ms (default: 10000)'
},
show: {
privateMode: {
type: 'boolean',
description: 'Whether to show the browser window (default false)'
description: 'Use incognito mode, no data persisted (default: false)'
},
sessionId: {
type: 'string',
description: 'Session identifier; separate sessions keep separate pages (default: default)'
newTab: {
type: 'boolean',
description: 'Open in new tab, required for parallel requests (default: false)'
},
showWindow: {
type: 'boolean',
description: 'Show browser window (default: true)'
}
},
required: ['url']
@ -41,7 +54,28 @@ export const openToolDefinition = {
}
export async function handleOpen(controller: CdpBrowserController, args: unknown) {
const { url, timeout, show, sessionId } = OpenSchema.parse(args)
const res = await controller.open(url, timeout ?? 10000, show ?? false, sessionId ?? 'default')
return successResponse(JSON.stringify(res))
try {
const { url, format, timeout, privateMode, newTab, showWindow } = OpenSchema.parse(args)
if (format) {
const { tabId, content } = await controller.fetch(
url,
format,
timeout ?? 10000,
privateMode ?? false,
newTab ?? false,
showWindow
)
return successResponse(JSON.stringify({ tabId, content }))
} else {
const res = await controller.open(url, timeout ?? 10000, privateMode ?? false, newTab ?? false, showWindow)
return successResponse(JSON.stringify(res))
}
} catch (error) {
logger.error('Open failed', {
error,
url: args && typeof args === 'object' && 'url' in args ? args.url : undefined
})
return errorResponse(error instanceof Error ? error : String(error))
}
}

View File

@ -1,34 +1,43 @@
import * as z from 'zod'
import type { CdpBrowserController } from '../controller'
import { successResponse } from './utils'
import { logger } from '../types'
import { errorResponse, successResponse } from './utils'
/** Zod schema for validating reset tool arguments */
export const ResetSchema = z.object({
sessionId: z.string().optional().describe('Session identifier to reset; omit to reset all sessions')
privateMode: z.boolean().optional().describe('true=private window, false=normal window, omit=all windows'),
tabId: z.string().optional().describe('Close specific tab only (requires privateMode)')
})
/** MCP tool definition for the reset tool */
export const resetToolDefinition = {
name: 'reset',
description: 'Reset the controlled window and detach debugger',
description:
'Close browser windows and clear state. Call when done browsing to free resources. Omit all parameters to close everything.',
inputSchema: {
type: 'object',
properties: {
sessionId: {
privateMode: {
type: 'boolean',
description: 'true=reset private window only, false=reset normal window only, omit=reset all'
},
tabId: {
type: 'string',
description: 'Session identifier to reset; omit to reset all sessions'
description: 'Close specific tab only (requires privateMode to be set)'
}
}
}
}
/**
* Handler for the reset MCP tool.
* Closes browser window(s) and detaches debugger for the specified session or all sessions.
*/
export async function handleReset(controller: CdpBrowserController, args: unknown) {
const { sessionId } = ResetSchema.parse(args)
await controller.reset(sessionId)
return successResponse('reset')
try {
const { privateMode, tabId } = ResetSchema.parse(args)
await controller.reset(privateMode, tabId)
return successResponse('reset')
} catch (error) {
logger.error('Reset failed', {
error,
privateMode: args && typeof args === 'object' && 'privateMode' in args ? args.privateMode : undefined
})
return errorResponse(error instanceof Error ? error : String(error))
}
}

View File

@ -5,9 +5,10 @@ export function successResponse(text: string) {
}
}
export function errorResponse(error: Error) {
export function errorResponse(error: Error | string) {
const message = error instanceof Error ? error.message : error
return {
content: [{ type: 'text', text: error.message }],
content: [{ type: 'text', text: message }],
isError: true
}
}

View File

@ -1,4 +1,24 @@
import { loggerService } from '@logger'
import type { BrowserView, BrowserWindow } from 'electron'
export const logger = loggerService.withContext('MCPBrowserCDP')
export const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0'
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: BrowserView
url: string
title: string
lastActive: number
}
export interface WindowInfo {
windowKey: string
privateMode: boolean
window: BrowserWindow
tabs: Map<string, TabInfo>
activeTabId: string | null
lastActive: number
tabBarView?: BrowserView
}

View File

@ -255,6 +255,12 @@ export class WindowService {
}
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
// Fix for Electron bug where zoom resets during in-page navigation (route changes)
// This complements the resize-based workaround by catching navigation events
mainWindow.webContents.on('did-navigate-in-page', () => {
mainWindow.webContents.setZoomFactor(preferenceService.get('app.zoom_factor'))
})
mainWindow.webContents.on('will-navigate', (event, url) => {
if (url.includes('localhost:517')) {
return
@ -516,7 +522,9 @@ export class WindowService {
miniWindowState.manage(this.miniWindow)
//miniWindow should show in current desktop
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
this.miniWindow?.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true
})
//make miniWindow always on top of fullscreen apps with level set
//[mac] level higher than 'floating' will cover the pinyin input method
this.miniWindow.setAlwaysOnTop(true, 'floating')
@ -635,6 +643,11 @@ export class WindowService {
return
} else if (isMac) {
this.miniWindow.hide()
const majorVersion = parseInt(process.getSystemVersion().split('.')[0], 10)
if (majorVersion >= 26) {
// on macOS 26+, the popup of the mimiWindow would not change the focus to previous application.
return
}
if (!this.wasMainWindowFocused) {
app.hide()
}

View File

@ -13,6 +13,7 @@ import esES from '../../renderer/src/i18n/translate/es-es.json'
import frFR from '../../renderer/src/i18n/translate/fr-fr.json'
import JaJP from '../../renderer/src/i18n/translate/ja-jp.json'
import ptPT from '../../renderer/src/i18n/translate/pt-pt.json'
import roRO from '../../renderer/src/i18n/translate/ro-ro.json'
import RuRu from '../../renderer/src/i18n/translate/ru-ru.json'
export const locales = Object.fromEntries(
@ -26,7 +27,8 @@ export const locales = Object.fromEntries(
['el-GR', elGR],
['es-ES', esES],
['fr-FR', frFR],
['pt-PT', ptPT]
['pt-PT', ptPT],
['ro-RO', roRO]
].map(([locale, translation]) => [locale, { translation }])
)

View File

@ -119,6 +119,21 @@ export class AiSdkToChunkAdapter {
}
}
/**
* THINKING_COMPLETE chunk
* @param final reasoningContent
* @returns THINKING_COMPLETE chunk
*/
private emitThinkingCompleteIfNeeded(final: { reasoningContent: string; [key: string]: any }) {
if (final.reasoningContent) {
this.onChunk({
type: ChunkType.THINKING_COMPLETE,
text: final.reasoningContent
})
final.reasoningContent = ''
}
}
/**
* AI SDK chunk Cherry Studio chunk
* @param chunk AI SDK chunk
@ -144,6 +159,9 @@ export class AiSdkToChunkAdapter {
}
// === 文本相关事件 ===
case 'text-start':
// 如果有未完成的思考内容,先生成 THINKING_COMPLETE
// 这处理了某些提供商不发送 reasoning-end 事件的情况
this.emitThinkingCompleteIfNeeded(final)
this.onChunk({
type: ChunkType.TEXT_START
})
@ -214,11 +232,7 @@ export class AiSdkToChunkAdapter {
})
break
case 'reasoning-end':
this.onChunk({
type: ChunkType.THINKING_COMPLETE,
text: final.reasoningContent || ''
})
final.reasoningContent = ''
this.emitThinkingCompleteIfNeeded(final)
break
// === 工具调用相关事件(原始 AI SDK 事件,如果没有被中间件处理) ===

View File

@ -224,6 +224,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
afterClose={onClose}
centered={!isFullscreen}
destroyOnHidden
forceRender={isFullscreen}
mask={!isFullscreen}
maskClosable={false}
width={isFullscreen ? '100vw' : '90vw'}

View File

@ -45,6 +45,7 @@ const i18nMap: Record<LanguageVarious, typeof en> = {
'fr-FR': fr,
'ja-JP': ja,
'pt-PT': pt_PT,
'ro-RO': en, // No Romanian available, fallback to English
'ru-RU': ru_RU
}
@ -60,6 +61,7 @@ const dataSourceMap: Record<LanguageVarious, string> = {
'fr-FR': dataFR,
'ja-JP': dataJA,
'pt-PT': dataPT,
'ro-RO': dataEN, // No Romanian CLDR available, fallback to English
'ru-RU': dataRU
}
@ -75,6 +77,7 @@ const localeMap: Record<LanguageVarious, string> = {
'fr-FR': 'fr',
'ja-JP': 'ja',
'pt-PT': 'pt',
'ro-RO': 'en',
'ru-RU': 'ru'
}

View File

@ -212,6 +212,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://api.302.ai',
anthropicApiHost: 'https://api.302.ai',
models: SYSTEM_MODELS['302ai'],
isSystem: true,
enabled: false

View File

@ -9,6 +9,7 @@ import esES from 'antd/locale/es_ES'
import frFR from 'antd/locale/fr_FR'
import jaJP from 'antd/locale/ja_JP'
import ptPT from 'antd/locale/pt_PT'
import roRO from 'antd/locale/ro_RO'
import ruRU from 'antd/locale/ru_RU'
import zhCN from 'antd/locale/zh_CN'
import zhTW from 'antd/locale/zh_TW'
@ -140,6 +141,8 @@ function getAntdLocale(language: LanguageVarious) {
return frFR
case 'pt-PT':
return ptPT
case 'ro-RO':
return roRO
default:
return zhCN
}

View File

@ -7,7 +7,7 @@ interface UseSmoothStreamOptions {
initialText?: string
}
const languages = ['en-US', 'de-DE', 'es-ES', 'zh-CN', 'zh-TW', 'ja-JP', 'ru-RU', 'el-GR', 'fr-FR', 'pt-PT']
const languages = ['en-US', 'de-DE', 'es-ES', 'zh-CN', 'zh-TW', 'ja-JP', 'ru-RU', 'el-GR', 'fr-FR', 'pt-PT', 'ro-RO']
const segmenter = new Intl.Segmenter(languages)
export const useSmoothStream = ({ onUpdate, streamDone, minDelay = 10, initialText = '' }: UseSmoothStreamOptions) => {

View File

@ -37,18 +37,16 @@ export default function useTranslate() {
const getLanguageByLangcode = useCallback(
(langCode: string) => {
if (!isLoaded) {
logger.verbose('Translate languages are not loaded yet. Return UNKNOWN.')
return UNKNOWN
}
const result = translateLanguages.find((item) => item.langCode === langCode)
if (result) {
return result
} else if (!isLoaded) {
logger.verbose('Translate languages are not loaded yet. Return UNKNOWN.')
} else {
logger.warn(`Unknown language ${langCode}`)
return UNKNOWN
}
return UNKNOWN
},
[isLoaded, translateLanguages]
)
@ -64,6 +62,7 @@ export default function useTranslate() {
prompt,
settings,
translateLanguages,
isLoaded,
getLanguageByLangcode,
updateSettings: handleUpdateSettings
}

View File

@ -15,6 +15,7 @@ import esES from './translate/es-es.json'
import frFR from './translate/fr-fr.json'
import jaJP from './translate/ja-jp.json'
import ptPT from './translate/pt-pt.json'
import roRO from './translate/ro-ro.json'
import ruRU from './translate/ru-ru.json'
const logger = loggerService.withContext('I18N')
@ -30,7 +31,8 @@ const resources = Object.fromEntries(
['el-GR', elGR],
['es-ES', esES],
['fr-FR', frFR],
['pt-PT', ptPT]
['pt-PT', ptPT],
['ro-RO', roRO]
].map(([locale, translation]) => [locale, { translation }])
)

View File

@ -3165,6 +3165,7 @@
"label": "App Data",
"migration_title": "Data Migration",
"new_path": "New Path",
"open": "Open Directory",
"original_path": "Original Path",
"path_change_failed": "Failed to change data directory",
"path_changed_without_copy": "Path changed successfully",

View File

@ -3165,6 +3165,7 @@
"label": "应用数据",
"migration_title": "数据迁移",
"new_path": "新路径",
"open": "打开目录",
"original_path": "原始路径",
"path_change_failed": "数据目录更改失败",
"path_changed_without_copy": "路径已更改成功",

View File

@ -3165,6 +3165,7 @@
"label": "應用程式資料",
"migration_title": "資料移轉",
"new_path": "新路徑",
"open": "開啟目錄",
"original_path": "原始路徑",
"path_change_failed": "資料目錄變更失敗",
"path_changed_without_copy": "路徑已變更成功",

View File

@ -3165,6 +3165,7 @@
"label": "Anwendungsdaten",
"migration_title": "Datenmigration",
"new_path": "Neuer Pfad",
"open": "Offenes Verzeichnis",
"original_path": "Ursprünglicher Pfad",
"path_change_failed": "Datenverzeichnisänderung fehlgeschlagen",
"path_changed_without_copy": "Pfad erfolgreich geändert",

View File

@ -3165,6 +3165,7 @@
"label": "Δεδομένα εφαρμογής",
"migration_title": "Μεταφορά δεδομένων",
"new_path": "Νέα διαδρομή",
"open": "Ανοιχτός Κατάλογος",
"original_path": "Αρχική διαδρομή",
"path_change_failed": "Η αλλαγή του καταλόγου δεδομένων απέτυχε",
"path_changed_without_copy": "Η διαδρομή άλλαξε επιτυχώς",

View File

@ -3165,6 +3165,7 @@
"label": "Datos de la aplicación",
"migration_title": "Migración de datos",
"new_path": "Nueva ruta",
"open": "Directorio abierto",
"original_path": "Ruta original",
"path_change_failed": "Error al cambiar el directorio de datos",
"path_changed_without_copy": "La ruta se ha cambiado correctamente",

View File

@ -3165,6 +3165,7 @@
"label": "Données de l'application",
"migration_title": "Migration des données",
"new_path": "Nouveau chemin",
"open": "Répertoire ouvert",
"original_path": "Chemin d'origine",
"path_change_failed": "Échec de la modification du répertoire de données",
"path_changed_without_copy": "Le chemin a été modifié avec succès",

View File

@ -3165,6 +3165,7 @@
"label": "アプリデータ",
"migration_title": "データ移行",
"new_path": "新しいパス",
"open": "オープンディレクトリ",
"original_path": "元のパス",
"path_change_failed": "データディレクトリの変更に失敗しました",
"path_changed_without_copy": "パスが変更されました。",

View File

@ -3165,6 +3165,7 @@
"label": "Dados do aplicativo",
"migration_title": "Migração de Dados",
"new_path": "Novo Caminho",
"open": "Diretório Aberto",
"original_path": "Caminho Original",
"path_change_failed": "Falha ao alterar o diretório de dados",
"path_changed_without_copy": "O caminho foi alterado com sucesso",

File diff suppressed because it is too large Load Diff

View File

@ -3165,6 +3165,7 @@
"label": "Данные приложения",
"migration_title": "Миграция данных",
"new_path": "Новый путь",
"open": "Открыть каталог",
"original_path": "Исходный путь",
"path_change_failed": "Сбой изменения каталога данных",
"path_changed_without_copy": "Путь изменен успешно",

View File

@ -43,6 +43,7 @@ export const CLAUDE_SUPPORTED_PROVIDERS = [
'dmxapi',
'new-api',
'cherryin',
'302ai',
...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS
]
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api', 'cherryin']
@ -96,6 +97,11 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
anthropic: {
api_base_url: 'https://api.minimaxi.com/anthropic'
}
},
'302ai': {
anthropic: {
api_base_url: 'https://api.302.ai'
}
}
}

View File

@ -96,7 +96,7 @@ export const TopicManagePanel: React.FC<TopicManagePanelProps> = ({
// Topics that can be selected (non-pinned, and filtered when in search mode)
const selectableTopics = useMemo(() => {
const baseTopics = isSearchMode ? filteredTopics : assistant.topics
return baseTopics.filter((topic) => !topic.pinned)
return (baseTopics ?? []).filter((topic) => !topic.pinned)
}, [assistant.topics, filteredTopics, isSearchMode])
// Check if all selectable topics are selected

View File

@ -170,9 +170,7 @@ const AboutSettings: FC = () => {
const onOpenDocs = () => {
const isChinese = i18n.language.startsWith('zh')
window.api.openWebsite(
isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us'
)
window.api.openWebsite(isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/docs/en-us')
}
return (

View File

@ -1,11 +1,4 @@
import {
CloudServerOutlined,
CloudSyncOutlined,
FileSearchOutlined,
LoadingOutlined,
WifiOutlined,
YuqueOutlined
} from '@ant-design/icons'
import { CloudServerOutlined, CloudSyncOutlined, LoadingOutlined, WifiOutlined, YuqueOutlined } from '@ant-design/icons'
import { Button, RowFlex, Switch } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import DividerWithText from '@renderer/components/DividerWithText'
@ -22,8 +15,8 @@ import { reset } from '@renderer/services/BackupService'
import type { AppInfo } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import { occupiedDirs } from '@shared/config/constant'
import { Progress, Typography } from 'antd'
import { FileText, FolderCog, FolderInput, FolderOpen, SaveIcon } from 'lucide-react'
import { Progress, Tooltip, Typography } from 'antd'
import { FileText, FolderCog, FolderInput, FolderOpen, FolderOutput, SaveIcon } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -644,10 +637,12 @@ const DataSettings: FC = () => {
onClick={() => handleOpenPath(appInfo?.appDataPath)}>
{appInfo?.appDataPath}
</PathText>
<StyledIcon onClick={() => handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} />
<Tooltip title={t('settings.data.app_data.select')}>
<FolderOutput onClick={handleSelectAppDataPath} style={{ cursor: 'pointer' }} size={16} />
</Tooltip>
<RowFlex className="ml-2 gap-[5px]">
<Button variant="ghost" size="sm" onClick={handleSelectAppDataPath}>
{t('settings.data.app_data.select')}
<Button variant="ghost" size="sm" onClick={() => handleOpenPath(appInfo?.appDataPath)}>
{t('settings.data.app_data.open')}
</Button>
</RowFlex>
</PathRow>
@ -659,7 +654,6 @@ const DataSettings: FC = () => {
<PathText style={{ color: 'var(--color-text-3)' }} onClick={() => handleOpenPath(appInfo?.logsPath)}>
{appInfo?.logsPath}
</PathText>
<StyledIcon onClick={() => handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} />
<RowFlex className="ml-2 gap-[5px]">
<Button variant="ghost" size="sm" onClick={() => handleOpenPath(appInfo?.logsPath)}>
{t('settings.data.app_logs.button')}
@ -721,16 +715,6 @@ const Container = styled(RowFlex)`
flex: 1;
`
const StyledIcon = styled(FileSearchOutlined)`
color: var(--color-text-2);
cursor: pointer;
transition: color 0.3s;
&:hover {
color: var(--color-text-1);
}
`
const MenuList = styled.div`
display: flex;
flex-direction: column;

View File

@ -140,7 +140,8 @@ const GeneralSettings: FC = () => {
{ value: 'el-GR', label: 'Ελληνικά', flag: '🇬🇷' },
{ value: 'es-ES', label: 'Español', flag: '🇪🇸' },
{ value: 'fr-FR', label: 'Français', flag: '🇫🇷' },
{ value: 'pt-PT', label: 'Português', flag: '🇵🇹' }
{ value: 'pt-PT', label: 'Português', flag: '🇵🇹' },
{ value: 'ro-RO', label: 'Română', flag: '🇷🇴' }
]
const handleNotificationChange = (type: NotificationSource, value: boolean) => {

View File

@ -11,6 +11,7 @@ export type GroupTranslations = {
'ru-RU': string
'ja-JP': string
'pt-PT': string
'ro-RO': string
}
}
@ -25,7 +26,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '我的',
'ru-RU': 'Мои агенты',
'ja-JP': '私のエージェント',
'pt-PT': 'Meus Agentes'
'pt-PT': 'Meus Agentes',
'ro-RO': 'Mă'
},
: {
'el-GR': 'Επαγγελμα',
@ -37,7 +39,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '職業',
'ru-RU': 'Карьера',
'ja-JP': 'キャリア',
'pt-PT': 'Profissional'
'pt-PT': 'Profissional',
'ro-RO': 'Profesional'
},
: {
'el-GR': 'Εμπορικός',
@ -49,7 +52,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '商業',
'ru-RU': 'Бизнес',
'ja-JP': 'ビジネス',
'pt-PT': 'Negócio'
'pt-PT': 'Negócio',
'ro-RO': 'Comercial'
},
: {
'el-GR': 'Εργαλεία',
@ -61,7 +65,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '工具',
'ru-RU': 'Инструменты',
'ja-JP': 'ツール',
'pt-PT': 'Ferramentas'
'pt-PT': 'Ferramentas',
'ro-RO': 'Utilitare'
},
: {
'el-GR': 'Γλώσσα',
@ -73,7 +78,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '語言',
'ru-RU': 'Язык',
'ja-JP': '言語',
'pt-PT': 'Idioma'
'pt-PT': 'Idioma',
'ro-RO': 'Limba'
},
: {
'el-GR': 'Γραφείο',
@ -85,7 +91,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '辦公',
'ru-RU': 'Офис',
'ja-JP': 'オフィス',
'pt-PT': 'Escritório'
'pt-PT': 'Escritório',
'ro-RO': 'Oficiu'
},
: {
'el-GR': 'Γενικά',
@ -97,7 +104,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '通用',
'ru-RU': 'Общее',
'ja-JP': '一般',
'pt-PT': 'Geral'
'pt-PT': 'Geral',
'ro-RO': 'General'
},
: {
'el-GR': 'Γράφημα',
@ -109,7 +117,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '寫作',
'ru-RU': 'Письмо',
'ja-JP': '書き込み',
'pt-PT': 'Escrita'
'pt-PT': 'Escrita',
'ro-RO': 'Scrisoare'
},
: {
'el-GR': 'Επιλεγμένο',
@ -121,7 +130,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '精選',
'ru-RU': 'Избранное',
'ja-JP': '特集',
'pt-PT': 'Destaque'
'pt-PT': 'Destaque',
'ro-RO': 'Recomandat'
},
: {
'el-GR': 'Προγραμματισμός',
@ -133,7 +143,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '編程',
'ru-RU': 'Программирование',
'ja-JP': 'プログラミング',
'pt-PT': 'Programação'
'pt-PT': 'Programação',
'ro-RO': 'Programare'
},
: {
'el-GR': 'Αίσθημα',
@ -145,7 +156,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '情感',
'ru-RU': 'Эмоции',
'ja-JP': '感情',
'pt-PT': 'Emoção'
'pt-PT': 'Emoção',
'ro-RO': 'Emoție'
},
: {
'el-GR': 'Εκπαίδευση',
@ -157,7 +169,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '教育',
'ru-RU': 'Образование',
'ja-JP': '教育',
'pt-PT': 'Educação'
'pt-PT': 'Educação',
'ro-RO': 'Educație'
},
: {
'el-GR': 'Κreativiteit',
@ -169,7 +182,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '創意',
'ru-RU': 'Креатив',
'ja-JP': 'クリエイティブ',
'pt-PT': 'Criativo'
'pt-PT': 'Criativo',
'ro-RO': 'Creativ'
},
: {
'el-GR': 'Ακαδημικός',
@ -181,7 +195,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '學術',
'ru-RU': 'Академический',
'ja-JP': 'アカデミック',
'pt-PT': 'Académico'
'pt-PT': 'Académico',
'ro-RO': 'Academic'
},
: {
'el-GR': 'Δημιουργικό',
@ -193,7 +208,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '設計',
'ru-RU': 'Дизайн',
'ja-JP': 'デザイン',
'pt-PT': 'Design'
'pt-PT': 'Design',
'ro-RO': 'Design'
},
: {
'el-GR': 'Τέχνη',
@ -205,7 +221,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '藝術',
'ru-RU': 'Искусство',
'ja-JP': 'アート',
'pt-PT': 'Arte'
'pt-PT': 'Arte',
'ro-RO': 'Art'
},
: {
'el-GR': 'Αναψυχή',
@ -217,7 +234,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '娛樂',
'ru-RU': 'Развлечения',
'ja-JP': 'エンターテイメント',
'pt-PT': 'Entretenimento'
'pt-PT': 'Entretenimento',
'ro-RO': 'Entertainment'
},
: {
'el-GR': 'Ζωή',
@ -229,7 +247,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '生活',
'ru-RU': 'Жизнь',
'ja-JP': '生活',
'pt-PT': 'Vida'
'pt-PT': 'Vida',
'ro-RO': 'Life'
},
: {
'el-GR': 'Υγεία',
@ -241,7 +260,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '醫療',
'ru-RU': 'Медицина',
'ja-JP': '医療',
'pt-PT': 'Saúde'
'pt-PT': 'Saúde',
'ro-RO': 'Medical'
},
: {
'el-GR': 'Παιχνίδια',
@ -253,7 +273,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '遊戲',
'ru-RU': 'Игры',
'ja-JP': 'ゲーム',
'pt-PT': 'Jogos'
'pt-PT': 'Jogos',
'ro-RO': 'Games'
},
: {
'el-GR': 'Γραφήματα',
@ -265,7 +286,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '翻譯',
'ru-RU': 'Перевод',
'ja-JP': '翻訳',
'pt-PT': 'Tradução'
'pt-PT': 'Tradução',
'ro-RO': 'Translation'
},
: {
'el-GR': 'Μουσική',
@ -277,7 +299,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '音樂',
'ru-RU': 'Музыка',
'ja-JP': '音楽',
'pt-PT': 'Música'
'pt-PT': 'Música',
'ro-RO': 'Music'
},
: {
'el-GR': 'Αξιολόγηση',
@ -289,7 +312,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '點評',
'ru-RU': 'Обзор',
'ja-JP': 'レビュー',
'pt-PT': 'Revisão'
'pt-PT': 'Revisão',
'ro-RO': 'Review'
},
: {
'el-GR': 'Γραφήματα',
@ -301,7 +325,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '文案',
'ru-RU': 'Копирайтинг',
'ja-JP': 'コピーライティング',
'pt-PT': 'Escrita'
'pt-PT': 'Escrita',
'ro-RO': 'Copywriting'
},
: {
'el-GR': 'Εγκυκλοπαίδεια',
@ -313,7 +338,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '百科',
'ru-RU': 'Энциклопедия',
'ja-JP': '百科事典',
'pt-PT': 'Enciclopédia'
'pt-PT': 'Enciclopédia',
'ro-RO': 'Encyclopedia'
},
: {
'el-GR': 'Υγεία',
@ -325,7 +351,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '健康',
'ru-RU': 'Здоровье',
'ja-JP': '健康',
'pt-PT': 'Saúde'
'pt-PT': 'Saúde',
'ro-RO': 'Health'
},
: {
'el-GR': 'Μάρκετινγκ',
@ -337,7 +364,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '營銷',
'ru-RU': 'Маркетинг',
'ja-JP': 'マーケティング',
'pt-PT': 'Marketing'
'pt-PT': 'Marketing',
'ro-RO': 'Marketing'
},
: {
'el-GR': 'Επιστήμη',
@ -349,7 +377,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '科學',
'ru-RU': 'Наука',
'ja-JP': '科学',
'pt-PT': 'Ciência'
'pt-PT': 'Ciência',
'ro-RO': 'Science'
},
: {
'el-GR': 'Ανάλυση',
@ -361,7 +390,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '分析',
'ru-RU': 'Анализ',
'ja-JP': '分析',
'pt-PT': 'Análise'
'pt-PT': 'Análise',
'ro-RO': 'Analysis'
},
: {
'el-GR': 'Νόμος',
@ -373,7 +403,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '法律',
'ru-RU': 'Право',
'ja-JP': '法律',
'pt-PT': 'Legal'
'pt-PT': 'Legal',
'ro-RO': 'Legal'
},
: {
'el-GR': 'Συμβουλή',
@ -385,7 +416,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '諮詢',
'ru-RU': 'Консалтинг',
'ja-JP': 'コンサルティング',
'pt-PT': 'Consultoria'
'pt-PT': 'Consultoria',
'ro-RO': 'Consulting'
},
: {
'el-GR': 'Φορολογία',
@ -397,7 +429,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '金融',
'ru-RU': 'Финансы',
'ja-JP': '金融',
'pt-PT': 'Finanças'
'pt-PT': 'Finanças',
'ro-RO': 'Finance'
},
: {
'el-GR': 'Τουρισμός',
@ -409,7 +442,8 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '旅遊',
'ru-RU': 'Путешествия',
'ja-JP': '旅行',
'pt-PT': 'Viagens'
'pt-PT': 'Viagens',
'ro-RO': 'Travel'
},
: {
'el-GR': 'Διοίκηση',
@ -421,6 +455,7 @@ export const groupTranslations: GroupTranslations = {
'zh-TW': '管理',
'ru-RU': 'Управление',
'ja-JP': '管理',
'pt-PT': 'Gestão'
'pt-PT': 'Gestão',
'ro-RO': 'Management'
}
}

View File

@ -117,7 +117,6 @@ export async function getDefaultTranslateAssistant(
// disable reasoning if it could be disabled, otherwise no configuration
const reasoningEffort = supportedOptions?.includes('none') ? 'none' : 'default'
const settings = {
temperature: 0.7,
reasoning_effort: reasoningEffort,
..._settings
} satisfies Partial<AssistantSettings>

View File

@ -22,7 +22,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { NotificationService } from '@renderer/services/NotificationService'
import { estimateMessagesUsage } from '@renderer/services/TokenService'
import type { Assistant } from '@renderer/types'
import type { PlaceholderMessageBlock, Response } from '@renderer/types/newMessage'
import type { PlaceholderMessageBlock, Response, ThinkingMessageBlock } from '@renderer/types/newMessage'
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { uuid } from '@renderer/utils'
import { isAbortError, serializeError } from '@renderer/utils/error'
@ -48,10 +48,11 @@ interface BaseCallbacksDependencies {
topicId: string
assistantMsgId: string
assistant: Assistant
getCurrentThinkingInfo?: () => { blockId: string | null; millsec: number }
}
export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => {
const { blockManager, topicId, assistantMsgId, assistant } = deps
const { blockManager, topicId, assistantMsgId, assistant, getCurrentThinkingInfo } = deps
const startTime = Date.now()
const notificationService = NotificationService.getInstance()
@ -128,10 +129,17 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => {
const possibleBlockId = findBlockIdForCompletion()
if (possibleBlockId) {
// Update previous block status to ERROR/PAUSED
const changes = {
// Update previous block status to ERROR/PAUSED/PAUSED
const changes: Partial<ThinkingMessageBlock> = {
status: isErrorTypeAbort ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR
}
// 如果是 thinking block保留实际思考时间
if (blockManager.lastBlockType === MessageBlockType.THINKING) {
const thinkingInfo = getCurrentThinkingInfo?.()
if (thinkingInfo?.blockId === possibleBlockId && thinkingInfo?.millsec && thinkingInfo.millsec > 0) {
changes.thinking_millsec = thinkingInfo.millsec
}
}
blockManager.smartBlockUpdate(possibleBlockId, changes, blockManager.lastBlockType!, true)
}
@ -140,12 +148,25 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => {
const currentMessage = streamingService.getMessage(assistantMsgId)
if (currentMessage) {
const allBlockRefs = findAllBlocks(currentMessage)
// 获取当前思考信息(如果有),用于保留实际思考时间
const thinkingInfo = getCurrentThinkingInfo?.()
for (const blockRef of allBlockRefs) {
const block = streamingService.getBlock(blockRef.id)
if (block && block.status === MessageBlockStatus.STREAMING && block.id !== possibleBlockId) {
streamingService.updateBlock(block.id, {
// 构建更新对象
const changes: Partial<ThinkingMessageBlock> = {
status: isErrorTypeAbort ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR
})
}
// 如果是 thinking block 且有思考时间信息,保留实际思考时间
if (
block.type === MessageBlockType.THINKING &&
thinkingInfo?.blockId === block.id &&
thinkingInfo?.millsec &&
thinkingInfo.millsec > 0
) {
changes.thinking_millsec = thinkingInfo.millsec
}
streamingService.updateBlock(block.id, changes)
}
}
}

View File

@ -45,18 +45,19 @@ interface CallbacksDependencies {
export const createCallbacks = (deps: CallbacksDependencies) => {
const { blockManager, topicId, assistantMsgId, assistant } = deps
// 首先创建 thinkingCallbacks ,以便传递 getCurrentThinkingInfo 给 baseCallbacks
const thinkingCallbacks = createThinkingCallbacks({
blockManager,
assistantMsgId
})
// Create base callbacks (lifecycle, error, complete)
const baseCallbacks = createBaseCallbacks({
blockManager,
topicId,
assistantMsgId,
assistant
})
// Create specialized callbacks for each block type
const thinkingCallbacks = createThinkingCallbacks({
blockManager,
assistantMsgId
assistant,
getCurrentThinkingInfo: thinkingCallbacks.getCurrentThinkingInfo
})
const toolCallbacks = createToolCallbacks({

View File

@ -19,6 +19,12 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) =>
let thinking_millsec_now: number = 0
return {
// 获取当前思考时间(用于停止回复时保留思考时间)
getCurrentThinkingInfo: () => ({
blockId: thinkingBlockId,
millsec: thinking_millsec_now > 0 ? performance.now() - thinking_millsec_now : 0
}),
onThinkingStart: async () => {
if (blockManager.hasInitialPlaceholder) {
const changes: Partial<MessageBlock> = {

View File

@ -83,7 +83,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 191,
version: 192,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},

View File

@ -3146,6 +3146,21 @@ const migrateConfig = {
logger.error('migrate 191 error', error as Error)
return state
}
},
'192': (state: RootState) => {
try {
state.llm.providers.forEach((provider) => {
if (provider.id === '302ai') {
provider.anthropicApiHost = 'https://api.302.ai'
}
})
state.settings.readClipboardAtStartup = false
logger.info('migrate 192 success')
return state
} catch (error) {
logger.error('migrate 192 error', error as Error)
return state
}
}
}

View File

@ -457,7 +457,18 @@ export type MinAppType = {
}
/** 有限的UI语言 */
// export type LanguageVarious = 'zh-CN' | 'zh-TW' | 'el-GR' | 'en-US' | 'es-ES' | 'fr-FR' | 'ja-JP' | 'pt-PT' | 'ru-RU'
// export type LanguageVarious =
// | 'zh-CN'
// | 'zh-TW'
// | 'de-DE'
// | 'el-GR'
// | 'en-US'
// | 'es-ES'
// | 'fr-FR'
// | 'ja-JP'
// | 'pt-PT'
// | 'ro-RO'
// | 'ru-RU'
export type CodeStyleVarious = 'auto' | string

View File

@ -83,9 +83,7 @@ const detectLanguageByLLM = async (inputText: string): Promise<TranslateLanguage
const assistant: Assistant = getDefaultAssistant()
assistant.model = model
assistant.settings = {
temperature: 0.7
}
assistant.settings = {}
assistant.prompt = LANG_DETECT_PROMPT.replace('{{list_lang}}', listLangText).replace('{{input}}', text)
const onChunk: (chunk: Chunk) => void = (chunk: Chunk) => {
@ -257,6 +255,7 @@ export const getTranslateOptions = async () => {
}))
return [...builtinLanguages, ...transformedCustomLangs]
} catch (e) {
logger.error('[getTranslateOptions] Failed to get custom languages. Fallback to builtinLanguages', e as Error)
return builtinLanguages
}
}

View File

@ -11,7 +11,6 @@ import useTranslate from '@renderer/hooks/useTranslate'
import MessageContent from '@renderer/pages/home/Messages/MessageContent'
import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import type { Assistant, Topic, TranslateLanguage, TranslateLanguageCode } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { abortCompletion } from '@renderer/utils/abortController'
import { detectLanguage } from '@renderer/utils/translate'
import { defaultLanguage } from '@shared/config/constant'
@ -35,72 +34,101 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const { t } = useTranslation()
const [language] = usePreference('app.language')
const [translateModelPrompt] = usePreference('feature.translate.model_prompt')
const { getLanguageByLangcode, isLoaded: isLanguagesLoaded } = useTranslate()
const [targetLanguage, setTargetLanguage] = useState<TranslateLanguage>(LanguagesEnum.enUS)
const [alterLanguage, setAlterLanguage] = useState<TranslateLanguage>(LanguagesEnum.zhCN)
const [targetLanguage, setTargetLanguage] = useState<TranslateLanguage>(() => {
const lang = getLanguageByLangcode(language || navigator.language || defaultLanguage)
if (lang !== UNKNOWN) {
return lang
} else {
logger.warn('[initialize targetLanguage] Unexpected UNKNOWN. Fallback to zh-CN')
return LanguagesEnum.zhCN
}
})
const [alterLanguage, setAlterLanguage] = useState<TranslateLanguage>(LanguagesEnum.enUS)
const [error, setError] = useState('')
const [showOriginal, setShowOriginal] = useState(false)
const [isContented, setIsContented] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [contentToCopy, setContentToCopy] = useState('')
const { getLanguageByLangcode } = useTranslate()
// Use useRef for values that shouldn't trigger re-renders
const initialized = useRef(false)
const assistantRef = useRef<Assistant | null>(null)
const topicRef = useRef<Topic | null>(null)
const askId = useRef('')
const targetLangRef = useRef(targetLanguage)
useEffect(() => {
runAsyncFunction(async () => {
const biDirectionLangPair = await db.settings.get({ id: 'translate:bidirectional:pair' })
// It's called only in initialization.
// It will change target/alter language, so fetchResult will be triggered. Be careful!
const updateLanguagePair = useCallback(async () => {
// Only called is when languages loaded.
// It ensure we could get right language from getLanguageByLangcode.
if (!isLanguagesLoaded) {
logger.silly('[updateLanguagePair] Languages are not loaded. Skip.')
return
}
let targetLang: TranslateLanguage
let alterLang: TranslateLanguage
if (!biDirectionLangPair || !biDirectionLangPair.value[0]) {
const lang = getLanguageByLangcode(language || navigator.language || defaultLanguage)
if (lang !== UNKNOWN) {
targetLang = lang
} else {
logger.warn('Fallback to zh-CN')
targetLang = LanguagesEnum.zhCN
}
} else {
targetLang = getLanguageByLangcode(biDirectionLangPair.value[0])
}
if (!biDirectionLangPair || !biDirectionLangPair.value[1]) {
alterLang = LanguagesEnum.enUS
} else {
alterLang = getLanguageByLangcode(biDirectionLangPair.value[1])
}
const biDirectionLangPair = await db.settings.get({ id: 'translate:bidirectional:pair' })
if (biDirectionLangPair && biDirectionLangPair.value[0]) {
const targetLang = getLanguageByLangcode(biDirectionLangPair.value[0])
setTargetLanguage(targetLang)
targetLangRef.current = targetLang
}
if (biDirectionLangPair && biDirectionLangPair.value[1]) {
const alterLang = getLanguageByLangcode(biDirectionLangPair.value[1])
setAlterLanguage(alterLang)
})
}, [getLanguageByLangcode, language])
}
}, [getLanguageByLangcode, isLanguagesLoaded])
// Initialize values only once when action changes
useEffect(() => {
if (initialized.current || !action.selectedText) return
// Initialize values only once
const initialize = useCallback(async () => {
if (initialized.current) {
logger.silly('[initialize] Already initialized.')
return
}
// Only try to initialize when languages loaded, so updateLanguagePair would not fail.
if (!isLanguagesLoaded) {
logger.silly('[initialize] Languages not loaded. Skip initialization.')
return
}
// Edge case
if (action.selectedText === undefined) {
logger.error('[initialize] No selected text.')
return
}
logger.silly('[initialize] Start initialization.')
// Initialize language pair.
// It will update targetLangRef, so we could get latest target language in the following code
await updateLanguagePair()
// Initialize assistant
const currentAssistant = await getDefaultTranslateAssistant(targetLangRef.current, action.selectedText)
assistantRef.current = currentAssistant
// Initialize topic
topicRef.current = getDefaultTopic(currentAssistant.id)
initialized.current = true
}, [action.selectedText, isLanguagesLoaded, updateLanguagePair])
runAsyncFunction(async () => {
// Initialize assistant
const currentAssistant = await getDefaultTranslateAssistant(targetLanguage, action.selectedText!)
assistantRef.current = currentAssistant
// Initialize topic
topicRef.current = getDefaultTopic(currentAssistant.id)
})
}, [action, targetLanguage, translateModelPrompt])
// Try to initialize when:
// 1. action.selectedText change (generally will not)
// 2. isLanguagesLoaded change (only initialize when languages loaded)
// 3. updateLanguagePair change (depend on translateLanguages and isLanguagesLoaded)
useEffect(() => {
initialize()
}, [initialize])
const fetchResult = useCallback(async () => {
if (!assistantRef.current || !topicRef.current || !action.selectedText) return
if (!assistantRef.current || !topicRef.current || !action.selectedText || !initialized.current) return
const setAskId = (id: string) => {
askId.current = id
@ -146,6 +174,7 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
const assistant = await 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])
@ -162,7 +191,11 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
}, [allMessages])
const handleChangeLanguage = (targetLanguage: TranslateLanguage, alterLanguage: TranslateLanguage) => {
if (!initialized.current) {
return
}
setTargetLanguage(targetLanguage)
targetLangRef.current = targetLanguage
setAlterLanguage(alterLanguage)
db.settings.put({ id: 'translate:bidirectional:pair', value: [targetLanguage.langCode, alterLanguage.langCode] })