mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-11 16:39:15 +08:00
Merge remote-tracking branch 'origin/main' into feat/proxy-api-server
This commit is contained in:
commit
30c98ccc02
2
.github/workflows/auto-i18n.yml
vendored
2
.github/workflows/auto-i18n.yml
vendored
@ -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"
|
||||
|
||||
14
README.md
14
README.md
@ -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
|
||||
|
||||

|
||||

|
||||
|
||||
# ⭐️ Star History
|
||||
|
||||
|
||||
@ -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 统计
|
||||
|
||||

|
||||

|
||||
|
||||
# ⭐️ Star 记录
|
||||
|
||||
|
||||
@ -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 = `
|
||||
|
||||
@ -1061,12 +1061,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: {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
177
src/main/mcpServers/browser/README.md
Normal file
177
src/main/mcpServers/browser/README.md
Normal 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)
|
||||
3
src/main/mcpServers/browser/constants.ts
Normal file
3
src/main/mcpServers/browser/constants.ts
Normal 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
567
src/main/mcpServers/browser/tabbar-html.ts
Normal file
567
src/main/mcpServers/browser/tabbar-html.ts
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
var url = (tab.url || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
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>`
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(configManager.getZoomFactor())
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@ -8,6 +8,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'
|
||||
|
||||
const locales = Object.fromEntries(
|
||||
@ -21,7 +22,8 @@ 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 }])
|
||||
)
|
||||
|
||||
|
||||
@ -222,6 +222,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
afterClose={onClose}
|
||||
centered={!isFullscreen}
|
||||
destroyOnHidden
|
||||
forceRender={isFullscreen}
|
||||
mask={!isFullscreen}
|
||||
maskClosable={false}
|
||||
width={isFullscreen ? '100vw' : '90vw'}
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,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'
|
||||
@ -141,6 +142,8 @@ function getAntdLocale(language: LanguageVarious) {
|
||||
return frFR
|
||||
case 'pt-PT':
|
||||
return ptPT
|
||||
case 'ro-RO':
|
||||
return roRO
|
||||
default:
|
||||
return zhCN
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -36,18 +36,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]
|
||||
)
|
||||
@ -63,6 +61,7 @@ export default function useTranslate() {
|
||||
prompt,
|
||||
settings,
|
||||
translateLanguages,
|
||||
isLoaded,
|
||||
getLanguageByLangcode,
|
||||
updateSettings: handleUpdateSettings
|
||||
}
|
||||
|
||||
@ -14,6 +14,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')
|
||||
@ -29,7 +30,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 }])
|
||||
)
|
||||
|
||||
|
||||
5101
src/renderer/src/i18n/translate/ro-ro.json
Normal file
5101
src/renderer/src/i18n/translate/ro-ro.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -162,7 +162,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
|
||||
const { message: clearMessage } = getUserMessage({ assistant, topic, type: 'clear' })
|
||||
dispatch(newMessagesActions.addMessage({ topicId: topic.id, message: clearMessage }))
|
||||
await saveMessageAndBlocksToDB(clearMessage, [])
|
||||
await saveMessageAndBlocksToDB(topic.id, clearMessage, [])
|
||||
|
||||
scrollToBottom()
|
||||
} finally {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -170,7 +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'
|
||||
isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/docs/en-us'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -148,7 +148,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 notificationSettings = useSelector((state: RootState) => state.settings.notification)
|
||||
|
||||
@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,7 +116,6 @@ export 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>
|
||||
|
||||
@ -19,6 +19,7 @@ import { AiSdkToChunkAdapter } from '@renderer/aiCore/chunk/AiSdkToChunkAdapter'
|
||||
import { AgentApiClient } from '@renderer/api/agent'
|
||||
import db from '@renderer/databases'
|
||||
import { fetchMessagesSummary, transformMessagesAndFetch } from '@renderer/services/ApiService'
|
||||
import { dbService } from '@renderer/services/db'
|
||||
import { DbService } from '@renderer/services/db/DbService'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { BlockManager } from '@renderer/services/messageStreaming/BlockManager'
|
||||
@ -57,18 +58,18 @@ import { mutate } from 'swr'
|
||||
import type { AppDispatch, RootState } from '../index'
|
||||
import { removeManyBlocks, updateOneBlock, upsertManyBlocks, upsertOneBlock } from '../messageBlock'
|
||||
import { newMessagesActions, selectMessagesForTopic } from '../newMessage'
|
||||
import {
|
||||
bulkAddBlocksV2,
|
||||
clearMessagesFromDBV2,
|
||||
deleteMessageFromDBV2,
|
||||
deleteMessagesFromDBV2,
|
||||
loadTopicMessagesThunkV2,
|
||||
saveMessageAndBlocksToDBV2,
|
||||
updateBlocksV2,
|
||||
updateFileCountV2,
|
||||
updateMessageV2,
|
||||
updateSingleBlockV2
|
||||
} from './messageThunk.v2'
|
||||
// import {
|
||||
// bulkAddBlocksV2,
|
||||
// clearMessagesFromDBV2,
|
||||
// deleteMessageFromDBV2,
|
||||
// deleteMessagesFromDBV2,
|
||||
// loadTopicMessagesThunkV2,
|
||||
// saveMessageAndBlocksToDBV2,
|
||||
// updateBlocksV2,
|
||||
// updateFileCountV2,
|
||||
// updateMessageV2,
|
||||
// updateSingleBlockV2
|
||||
// } from './messageThunk.v2'
|
||||
|
||||
const logger = loggerService.withContext('MessageThunk')
|
||||
|
||||
@ -363,9 +364,9 @@ const createAgentMessageStream = async (
|
||||
return createSSEReadableStream(response.body, signal)
|
||||
}
|
||||
// TODO: 后续可以将db操作移到Listener Middleware中
|
||||
export const saveMessageAndBlocksToDB = async (message: Message, blocks: MessageBlock[], messageIndex: number = -1) => {
|
||||
return saveMessageAndBlocksToDBV2(message.topicId, message, blocks, messageIndex)
|
||||
}
|
||||
// export const saveMessageAndBlocksToDB = async (message: Message, blocks: MessageBlock[], messageIndex: number = -1) => {
|
||||
// return saveMessageAndBlocksToDBV2(message.topicId, message, blocks, messageIndex)
|
||||
// }
|
||||
|
||||
const updateExistingMessageAndBlocksInDB = async (
|
||||
updatedMessage: Partial<Message> & Pick<Message, 'id' | 'topicId'>,
|
||||
@ -374,7 +375,7 @@ const updateExistingMessageAndBlocksInDB = async (
|
||||
try {
|
||||
// Always update blocks if provided
|
||||
if (updatedBlocks.length > 0) {
|
||||
await updateBlocksV2(updatedBlocks)
|
||||
await updateBlocks(updatedBlocks)
|
||||
}
|
||||
|
||||
// Check if there are message properties to update beyond id and topicId
|
||||
@ -386,7 +387,7 @@ const updateExistingMessageAndBlocksInDB = async (
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
await updateMessageV2(updatedMessage.topicId, updatedMessage.id, messageUpdatesPayload)
|
||||
await updateMessage(updatedMessage.topicId, updatedMessage.id, messageUpdatesPayload)
|
||||
|
||||
store.dispatch(updateTopicUpdatedAt({ topicId: updatedMessage.topicId }))
|
||||
}
|
||||
@ -432,7 +433,7 @@ const getBlockThrottler = (id: string) => {
|
||||
})
|
||||
|
||||
blockUpdateRafs.set(id, rafId)
|
||||
await updateSingleBlockV2(id, blockUpdate)
|
||||
await updateSingleBlock(id, blockUpdate)
|
||||
}, 150)
|
||||
|
||||
blockUpdateThrottlers.set(id, throttler)
|
||||
@ -893,7 +894,7 @@ export const sendMessage =
|
||||
userMessage.agentSessionId = activeAgentSession.agentSessionId
|
||||
}
|
||||
|
||||
await saveMessageAndBlocksToDB(userMessage, userMessageBlocks)
|
||||
await saveMessageAndBlocksToDB(topicId, userMessage, userMessageBlocks)
|
||||
dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
|
||||
if (userMessageBlocks.length > 0) {
|
||||
dispatch(upsertManyBlocks(userMessageBlocks))
|
||||
@ -911,7 +912,7 @@ export const sendMessage =
|
||||
if (activeAgentSession.agentSessionId && !assistantMessage.agentSessionId) {
|
||||
assistantMessage.agentSessionId = activeAgentSession.agentSessionId
|
||||
}
|
||||
await saveMessageAndBlocksToDB(assistantMessage, [])
|
||||
await saveMessageAndBlocksToDB(topicId, assistantMessage, [])
|
||||
dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
|
||||
|
||||
queue.add(async () => {
|
||||
@ -934,7 +935,7 @@ export const sendMessage =
|
||||
model: assistant.model,
|
||||
traceId: userMessage.traceId
|
||||
})
|
||||
await saveMessageAndBlocksToDB(assistantMessage, [])
|
||||
await saveMessageAndBlocksToDB(topicId, assistantMessage, [])
|
||||
dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
|
||||
|
||||
queue.add(async () => {
|
||||
@ -1000,11 +1001,11 @@ export const loadAgentSessionMessagesThunk =
|
||||
* Loads messages and their blocks for a specific topic from the database
|
||||
* and updates the Redux store.
|
||||
*/
|
||||
export const loadTopicMessagesThunk =
|
||||
(topicId: string, forceReload: boolean = false) =>
|
||||
async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
return loadTopicMessagesThunkV2(topicId, forceReload)(dispatch, getState)
|
||||
}
|
||||
// export const loadTopicMessagesThunk =
|
||||
// (topicId: string, forceReload: boolean = false) =>
|
||||
// async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
// return loadTopicMessagesThunkV2(topicId, forceReload)(dispatch, getState)
|
||||
// }
|
||||
|
||||
/**
|
||||
* Thunk to delete a single message and its associated blocks.
|
||||
@ -1023,7 +1024,7 @@ export const deleteSingleMessageThunk =
|
||||
try {
|
||||
dispatch(newMessagesActions.removeMessage({ topicId, messageId }))
|
||||
cleanupMultipleBlocks(dispatch, blockIdsToDelete)
|
||||
await deleteMessageFromDBV2(topicId, messageId)
|
||||
await deleteMessageFromDB(topicId, messageId)
|
||||
} catch (error) {
|
||||
logger.error(`[deleteSingleMessage] Failed to delete message ${messageId}:`, error as Error)
|
||||
}
|
||||
@ -1062,7 +1063,7 @@ export const deleteMessageGroupThunk =
|
||||
try {
|
||||
dispatch(newMessagesActions.removeMessagesByAskId({ topicId, askId }))
|
||||
cleanupMultipleBlocks(dispatch, blockIdsToDelete)
|
||||
await deleteMessagesFromDBV2(topicId, messageIdsToDelete)
|
||||
await deleteMessagesFromDB(topicId, messageIdsToDelete)
|
||||
} catch (error) {
|
||||
logger.error(`[deleteMessageGroup] Failed to delete messages with askId ${askId}:`, error as Error)
|
||||
}
|
||||
@ -1087,7 +1088,7 @@ export const clearTopicMessagesThunk =
|
||||
|
||||
dispatch(newMessagesActions.clearTopicMessages(topicId))
|
||||
cleanupMultipleBlocks(dispatch, blockIdsToDelete)
|
||||
await clearMessagesFromDBV2(topicId)
|
||||
await clearMessagesFromDB(topicId)
|
||||
} catch (error) {
|
||||
logger.error(`[clearTopicMessagesThunk] Failed to clear messages for topic ${topicId}:`, error as Error)
|
||||
}
|
||||
@ -1408,7 +1409,7 @@ export const updateTranslationBlockThunk =
|
||||
// 更新Redux状态
|
||||
dispatch(updateOneBlock({ id: blockId, changes }))
|
||||
|
||||
await updateSingleBlockV2(blockId, changes)
|
||||
await updateSingleBlock(blockId, changes)
|
||||
// Logger.log(`[updateTranslationBlockThunk] Successfully updated translation block ${blockId}.`)
|
||||
} catch (error) {
|
||||
logger.error(`[updateTranslationBlockThunk] Failed to update translation block ${blockId}:`, error as Error)
|
||||
@ -1479,7 +1480,7 @@ export const appendAssistantResponseThunk =
|
||||
const insertAtIndex = existingMessageIndex !== -1 ? existingMessageIndex + 1 : currentTopicMessageIds.length
|
||||
|
||||
// 4. Update Database (Save the stub to the topic's message list)
|
||||
await saveMessageAndBlocksToDB(newAssistantMessageStub, [], insertAtIndex)
|
||||
await saveMessageAndBlocksToDB(topicId, newAssistantMessageStub, [], insertAtIndex)
|
||||
|
||||
dispatch(
|
||||
newMessagesActions.insertMessageAtIndex({ topicId, message: newAssistantMessageStub, index: insertAtIndex })
|
||||
@ -1631,12 +1632,12 @@ export const cloneMessagesToNewTopicThunk =
|
||||
|
||||
// Add the NEW blocks
|
||||
if (clonedBlocks.length > 0) {
|
||||
await bulkAddBlocksV2(clonedBlocks)
|
||||
await bulkAddBlocks(clonedBlocks)
|
||||
}
|
||||
// Update file counts
|
||||
const uniqueFiles = [...new Map(filesToUpdateCount.map((f) => [f.id, f])).values()]
|
||||
for (const file of uniqueFiles) {
|
||||
await updateFileCountV2(file.id, 1, false)
|
||||
await updateFileCount(file.id, 1, false)
|
||||
}
|
||||
})
|
||||
|
||||
@ -1690,11 +1691,11 @@ export const updateMessageAndBlocksThunk =
|
||||
}
|
||||
// Update message properties if provided
|
||||
if (messageUpdates && Object.keys(messageUpdates).length > 0 && messageId) {
|
||||
await updateMessageV2(topicId, messageId, messageUpdates)
|
||||
await updateMessage(topicId, messageId, messageUpdates)
|
||||
}
|
||||
// Update blocks if provided
|
||||
if (blockUpdatesList.length > 0) {
|
||||
await updateBlocksV2(blockUpdatesList)
|
||||
await updateBlocks(blockUpdatesList)
|
||||
}
|
||||
|
||||
dispatch(updateTopicUpdatedAt({ topicId }))
|
||||
@ -1748,3 +1749,197 @@ export const removeBlocksThunk =
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
//以下内容从原 messageThunk.v2.ts 迁移过来,原文件已经删除
|
||||
//原因:v2.ts并不是v2数据重构的一部分,而相关命名对v2重构造成重大误解,故两文件合并,以消除误解
|
||||
|
||||
/**
|
||||
* Load messages for a topic using unified DbService
|
||||
*/
|
||||
export const loadTopicMessagesThunk =
|
||||
(topicId: string, forceReload: boolean = false) =>
|
||||
async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState()
|
||||
|
||||
dispatch(newMessagesActions.setCurrentTopicId(topicId))
|
||||
|
||||
// Skip if already cached and not forcing reload
|
||||
if (!forceReload && state.messages.messageIdsByTopic[topicId]) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: true }))
|
||||
|
||||
// Unified call - no need to check isAgentSessionTopicId
|
||||
const { messages, blocks } = await dbService.fetchMessages(topicId)
|
||||
|
||||
logger.silly('Loaded messages via DbService', {
|
||||
topicId,
|
||||
messageCount: messages.length,
|
||||
blockCount: blocks.length
|
||||
})
|
||||
|
||||
// Update Redux state with fetched data
|
||||
if (blocks.length > 0) {
|
||||
dispatch(upsertManyBlocks(blocks))
|
||||
}
|
||||
dispatch(newMessagesActions.messagesReceived({ topicId, messages }))
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load messages for topic ${topicId}:`, error as Error)
|
||||
// Could dispatch an error action here if needed
|
||||
} finally {
|
||||
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw topic data using unified DbService
|
||||
* Returns topic with messages array
|
||||
*/
|
||||
export const getRawTopic = async (topicId: string): Promise<{ id: string; messages: Message[] } | undefined> => {
|
||||
try {
|
||||
const rawTopic = await dbService.getRawTopic(topicId)
|
||||
logger.silly('Retrieved raw topic via DbService', { topicId, found: !!rawTopic })
|
||||
return rawTopic
|
||||
} catch (error) {
|
||||
logger.error('Failed to get raw topic:', { topicId, error })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update file reference count
|
||||
* Only applies to Dexie data source, no-op for agent sessions
|
||||
*/
|
||||
export const updateFileCount = async (fileId: string, delta: number, deleteIfZero: boolean = false): Promise<void> => {
|
||||
try {
|
||||
// Pass all parameters to dbService, including deleteIfZero
|
||||
await dbService.updateFileCount(fileId, delta, deleteIfZero)
|
||||
logger.silly('Updated file count', { fileId, delta, deleteIfZero })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update file count:', { fileId, delta, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single message from database
|
||||
*/
|
||||
export const deleteMessageFromDB = async (topicId: string, messageId: string): Promise<void> => {
|
||||
try {
|
||||
await dbService.deleteMessage(topicId, messageId)
|
||||
logger.silly('Deleted message via DbService', { topicId, messageId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete message:', { topicId, messageId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple messages from database
|
||||
*/
|
||||
export const deleteMessagesFromDB = async (topicId: string, messageIds: string[]): Promise<void> => {
|
||||
try {
|
||||
await dbService.deleteMessages(topicId, messageIds)
|
||||
logger.silly('Deleted messages via DbService', { topicId, count: messageIds.length })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete messages:', { topicId, messageIds, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all messages from a topic
|
||||
*/
|
||||
export const clearMessagesFromDB = async (topicId: string): Promise<void> => {
|
||||
try {
|
||||
await dbService.clearMessages(topicId)
|
||||
logger.silly('Cleared all messages via DbService', { topicId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear messages:', { topicId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a message and its blocks to database
|
||||
* Uses unified interface, no need for isAgentSessionTopicId check
|
||||
*/
|
||||
export const saveMessageAndBlocksToDB = async (
|
||||
topicId: string,
|
||||
message: Message,
|
||||
blocks: MessageBlock[],
|
||||
messageIndex: number = -1
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const blockIds = blocks.map((block) => block.id)
|
||||
const shouldSyncBlocks =
|
||||
blockIds.length > 0 && (!message.blocks || blockIds.some((id, index) => message.blocks?.[index] !== id))
|
||||
|
||||
const messageWithBlocks = shouldSyncBlocks ? { ...message, blocks: blockIds } : message
|
||||
// Direct call without conditional logic, now with messageIndex
|
||||
await dbService.appendMessage(topicId, messageWithBlocks, blocks, messageIndex)
|
||||
logger.silly('Saved message and blocks via DbService', {
|
||||
topicId,
|
||||
messageId: message.id,
|
||||
blockCount: blocks.length,
|
||||
messageIndex
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to save message and blocks:', { topicId, messageId: message.id, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a message in the database
|
||||
*/
|
||||
export const updateMessage = async (topicId: string, messageId: string, updates: Partial<Message>): Promise<void> => {
|
||||
try {
|
||||
await dbService.updateMessage(topicId, messageId, updates)
|
||||
logger.silly('Updated message via DbService', { topicId, messageId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update message:', { topicId, messageId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single message block
|
||||
*/
|
||||
export const updateSingleBlock = async (blockId: string, updates: Partial<MessageBlock>): Promise<void> => {
|
||||
try {
|
||||
await dbService.updateSingleBlock(blockId, updates)
|
||||
logger.silly('Updated single block via DbService', { blockId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update single block:', { blockId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk add message blocks (for new blocks)
|
||||
*/
|
||||
export const bulkAddBlocks = async (blocks: MessageBlock[]): Promise<void> => {
|
||||
try {
|
||||
await dbService.bulkAddBlocks(blocks)
|
||||
logger.silly('Bulk added blocks via DbService', { count: blocks.length })
|
||||
} catch (error) {
|
||||
logger.error('Failed to bulk add blocks:', { count: blocks.length, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple message blocks (upsert operation)
|
||||
*/
|
||||
export const updateBlocks = async (blocks: MessageBlock[]): Promise<void> => {
|
||||
try {
|
||||
await dbService.updateBlocks(blocks)
|
||||
logger.silly('Updated blocks via DbService', { count: blocks.length })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update blocks:', { count: blocks.length, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,249 +0,0 @@
|
||||
/**
|
||||
* @deprecated Scheduled for removal in v2.0.0
|
||||
* --------------------------------------------------------------------------
|
||||
* ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex)
|
||||
* --------------------------------------------------------------------------
|
||||
* STOP: Feature PRs affecting this file are currently BLOCKED.
|
||||
* Only critical bug fixes are accepted during this migration phase.
|
||||
*
|
||||
* This file is being refactored to v2 standards.
|
||||
* Any non-critical changes will conflict with the ongoing work.
|
||||
*
|
||||
* 🔗 Context & Status:
|
||||
* - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954
|
||||
* - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
/**
|
||||
* V2 implementations of message thunk functions using the unified DbService
|
||||
* These implementations will be gradually rolled out using feature flags
|
||||
*/
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { dbService } from '@renderer/services/db'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
|
||||
import type { AppDispatch, RootState } from '../index'
|
||||
import { upsertManyBlocks } from '../messageBlock'
|
||||
import { newMessagesActions } from '../newMessage'
|
||||
|
||||
const logger = loggerService.withContext('MessageThunkV2')
|
||||
|
||||
// =================================================================
|
||||
// Phase 2.1 - Batch 1: Read-only operations (lowest risk)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Load messages for a topic using unified DbService
|
||||
* This is the V2 implementation that will replace the original
|
||||
*/
|
||||
export const loadTopicMessagesThunkV2 =
|
||||
(topicId: string, forceReload: boolean = false) =>
|
||||
async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState()
|
||||
|
||||
dispatch(newMessagesActions.setCurrentTopicId(topicId))
|
||||
|
||||
// Skip if already cached and not forcing reload
|
||||
if (!forceReload && state.messages.messageIdsByTopic[topicId]) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: true }))
|
||||
|
||||
// Unified call - no need to check isAgentSessionTopicId
|
||||
const { messages, blocks } = await dbService.fetchMessages(topicId)
|
||||
|
||||
logger.silly('Loaded messages via DbService', {
|
||||
topicId,
|
||||
messageCount: messages.length,
|
||||
blockCount: blocks.length
|
||||
})
|
||||
|
||||
// Update Redux state with fetched data
|
||||
if (blocks.length > 0) {
|
||||
dispatch(upsertManyBlocks(blocks))
|
||||
}
|
||||
dispatch(newMessagesActions.messagesReceived({ topicId, messages }))
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load messages for topic ${topicId}:`, error as Error)
|
||||
// Could dispatch an error action here if needed
|
||||
} finally {
|
||||
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw topic data using unified DbService
|
||||
* Returns topic with messages array
|
||||
*/
|
||||
export const getRawTopicV2 = async (topicId: string): Promise<{ id: string; messages: Message[] } | undefined> => {
|
||||
try {
|
||||
const rawTopic = await dbService.getRawTopic(topicId)
|
||||
logger.silly('Retrieved raw topic via DbService', { topicId, found: !!rawTopic })
|
||||
return rawTopic
|
||||
} catch (error) {
|
||||
logger.error('Failed to get raw topic:', { topicId, error })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Phase 2.2 - Batch 2: Helper functions
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Update file reference count
|
||||
* Only applies to Dexie data source, no-op for agent sessions
|
||||
*/
|
||||
export const updateFileCountV2 = async (
|
||||
fileId: string,
|
||||
delta: number,
|
||||
deleteIfZero: boolean = false
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Pass all parameters to dbService, including deleteIfZero
|
||||
await dbService.updateFileCount(fileId, delta, deleteIfZero)
|
||||
logger.silly('Updated file count', { fileId, delta, deleteIfZero })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update file count:', { fileId, delta, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Phase 2.3 - Batch 3: Delete operations
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Delete a single message from database
|
||||
*/
|
||||
export const deleteMessageFromDBV2 = async (topicId: string, messageId: string): Promise<void> => {
|
||||
try {
|
||||
await dbService.deleteMessage(topicId, messageId)
|
||||
logger.silly('Deleted message via DbService', { topicId, messageId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete message:', { topicId, messageId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple messages from database
|
||||
*/
|
||||
export const deleteMessagesFromDBV2 = async (topicId: string, messageIds: string[]): Promise<void> => {
|
||||
try {
|
||||
await dbService.deleteMessages(topicId, messageIds)
|
||||
logger.silly('Deleted messages via DbService', { topicId, count: messageIds.length })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete messages:', { topicId, messageIds, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all messages from a topic
|
||||
*/
|
||||
export const clearMessagesFromDBV2 = async (topicId: string): Promise<void> => {
|
||||
try {
|
||||
await dbService.clearMessages(topicId)
|
||||
logger.silly('Cleared all messages via DbService', { topicId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear messages:', { topicId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Phase 2.4 - Batch 4: Complex write operations
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Save a message and its blocks to database
|
||||
* Uses unified interface, no need for isAgentSessionTopicId check
|
||||
*/
|
||||
export const saveMessageAndBlocksToDBV2 = async (
|
||||
topicId: string,
|
||||
message: Message,
|
||||
blocks: MessageBlock[],
|
||||
messageIndex: number = -1
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const blockIds = blocks.map((block) => block.id)
|
||||
const shouldSyncBlocks =
|
||||
blockIds.length > 0 && (!message.blocks || blockIds.some((id, index) => message.blocks?.[index] !== id))
|
||||
|
||||
const messageWithBlocks = shouldSyncBlocks ? { ...message, blocks: blockIds } : message
|
||||
// Direct call without conditional logic, now with messageIndex
|
||||
await dbService.appendMessage(topicId, messageWithBlocks, blocks, messageIndex)
|
||||
logger.silly('Saved message and blocks via DbService', {
|
||||
topicId,
|
||||
messageId: message.id,
|
||||
blockCount: blocks.length,
|
||||
messageIndex
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to save message and blocks:', { topicId, messageId: message.id, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Note: sendMessageV2 would be implemented here but it's more complex
|
||||
// and would require more of the supporting code from messageThunk.ts
|
||||
|
||||
// =================================================================
|
||||
// Phase 2.5 - Batch 5: Update operations
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Update a message in the database
|
||||
*/
|
||||
export const updateMessageV2 = async (topicId: string, messageId: string, updates: Partial<Message>): Promise<void> => {
|
||||
try {
|
||||
await dbService.updateMessage(topicId, messageId, updates)
|
||||
logger.silly('Updated message via DbService', { topicId, messageId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update message:', { topicId, messageId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single message block
|
||||
*/
|
||||
export const updateSingleBlockV2 = async (blockId: string, updates: Partial<MessageBlock>): Promise<void> => {
|
||||
try {
|
||||
await dbService.updateSingleBlock(blockId, updates)
|
||||
logger.silly('Updated single block via DbService', { blockId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update single block:', { blockId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk add message blocks (for new blocks)
|
||||
*/
|
||||
export const bulkAddBlocksV2 = async (blocks: MessageBlock[]): Promise<void> => {
|
||||
try {
|
||||
await dbService.bulkAddBlocks(blocks)
|
||||
logger.silly('Bulk added blocks via DbService', { count: blocks.length })
|
||||
} catch (error) {
|
||||
logger.error('Failed to bulk add blocks:', { count: blocks.length, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple message blocks (upsert operation)
|
||||
*/
|
||||
export const updateBlocksV2 = async (blocks: MessageBlock[]): Promise<void> => {
|
||||
try {
|
||||
await dbService.updateBlocks(blocks)
|
||||
logger.silly('Updated blocks via DbService', { count: blocks.length })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update blocks:', { count: blocks.length, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@ -474,6 +474,7 @@ export type LanguageVarious =
|
||||
| 'fr-FR'
|
||||
| 'ja-JP'
|
||||
| 'pt-PT'
|
||||
| 'ro-RO'
|
||||
| 'ru-RU'
|
||||
|
||||
export type CodeStyleVarious = 'auto' | string
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,6 @@ 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 type { ActionItem } from '@renderer/types/selectionTypes'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { abortCompletion } from '@renderer/utils/abortController'
|
||||
import { detectLanguage } from '@renderer/utils/translate'
|
||||
import { Tooltip } from 'antd'
|
||||
@ -32,70 +31,102 @@ const logger = loggerService.withContext('ActionTranslate')
|
||||
|
||||
const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
const { t } = useTranslation()
|
||||
const { translateModelPrompt, language } = useSettings()
|
||||
const { language } = useSettings()
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
setAlterLanguage(alterLang)
|
||||
})
|
||||
}, [getLanguageByLangcode, language])
|
||||
targetLangRef.current = targetLang
|
||||
}
|
||||
|
||||
// Initialize values only once when action changes
|
||||
useEffect(() => {
|
||||
if (initialized.current || !action.selectedText) return
|
||||
initialized.current = true
|
||||
if (biDirectionLangPair && biDirectionLangPair.value[1]) {
|
||||
const alterLang = getLanguageByLangcode(biDirectionLangPair.value[1])
|
||||
setAlterLanguage(alterLang)
|
||||
}
|
||||
}, [getLanguageByLangcode, isLanguagesLoaded])
|
||||
|
||||
// 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 = getDefaultTranslateAssistant(targetLanguage, action.selectedText)
|
||||
const currentAssistant = getDefaultTranslateAssistant(targetLangRef.current, action.selectedText)
|
||||
|
||||
assistantRef.current = currentAssistant
|
||||
|
||||
// Initialize topic
|
||||
topicRef.current = getDefaultTopic(currentAssistant.id)
|
||||
}, [action, targetLanguage, translateModelPrompt])
|
||||
initialized.current = true
|
||||
}, [action.selectedText, isLanguagesLoaded, updateLanguagePair])
|
||||
|
||||
// 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
|
||||
@ -141,6 +172,7 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
|
||||
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])
|
||||
|
||||
@ -157,7 +189,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] })
|
||||
|
||||
Loading…
Reference in New Issue
Block a user