Compare commits

..

2 Commits

Author SHA1 Message Date
MyPrototypeWhat
60d6fbe8f4 feat: refactor web search to provider-specific tools with advanced parameters
- Add ExaSearchTool and TavilySearchTool with provider-specific parameters
- Extend type system for Exa (neural search, date filters) and Tavily (AI answers, search depth)
- Update all providers to support ProviderSpecificParams interface
- Add searchResultAdapters for unified citation conversion
- Remove rawContent from LLM output and storage to reduce token usage
- Support favicon, highlights, answer, images metadata
- Update UI components to handle new tool names
- Preserve existing RAG compression and token cutoff capabilities

Breaking changes: None (backward compatible with existing providers)
2025-10-13 17:53:40 +08:00
MyPrototypeWhat
ff378ca567 feat: enhance web search functionality with abort signal support
- Updated WebSearchTool to accept an abort signal in the execute method.
- Modified various WebSearchProvider classes to include httpOptions for search methods, allowing for abort signal handling.
- Improved WebSearchService to prioritize external abort signals for better request management.
- Enhanced MessageTool to reflect tool status with appropriate UI feedback.
2025-10-09 17:44:42 +08:00
129 changed files with 2277 additions and 3552 deletions

View File

@@ -16,13 +16,10 @@ on:
jobs:
translate:
if: |
(github.event_name == 'issues')
|| (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot')
|| (
(github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment')
&& github.event.sender.type != 'Bot'
&& github.event.pull_request.head.repo.fork == false
)
(github.event_name == 'issues') ||
(github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') ||
(github.event_name == 'pull_request_review' && github.event.sender.type != 'Bot') ||
(github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot')
runs-on: ubuntu-latest
permissions:
contents: read
@@ -45,7 +42,7 @@ jobs:
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md
github_token: ${{ secrets.TOKEN_GITHUB_WRITE }}
allowed_non_write_users: "*"
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)"
prompt: |
你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件:
@@ -108,5 +105,3 @@ jobs:
使用以下命令获取完整信息:
gh issue view ${{ github.event.issue.number }} --json title,body,comments
env:
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}

View File

@@ -1,8 +1,8 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {

View File

@@ -2,7 +2,6 @@ import tseslint from '@electron-toolkit/eslint-config-ts'
import eslint from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config'
import importZod from 'eslint-plugin-import-zod'
import oxlint from 'eslint-plugin-oxlint'
import reactHooks from 'eslint-plugin-react-hooks'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
@@ -16,8 +15,7 @@ export default defineConfig([
{
plugins: {
'simple-import-sort': simpleImportSort,
'unused-imports': unusedImports,
'import-zod': importZod
'unused-imports': unusedImports
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
@@ -27,7 +25,6 @@ export default defineConfig([
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error',
'import-zod/prefer-zod-namespace': 'error'
}
},
// Configuration for ensuring compatibility with the original ESLint(8.x) rules

View File

@@ -99,10 +99,10 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.35",
"@ai-sdk/google-vertex": "^3.0.40",
"@ai-sdk/mistral": "^2.0.19",
"@ai-sdk/perplexity": "^2.0.13",
"@ai-sdk/amazon-bedrock": "^3.0.29",
"@ai-sdk/google-vertex": "^3.0.33",
"@ai-sdk/mistral": "^2.0.17",
"@ai-sdk/perplexity": "^2.0.11",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
@@ -152,7 +152,6 @@
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-node": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opeoginni/github-copilot-openai-compatible": "0.1.18",
"@playwright/test": "^1.52.0",
"@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5",
@@ -220,7 +219,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.68",
"ai": "^5.0.59",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
@@ -257,7 +256,6 @@
"emoji-picker-element": "^1.22.1",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"eslint": "^9.22.0",
"eslint-plugin-import-zod": "^1.2.0",
"eslint-plugin-oxlint": "^1.15.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
@@ -296,7 +294,7 @@
"notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"oxlint": "^1.22.0",
"oxlint": "^1.15.0",
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
"pdf-lib": "^1.17.1",
@@ -372,7 +370,6 @@
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
"esbuild": "^0.25.0",
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"node-abi": "4.12.0",
@@ -380,11 +377,10 @@
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"tar-fs": "^2.1.4",
"undici": "6.21.2",
"vite": "npm:rolldown-vite@latest",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch"
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -36,14 +36,14 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.27",
"@ai-sdk/azure": "^2.0.49",
"@ai-sdk/deepseek": "^1.0.23",
"@ai-sdk/openai": "^2.0.48",
"@ai-sdk/openai-compatible": "^1.0.22",
"@ai-sdk/anthropic": "^2.0.22",
"@ai-sdk/azure": "^2.0.42",
"@ai-sdk/deepseek": "^1.0.20",
"@ai-sdk/openai": "^2.0.42",
"@ai-sdk/openai-compatible": "^1.0.19",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.12",
"@ai-sdk/xai": "^2.0.26",
"@ai-sdk/provider-utils": "^3.0.10",
"@ai-sdk/xai": "^2.0.23",
"zod": "^4.1.5"
},
"devDependencies": {

View File

@@ -1,7 +1,7 @@
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { openai } from '@ai-sdk/openai'
import { InferToolInput, InferToolOutput, type Tool } from 'ai'
import { InferToolInput, InferToolOutput } from 'ai'
import { ProviderOptionsMap } from '../../../options/types'
import { OpenRouterSearchConfig } from './openrouter'
@@ -15,13 +15,6 @@ export type AnthropicSearchConfig = NonNullable<Parameters<typeof anthropic.tool
export type GoogleSearchConfig = NonNullable<Parameters<typeof google.tools.googleSearch>[0]>
export type XAISearchConfig = NonNullable<ProviderOptionsMap['xai']['searchParameters']>
type NormalizeTool<T> = T extends Tool<infer INPUT, infer OUTPUT> ? Tool<INPUT, OUTPUT> : Tool<any, any>
type AnthropicWebSearchTool = NormalizeTool<ReturnType<typeof anthropic.tools.webSearch_20250305>>
type OpenAIWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearch>>
type OpenAIChatWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearchPreview>>
type GoogleWebSearchTool = NormalizeTool<ReturnType<typeof google.tools.googleSearch>>
/**
* 插件初始化时接收的完整配置对象
*
@@ -66,7 +59,7 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
export type WebSearchToolOutputSchema = {
// Anthropic 工具 - 手动定义
anthropic: InferToolOutput<AnthropicWebSearchTool>
anthropic: InferToolOutput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
// OpenAI 工具 - 基于实际输出
// TODO: 上游定义不规范,是unknown
@@ -89,8 +82,8 @@ export type WebSearchToolOutputSchema = {
}
export type WebSearchToolInputSchema = {
anthropic: InferToolInput<AnthropicWebSearchTool>
openai: InferToolInput<OpenAIWebSearchTool>
google: InferToolInput<GoogleWebSearchTool>
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
anthropic: InferToolInput<ReturnType<typeof anthropic.tools.webSearch_20250305>>
openai: InferToolInput<ReturnType<typeof openai.tools.webSearch>>
google: InferToolInput<ReturnType<typeof google.tools.googleSearch>>
'openai-chat': InferToolInput<ReturnType<typeof openai.tools.webSearchPreview>>
}

View File

@@ -13,7 +13,7 @@ import { LanguageModelV2 } from '@ai-sdk/provider'
import { createXai } from '@ai-sdk/xai'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import { customProvider, Provider } from 'ai'
import * as z from 'zod'
import { z } from 'zod'
/**
* 基础 Provider IDs

View File

@@ -5,8 +5,8 @@ export enum IpcChannel {
App_SetLanguage = 'app:set-language',
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
App_ShowUpdateDialog = 'app:show-update-dialog',
App_CheckForUpdate = 'app:check-for-update',
App_QuitAndInstall = 'app:quit-and-install',
App_Reload = 'app:reload',
App_Quit = 'app:quit',
App_Info = 'app:info',
@@ -53,7 +53,6 @@ export enum IpcChannel {
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
Webview_SearchHotkey = 'webview:search-hotkey',
// Open
Open_Path = 'open:path',
@@ -96,9 +95,6 @@ export enum IpcChannel {
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
AgentMessage_GetHistory = 'agent-message:get-history',
// JavaScript
Js_Execute = 'js:execute',
//copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
@@ -238,6 +234,7 @@ export enum IpcChannel {
// events
BackupProgress = 'backup-progress',
ThemeUpdated = 'theme:updated',
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
RestoreProgress = 'restore-progress',
UpdateError = 'update-error',
UpdateAvailable = 'update-available',

View File

@@ -22,12 +22,3 @@ export type MCPProgressEvent = {
callId: string
progress: number // 0-1 range
}
export type WebviewKeyEvent = {
webviewId: number
key: string
control: boolean
meta: boolean
shift: boolean
alt: boolean
}

Binary file not shown.

View File

@@ -30,7 +30,6 @@ import selectionService, { initSelectionService } from './services/SelectionServ
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { initWebviewHotkeys } from './services/WebviewService'
const logger = loggerService.withContext('MainEntry')
@@ -109,7 +108,6 @@ if (!app.requestSingleInstanceLock()) {
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
initWebviewHotkeys()
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')

View File

@@ -37,7 +37,6 @@ import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService'
import { fileStorage as fileManager } from './services/FileStorage'
import FileService from './services/FileSystemService'
import { jsService } from './services/JsService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import MemoryService from './services/memory/MemoryService'
@@ -143,7 +142,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
// Update
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
// language
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
@@ -742,11 +741,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
)
// Register JavaScript execution handler
ipcMain.handle(IpcChannel.Js_Execute, async (_, code: string, timeout?: number) => {
return await jsService.executeScript(code, { timeout })
})
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
@@ -792,6 +786,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
setOpenLinkExternal(webviewId, isExternal)
)
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
const webview = webContents.fromId(webviewId)
if (!webview) return

View File

@@ -3,7 +3,7 @@ import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { net } from 'electron'
import * as z from 'zod'
import { z } from 'zod'
const logger = loggerService.withContext('DifyKnowledgeServer')

View File

@@ -6,7 +6,6 @@ import BraveSearchServer from './brave-search'
import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
import JsServer from './js'
import MemoryServer from './memory'
import PythonServer from './python'
import ThinkingServer from './sequentialthinking'
@@ -43,9 +42,6 @@ export function createInMemoryMCPServer(
case BuiltinMCPServerNames.python: {
return new PythonServer().server
}
case BuiltinMCPServerNames.js: {
return new JsServer().server
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@@ -5,7 +5,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
import { net } from 'electron'
import { JSDOM } from 'jsdom'
import TurndownService from 'turndown'
import * as z from 'zod'
import { z } from 'zod'
export const RequestPayloadSchema = z.object({
url: z.url(),

View File

@@ -8,7 +8,7 @@ import fs from 'fs/promises'
import { minimatch } from 'minimatch'
import os from 'os'
import path from 'path'
import * as z from 'zod'
import { z } from 'zod'
const logger = loggerService.withContext('MCP:FileSystemServer')

View File

@@ -1,139 +0,0 @@
// port from https://github.com/jlucaso1/mcp-javascript-sandbox
import { loggerService } from '@logger'
import { jsService } from '@main/services/JsService'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types'
import * as z from 'zod'
const TOOL_NAME = 'run_javascript_code'
const DEFAULT_TIMEOUT = 60_000
export const RequestPayloadSchema = z.object({
javascript_code: z.string().min(1).describe('The JavaScript code to execute in the sandbox.'),
timeout: z
.number()
.int()
.positive()
.max(5 * 60_000)
.optional()
.describe('Execution timeout in milliseconds (default 60000, max 300000).')
})
const logger = loggerService.withContext('MCPServer:JavaScript')
function formatExecutionResult(result: {
stdout: string
stderr: string
error?: string | undefined
exitCode: number
}) {
let combinedOutput = ''
if (result.stdout) {
combinedOutput += result.stdout
}
if (result.stderr) {
combinedOutput += `--- stderr ---\n${result.stderr}\n--- stderr ---\n`
}
if (result.error) {
combinedOutput += `--- Execution Error ---\n${result.error}\n--- Execution Error ---\n`
}
const isError = Boolean(result.error) || Boolean(result.stderr?.trim()) || result.exitCode !== 0
return {
combinedOutput: combinedOutput.trim(),
isError
}
}
class JsServer {
public server: Server
constructor() {
this.server = new Server(
{
name: 'MCP QuickJS Runner',
version: '1.0.0',
description: 'An MCP server that provides a tool to execute JavaScript code in a QuickJS WASM sandbox.'
},
{
capabilities: {
resources: {},
tools: {}
}
}
)
this.setupHandlers()
}
private setupHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: TOOL_NAME,
description:
'Executes the provided JavaScript code in a secure WASM sandbox (QuickJS). Returns stdout and stderr. Non-zero exit code indicates an error.',
inputSchema: z.toJSONSchema(RequestPayloadSchema)
}
]
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
if (name !== TOOL_NAME) {
return {
content: [{ type: 'text', text: `Tool not found: ${name}` }],
isError: true
}
}
const parseResult = RequestPayloadSchema.safeParse(args)
if (!parseResult.success) {
return {
content: [{ type: 'text', text: `Invalid arguments: ${parseResult.error.message}` }],
isError: true
}
}
const { javascript_code, timeout } = parseResult.data
try {
logger.debug('Executing JavaScript code via JsService')
const result = await jsService.executeScript(javascript_code, {
timeout: timeout ?? DEFAULT_TIMEOUT
})
const { combinedOutput, isError } = formatExecutionResult(result)
return {
content: [
{
type: 'text',
text: combinedOutput
}
],
isError
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error(`JavaScript execution failed: ${message}`)
return {
content: [
{
type: 'text',
text: `Server error during tool execution: ${message}`
}
],
isError: true
}
}
})
}
}
export default JsServer

View File

@@ -1,15 +1,17 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { locales } from '@main/utils/locales'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, net } from 'electron'
import { app, BrowserWindow, dialog, net } from 'electron'
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import semver from 'semver'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
@@ -24,6 +26,7 @@ const LANG_MARKERS = {
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
@@ -63,6 +66,7 @@ export default class AppUpdater {
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
this.releaseInfo = processedReleaseInfo
logger.info('update downloaded', processedReleaseInfo)
})
@@ -243,9 +247,37 @@ export default class AppUpdater {
}
}
public quitAndInstall() {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
public async showUpdateDialog(mainWindow: BrowserWindow) {
if (!this.releaseInfo) {
return
}
const locale = locales[configManager.getLanguage()]
const { update: updateLocale } = locale.translation
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
if (detail === '') {
detail = updateLocale.noReleaseNotes
}
dialog
.showMessageBox({
type: 'info',
title: updateLocale.title,
icon,
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
detail,
buttons: [updateLocale.later, updateLocale.install],
defaultId: 1,
cancelId: 0
})
.then(({ response }) => {
if (response === 1) {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
}
})
}
/**
@@ -317,9 +349,38 @@ export default class AppUpdater {
return processedInfo
}
/**
* Format release notes for display
* @param releaseNotes - Release notes in various formats
* @returns Formatted string for display
*/
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return ''
}
if (typeof releaseNotes === 'string') {
// Check if it contains multi-language markers
if (this.hasMultiLanguageMarkers(releaseNotes)) {
return this.parseMultiLangReleaseNotes(releaseNotes)
}
return releaseNotes
}
if (Array.isArray(releaseNotes)) {
return releaseNotes.map((note) => note.note).join('\n')
}
return ''
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null
}

View File

@@ -1,115 +0,0 @@
import { loggerService } from '@logger'
import type { JsExecutionResult } from './workers/JsWorker'
// oxlint-disable-next-line default
import createJsWorker from './workers/JsWorker?nodeWorker'
interface ExecuteScriptOptions {
timeout?: number
}
type WorkerResponse =
| {
success: true
result: JsExecutionResult
}
| {
success: false
error: string
}
const DEFAULT_TIMEOUT = 60_000
const logger = loggerService.withContext('JsService')
export class JsService {
private static instance: JsService | null = null
private constructor() {}
public static getInstance(): JsService {
if (!JsService.instance) {
JsService.instance = new JsService()
}
return JsService.instance
}
public async executeScript(code: string, options: ExecuteScriptOptions = {}): Promise<JsExecutionResult> {
const { timeout = DEFAULT_TIMEOUT } = options
if (!code || typeof code !== 'string') {
throw new Error('JavaScript code must be a non-empty string')
}
// Limit code size to 1MB to prevent memory issues
const MAX_CODE_SIZE = 1_000_000
if (code.length > MAX_CODE_SIZE) {
throw new Error(`JavaScript code exceeds maximum size of ${MAX_CODE_SIZE / 1_000_000}MB`)
}
return new Promise<JsExecutionResult>((resolve, reject) => {
const worker = createJsWorker({
workerData: { code },
argv: [],
trackUnmanagedFds: false
})
let settled = false
let timeoutId: NodeJS.Timeout | null = null
const cleanup = async () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
try {
await worker.terminate()
} catch {
// ignore termination errors
}
}
const settleSuccess = async (result: JsExecutionResult) => {
if (settled) return
settled = true
await cleanup()
resolve(result)
}
const settleError = async (error: Error) => {
if (settled) return
settled = true
await cleanup()
reject(error)
}
worker.once('message', async (message: WorkerResponse) => {
if (message.success) {
await settleSuccess(message.result)
} else {
await settleError(new Error(message.error))
}
})
worker.once('error', async (error) => {
logger.error(`JsWorker error: ${error instanceof Error ? error.message : String(error)}`)
await settleError(error instanceof Error ? error : new Error(String(error)))
})
worker.once('exit', async (exitCode) => {
if (!settled && exitCode !== 0) {
await settleError(new Error(`JsWorker exited with code ${exitCode}`))
}
})
timeoutId = setTimeout(() => {
logger.warn(`JavaScript execution timed out after ${timeout}ms`)
settleError(new Error('JavaScript execution timed out')).catch((err) => {
logger.error('Error during timeout cleanup:', err instanceof Error ? err : new Error(String(err)))
})
}, timeout)
})
}
}
export const jsService = JsService.getInstance()

View File

@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
import { fileStorage } from '@main/services/FileStorage'
import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils'
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
import { getAllFiles } from '@main/utils/file'
import { TraceMethod } from '@mcp-trace/trace-core'
import { MB } from '@shared/config/constant'
import type { LoaderReturn } from '@shared/config/types'
@@ -147,16 +147,11 @@ class KnowledgeService {
}
}
private getDbPath = (id: string): string => {
// 消除网络搜索requestI d中的特殊字符
return path.join(this.storageDir, sanitizeFilename(id, '_'))
}
/**
* Delete knowledge base file
*/
private deleteKnowledgeFile = (id: string): boolean => {
const dbPath = this.getDbPath(id)
const dbPath = path.join(this.storageDir, id)
if (fs.existsSync(dbPath)) {
try {
fs.rmSync(dbPath, { recursive: true })
@@ -249,8 +244,7 @@ class KnowledgeService {
dimensions
})
try {
const dbPath = this.getDbPath(id)
const libSqlDb = new LibSqlDb({ path: dbPath })
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
// Save database instance for later closing
this.dbInstances.set(id, libSqlDb)

View File

@@ -1,5 +1,4 @@
import { IpcChannel } from '@shared/IpcChannel'
import { app, session, shell, webContents } from 'electron'
import { session, shell, webContents } from 'electron'
/**
* init the useragent of the webview session
@@ -37,61 +36,3 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
}
})
}
const attachKeyboardHandler = (contents: Electron.WebContents) => {
if (contents.getType?.() !== 'webview') {
return
}
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
if (!input) {
return
}
const key = input.key?.toLowerCase()
if (!key) {
return
}
const isFindShortcut = (input.control || input.meta) && key === 'f'
const isEscape = key === 'escape'
const isEnter = key === 'enter'
if (!isFindShortcut && !isEscape && !isEnter) {
return
}
// Prevent default to override the guest page's native find dialog
// and keep shortcuts routed to our custom search overlay
event.preventDefault()
const host = contents.hostWebContents
if (!host || host.isDestroyed()) {
return
}
host.send(IpcChannel.Webview_SearchHotkey, {
webviewId: contents.id,
key,
control: Boolean(input.control),
meta: Boolean(input.meta),
shift: Boolean(input.shift),
alt: Boolean(input.alt)
})
}
contents.on('before-input-event', handleBeforeInput)
contents.once('destroyed', () => {
contents.removeListener('before-input-event', handleBeforeInput)
})
}
export function initWebviewHotkeys() {
webContents.getAllWebContents().forEach((contents) => {
if (contents.isDestroyed()) return
attachKeyboardHandler(contents)
})
app.on('web-contents-created', (_, contents) => {
attachKeyboardHandler(contents)
})
}

View File

@@ -274,4 +274,46 @@ describe('AppUpdater', () => {
expect(result.releaseNotes).toBeNull()
})
})
describe('formatReleaseNotes', () => {
it('should format string release notes with markers', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('English')
})
it('should format string release notes without markers', () => {
const notes = 'Simple notes'
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Simple notes')
})
it('should format array release notes', () => {
const notes = [
{ version: '1.0.0', note: 'Note 1' },
{ version: '1.0.1', note: 'Note 2' }
]
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Note 1\nNote 2')
})
it('should handle null release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(null)
expect(result).toBe('')
})
it('should handle undefined release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(undefined)
expect(result).toBe('')
})
})
})

View File

@@ -4,7 +4,7 @@ import {
OAuthTokens
} from '@modelcontextprotocol/sdk/shared/auth.js'
import EventEmitter from 'events'
import * as z from 'zod'
import { z } from 'zod'
export interface OAuthStorageData {
clientInfo?: OAuthClientInformation

View File

@@ -1,7 +1,7 @@
import { loadOcrImage } from '@main/utils/ocr'
import { ImageFileMetadata, isImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types'
import { net } from 'electron'
import * as z from 'zod'
import { z } from 'zod'
import { OcrBaseService } from './OcrBaseService'

View File

@@ -1,118 +0,0 @@
import { mkdtemp, open, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { WASI } from 'node:wasi'
import { parentPort, workerData } from 'node:worker_threads'
import loadWasm from '../../../../resources/wasm/qjs-wasi.wasm?loader'
interface WorkerPayload {
code: string
}
export interface JsExecutionResult {
stdout: string
stderr: string
error?: string
exitCode: number
}
if (!parentPort) {
throw new Error('JsWorker requires a parent port')
}
async function runQuickJsInSandbox(jsCode: string): Promise<JsExecutionResult> {
let tempDir: string | undefined
let stdoutHandle: Awaited<ReturnType<typeof open>> | undefined
let stderrHandle: Awaited<ReturnType<typeof open>> | undefined
let stdoutPath: string | undefined
let stderrPath: string | undefined
try {
tempDir = await mkdtemp(join(tmpdir(), 'quickjs-wasi-'))
stdoutPath = join(tempDir, 'stdout.log')
stderrPath = join(tempDir, 'stderr.log')
stdoutHandle = await open(stdoutPath, 'w')
stderrHandle = await open(stderrPath, 'w')
const wasi = new WASI({
version: 'preview1',
args: ['qjs', '-e', jsCode],
env: {}, // Empty environment for security - don't expose host env vars
stdin: 0,
stdout: stdoutHandle.fd,
stderr: stderrHandle.fd,
returnOnExit: true
})
const instance = await loadWasm(wasi.getImportObject() as WebAssembly.Imports)
let exitCode = 0
try {
exitCode = wasi.start(instance)
} catch (wasiError: any) {
return {
stdout: '',
stderr: `WASI start error: ${wasiError?.message ?? String(wasiError)}`,
error: `Sandbox execution failed during start: ${wasiError?.message ?? String(wasiError)}`,
exitCode: -1
}
}
// Close handles before reading files to prevent descriptor leak
const _stdoutHandle = stdoutHandle
stdoutHandle = undefined
await _stdoutHandle.close()
const _stderrHandle = stderrHandle
stderrHandle = undefined
await _stderrHandle.close()
const capturedStdout = await readFile(stdoutPath, 'utf8')
const capturedStderr = await readFile(stderrPath, 'utf8')
let executionError: string | undefined
if (exitCode !== 0) {
executionError = `QuickJS process exited with code ${exitCode}. Check stderr for details.`
}
return {
stdout: capturedStdout,
stderr: capturedStderr,
error: executionError,
exitCode
}
} catch (error: any) {
return {
stdout: '',
stderr: '',
error: `Sandbox setup or execution failed: ${error?.message ?? String(error)}`,
exitCode: -1
}
} finally {
if (stdoutHandle) await stdoutHandle.close()
if (stderrHandle) await stderrHandle.close()
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
}
}
}
async function execute(code: string) {
return runQuickJsInSandbox(code)
}
const payload = workerData as WorkerPayload | undefined
if (!payload?.code || typeof payload.code !== 'string') {
parentPort.postMessage({ success: false, error: 'JavaScript code must be provided to the worker' })
} else {
execute(payload.code)
.then((result) => {
parentPort?.postMessage({ success: true, result })
})
.catch((error: any) => {
const errorMessage = error instanceof Error ? error.message : String(error)
parentPort?.postMessage({ success: false, error: errorMessage })
})
}

View File

@@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types'
import type { FileChangeEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import type { Notification } from '@types'
import {
@@ -51,7 +51,7 @@ const api = {
setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),
setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable),
setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages),
@@ -223,7 +223,7 @@ const api = {
create: (base: KnowledgeBaseParams, context?: SpanContext) =>
tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base),
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Reset, base),
delete: (id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, id),
delete: (base: KnowledgeBaseParams, id: string) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Delete, base, id),
add: ({
base,
item,
@@ -345,9 +345,6 @@ const api = {
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout)
},
js: {
execute: (code: string, timeout?: number) => ipcRenderer.invoke(IpcChannel.Js_Execute, code, timeout)
},
shell: {
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
},
@@ -393,16 +390,7 @@ const api = {
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
callback(payload)
}
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
return () => {
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
}
}
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
},
storeSync: {
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),

View File

@@ -166,7 +166,9 @@ export abstract class OpenAIBaseClient<
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers
...this.provider.extra_headers,
...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}),
...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {})
}
}) as TSdkInstance
}

View File

@@ -24,8 +24,10 @@ import { generateText } from 'ai'
import { isEmpty } from 'lodash'
import { MemoryProcessor } from '../../services/MemoryProcessor'
import { exaSearchTool } from '../tools/ExaSearchTool'
import { knowledgeSearchTool } from '../tools/KnowledgeSearchTool'
import { memorySearchTool } from '../tools/MemorySearchTool'
import { tavilySearchTool } from '../tools/TavilySearchTool'
import { webSearchToolWithPreExtractedKeywords } from '../tools/WebSearchTool'
const logger = loggerService.withContext('SearchOrchestrationPlugin')
@@ -316,13 +318,28 @@ export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string)
const needsSearch = analysisResult.websearch.question && analysisResult.websearch.question[0] !== 'not_needed'
if (needsSearch) {
// onChunk({ type: ChunkType.EXTERNEL_TOOL_IN_PROGRESS })
// logger.info('🌐 Adding web search tool with pre-extracted keywords')
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
assistant.webSearchProviderId,
analysisResult.websearch,
context.requestId
)
// 根据 Provider ID 动态选择工具
switch (assistant.webSearchProviderId) {
case 'exa':
logger.info('🌐 Adding Exa search tool (provider-specific)')
// Exa 工具直接接受单个查询字符串,使用第一个问题或合并所有问题
params.tools['builtin_exa_search'] = exaSearchTool(context.requestId)
break
case 'tavily':
logger.info('🌐 Adding Tavily search tool (provider-specific)')
// Tavily 工具直接接受单个查询字符串
params.tools['builtin_tavily_search'] = tavilySearchTool(context.requestId)
break
default:
logger.info('🌐 Adding web search tool with pre-extracted keywords')
// 其他 Provider 使用通用的 WebSearchTool
params.tools['builtin_web_search'] = webSearchToolWithPreExtractedKeywords(
assistant.webSearchProviderId,
analysisResult.websearch,
context.requestId
)
break
}
}
}

View File

@@ -23,7 +23,6 @@ import { CherryWebSearchConfig } from '@renderer/store/websearch'
import { type Assistant, type MCPTool, type Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern'
import { replacePromptVariables } from '@renderer/utils/prompt'
import type { ModelMessage, Tool } from 'ai'
import { stepCountIs } from 'ai'
@@ -160,14 +159,14 @@ export async function buildStreamTextParams(
abortSignal: options.requestOptions?.signal,
headers: options.requestOptions?.headers,
providerOptions,
stopWhen: stepCountIs(20),
stopWhen: stepCountIs(10),
maxRetries: 0
}
if (tools) {
params.tools = tools
}
if (assistant.prompt) {
params.system = await replacePromptVariables(assistant.prompt, model.name)
params.system = assistant.prompt
}
logger.debug('params', params)
return {

View File

@@ -1,89 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@renderer/services/LoggerService', () => ({
loggerService: {
withContext: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
})
}
}))
vi.mock('@renderer/services/AssistantService', () => ({
getProviderByModel: vi.fn()
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({ copilot: { defaultHeaders: {} } })
}
}))
import type { Model, Provider } from '@renderer/types'
import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants'
import { providerToAiSdkConfig } from '../providerConfig'
const createWindowKeyv = () => {
const store = new Map<string, string>()
return {
get: (key: string) => store.get(key),
set: (key: string, value: string) => {
store.set(key, value)
}
}
}
const createCopilotProvider = (): Provider => ({
id: 'copilot',
type: 'openai',
name: 'GitHub Copilot',
apiKey: 'test-key',
apiHost: 'https://api.githubcopilot.com',
models: [],
isSystem: true
})
const createModel = (id: string, name = id): Model => ({
id,
name,
provider: 'copilot',
group: 'copilot'
})
describe('Copilot responses routing', () => {
beforeEach(() => {
;(globalThis as any).window = {
...(globalThis as any).window,
keyv: createWindowKeyv()
}
})
it('detects official GPT-5 Codex identifiers case-insensitively', () => {
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'gpt-5-codex'))).toBe(true)
expect(isCopilotResponsesModel(createModel('GPT-5-CODEX', 'GPT-5-CODEX'))).toBe(true)
expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'custom-name'))).toBe(true)
expect(isCopilotResponsesModel(createModel('custom-id', 'custom-name'))).toBe(false)
})
it('configures gpt-5-codex with the Copilot provider', () => {
const provider = createCopilotProvider()
const config = providerToAiSdkConfig(provider, createModel('gpt-5-codex', 'GPT-5-CODEX'))
expect(config.providerId).toBe('github-copilot-openai-compatible')
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_EDITOR_VERSION)
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
expect(config.options.headers?.['copilot-vision-request']).toBe('true')
})
it('uses the Copilot provider for other models and keeps headers', () => {
const provider = createCopilotProvider()
const config = providerToAiSdkConfig(provider, createModel('gpt-4'))
expect(config.providerId).toBe('github-copilot-openai-compatible')
expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_DEFAULT_HEADERS['Editor-Version'])
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
})
})

View File

@@ -1,25 +0,0 @@
import type { Model } from '@renderer/types'
export const COPILOT_EDITOR_VERSION = 'vscode/1.104.1'
export const COPILOT_PLUGIN_VERSION = 'copilot-chat/0.26.7'
export const COPILOT_INTEGRATION_ID = 'vscode-chat'
export const COPILOT_USER_AGENT = 'GitHubCopilotChat/0.26.7'
export const COPILOT_DEFAULT_HEADERS = {
'Copilot-Integration-Id': COPILOT_INTEGRATION_ID,
'User-Agent': COPILOT_USER_AGENT,
'Editor-Version': COPILOT_EDITOR_VERSION,
'Editor-Plugin-Version': COPILOT_PLUGIN_VERSION,
'editor-version': COPILOT_EDITOR_VERSION,
'editor-plugin-version': COPILOT_PLUGIN_VERSION,
'copilot-vision-request': 'true'
} as const
// Models that require the OpenAI Responses endpoint when routed through GitHub Copilot (#10560)
const COPILOT_RESPONSES_MODEL_IDS = ['gpt-5-codex']
export function isCopilotResponsesModel(model: Model): boolean {
const normalizedId = model.id?.trim().toLowerCase()
const normalizedName = model.name?.trim().toLowerCase()
return COPILOT_RESPONSES_MODEL_IDS.some((target) => normalizedId === target || normalizedName === target)
}

View File

@@ -28,8 +28,7 @@ const STATIC_PROVIDER_MAPPING: Record<string, ProviderId> = {
gemini: 'google', // Google Gemini -> google
'azure-openai': 'azure', // Azure OpenAI -> azure
'openai-response': 'openai', // OpenAI Responses -> openai
grok: 'xai', // Grok -> xai
copilot: 'github-copilot-openai-compatible'
grok: 'xai' // Grok -> xai
}
/**

View File

@@ -21,7 +21,6 @@ import { formatApiHost } from '@renderer/utils/api'
import { cloneDeep, trim } from 'lodash'
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
import { COPILOT_DEFAULT_HEADERS } from './constants'
import { getAiSdkProviderId } from './factory'
const logger = loggerService.withContext('ProviderConfigProcessor')
@@ -110,9 +109,6 @@ function formatProviderApiHost(provider: Provider): Provider {
if (!formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatted.apiHost
}
} else if (formatted.id === 'copilot') {
const trimmed = trim(formatted.apiHost)
formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
} else if (formatted.type === 'gemini') {
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
} else {
@@ -155,26 +151,6 @@ export function providerToAiSdkConfig(
baseURL: trim(actualProvider.apiHost),
apiKey: getRotatedApiKey(actualProvider)
}
const isCopilotProvider = actualProvider.id === 'copilot'
if (isCopilotProvider) {
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
headers: {
...COPILOT_DEFAULT_HEADERS,
...storedHeaders,
...actualProvider.extra_headers
},
name: actualProvider.id,
includeUsage: true
})
return {
providerId: 'github-copilot-openai-compatible',
options
}
}
// 处理OpenAI模式
const extraOptions: any = {}
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
@@ -196,6 +172,15 @@ export function providerToAiSdkConfig(
}
}
}
// copilot
if (actualProvider.id === 'copilot') {
extraOptions.headers = {
...extraOptions.headers,
'editor-version': 'vscode/1.97.2',
'copilot-vision-request': 'true'
}
}
// azure
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
extraOptions.apiVersion = actualProvider.apiVersion
@@ -244,6 +229,7 @@ export function providerToAiSdkConfig(
}
}
// 如果AI SDK支持该provider使用原生配置
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions)
return {
@@ -291,17 +277,9 @@ export async function prepareSpecialProviderConfig(
) {
switch (provider.id) {
case 'copilot': {
const defaultHeaders = store.getState().copilot.defaultHeaders ?? {}
const headers = {
...COPILOT_DEFAULT_HEADERS,
...defaultHeaders
}
const { token } = await window.api.copilot.getToken(headers)
const defaultHeaders = store.getState().copilot.defaultHeaders
const { token } = await window.api.copilot.getToken(defaultHeaders)
config.options.apiKey = token
config.options.headers = {
...headers,
...config.options.headers
}
break
}
case 'cherryai': {

View File

@@ -32,14 +32,6 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
supportsImageGeneration: true,
aliases: ['vertexai-anthropic']
},
{
id: 'github-copilot-openai-compatible',
name: 'GitHub Copilot OpenAI Compatible',
import: () => import('@opeoginni/github-copilot-openai-compatible'),
creatorFunctionName: 'createGitHubCopilotOpenAICompatible',
supportsImageGeneration: false,
aliases: ['copilot', 'github-copilot']
},
{
id: 'bedrock',
name: 'Amazon Bedrock',

View File

@@ -0,0 +1,166 @@
import { loggerService } from '@logger'
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
import WebSearchService from '@renderer/services/WebSearchService'
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
const logger = loggerService.withContext('ExaSearchTool')
/**
* Exa 专用搜索工具 - 暴露 Exa 的高级搜索能力给 LLM
* 支持 Neural Search、Category Filtering、Date Range 等功能
*/
export const exaSearchTool = (requestId: string) => {
const webSearchProvider = WebSearchService.getWebSearchProvider('exa')
if (!webSearchProvider) {
throw new Error('Exa provider not found or not configured')
}
return tool({
name: 'builtin_exa_search',
description: `Advanced AI-powered search using Exa.ai with neural understanding and filtering capabilities.
Key Features:
- Neural Search: AI-powered semantic search that understands intent
- Search Type: Choose between neural (AI), keyword (traditional), or auto mode
- Category Filter: Focus on specific content types (company, research paper, news, etc.)
- Date Range: Filter by publication date
- Auto-prompt: Let Exa optimize your query automatically
Best for: Research, finding specific types of content, semantic search, and understanding complex queries.`,
inputSchema: z.object({
query: z.string().describe('The search query - be specific and clear'),
numResults: z.number().min(1).max(20).optional().describe('Number of results to return (1-20, default: 5)'),
type: z
.enum(['neural', 'keyword', 'auto', 'fast'])
.optional()
.describe(
'Search type: neural (embeddings-based), keyword (Google-like SERP), auto (default, intelligently combines both), or fast (streamlined versions)'
),
category: z
.string()
.optional()
.describe(
'Filter by content category: company, research paper, news, github, tweet, movie, song, personal site, pdf, etc.'
),
startPublishedDate: z
.string()
.optional()
.describe('Start date filter based on published date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
endPublishedDate: z
.string()
.optional()
.describe('End date filter based on published date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
startCrawlDate: z
.string()
.optional()
.describe('Start date filter based on crawl date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
endCrawlDate: z
.string()
.optional()
.describe('End date filter based on crawl date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)'),
useAutoprompt: z.boolean().optional().describe('Let Exa optimize your query automatically (recommended: true)')
}),
execute: async (params, { abortSignal }) => {
// 构建 provider 特定参数(排除 query 和 numResults这些由系统控制
const providerParams: ProviderSpecificParams = {
exa: {
type: params.type,
category: params.category,
startPublishedDate: params.startPublishedDate,
endPublishedDate: params.endPublishedDate,
startCrawlDate: params.startCrawlDate,
endCrawlDate: params.endCrawlDate,
useAutoprompt: params.useAutoprompt
}
}
// 构建 ExtractResults 结构
const extractResults: ExtractResults = {
websearch: {
question: [params.query]
}
}
// 统一调用 processWebsearch - 保留所有中间件时间戳、黑名单、tracing、压缩
const finalResults: WebSearchProviderResponse = await WebSearchService.processWebsearch(
webSearchProvider,
extractResults,
requestId,
abortSignal,
providerParams
)
logger.info(`Exa search completed: ${finalResults.results.length} results for "${params.query}"`)
return finalResults
},
toModelOutput: (results) => {
let summary = 'No search results found.'
if (results.query && results.results.length > 0) {
summary = `Found ${results.results.length} relevant sources using Exa AI search. Use [number] format to cite specific information.`
}
const citationData = results.results.map((result, index) => {
const citation: any = {
number: index + 1,
title: result.title,
content: result.content,
url: result.url
}
// 添加 Exa 特有的元数据
if ('favicon' in result && result.favicon) {
citation.favicon = result.favicon
}
if ('author' in result && result.author) {
citation.author = result.author
}
if ('publishedDate' in result && result.publishedDate) {
citation.publishedDate = result.publishedDate
}
if ('score' in result && result.score !== undefined) {
citation.score = result.score
}
if ('highlights' in result && result.highlights) {
citation.highlights = result.highlights
}
return citation
})
// 使用 REFERENCE_PROMPT 格式化引用
const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
const fullInstructions = REFERENCE_PROMPT.replace(
'{question}',
"Based on the Exa search results, please answer the user's question with proper citations."
).replace('{references}', referenceContent)
return {
type: 'content',
value: [
{
type: 'text',
text: 'Exa AI Search: Neural search with semantic understanding and rich metadata (author, publish date, highlights).'
},
{
type: 'text',
text: summary
},
{
type: 'text',
text: fullInstructions
}
]
}
}
})
}
export type ExaSearchToolOutput = InferToolOutput<ReturnType<typeof exaSearchTool>>
export type ExaSearchToolInput = InferToolInput<ReturnType<typeof exaSearchTool>>

View File

@@ -4,7 +4,7 @@ import type { Assistant, KnowledgeReference } from '@renderer/types'
import { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { isEmpty } from 'lodash'
import * as z from 'zod'
import { z } from 'zod'
/**
* 知识库搜索工具

View File

@@ -1,7 +1,7 @@
import store from '@renderer/store'
import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import * as z from 'zod'
import { z } from 'zod'
import { MemoryProcessor } from '../../services/MemoryProcessor'

View File

@@ -0,0 +1,161 @@
import { loggerService } from '@logger'
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
import WebSearchService from '@renderer/services/WebSearchService'
import { ProviderSpecificParams, WebSearchProviderResponse } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import { z } from 'zod'
const logger = loggerService.withContext('TavilySearchTool')
/**
* Tavily 专用搜索工具 - 暴露 Tavily 的高级搜索能力给 LLM
* 支持 AI-powered answers、Search depth control、Topic filtering 等功能
*/
export const tavilySearchTool = (requestId: string) => {
const webSearchProvider = WebSearchService.getWebSearchProvider('tavily')
if (!webSearchProvider) {
throw new Error('Tavily provider not found or not configured')
}
return tool({
name: 'builtin_tavily_search',
description: `AI-powered search using Tavily with direct answers and comprehensive content extraction.
Key Features:
- Direct AI Answer: Get a concise, factual answer extracted from search results
- Search Depth: Choose between basic (fast) or advanced (comprehensive) search
- Topic Focus: Filter by general, news, or finance topics
- Full Content: Access complete webpage content, not just snippets
- Rich Media: Optionally include relevant images from search results
Best for: Quick factual answers, news monitoring, financial research, and comprehensive content analysis.`,
inputSchema: z.object({
query: z.string().describe('The search query - be specific and clear'),
maxResults: z
.number()
.min(1)
.max(20)
.optional()
.describe('Maximum number of results to return (1-20, default: 5)'),
topic: z
.enum(['general', 'news', 'finance'])
.optional()
.describe('Topic filter: general (default), news (latest news), or finance (financial/market data)'),
searchDepth: z
.enum(['basic', 'advanced'])
.optional()
.describe('Search depth: basic (faster, top results) or advanced (slower, more comprehensive)'),
includeAnswer: z
.boolean()
.optional()
.describe('Include AI-generated direct answer extracted from results (default: true)'),
includeRawContent: z
.boolean()
.optional()
.describe('Include full webpage content instead of just snippets (default: true)'),
includeImages: z.boolean().optional().describe('Include relevant images from search results (default: false)')
}),
execute: async (params, { abortSignal }) => {
try {
// 构建 provider 特定参数
const providerParams: ProviderSpecificParams = {
tavily: {
topic: params.topic,
searchDepth: params.searchDepth,
includeAnswer: params.includeAnswer,
includeRawContent: params.includeRawContent,
includeImages: params.includeImages
}
}
// 构建 ExtractResults 结构
const extractResults: ExtractResults = {
websearch: {
question: [params.query]
}
}
// 统一调用 processWebsearch - 保留所有中间件时间戳、黑名单、tracing、压缩
const finalResults: WebSearchProviderResponse = await WebSearchService.processWebsearch(
webSearchProvider,
extractResults,
requestId,
abortSignal,
providerParams
)
logger.info(`Tavily search completed: ${finalResults.results.length} results for "${params.query}"`)
return finalResults
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
logger.info('Tavily search aborted')
throw error
}
logger.error('Tavily search failed:', error as Error)
throw new Error(`Tavily search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
},
toModelOutput: (results) => {
let summary = 'No search results found.'
if (results.query && results.results.length > 0) {
summary = `Found ${results.results.length} relevant sources using Tavily AI search. Use [number] format to cite specific information.`
}
const citationData = results.results.map((result, index) => {
const citation: any = {
number: index + 1,
title: result.title,
content: result.content,
url: result.url
}
// 添加 Tavily 特有的元数据
if ('answer' in result && result.answer) {
citation.answer = result.answer // Tavily 的直接答案
}
if ('images' in result && result.images && result.images.length > 0) {
citation.images = result.images // Tavily 的图片
}
if ('score' in result && result.score !== undefined) {
citation.score = result.score
}
return citation
})
// 使用 REFERENCE_PROMPT 格式化引用
const referenceContent = `\`\`\`json\n${JSON.stringify(citationData, null, 2)}\n\`\`\``
const fullInstructions = REFERENCE_PROMPT.replace(
'{question}',
"Based on the Tavily search results, please answer the user's question with proper citations."
).replace('{references}', referenceContent)
return {
type: 'content',
value: [
{
type: 'text',
text: 'Tavily AI Search: AI-powered with direct answers, full content extraction, and optional image results.'
},
{
type: 'text',
text: summary
},
{
type: 'text',
text: fullInstructions
}
]
}
}
})
}
export type TavilySearchToolOutput = InferToolOutput<ReturnType<typeof tavilySearchTool>>
export type TavilySearchToolInput = InferToolInput<ReturnType<typeof tavilySearchTool>>

View File

@@ -3,7 +3,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { ExtractResults } from '@renderer/utils/extract'
import { type InferToolInput, type InferToolOutput, tool } from 'ai'
import * as z from 'zod'
import { z } from 'zod'
/**
* 使用预提取关键词的网络搜索工具
@@ -40,7 +40,7 @@ You can use this tool as-is to search with the prepared queries, or provide addi
.describe('Optional additional context, keywords, or specific focus to enhance the search')
}),
execute: async ({ additionalContext }) => {
execute: async ({ additionalContext }, { abortSignal }) => {
let finalQueries = [...extractedKeywords.question]
if (additionalContext?.trim()) {
@@ -67,7 +67,15 @@ You can use this tool as-is to search with the prepared queries, or provide addi
links: extractedKeywords.links
}
}
searchResults = await WebSearchService.processWebsearch(webSearchProvider!, extractResults, requestId)
// abortSignal?.addEventListener('abort', () => {
// console.log('tool_call_abortSignal', abortSignal?.aborted)
// })
searchResults = await WebSearchService.processWebsearch(
webSearchProvider!,
extractResults,
requestId,
abortSignal
)
return searchResults
},

View File

@@ -6,7 +6,6 @@ import {
getThinkModelType,
isDeepSeekHybridInferenceModel,
isDoubaoThinkingAutoModel,
isGrok4FastReasoningModel,
isGrokReasoningModel,
isOpenAIReasoningModel,
isQwenAlwaysThinkModel,
@@ -53,12 +52,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
return {}
}
// Don't disable reasoning for models that require it
if (
isGrokReasoningModel(model) ||
isOpenAIReasoningModel(model) ||
isQwenAlwaysThinkModel(model) ||
model.id.includes('seed-oss')
) {
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
@@ -106,7 +100,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// reasoningEffort有效的情况
// DeepSeek hybrid inference models, v3.1 and maybe more in the future
// 不同的 provider 有不同的思考控制方式,在这里统一解决
if (isDeepSeekHybridInferenceModel(model)) {
if (isSystemProvider(provider)) {
switch (provider.id) {
@@ -149,16 +142,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// OpenRouter models
if (model.provider === SystemProviderIds.openrouter) {
// Grok 4 Fast doesn't support effort levels, always use enabled: true
if (isGrok4FastReasoningModel(model)) {
return {
reasoning: {
enabled: true // Ignore effort level, just enable reasoning
}
}
}
// Other OpenRouter models that support effort levels
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
return {
reasoning: {
@@ -429,13 +412,6 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
return {}
}
/**
* Get XAI-specific reasoning parameters
* This function should only be called for XAI provider models
* @param assistant - The assistant configuration
* @param model - The model being used
* @returns XAI-specific reasoning parameters
*/
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isSupportedReasoningEffortGrokModel(model)) {
return {}
@@ -443,11 +419,6 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
if (!reasoningEffort) {
return {}
}
// For XAI provider Grok models, use reasoningEffort parameter directly
return {
reasoningEffort
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -87,8 +87,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
const [tools, setTools] = useState<ActionTool[]>([])
const isExecutable = useMemo(() => {
const executableLanguages = ['python', 'py', 'javascript', 'js']
return codeExecution.enabled && executableLanguages.includes(language.toLowerCase())
return codeExecution.enabled && language === 'python'
}, [codeExecution.enabled, language])
const sourceViewRef = useRef<CodeEditorHandles>(null)
@@ -153,49 +152,21 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
setIsRunning(true)
setExecutionResult(null)
const isPython = ['python', 'py'].includes(language.toLowerCase())
const isJavaScript = ['javascript', 'js'].includes(language.toLowerCase())
if (isPython) {
pyodideService
.runScript(children, {}, codeExecution.timeoutMinutes * 60000)
.then((result) => {
setExecutionResult(result)
pyodideService
.runScript(children, {}, codeExecution.timeoutMinutes * 60000)
.then((result) => {
setExecutionResult(result)
})
.catch((error) => {
logger.error('Unexpected error:', error)
setExecutionResult({
text: `Unexpected error: ${error.message || 'Unknown error'}`
})
.catch((error) => {
logger.error('Unexpected error:', error)
setExecutionResult({
text: `Unexpected error: ${error.message || 'Unknown error'}`
})
})
.finally(() => {
setIsRunning(false)
})
} else if (isJavaScript) {
window.api.js
.execute(children, codeExecution.timeoutMinutes * 60000)
.then((result) => {
if (result.error) {
setExecutionResult({
text: `Error: ${result.error}\n${result.stderr || ''}`
})
} else {
setExecutionResult({
text: result.stdout || (result.stderr ? `stderr: ${result.stderr}` : 'Execution completed')
})
}
})
.catch((error) => {
logger.error('Unexpected error:', error)
setExecutionResult({
text: `Unexpected error: ${error.message || 'Unknown error'}`
})
})
.finally(() => {
setIsRunning(false)
})
}
}, [children, codeExecution.timeoutMinutes, language])
})
.finally(() => {
setIsRunning(false)
})
}, [children, codeExecution.timeoutMinutes])
const showPreviewTools = useMemo(() => {
return viewMode !== 'source' && hasSpecialView

View File

@@ -549,7 +549,7 @@ const MinappPopupContainer: React.FC = () => {
{/* 在所有小程序中显示GoogleLoginTip */}
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
{!isReady && (
<EmptyView style={{ backgroundColor: 'var(--color-background-soft)' }}>
<EmptyView>
<Avatar
src={currentAppInfo?.logo}
size={80}

View File

@@ -10,7 +10,6 @@ interface ShowParams {
providerId: string
title?: string
showHealthCheck?: boolean
providerType?: 'llm' | 'webSearch' | 'preprocess'
}
interface Props extends ShowParams {
@@ -20,7 +19,7 @@ interface Props extends ShowParams {
/**
* API Key 列表弹窗容器组件
*/
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true, providerType }) => {
const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealthCheck = true }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
@@ -33,20 +32,14 @@ const PopupContainer: React.FC<Props> = ({ providerId, title, resolve, showHealt
}
const ListComponent = useMemo(() => {
const type =
providerType ||
(isWebSearchProviderId(providerId) ? 'webSearch' : isPreprocessProviderId(providerId) ? 'preprocess' : 'llm')
switch (type) {
case 'webSearch':
return <WebSearchApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
case 'preprocess':
return <DocPreprocessApiKeyList providerId={providerId as any} showHealthCheck={showHealthCheck} />
case 'llm':
default:
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
if (isWebSearchProviderId(providerId)) {
return <WebSearchApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
}, [providerId, showHealthCheck, providerType])
if (isPreprocessProviderId(providerId)) {
return <DocPreprocessApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}
return <LlmApiKeyList providerId={providerId} showHealthCheck={showHealthCheck} />
}, [providerId, showHealthCheck])
return (
<Modal

View File

@@ -1,101 +0,0 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger'
import { handleSaveData } from '@renderer/store'
import { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Markdown from 'react-markdown'
const logger = loggerService.withContext('UpdateDialog')
interface UpdateDialogProps {
isOpen: boolean
onClose: () => void
releaseInfo: UpdateInfo | null
}
const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInfo }) => {
const { t } = useTranslation()
const [isInstalling, setIsInstalling] = useState(false)
useEffect(() => {
if (isOpen && releaseInfo) {
logger.info('Update dialog opened', { version: releaseInfo.version })
}
}, [isOpen, releaseInfo])
const handleInstall = async () => {
setIsInstalling(true)
try {
await handleSaveData()
await window.api.quitAndInstall()
} catch (error) {
logger.error('Failed to save data before update', error as Error)
setIsInstalling(false)
window.toast.error(t('update.saveDataError'))
}
}
const releaseNotes = releaseInfo?.releaseNotes
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
base: 'max-h-[85vh]',
header: 'border-b border-divider',
footer: 'border-t border-divider'
}}>
<ModalContent>
{(onModalClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h3 className="font-semibold text-lg">{t('update.title')}</h3>
<p className="text-default-500 text-small">
{t('update.message').replace('{{version}}', releaseInfo?.version || '')}
</p>
</ModalHeader>
<ModalBody>
<ScrollShadow className="max-h-[450px]" hideScrollBar>
<div className="markdown rounded-lg bg-default-50 p-4">
<Markdown>
{typeof releaseNotes === 'string'
? releaseNotes
: Array.isArray(releaseNotes)
? releaseNotes
.map((note: ReleaseNoteInfo) => note.note)
.filter(Boolean)
.join('\n\n')
: t('update.noReleaseNotes')}
</Markdown>
</div>
</ScrollShadow>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onModalClose} isDisabled={isInstalling}>
{t('update.later')}
</Button>
<Button
color="primary"
onPress={async () => {
await handleInstall()
onModalClose()
}}
isLoading={isInstalling}>
{t('update.install')}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)
}
export default UpdateDialog

View File

@@ -22,6 +22,8 @@ import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?
import GoogleAppLogo from '@renderer/assets/images/apps/google.svg?url'
import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
import GrokXAppLogo from '@renderer/assets/images/apps/grok-x.png?url'
import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg?url'
import KimiAppLogo from '@renderer/assets/images/apps/kimi.webp?url'
import LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?url'
import LeChatLogo from '@renderer/assets/images/apps/lechat.png?url'
@@ -30,13 +32,13 @@ import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
import n8nLogo from '@renderer/assets/images/apps/n8n.svg?url'
import NamiAiLogo from '@renderer/assets/images/apps/nm.png?url'
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm-search.webp?url'
import NotebookLMAppLogo from '@renderer/assets/images/apps/notebooklm.svg?url'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.webp?url'
import StepfunAppLogo from '@renderer/assets/images/apps/stepfun.png?url'
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
@@ -44,6 +46,7 @@ import WPSLingXiLogo from '@renderer/assets/images/apps/wpslingxi.webp?url'
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
import YouLogo from '@renderer/assets/images/apps/you.jpg?url'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.webp?url'
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
import ZaiAppLogo from '@renderer/assets/images/apps/zai.png?url'
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
@@ -147,9 +150,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
},
{
id: 'stepfun',
name: i18n.t('minapps.stepfun'),
url: 'https://stepfun.com',
logo: StepfunAppLogo,
name: i18n.t('minapps.yuewen'),
url: 'https://yuewen.cn/chats/new',
logo: YuewenAppLogo,
bodered: true
},
{
@@ -260,6 +263,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://www.tiangong.cn/',
bodered: true
},
{
id: 'hugging-chat',
name: 'HuggingChat',
logo: HuggingChatLogo,
url: 'https://huggingface.co/chat/',
bodered: true
},
{
id: 'Felo',
name: 'Felo',
@@ -287,6 +297,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://bot.n.cn/',
bodered: true
},
{
id: 'nm-search',
name: i18n.t('minapps.nami-ai-search'),
logo: NamiAiSearchLogo,
url: 'https://www.n.cn/',
bodered: true
},
{
id: 'thinkany',
name: 'ThinkAny',
@@ -297,6 +314,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
padding: 5
}
},
{
id: 'hika',
name: 'Hika',
logo: HikaLogo,
url: 'https://hika.fyi/',
bodered: true
},
{
id: 'github-copilot',
name: 'GitHub Copilot',

View File

@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
// Default quick assistant model
glm45FlashModel
],
cherryin: [],
// cherryin: [],
vertexai: [],
'302ai': [
{

View File

@@ -14,7 +14,7 @@ import { GEMINI_FLASH_MODEL_REGEX } from './websearch'
// Reasoning models
export const REASONING_REGEX =
/^(?!.*-non-reasoning\b)(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4|4-fast)(?:-[\w-]+)?\b.*)$/i
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4)(?:-[\w-]+)?\b.*)$/i
// 模型类型到支持的reasoning_effort的映射表
// TODO: refactor this. too many identical options
@@ -24,7 +24,6 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
gpt5_codex: ['low', 'medium', 'high'] as const,
grok: ['low', 'high'] as const,
grok4_fast: ['auto'] as const,
gemini: ['low', 'medium', 'high', 'auto'] as const,
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
qwen: ['low', 'medium', 'high'] as const,
@@ -44,7 +43,6 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
grok4_fast: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
@@ -68,8 +66,6 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
}
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
thinkingModelType = 'o'
} else if (isGrok4FastReasoningModel(model)) {
thinkingModelType = 'grok4_fast'
} else if (isSupportedThinkingTokenGeminiModel(model)) {
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
thinkingModelType = 'gemini'
@@ -146,46 +142,19 @@ export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
}
const modelId = getLowerBaseModelName(model.id)
const providerId = model.provider.toLowerCase()
if (modelId.includes('grok-3-mini')) {
return true
}
if (providerId === 'openrouter' && modelId.includes('grok-4-fast')) {
return true
}
return false
}
/**
* Checks if the model is Grok 4 Fast reasoning version
* Explicitly excludes non-reasoning variants (models with 'non-reasoning' in their ID)
*
* Note: XAI official uses different model IDs for reasoning vs non-reasoning
* Third-party providers like OpenRouter expose a single ID with reasoning parameters, while first-party providers require separate IDs. Only the OpenRouter variant supports toggling.
*
* @param model - The model to check
* @returns true if the model is a reasoning-enabled Grok 4 Fast model
*/
export function isGrok4FastReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('grok-4-fast') && !modelId.includes('non-reasoning')
}
export function isGrokReasoningModel(model?: Model): boolean {
if (!model) {
return false
}
const modelId = getLowerBaseModelName(model.id)
if (
isSupportedReasoningEffortGrokModel(model) ||
(modelId.includes('grok-4') && !modelId.includes('non-reasoning'))
) {
if (isSupportedReasoningEffortGrokModel(model) || modelId.includes('grok-4')) {
return true
}
@@ -296,11 +265,7 @@ export function isQwenAlwaysThinkModel(model?: Model): boolean {
return false
}
const modelId = getLowerBaseModelName(model.id, '/')
// 包括 qwen3 开头的 thinking 模型和 qwen3-vl 的 thinking 模型
return (
(modelId.startsWith('qwen3') && modelId.includes('thinking')) ||
(modelId.includes('qwen3-vl') && modelId.includes('thinking'))
)
return modelId.startsWith('qwen3') && modelId.includes('thinking')
}
// Doubao 支持思考模式的模型正则
@@ -364,10 +329,7 @@ export const isPerplexityReasoningModel = (model?: Model): boolean => {
}
const modelId = getLowerBaseModelName(model.id, '/')
return (
isSupportedReasoningEffortPerplexityModel(model) ||
(modelId.includes('reasoning') && !modelId.includes('non-reasoning'))
)
return isSupportedReasoningEffortPerplexityModel(model) || modelId.includes('reasoning')
}
export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean => {
@@ -481,8 +443,6 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
// qwen-plus-x 系列自 qwen-plus-2025-07-28 后模型最长思维链变为 81_920, qwen-plus 模型于 2025.9.16 同步变更
'qwen3-235b-a22b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-30b-a3b-thinking-2507$': { min: 0, max: 81_920 },
'qwen3-vl-235b-a22b-thinking$': { min: 0, max: 81_920 },
'qwen3-vl-30b-a3b-thinking$': { min: 0, max: 81_920 },
'qwen-plus-2025-07-14$': { min: 0, max: 38_912 },
'qwen-plus-2025-04-28$': { min: 0, max: 38_912 },
'qwen3-1\\.7b$': { min: 0, max: 30_720 },

View File

@@ -24,7 +24,7 @@ const visionAllowedModels = [
'qwen2.5-vl',
'qwen3-vl',
'qwen2.5-omni',
'qwen3-omni(?:-[\\w-]+)?',
'qwen3-omni',
'qvq',
'internvl2',
'grok-vision-beta',
@@ -82,14 +82,14 @@ export const IMAGE_ENHANCEMENT_MODELS = [
'grok-2-image(?:-[\\w-]+)?',
'qwen-image-edit',
'gpt-image-1',
'gemini-2.5-flash-image',
'gemini-2.5-flash-image-preview',
'gemini-2.0-flash-preview-image-generation'
]
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
// Models that should auto-enable image generation button when selected
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image-preview', ...DEDICATED_IMAGE_MODELS]
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
'o3',
@@ -107,7 +107,7 @@ export const GENERATE_IMAGE_MODELS = [
'gemini-2.0-flash-exp',
'gemini-2.0-flash-exp-image-generation',
'gemini-2.0-flash-preview-image-generation',
'gemini-2.5-flash-image',
'gemini-2.5-flash-image-preview',
...DEDICATED_IMAGE_MODELS
]

View File

@@ -82,16 +82,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
}
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
cherryin: {
id: 'cherryin',
name: 'CherryIN',
type: 'openai',
apiKey: '',
apiHost: 'https://open.cherryin.net',
models: [],
isSystem: true,
enabled: true
},
// cherryin: {
// id: 'cherryin',
// name: 'CherryIN',
// type: 'openai',
// apiKey: '',
// apiHost: 'https://open.cherryin.ai',
// models: [],
// isSystem: true,
// enabled: true
// },
silicon: {
id: 'silicon',
name: 'Silicon',
@@ -742,17 +742,17 @@ type ProviderUrls = {
}
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
cherryin: {
api: {
url: 'https://open.cherryin.net'
},
websites: {
official: 'https://open.cherryin.ai',
apiKey: 'https://open.cherryin.ai/console/token',
docs: 'https://open.cherryin.ai',
models: 'https://open.cherryin.ai/pricing'
}
},
// cherryin: {
// api: {
// url: 'https://open.cherryin.ai'
// },
// websites: {
// official: 'https://open.cherryin.ai',
// apiKey: 'https://open.cherryin.ai/console/token',
// docs: 'https://open.cherryin.ai',
// models: 'https://open.cherryin.ai/pricing'
// }
// },
ph8: {
api: {
url: 'https://ph8.co'

View File

@@ -360,7 +360,7 @@ export const useKnowledgeBases = () => {
const deleteKnowledgeBase = (baseId: string) => {
const base = bases.find((b) => b.id === baseId)
if (!base) return
dispatch(deleteBase({ baseId }))
dispatch(deleteBase({ baseId, baseParams: getKnowledgeBaseParams(base) }))
// remove assistant knowledge_base
const _assistants = assistants.map((assistant) => {

View File

@@ -329,8 +329,7 @@ const builtInMcpDescriptionKeyMap: Record<BuiltinMCPServerName, string> = {
[BuiltinMCPServerNames.fetch]: 'settings.mcp.builtinServersDescriptions.fetch',
[BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem',
[BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge',
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python',
[BuiltinMCPServerNames.js]: 'settings.mcp.builtinServersDescriptions.js'
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python'
} as const
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -3571,7 +3571,6 @@
"dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key",
"fetch": "MCP server for retrieving URL web content",
"filesystem": "A Node.js server implementing the Model Context Protocol (MCP) for file system operations. Requires configuration of directories allowed for access.",
"js": "Execute JavaScript code in a secure sandbox environment. Run JavaScript using QuickJS, supporting most standard libraries and popular third-party libraries.",
"mcp_auto_install": "Automatically install MCP service (beta)",
"memory": "Persistent memory implementation based on a local knowledge graph. This enables the model to remember user-related information across different conversations. Requires configuring the MEMORY_FILE_PATH environment variable.",
"no": "No description",
@@ -4642,7 +4641,6 @@
"later": "Later",
"message": "New version {{version}} is ready, do you want to install it now?",
"noReleaseNotes": "No release notes",
"saveDataError": "Failed to save data, please try again.",
"title": "Update"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "纳米AI搜索",
"qwen": "通义千问",
"sensechat": "商量",
"stepfun": "阶跃AI",
"tencent-yuanbao": "腾讯元宝",
"tiangong-ai": "天工AI",
"wanzhi": "万知",
"wenxin": "文心一言",
"wps-copilot": "WPS灵犀",
"xiaoyi": "小艺",
"yuewen": "跃问",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -3571,7 +3571,6 @@
"dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key",
"fetch": "用于获取 URL 网页内容的 MCP 服务器",
"filesystem": "实现文件系统操作的模型上下文协议MCP的 Node.js 服务器。需要配置允许访问的目录",
"js": "在安全的沙盒环境中执行 JavaScript 代码。使用 quickJs 运行 JavaScript支持大多数标准库和流行的第三方库",
"mcp_auto_install": "自动安装 MCP 服务(测试版)",
"memory": "基于本地知识图谱的持久性记忆基础实现。这使得模型能够在不同对话间记住用户的相关信息。需要配置 MEMORY_FILE_PATH 环境变量。",
"no": "无描述",
@@ -4642,7 +4641,6 @@
"later": "稍后",
"message": "发现新版本 {{version}},是否立即安装?",
"noReleaseNotes": "暂无更新日志",
"saveDataError": "保存数据失败,请重试",
"title": "更新提示"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "納米AI搜索",
"qwen": "通義千問",
"sensechat": "商量",
"stepfun": "階躍AI",
"tencent-yuanbao": "騰訊元寶",
"tiangong-ai": "天工AI",
"wanzhi": "萬知",
"wenxin": "文心一言",
"wps-copilot": "WPS靈犀",
"xiaoyi": "小藝",
"yuewen": "躍問",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -3571,7 +3571,6 @@
"dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key",
"fetch": "用於獲取 URL 網頁內容的 MCP 伺服器",
"filesystem": "實現文件系統操作的模型上下文協議MCP的 Node.js 伺服器。需要配置允許訪問的目錄",
"js": "在安全的沙盒環境中執行 JavaScript 程式碼。使用 quickJs 執行 JavaScript支援大多數標準函式庫和流行的第三方函式庫",
"mcp_auto_install": "自動安裝 MCP 服務(測試版)",
"memory": "基於本地知識圖譜的持久性記憶基礎實現。這使得模型能夠在不同對話間記住使用者的相關資訊。需要配置 MEMORY_FILE_PATH 環境變數。",
"no": "無描述",
@@ -4642,7 +4641,6 @@
"later": "稍後",
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "保存數據失敗,請重試",
"title": "更新提示"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -4641,7 +4641,6 @@
"later": "Μετά",
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
"noReleaseNotes": "Χωρίς σημειώσεις",
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
"title": "Ενημέρωση"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -4641,7 +4641,6 @@
"later": "Más tarde",
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
"noReleaseNotes": "Sin notas de la versión",
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
"title": "Actualización"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -4641,7 +4641,6 @@
"later": "Plus tard",
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
"noReleaseNotes": "Aucune note de version",
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
"title": "Mise à jour"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "通義千問",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "騰訊元宝",
"tiangong-ai": "Skywork",
"wanzhi": "万知",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "小藝",
"yuewen": "躍問",
"zhihu": "知乎直答"
},
"miniwindow": {
@@ -4641,7 +4641,6 @@
"later": "後で",
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
"noReleaseNotes": "暫無更新日誌",
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
"title": "更新"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -4641,7 +4641,6 @@
"later": "Mais tarde",
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
"noReleaseNotes": "Sem notas de versão",
"saveDataError": "Falha ao salvar os dados, tente novamente",
"title": "Atualização"
},
"warning": {

View File

@@ -1806,13 +1806,13 @@
"nami-ai-search": "Nami AI Search",
"qwen": "Qwen",
"sensechat": "SenseChat",
"stepfun": "Stepfun",
"tencent-yuanbao": "Tencent Yuanbao",
"tiangong-ai": "Skywork",
"wanzhi": "Wanzhi",
"wenxin": "ERNIE",
"wps-copilot": "WPS Copilot",
"xiaoyi": "Xiaoyi",
"yuewen": "Yuewen",
"zhihu": "Zhihu"
},
"miniwindow": {
@@ -4641,7 +4641,6 @@
"later": "Позже",
"message": "Новая версия {{version}} готова, установить сейчас?",
"noReleaseNotes": "Нет заметок об обновлении",
"saveDataError": "Ошибка сохранения данных, повторите попытку",
"title": "Обновление"
},
"warning": {

View File

@@ -2,7 +2,7 @@ import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { Tooltip } from 'antd'
import React, { memo, useCallback, useMemo } from 'react'
import styled from 'styled-components'
import * as z from 'zod'
import { z } from 'zod'
export const CitationSchema = z.object({
url: z.url(),

View File

@@ -359,7 +359,8 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
&.vertical {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 8px;
overflow: hidden;
overflow-y: auto;
overflow-x: hidden;
}
&.grid {
grid-template-columns: repeat(

View File

@@ -535,30 +535,7 @@ const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ i
}
const highlight = async () => {
// 处理转义字符
let processedString = resultString
try {
// 尝试解析字符串以处理可能的转义
const parsed = JSON.parse(resultString)
if (typeof parsed === 'string') {
// 如果解析后是字符串,再次尝试解析(处理双重转义)
try {
const doubleParsed = JSON.parse(parsed)
processedString = JSON.stringify(doubleParsed, null, 2)
} catch {
// 不是有效的 JSON使用解析后的字符串
processedString = parsed
}
} else {
// 重新格式化 JSON
processedString = JSON.stringify(parsed, null, 2)
}
} catch {
// 解析失败,使用原始字符串
processedString = resultString
}
const result = await highlightCode(processedString, 'json')
const result = await highlightCode(resultString, 'json')
setStyledResult(result)
}

View File

@@ -1,5 +1,8 @@
import { NormalToolResponse } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, ToolMessageBlock } from '@renderer/types/newMessage'
import { TFunction } from 'i18next'
import { Pause } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { MessageAgentTools } from './MessageAgentTools'
import { MessageKnowledgeSearchToolTitle } from './MessageKnowledgeSearch'
@@ -35,14 +38,28 @@ const isAgentTool = (toolName: string) => {
return false
}
const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null => {
const ChooseTool = (
toolResponse: NormalToolResponse,
status: MessageBlockStatus,
t: TFunction
): React.ReactNode | null => {
let toolName = toolResponse.tool.name
const toolType = toolResponse.tool.type
if (toolName.startsWith(prefix)) {
toolName = toolName.slice(prefix.length)
if (status === MessageBlockStatus.PAUSED) {
return (
<div className="flex items-center gap-1">
<Pause className="h-4 w-4" />
<span>{t('message.tools.aborted')}</span>
</div>
)
}
switch (toolName) {
case 'web_search':
case 'web_search_preview':
case 'exa_search':
case 'tavily_search':
return toolType === 'provider' ? null : <MessageWebSearchToolTitle toolResponse={toolResponse} />
case 'knowledge_search':
return <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />
@@ -58,12 +75,13 @@ const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null =>
}
export default function MessageTool({ block }: Props) {
const { t } = useTranslation()
// FIXME: 语义错误,这里已经不是 MCP tool 了,更改rawMcpToolResponse需要改用户数据, 所以暂时保留
const toolResponse = block.metadata?.rawMcpToolResponse as NormalToolResponse
if (!toolResponse) return null
const toolRenderer = ChooseTool(toolResponse as NormalToolResponse)
const toolRenderer = ChooseTool(toolResponse as NormalToolResponse, block.status, t)
if (!toolRenderer) return null

View File

@@ -1,3 +1,5 @@
import { ExaSearchToolInput, ExaSearchToolOutput } from '@renderer/aiCore/tools/ExaSearchTool'
import { TavilySearchToolInput, TavilySearchToolOutput } from '@renderer/aiCore/tools/TavilySearchTool'
import { WebSearchToolInput, WebSearchToolOutput } from '@renderer/aiCore/tools/WebSearchTool'
import Spinner from '@renderer/components/Spinner'
import { NormalToolResponse } from '@renderer/types'
@@ -8,17 +10,31 @@ import styled from 'styled-components'
const { Text } = Typography
// 联合类型 - 支持多种搜索工具
type SearchToolInput = WebSearchToolInput | ExaSearchToolInput | TavilySearchToolInput
type SearchToolOutput = WebSearchToolOutput | ExaSearchToolOutput | TavilySearchToolOutput
export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => {
const { t } = useTranslation()
const toolInput = toolResponse.arguments as WebSearchToolInput
const toolOutput = toolResponse.response as WebSearchToolOutput
const toolInput = toolResponse.arguments as SearchToolInput
const toolOutput = toolResponse.response as SearchToolOutput
// 根据不同的工具类型获取查询内容
const getQueryText = () => {
if ('additionalContext' in toolInput) {
return toolInput.additionalContext ?? ''
}
if ('query' in toolInput) {
return toolInput.query ?? ''
}
return ''
}
return toolResponse.status !== 'done' ? (
<Spinner
text={
<PrepareToolWrapper>
{t('message.searching')}
<span>{toolInput?.additionalContext ?? ''}</span>
<span>{getQueryText()}</span>
</PrepareToolWrapper>
}
/>

View File

@@ -1,40 +0,0 @@
import { Button, Divider } from '@heroui/react'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { AgentSettingsPopup } from '@renderer/pages/settings/AgentSettings'
import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings'
import AgentEssentialSettings from '@renderer/pages/settings/AgentSettings/AgentEssentialSettings'
import { GetAgentResponse } from '@renderer/types/agent'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
agent: GetAgentResponse | undefined | null
update: ReturnType<typeof useUpdateAgent>['updateAgent']
}
const AgentSettingsTab: FC<Props> = ({ agent, update }) => {
const { t } = useTranslation()
const onMoreSetting = () => {
if (agent?.id) {
AgentSettingsPopup.show({ agentId: agent.id! })
}
}
if (!agent) {
return null
}
return (
<div className="w-[var(--assistants-width)] p-2 px-3 pt-4">
<AgentEssentialSettings agent={agent} update={update} showModelSetting={false} />
<AdvancedSettings agentBase={agent} update={update} />
<Divider className="my-2" />
<Button size="sm" fullWidth onPress={onMoreSetting}>
{t('settings.moresetting.label')}
</Button>
</div>
)
}
export default AgentSettingsTab

View File

@@ -1,26 +1,10 @@
import { Alert, Spinner } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAssistantsTabSortType } from '@renderer/hooks/useStore'
import { useTags } from '@renderer/hooks/useTags'
import { useAppDispatch } from '@renderer/store'
import { addIknowAction } from '@renderer/store/runtime'
import { Assistant, AssistantsSortType } from '@renderer/types'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Assistant } from '@renderer/types'
import { FC, useRef } from 'react'
import styled from 'styled-components'
import UnifiedAddButton from './components/UnifiedAddButton'
import { UnifiedList } from './components/UnifiedList'
import { UnifiedTagGroups } from './components/UnifiedTagGroups'
import { useActiveAgent } from './hooks/useActiveAgent'
import { useUnifiedGrouping } from './hooks/useUnifiedGrouping'
import { useUnifiedItems } from './hooks/useUnifiedItems'
import { useUnifiedSorting } from './hooks/useUnifiedSorting'
import { AgentSection } from './components/AgentSection'
import Assistants from './components/Assistants'
interface AssistantsTabProps {
activeAssistant: Assistant
@@ -29,143 +13,12 @@ interface AssistantsTabProps {
onCreateDefaultAssistant: () => void
}
const ALERT_KEY = 'enable_api_server_to_use_agent'
const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props
const containerRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
const { apiServer } = useSettings()
const { iknow, chat } = useRuntime()
const dispatch = useAppDispatch()
// Agent related hooks
const { agents, deleteAgent, isLoading: agentsLoading, error: agentsError } = useAgents()
const { activeAgentId } = chat
const { setActiveAgentId } = useActiveAgent()
// Assistant related hooks
const { assistants, removeAssistant, copyAssistant, updateAssistants } = useAssistants()
const { addAssistantPreset } = useAssistantPresets()
const { collapsedTags, toggleTagCollapse } = useTags()
const { assistantsTabSortType = 'list', setAssistantsTabSortType } = useAssistantsTabSortType()
const [dragging, setDragging] = useState(false)
// Unified items management
const { unifiedItems, handleUnifiedListReorder } = useUnifiedItems({
agents,
assistants,
apiServerEnabled: apiServer.enabled,
agentsLoading,
agentsError,
updateAssistants
})
// Sorting
const { sortByPinyinAsc, sortByPinyinDesc } = useUnifiedSorting({
unifiedItems,
updateAssistants
})
// Grouping
const { groupedUnifiedItems, handleUnifiedGroupReorder } = useUnifiedGrouping({
unifiedItems,
assistants,
agents,
apiServerEnabled: apiServer.enabled,
agentsLoading,
agentsError,
updateAssistants
})
useEffect(() => {
if (!agentsLoading && agents.length > 0 && !activeAgentId && apiServer.enabled) {
setActiveAgentId(agents[0].id)
}
}, [agentsLoading, agents, activeAgentId, setActiveAgentId, apiServer.enabled])
const onDeleteAssistant = useCallback(
(assistant: Assistant) => {
const remaining = assistants.filter((a) => a.id !== assistant.id)
if (assistant.id === activeAssistant?.id) {
const newActive = remaining[remaining.length - 1]
newActive ? setActiveAssistant(newActive) : onCreateDefaultAssistant()
}
removeAssistant(assistant.id)
},
[activeAssistant, assistants, removeAssistant, setActiveAssistant, onCreateDefaultAssistant]
)
const handleSortByChange = useCallback(
(sortType: AssistantsSortType) => {
setAssistantsTabSortType(sortType)
},
[setAssistantsTabSortType]
)
return (
<Container className="assistants-tab" ref={containerRef}>
{!apiServer.enabled && !iknow[ALERT_KEY] && (
<Alert
color="warning"
title={t('agent.warning.enable_server')}
isClosable
onClose={() => {
dispatch(addIknowAction(ALERT_KEY))
}}
/>
)}
{agentsLoading && <Spinner />}
{apiServer.enabled && agentsError && <Alert color="danger" title={t('agent.list.error.failed')} />}
{assistantsTabSortType === 'tags' ? (
<UnifiedTagGroups
groupedItems={groupedUnifiedItems}
activeAssistantId={activeAssistant.id}
activeAgentId={activeAgentId}
sortBy={assistantsTabSortType}
collapsedTags={collapsedTags}
onGroupReorder={handleUnifiedGroupReorder}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}
onToggleTagCollapse={toggleTagCollapse}
onAssistantSwitch={setActiveAssistant}
onAssistantDelete={onDeleteAssistant}
onAgentDelete={deleteAgent}
onAgentPress={setActiveAgentId}
addPreset={addAssistantPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
) : (
<UnifiedList
items={unifiedItems}
activeAssistantId={activeAssistant.id}
activeAgentId={activeAgentId}
sortBy={assistantsTabSortType}
onReorder={handleUnifiedListReorder}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}
onAssistantSwitch={setActiveAssistant}
onAssistantDelete={onDeleteAssistant}
onAgentDelete={deleteAgent}
onAgentPress={setActiveAgentId}
addPreset={addAssistantPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
)}
<UnifiedAddButton onCreateAssistant={onCreateAssistant} />
{!dragging && <div style={{ minHeight: 10 }}></div>}
<AgentSection />
<Assistants {...props} />
</Container>
)
}
@@ -174,6 +27,7 @@ const Container = styled(Scrollbar)`
display: flex;
flex-direction: column;
padding: 10px;
margin-top: 3px;
`
export default AssistantsTab

View File

@@ -1,24 +0,0 @@
import { Button, ButtonProps, cn } from '@heroui/react'
import { PlusIcon } from 'lucide-react'
import { FC } from 'react'
interface Props extends ButtonProps {
children: React.ReactNode
}
const AddButton: FC<Props> = ({ children, className, ...props }) => {
return (
<Button
{...props}
onPress={props.onPress}
className={cn(
'h-9 w-[calc(var(--assistants-width)-20px)] justify-start rounded-lg bg-transparent px-3 text-[13px] text-[var(--color-text-2)] hover:bg-[var(--color-list-item)]',
className
)}
startContent={<PlusIcon size={16} className="shrink-0" />}>
{children}
</Button>
)
}
export default AddButton

View File

@@ -1,14 +1,11 @@
import { cn } from '@heroui/react'
import { Button, Chip, cn } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useSettings } from '@renderer/hooks/useSettings'
import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings/AgentSettingsPopup'
import { AgentLabel } from '@renderer/pages/settings/AgentSettings/shared'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { AgentEntity } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import { Bot } from 'lucide-react'
import { FC, memo, useCallback } from 'react'
import { FC, memo } from 'react'
import { useTranslation } from 'react-i18next'
// const logger = loggerService.withContext('AgentItem')
@@ -23,100 +20,81 @@ interface AgentItemProps {
const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) => {
const { t } = useTranslation()
const { sessions } = useSessions(agent.id)
const { clickAssistantToShowTopic, topicPosition } = useSettings()
const handlePress = useCallback(() => {
// Show session sidebar if setting is enabled (reusing the assistant setting for consistency)
if (clickAssistantToShowTopic) {
if (topicPosition === 'left') {
EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
}
}
onPress()
}, [clickAssistantToShowTopic, topicPosition, onPress])
return (
<ContextMenu modal={false}>
<ContextMenuTrigger>
<Container onClick={handlePress} isActive={isActive}>
<AssistantNameRow className="name" title={agent.name ?? agent.id}>
<AgentNameWrapper>
<>
<ContextMenu modal={false}>
<ContextMenuTrigger>
<ButtonContainer onPress={onPress} className={isActive ? 'active' : ''}>
<AssistantNameRow className="name flex w-full justify-between" title={agent.name ?? agent.id}>
<AgentLabel agent={agent} />
</AgentNameWrapper>
</AssistantNameRow>
<MenuButton>
{isActive ? <SessionCount>{sessions.length}</SessionCount> : <Bot size={14} className="text-primary" />}
</MenuButton>
</Container>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem key="edit" onClick={() => AgentSettingsPopup.show({ agentId: agent.id })}>
<EditIcon size={14} />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuItem
key="delete"
className="text-danger"
onClick={() => {
window.modal.confirm({
title: t('agent.delete.title'),
content: t('agent.delete.content'),
centered: true,
okButtonProps: { danger: true },
onOk: () => onDelete(agent)
})
}}>
<DeleteIcon size={14} className="lucide-custom text-danger" />
<span className="text-danger">{t('common.delete')}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{isActive && (
<Chip
variant="bordered"
size="sm"
radius="full"
className="aspect-square h-5 w-5 items-center justify-center border-[0.5px] bg-background text-[10px]">
{sessions.length}
</Chip>
)}
</AssistantNameRow>
</ButtonContainer>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
key="edit"
onClick={async () => {
// onOpen()
await AgentSettingsPopup.show({
agentId: agent.id
})
}}>
<EditIcon size={14} />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuItem
key="delete"
className="text-danger"
onClick={() => {
window.modal.confirm({
title: t('agent.delete.title'),
content: t('agent.delete.content'),
centered: true,
okButtonProps: { danger: true },
onOk: () => onDelete(agent)
})
}}>
<DeleteIcon size={14} className="lucide-custom text-danger" />
<span className="text-danger">{t('common.delete')}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* <AgentModal isOpen={isOpen} onClose={onClose} agent={agent} /> */}
</>
)
}
export const Container: React.FC<{ isActive?: boolean } & React.HTMLAttributes<HTMLDivElement>> = ({
className,
isActive,
...props
}) => (
<div
const ButtonContainer: React.FC<React.ComponentProps<typeof Button>> = ({ className, children, ...props }) => (
<Button
{...props}
className={cn(
'relative flex h-[37px] w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-[var(--list-item-border-radius)] border border-transparent px-2 hover:bg-[var(--color-list-item-hover)]',
isActive && 'bg-[var(--color-list-item)] shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]',
'relative mb-2 flex h-[37px] flex-row justify-between p-2.5',
'rounded-[var(--list-item-border-radius)]',
'border-[0.5px] border-transparent',
'w-[calc(var(--assistants-width)_-_20px)]',
'bg-transparent hover:bg-[var(--color-list-item)] hover:shadow-sm',
'cursor-pointer',
className?.includes('active') && 'bg-[var(--color-list-item)] shadow-sm',
className
)}
{...props}
/>
)}>
{children}
</Button>
)
export const AssistantNameRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
const AssistantNameRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn('flex min-w-0 flex-1 flex-row items-center gap-2 text-[13px] text-[var(--color-text)]', className)}
{...props}
/>
)
export const AgentNameWrapper: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div className={cn('min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap', className)} {...props} />
)
export const MenuButton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'absolute top-[6px] right-[9px] flex h-[22px] min-h-[22px] w-[22px] flex-row items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-background)] px-[5px]',
className
)}
{...props}
/>
)
export const SessionCount: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'flex flex-row items-center justify-center rounded-full text-[10px] text-[var(--color-text)]',
className
)}
{...props}
className={cn('text-[13px] text-[var(--color-text)]', 'flex flex-row items-center gap-2', className)}
/>
)

View File

@@ -0,0 +1,39 @@
import { Alert } from '@heroui/react'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { addIknowAction } from '@renderer/store/runtime'
import { useTranslation } from 'react-i18next'
import { Agents } from './Agents'
import { SectionName } from './SectionName'
const ALERT_KEY = 'enable_api_server_to_use_agent'
export const AgentSection = () => {
const { t } = useTranslation()
const { apiServer } = useSettings()
const { iknow } = useRuntime()
const dispatch = useAppDispatch()
if (!apiServer.enabled) {
if (iknow[ALERT_KEY]) return null
return (
<Alert
color="warning"
title={t('agent.warning.enable_server')}
isClosable
onClose={() => {
dispatch(addIknowAction(ALERT_KEY))
}}
/>
)
}
return (
<div className="agents-tab mb-2 h-full w-full">
<SectionName name={t('common.agent_other')} />
<Agents />
</div>
)
}

View File

@@ -1,4 +1,3 @@
import { cn } from '@heroui/react'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import EmojiIcon from '@renderer/components/EmojiIcon'
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons'
@@ -29,8 +28,9 @@ import {
Tag,
Tags
} from 'lucide-react'
import { FC, memo, PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react'
import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import * as tinyPinyin from 'tiny-pinyin'
import AssistantTagsPopup from './AssistantTagsPopup'
@@ -46,8 +46,6 @@ interface AssistantItemProps {
copyAssistant: (assistant: Assistant) => void
onTagClick?: (tag: string) => void
handleSortByChange?: (sortType: AssistantsSortType) => void
sortByPinyinAsc?: () => void
sortByPinyinDesc?: () => void
}
const AssistantItem: FC<AssistantItemProps> = ({
@@ -58,9 +56,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
onDelete,
addPreset,
copyAssistant,
handleSortByChange,
sortByPinyinAsc: externalSortByPinyinAsc,
sortByPinyinDesc: externalSortByPinyinDesc
handleSortByChange
}) => {
const { t } = useTranslation()
const { allTags } = useTags()
@@ -82,19 +78,14 @@ const AssistantItem: FC<AssistantItemProps> = ({
setIsPending(hasPending)
}, [isActive, assistant.topics])
// Local sort functions
const localSortByPinyinAsc = useCallback(() => {
const sortByPinyinAsc = useCallback(() => {
updateAssistants(sortAssistantsByPinyin(assistants, true))
}, [assistants, updateAssistants])
const localSortByPinyinDesc = useCallback(() => {
const sortByPinyinDesc = useCallback(() => {
updateAssistants(sortAssistantsByPinyin(assistants, false))
}, [assistants, updateAssistants])
// Use external sort functions if provided, otherwise use local ones
const sortByPinyinAsc = externalSortByPinyinAsc || localSortByPinyinAsc
const sortByPinyinDesc = externalSortByPinyinDesc || localSortByPinyinDesc
const menuItems = useMemo(
() =>
getMenuItems({
@@ -154,7 +145,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
menu={{ items: menuItems }}
trigger={['contextMenu']}
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
<Container onClick={handleSwitch} isActive={isActive}>
<Container onClick={handleSwitch} className={isActive ? 'active' : ''}>
<AssistantNameRow className="name" title={fullAssistantName}>
{assistantIconType === 'model' ? (
<ModelAvatar
@@ -389,75 +380,65 @@ function getMenuItems({
]
}
const Container = ({
children,
isActive,
className,
...props
}: PropsWithChildren<{ isActive?: boolean } & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn(
'relative flex h-[37px] w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-[var(--list-item-border-radius)] border-[0.5px] border-transparent px-2 hover:bg-[var(--color-list-item-hover)]',
isActive && 'bg-[var(--color-list-item)] shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]',
className
)}>
{children}
</div>
)
const Container = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0 8px;
height: 37px;
position: relative;
border-radius: var(--list-item-border-radius);
border: 0.5px solid transparent;
width: calc(var(--assistants-width) - 20px);
cursor: pointer;
const AssistantNameRow = ({
children,
className,
...props
}: PropsWithChildren<{} & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn('flex min-w-0 flex-1 flex-row items-center gap-2 text-[13px] text-[var(--color-text)]', className)}>
{children}
</div>
)
&:hover {
background-color: var(--color-list-item-hover);
}
&.active {
background-color: var(--color-list-item);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
`
const AssistantName = ({
children,
className,
...props
}: PropsWithChildren<{} & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn('min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[13px]', className)}>
{children}
</div>
)
const AssistantNameRow = styled.div`
color: var(--color-text);
font-size: 13px;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
`
const MenuButton = ({
children,
className,
...props
}: PropsWithChildren<{} & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn(
'absolute top-[6px] right-[9px] flex h-[22px] min-h-[22px] min-w-[22px] flex-row items-center justify-center rounded-[11px] border-[0.5px] border-[var(--color-border)] bg-[var(--color-background)] px-[5px]',
className
)}>
{children}
</div>
)
const AssistantName = styled.div`
font-size: 13px;
`
const TopicCount = ({
children,
className,
...props
}: PropsWithChildren<{} & React.HTMLAttributes<HTMLDivElement>>) => (
<div
{...props}
className={cn(
'flex flex-row items-center justify-center rounded-[10px] text-[10px] text-[var(--color-text)]',
className
)}>
{children}
</div>
)
const MenuButton = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
min-width: 22px;
height: 22px;
min-height: 22px;
border-radius: 11px;
position: absolute;
background-color: var(--color-background);
right: 9px;
top: 6px;
padding: 0 5px;
border: 0.5px solid var(--color-border);
`
const TopicCount = styled.div`
color: var(--color-text);
font-size: 10px;
border-radius: 10px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
`
export default memo(AssistantItem)

View File

@@ -1,4 +1,4 @@
import { Alert, Spinner } from '@heroui/react'
import { Alert, Button, Spinner } from '@heroui/react'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
@@ -13,10 +13,10 @@ import {
import { CreateSessionForm } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus } from 'lucide-react'
import { memo, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import AddButton from './AddButton'
import SessionItem from './SessionItem'
// const logger = loggerService.withContext('SessionsTab')
@@ -115,9 +115,12 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
transition={{ duration: 0.3 }}
className="sessions-tab flex h-full w-full flex-col p-2">
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
<AddButton onPress={handleCreateSession} className="mb-2">
<Button
onPress={handleCreateSession}
className="mb-2 w-full justify-start bg-transparent text-foreground-500 hover:bg-accent">
<Plus size={16} className="mr-1 shrink-0" />
{t('agent.session.add.title')}
</AddButton>
</Button>
</motion.div>
<AnimatePresence>
{/* h-9 */}

View File

@@ -1,63 +0,0 @@
import { DownOutlined, RightOutlined } from '@ant-design/icons'
import { cn } from '@heroui/react'
import { Tooltip } from 'antd'
import { FC, ReactNode } from 'react'
interface TagGroupProps {
tag: string
isCollapsed: boolean
onToggle: (tag: string) => void
showTitle?: boolean
children: ReactNode
}
export const TagGroup: FC<TagGroupProps> = ({ tag, isCollapsed, onToggle, showTitle = true, children }) => {
return (
<TagsContainer>
{showTitle && (
<GroupTitle onClick={() => onToggle(tag)}>
<Tooltip title={tag}>
<GroupTitleName>
{isCollapsed ? (
<RightOutlined style={{ fontSize: '10px', marginRight: '5px' }} />
) : (
<DownOutlined style={{ fontSize: '10px', marginRight: '5px' }} />
)}
{tag}
</GroupTitleName>
</Tooltip>
<GroupTitleDivider />
</GroupTitle>
)}
{!isCollapsed && <div>{children}</div>}
</TagsContainer>
)
}
const TagsContainer: FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, ...props }) => (
<div className={cn('flex flex-col gap-2')} {...props}>
{children}
</div>
)
const GroupTitle: FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, ...props }) => (
<div
className={cn(
'my-1 flex h-6 cursor-pointer flex-row items-center justify-between font-medium text-[var(--color-text-2)] text-xs'
)}
{...props}>
{children}
</div>
)
const GroupTitleName: FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, ...props }) => (
<div
className={cn('mr-1 box-border flex max-w-[50%] truncate px-1 text-[13px] text-[var(--color-text)] leading-6')}
{...props}>
{children}
</div>
)
const GroupTitleDivider: FC<React.HTMLAttributes<HTMLDivElement>> = (props) => (
<div className={cn('flex-1 border-[var(--color-border)] border-t')} {...props} />
)

View File

@@ -41,6 +41,7 @@ import {
PackagePlus,
PinIcon,
PinOffIcon,
PlusIcon,
Save,
Sparkles,
UploadIcon,
@@ -51,8 +52,6 @@ import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'
import AddButton from './AddButton'
interface Props {
assistant: Assistant
activeTopic: Topic
@@ -498,12 +497,13 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
className="topics-tab"
list={sortedTopics}
onUpdate={updateTopics}
style={{ height: '100%', padding: '11px 0 10px 10px' }}
style={{ height: '100%', padding: '13px 0 10px 10px' }}
itemContainerStyle={{ paddingBottom: '8px' }}
header={
<AddButton onPress={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} className="mb-2">
<AddTopicButton onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
<PlusIcon size={16} />
{t('chat.add.topic.title')}
</AddButton>
</AddTopicButton>
}>
{(topic) => {
const isActive = topic.id === activeTopic?.id
@@ -740,6 +740,31 @@ const FulfilledIndicator = styled.div.attrs({
background-color: var(--color-status-success);
`
const AddTopicButton = styled.div`
display: flex;
align-items: center;
gap: 6px;
width: calc(100% - 10px);
padding: 7px 12px;
margin-bottom: 8px;
background: transparent;
color: var(--color-text-2);
font-size: 13px;
border-radius: var(--list-item-border-radius);
cursor: pointer;
transition: all 0.2s;
margin-top: -5px;
&:hover {
background-color: var(--color-list-item-hover);
color: var(--color-text-1);
}
.anticon {
font-size: 12px;
}
`
const TopicPromptText = styled.div`
color: var(--color-text-2);
font-size: 12px;

View File

@@ -1,61 +0,0 @@
import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react'
import { AgentModal } from '@renderer/components/Popups/agent/AgentModal'
import { Bot, MessageSquare } from 'lucide-react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AddButton from './AddButton'
interface UnifiedAddButtonProps {
onCreateAssistant: () => void
}
const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => {
const { t } = useTranslation()
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const [isAgentModalOpen, setIsAgentModalOpen] = useState(false)
const handleAddAssistant = () => {
setIsPopoverOpen(false)
onCreateAssistant()
}
const handleAddAgent = () => {
setIsPopoverOpen(false)
setIsAgentModalOpen(true)
}
return (
<div className="mb-1">
<Popover
isOpen={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
placement="bottom"
classNames={{ content: 'p-0 min-w-[200px]' }}>
<PopoverTrigger>
<AddButton>{t('chat.add.assistant.title')}</AddButton>
</PopoverTrigger>
<PopoverContent>
<div className="flex w-full flex-col gap-1 p-1">
<Button
onPress={handleAddAssistant}
className="w-full justify-start bg-transparent hover:bg-[var(--color-list-item)]"
startContent={<MessageSquare size={16} className="shrink-0" />}>
{t('chat.add.assistant.title')}
</Button>
<Button
onPress={handleAddAgent}
className="w-full justify-start bg-transparent hover:bg-[var(--color-list-item)]"
startContent={<Bot size={16} className="shrink-0" />}>
{t('agent.add.title')}
</Button>
</div>
</PopoverContent>
</Popover>
<AgentModal isOpen={isAgentModalOpen} onClose={() => setIsAgentModalOpen(false)} />
</div>
)
}
export default UnifiedAddButton

View File

@@ -1,108 +0,0 @@
import { DraggableList } from '@renderer/components/DraggableList'
import { Assistant, AssistantsSortType } from '@renderer/types'
import { FC, useCallback } from 'react'
import { UnifiedItem } from '../hooks/useUnifiedItems'
import AgentItem from './AgentItem'
import AssistantItem from './AssistantItem'
interface UnifiedListProps {
items: UnifiedItem[]
activeAssistantId: string
activeAgentId: string | null
sortBy: AssistantsSortType
onReorder: (newList: UnifiedItem[]) => void
onDragStart: () => void
onDragEnd: () => void
onAssistantSwitch: (assistant: Assistant) => void
onAssistantDelete: (assistant: Assistant) => void
onAgentDelete: (agentId: string) => void
onAgentPress: (agentId: string) => void
addPreset: (assistant: Assistant) => void
copyAssistant: (assistant: Assistant) => void
onCreateDefaultAssistant: () => void
handleSortByChange: (sortType: AssistantsSortType) => void
sortByPinyinAsc: () => void
sortByPinyinDesc: () => void
}
export const UnifiedList: FC<UnifiedListProps> = (props) => {
const {
items,
activeAssistantId,
activeAgentId,
sortBy,
onReorder,
onDragStart,
onDragEnd,
onAssistantSwitch,
onAssistantDelete,
onAgentDelete,
onAgentPress,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
handleSortByChange,
sortByPinyinAsc,
sortByPinyinDesc
} = props
const renderUnifiedItem = useCallback(
(item: UnifiedItem) => {
if (item.type === 'agent') {
return (
<AgentItem
key={`agent-${item.data.id}`}
agent={item.data}
isActive={item.data.id === activeAgentId}
onDelete={() => onAgentDelete(item.data.id)}
onPress={() => onAgentPress(item.data.id)}
/>
)
} else {
return (
<AssistantItem
key={`assistant-${item.data.id}`}
assistant={item.data}
isActive={item.data.id === activeAssistantId}
sortBy={sortBy}
onSwitch={onAssistantSwitch}
onDelete={onAssistantDelete}
addPreset={addPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
)
}
},
[
activeAgentId,
activeAssistantId,
sortBy,
onAssistantSwitch,
onAssistantDelete,
onAgentDelete,
onAgentPress,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
handleSortByChange,
sortByPinyinAsc,
sortByPinyinDesc
]
)
return (
<DraggableList
list={items}
itemKey={(item) => `${item.type}-${item.data.id}`}
onUpdate={onReorder}
onDragStart={onDragStart}
onDragEnd={onDragEnd}>
{renderUnifiedItem}
</DraggableList>
)
}

View File

@@ -1,132 +0,0 @@
import { DraggableList } from '@renderer/components/DraggableList'
import { Assistant, AssistantsSortType } from '@renderer/types'
import { FC, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { UnifiedItem } from '../hooks/useUnifiedItems'
import AgentItem from './AgentItem'
import AssistantItem from './AssistantItem'
import { TagGroup } from './TagGroup'
interface GroupedItems {
tag: string
items: UnifiedItem[]
}
interface UnifiedTagGroupsProps {
groupedItems: GroupedItems[]
activeAssistantId: string
activeAgentId: string | null
sortBy: AssistantsSortType
collapsedTags: Record<string, boolean>
onGroupReorder: (tag: string, newList: UnifiedItem[]) => void
onDragStart: () => void
onDragEnd: () => void
onToggleTagCollapse: (tag: string) => void
onAssistantSwitch: (assistant: Assistant) => void
onAssistantDelete: (assistant: Assistant) => void
onAgentDelete: (agentId: string) => void
onAgentPress: (agentId: string) => void
addPreset: (assistant: Assistant) => void
copyAssistant: (assistant: Assistant) => void
onCreateDefaultAssistant: () => void
handleSortByChange: (sortType: AssistantsSortType) => void
sortByPinyinAsc: () => void
sortByPinyinDesc: () => void
}
export const UnifiedTagGroups: FC<UnifiedTagGroupsProps> = (props) => {
const {
groupedItems,
activeAssistantId,
activeAgentId,
sortBy,
collapsedTags,
onGroupReorder,
onDragStart,
onDragEnd,
onToggleTagCollapse,
onAssistantSwitch,
onAssistantDelete,
onAgentDelete,
onAgentPress,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
handleSortByChange,
sortByPinyinAsc,
sortByPinyinDesc
} = props
const { t } = useTranslation()
const renderUnifiedItem = useCallback(
(item: UnifiedItem) => {
if (item.type === 'agent') {
return (
<AgentItem
key={`agent-${item.data.id}`}
agent={item.data}
isActive={item.data.id === activeAgentId}
onDelete={() => onAgentDelete(item.data.id)}
onPress={() => onAgentPress(item.data.id)}
/>
)
} else {
return (
<AssistantItem
key={`assistant-${item.data.id}`}
assistant={item.data}
isActive={item.data.id === activeAssistantId}
sortBy={sortBy}
onSwitch={onAssistantSwitch}
onDelete={onAssistantDelete}
addPreset={addPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
sortByPinyinAsc={sortByPinyinAsc}
sortByPinyinDesc={sortByPinyinDesc}
/>
)
}
},
[
activeAgentId,
activeAssistantId,
sortBy,
onAssistantSwitch,
onAssistantDelete,
onAgentDelete,
onAgentPress,
addPreset,
copyAssistant,
onCreateDefaultAssistant,
handleSortByChange,
sortByPinyinAsc,
sortByPinyinDesc
]
)
return (
<div>
{groupedItems.map((group) => (
<TagGroup
key={group.tag}
tag={group.tag}
isCollapsed={collapsedTags[group.tag]}
onToggle={onToggleTagCollapse}
showTitle={group.tag !== t('assistants.tags.untagged')}>
<DraggableList
list={group.items}
itemKey={(item) => `${item.type}-${item.data.id}`}
onUpdate={(newList) => onGroupReorder(group.tag, newList)}
onDragStart={onDragStart}
onDragEnd={onDragEnd}>
{renderUnifiedItem}
</DraggableList>
</TagGroup>
))}
</div>
)
}

View File

@@ -1,19 +0,0 @@
import { useAgentSessionInitializer } from '@renderer/hooks/agents/useAgentSessionInitializer'
import { useAppDispatch } from '@renderer/store'
import { setActiveAgentId as setActiveAgentIdAction } from '@renderer/store/runtime'
import { useCallback } from 'react'
export const useActiveAgent = () => {
const dispatch = useAppDispatch()
const { initializeAgentSession } = useAgentSessionInitializer()
const setActiveAgentId = useCallback(
async (id: string) => {
dispatch(setActiveAgentIdAction(id))
await initializeAgentSession(id)
},
[dispatch, initializeAgentSession]
)
return { setActiveAgentId }
}

View File

@@ -1,140 +0,0 @@
import { useAppDispatch } from '@renderer/store'
import { setUnifiedListOrder } from '@renderer/store/assistants'
import { AgentEntity, Assistant } from '@renderer/types'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { UnifiedItem } from './useUnifiedItems'
interface UseUnifiedGroupingOptions {
unifiedItems: UnifiedItem[]
assistants: Assistant[]
agents: AgentEntity[]
apiServerEnabled: boolean
agentsLoading: boolean
agentsError: Error | null
updateAssistants: (assistants: Assistant[]) => void
}
export const useUnifiedGrouping = (options: UseUnifiedGroupingOptions) => {
const { unifiedItems, assistants, agents, apiServerEnabled, agentsLoading, agentsError, updateAssistants } = options
const { t } = useTranslation()
const dispatch = useAppDispatch()
// Group unified items by tags
const groupedUnifiedItems = useMemo(() => {
const groups = new Map<string, UnifiedItem[]>()
unifiedItems.forEach((item) => {
if (item.type === 'agent') {
// Agents go to untagged group
const groupKey = t('assistants.tags.untagged')
if (!groups.has(groupKey)) {
groups.set(groupKey, [])
}
groups.get(groupKey)!.push(item)
} else {
// Assistants use their tags
const tags = item.data.tags?.length ? item.data.tags : [t('assistants.tags.untagged')]
tags.forEach((tag) => {
if (!groups.has(tag)) {
groups.set(tag, [])
}
groups.get(tag)!.push(item)
})
}
})
// Sort groups: untagged first, then tagged groups
const untaggedKey = t('assistants.tags.untagged')
const sortedGroups = Array.from(groups.entries()).sort(([tagA], [tagB]) => {
if (tagA === untaggedKey) return -1
if (tagB === untaggedKey) return 1
return 0
})
return sortedGroups.map(([tag, items]) => ({ tag, items }))
}, [unifiedItems, t])
const handleUnifiedGroupReorder = useCallback(
(tag: string, newGroupList: UnifiedItem[]) => {
// Extract only assistants from the new list for updating
const newAssistants = newGroupList.filter((item) => item.type === 'assistant').map((item) => item.data)
// Update assistants state
let insertIndex = 0
const updatedAssistants = assistants.map((a) => {
const tags = a.tags?.length ? a.tags : [t('assistants.tags.untagged')]
if (tags.includes(tag)) {
const replaced = newAssistants[insertIndex]
insertIndex += 1
return replaced || a
}
return a
})
updateAssistants(updatedAssistants)
// Rebuild unified order and save to Redux
const newUnifiedItems: UnifiedItem[] = []
const availableAgents = new Map<string, AgentEntity>()
const availableAssistants = new Map<string, Assistant>()
if (apiServerEnabled && !agentsLoading && !agentsError) {
agents.forEach((agent) => availableAgents.set(agent.id, agent))
}
updatedAssistants.forEach((assistant) => availableAssistants.set(assistant.id, assistant))
// Reconstruct order based on current groupedUnifiedItems structure
groupedUnifiedItems.forEach((group) => {
if (group.tag === tag) {
// Use the new group list for this tag
newGroupList.forEach((item) => {
newUnifiedItems.push(item)
if (item.type === 'agent') {
availableAgents.delete(item.data.id)
} else {
availableAssistants.delete(item.data.id)
}
})
} else {
// Keep existing order for other tags
group.items.forEach((item) => {
newUnifiedItems.push(item)
if (item.type === 'agent') {
availableAgents.delete(item.data.id)
} else {
availableAssistants.delete(item.data.id)
}
})
}
})
// Add any remaining items
availableAgents.forEach((agent) => newUnifiedItems.push({ type: 'agent', data: agent }))
availableAssistants.forEach((assistant) => newUnifiedItems.push({ type: 'assistant', data: assistant }))
// Save to Redux
const orderToSave = newUnifiedItems.map((item) => ({
type: item.type,
id: item.data.id
}))
dispatch(setUnifiedListOrder(orderToSave))
},
[
assistants,
t,
updateAssistants,
apiServerEnabled,
agentsLoading,
agentsError,
agents,
groupedUnifiedItems,
dispatch
]
)
return {
groupedUnifiedItems,
handleUnifiedGroupReorder
}
}

View File

@@ -1,73 +0,0 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setUnifiedListOrder } from '@renderer/store/assistants'
import { AgentEntity, Assistant } from '@renderer/types'
import { useCallback, useMemo } from 'react'
export type UnifiedItem = { type: 'agent'; data: AgentEntity } | { type: 'assistant'; data: Assistant }
interface UseUnifiedItemsOptions {
agents: AgentEntity[]
assistants: Assistant[]
apiServerEnabled: boolean
agentsLoading: boolean
agentsError: Error | null
updateAssistants: (assistants: Assistant[]) => void
}
export const useUnifiedItems = (options: UseUnifiedItemsOptions) => {
const { agents, assistants, apiServerEnabled, agentsLoading, agentsError, updateAssistants } = options
const dispatch = useAppDispatch()
const unifiedListOrder = useAppSelector((state) => state.assistants.unifiedListOrder || [])
// Create unified items list (agents + assistants) with saved order
const unifiedItems = useMemo(() => {
const items: UnifiedItem[] = []
// Collect all available items
const availableAgents = new Map<string, AgentEntity>()
const availableAssistants = new Map<string, Assistant>()
if (apiServerEnabled && !agentsLoading && !agentsError) {
agents.forEach((agent) => availableAgents.set(agent.id, agent))
}
assistants.forEach((assistant) => availableAssistants.set(assistant.id, assistant))
// Apply saved order
unifiedListOrder.forEach((item) => {
if (item.type === 'agent' && availableAgents.has(item.id)) {
items.push({ type: 'agent', data: availableAgents.get(item.id)! })
availableAgents.delete(item.id)
} else if (item.type === 'assistant' && availableAssistants.has(item.id)) {
items.push({ type: 'assistant', data: availableAssistants.get(item.id)! })
availableAssistants.delete(item.id)
}
})
// Add new items (not in saved order) to the end
availableAgents.forEach((agent) => items.push({ type: 'agent', data: agent }))
availableAssistants.forEach((assistant) => items.push({ type: 'assistant', data: assistant }))
return items
}, [agents, assistants, apiServerEnabled, agentsLoading, agentsError, unifiedListOrder])
const handleUnifiedListReorder = useCallback(
(newList: UnifiedItem[]) => {
// Save the unified order to Redux
const orderToSave = newList.map((item) => ({
type: item.type,
id: item.data.id
}))
dispatch(setUnifiedListOrder(orderToSave))
// Extract and update assistants order
const newAssistants = newList.filter((item) => item.type === 'assistant').map((item) => item.data)
updateAssistants(newAssistants)
},
[dispatch, updateAssistants]
)
return {
unifiedItems,
handleUnifiedListReorder
}
}

View File

@@ -1,56 +0,0 @@
import { useAppDispatch } from '@renderer/store'
import { setUnifiedListOrder } from '@renderer/store/assistants'
import { Assistant } from '@renderer/types'
import { useCallback } from 'react'
import * as tinyPinyin from 'tiny-pinyin'
import { UnifiedItem } from './useUnifiedItems'
interface UseUnifiedSortingOptions {
unifiedItems: UnifiedItem[]
updateAssistants: (assistants: Assistant[]) => void
}
export const useUnifiedSorting = (options: UseUnifiedSortingOptions) => {
const { unifiedItems, updateAssistants } = options
const dispatch = useAppDispatch()
const sortUnifiedItemsByPinyin = useCallback((items: UnifiedItem[], isAscending: boolean) => {
return [...items].sort((a, b) => {
const nameA = a.type === 'agent' ? a.data.name || a.data.id : a.data.name
const nameB = b.type === 'agent' ? b.data.name || b.data.id : b.data.name
const pinyinA = tinyPinyin.convertToPinyin(nameA, '', true)
const pinyinB = tinyPinyin.convertToPinyin(nameB, '', true)
return isAscending ? pinyinA.localeCompare(pinyinB) : pinyinB.localeCompare(pinyinA)
})
}, [])
const sortByPinyinAsc = useCallback(() => {
const sorted = sortUnifiedItemsByPinyin(unifiedItems, true)
const orderToSave = sorted.map((item) => ({
type: item.type,
id: item.data.id
}))
dispatch(setUnifiedListOrder(orderToSave))
// Also update assistants order
const newAssistants = sorted.filter((item) => item.type === 'assistant').map((item) => item.data)
updateAssistants(newAssistants)
}, [unifiedItems, sortUnifiedItemsByPinyin, dispatch, updateAssistants])
const sortByPinyinDesc = useCallback(() => {
const sorted = sortUnifiedItemsByPinyin(unifiedItems, false)
const orderToSave = sorted.map((item) => ({
type: item.type,
id: item.data.id
}))
dispatch(setUnifiedListOrder(orderToSave))
// Also update assistants order
const newAssistants = sorted.filter((item) => item.type === 'assistant').map((item) => item.data)
updateAssistants(newAssistants)
}, [unifiedItems, sortUnifiedItemsByPinyin, dispatch, updateAssistants])
return {
sortByPinyinAsc,
sortByPinyinDesc
}
}

View File

@@ -1,6 +1,4 @@
import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
@@ -13,7 +11,6 @@ import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AgentSettingsTab from './AgentSettingsTab'
import Assistants from './AssistantsTab'
import Settings from './SettingsTab'
import Topics from './TopicsTab'
@@ -44,12 +41,11 @@ const HomeTabs: FC<Props> = ({
const { defaultAssistant } = useDefaultAssistant()
const { toggleShowTopics } = useShowTopics()
const { isLeftNavbar } = useNavbarPosition()
const { t } = useTranslation()
const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId } = chat
const { agent } = useAgent(activeAgentId)
const { updateAgent } = useUpdateAgent()
const { t } = useTranslation()
const { chat } = useRuntime()
const { activeTopicOrSession } = chat
const isSessionView = activeTopicOrSession === 'session'
const isTopicView = activeTopicOrSession === 'topic'
@@ -65,6 +61,7 @@ const HomeTabs: FC<Props> = ({
}
const showTab = position === 'left' && topicPosition === 'left'
const shouldShowSettingsTab = !isSessionView
const onCreateAssistant = async () => {
const assistant = await AddAssistantPopup.show()
@@ -107,6 +104,12 @@ const HomeTabs: FC<Props> = ({
}
}, [position, tab, topicPosition, forceToSeeAllTab])
useEffect(() => {
if (activeTopicOrSession === 'session' && tab === 'settings') {
setTab('topic')
}
}, [activeTopicOrSession, tab])
return (
<Container
style={{ ...border, ...style }}
@@ -117,11 +120,13 @@ const HomeTabs: FC<Props> = ({
{t('assistants.abbr')}
</TabItem>
<TabItem active={tab === 'topic'} onClick={() => setTab('topic')}>
{t('common.topics')}
</TabItem>
<TabItem active={tab === 'settings'} onClick={() => setTab('settings')}>
{t('settings.title')}
{isTopicView ? t('common.topics') : t('agent.session.label_other')}
</TabItem>
{shouldShowSettingsTab && (
<TabItem active={tab === 'settings'} onClick={() => setTab('settings')}>
{t('settings.title')}
</TabItem>
)}
</CustomTabs>
)}
@@ -153,8 +158,7 @@ const HomeTabs: FC<Props> = ({
position={position}
/>
)}
{tab === 'settings' && isTopicView && <Settings assistant={activeAssistant} />}
{tab === 'settings' && isSessionView && <AgentSettingsTab agent={agent} update={updateAgent} />}
{tab === 'settings' && shouldShowSettingsTab && <Settings assistant={activeAssistant} />}
</TabContent>
</Container>
)
@@ -210,7 +214,7 @@ const CustomTabs = styled.div`
const TabItem = styled.button<{ active: boolean }>`
flex: 1;
height: 30px;
height: 32px;
border: none;
background: transparent;
color: ${(props) => (props.active ? 'var(--color-text)' : 'var(--color-text-secondary)')};
@@ -235,7 +239,7 @@ const TabItem = styled.button<{ active: boolean }>`
&::after {
content: '';
position: absolute;
bottom: -8px;
bottom: -9px;
left: 50%;
transform: translateX(-50%);
width: ${(props) => (props.active ? '30px' : '0')};

View File

@@ -1,6 +1,4 @@
import { SyncOutlined } from '@ant-design/icons'
import { useDisclosure } from '@heroui/react'
import UpdateDialog from '@renderer/components/UpdateDialog'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { Button } from 'antd'
@@ -12,7 +10,6 @@ const UpdateAppButton: FC = () => {
const { update } = useRuntime()
const { autoCheckUpdate } = useSettings()
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
if (!update) {
return null
@@ -26,15 +23,13 @@ const UpdateAppButton: FC = () => {
<Container>
<UpdateButton
className="nodrag"
onClick={onOpen}
onClick={() => window.api.showUpdateDialog()}
icon={<SyncOutlined />}
color="orange"
variant="outlined"
size="small">
{t('button.update_available')}
</UpdateButton>
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={update.info || null} />
</Container>
)
}

View File

@@ -14,7 +14,6 @@ import styled from 'styled-components'
// Tab 模式下新的页面壳,不再直接创建 WebView而是依赖全局 MinAppTabsPool
import MinimalToolbar from './components/MinimalToolbar'
import WebviewSearch from './components/WebviewSearch'
const logger = loggerService.withContext('MinAppPage')
@@ -185,7 +184,6 @@ const MinAppPage: FC = () => {
onOpenDevTools={handleOpenDevTools}
/>
</ToolbarWrapper>
<WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
{!isReady && (
<LoadingMask>
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />

View File

@@ -1,377 +0,0 @@
import { Button, Input } from '@heroui/react'
import { loggerService } from '@logger'
import type { WebviewTag } from 'electron'
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
type FoundInPageResult = Electron.FoundInPageResult
interface WebviewSearchProps {
webviewRef: React.RefObject<WebviewTag | null>
isWebviewReady: boolean
appId: string
}
const logger = loggerService.withContext('WebviewSearch')
const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, appId }) => {
const { t } = useTranslation()
const [isVisible, setIsVisible] = useState(false)
const [query, setQuery] = useState('')
const [matchCount, setMatchCount] = useState(0)
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const focusFrameRef = useRef<number | null>(null)
const lastAppIdRef = useRef<string>(appId)
const attachedWebviewRef = useRef<WebviewTag | null>(null)
const activeWebview = webviewRef.current ?? null
const focusInput = useCallback(() => {
if (focusFrameRef.current !== null) {
window.cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
focusFrameRef.current = window.requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
}, [])
const resetSearchState = useCallback((options?: { keepQuery?: boolean }) => {
if (!options?.keepQuery) {
setQuery('')
}
setMatchCount(0)
setActiveIndex(0)
}, [])
const ensureWebviewReady = useCallback(
(candidate: WebviewTag | null) => {
if (!candidate) return null
try {
const webContentsId = candidate.getWebContentsId?.()
if (!webContentsId) {
logger.debug('WebviewSearch: missing webContentsId before action', { appId })
return null
}
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed before action', { appId, error })
return null
}
return candidate
},
[appId]
)
const stopFindOnWebview = useCallback(
(webview: WebviewTag | null) => {
const usable = ensureWebviewReady(webview)
if (!usable) return false
try {
usable.stopFindInPage('clearSelection')
return true
} catch (error) {
logger.debug('stopFindInPage failed', { appId, error })
return false
}
},
[appId, ensureWebviewReady]
)
const getUsableWebview = useCallback(() => {
const candidates = [webviewRef.current, attachedWebviewRef.current]
for (const candidate of candidates) {
const usable = ensureWebviewReady(candidate)
if (usable) {
return usable
}
}
return null
}, [ensureWebviewReady, webviewRef])
const stopSearch = useCallback(() => {
const target = getUsableWebview()
if (!target) return
stopFindOnWebview(target)
}, [getUsableWebview, stopFindOnWebview])
const closeSearch = useCallback(() => {
setIsVisible(false)
stopSearch()
resetSearchState({ keepQuery: true })
}, [resetSearchState, stopSearch])
const performSearch = useCallback(
(text: string, options?: Electron.FindInPageOptions) => {
const target = getUsableWebview()
if (!target) {
logger.debug('Skip performSearch: webview not attached')
return
}
if (!text) {
stopSearch()
resetSearchState({ keepQuery: true })
return
}
try {
target.findInPage(text, options)
} catch (error) {
logger.error('findInPage failed', { error })
window.toast?.error(t('common.error'))
}
},
[getUsableWebview, resetSearchState, stopSearch, t]
)
const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
if (!event.result) return
const { activeMatchOrdinal, matches } = event.result
if (matches !== undefined) {
setMatchCount(matches)
}
if (activeMatchOrdinal !== undefined) {
setActiveIndex(activeMatchOrdinal)
}
}, [])
const openSearch = useCallback(() => {
if (!isWebviewReady) {
logger.debug('Skip openSearch: webview not ready')
return
}
setIsVisible(true)
focusInput()
}, [focusInput, isWebviewReady])
const goToNext = useCallback(() => {
if (!query) return
performSearch(query, { forward: true, findNext: true })
}, [performSearch, query])
const goToPrevious = useCallback(() => {
if (!query) return
performSearch(query, { forward: false, findNext: true })
}, [performSearch, query])
useEffect(() => {
attachedWebviewRef.current = activeWebview
if (!activeWebview) {
return
}
const handle = handleFoundInPage
activeWebview.addEventListener('found-in-page', handle)
return () => {
activeWebview.removeEventListener('found-in-page', handle)
if (attachedWebviewRef.current === activeWebview) {
stopFindOnWebview(activeWebview)
attachedWebviewRef.current = null
}
}
}, [activeWebview, handleFoundInPage, stopFindOnWebview])
useEffect(() => {
if (!activeWebview) return
if (!isWebviewReady) return
const onFindShortcut = window.api?.webview?.onFindShortcut
if (!onFindShortcut) return
let webContentsId: number | undefined
try {
webContentsId = activeWebview.getWebContentsId?.()
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed', { appId, error })
return
}
if (!webContentsId) {
logger.warn('WebviewSearch: missing webContentsId', { appId })
return
}
const unsubscribe = onFindShortcut(({ webviewId, key, control, meta, shift }) => {
if (webviewId !== webContentsId) return
if ((control || meta) && key === 'f') {
openSearch()
return
}
if (!isVisible) return
if (key === 'escape') {
closeSearch()
return
}
if (key === 'enter') {
if (shift) {
goToPrevious()
} else {
goToNext()
}
}
})
return () => {
unsubscribe?.()
}
}, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, isWebviewReady, openSearch])
useEffect(() => {
if (!isVisible) return
focusInput()
}, [focusInput, isVisible])
useEffect(() => {
if (!isVisible) return
if (!query) {
performSearch('')
return
}
performSearch(query)
}, [activeWebview, isVisible, performSearch, query])
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {
event.preventDefault()
openSearch()
return
}
if (!isVisible) return
if (event.key === 'Escape') {
event.preventDefault()
closeSearch()
return
}
if (event.key === 'Enter') {
event.preventDefault()
if (event.shiftKey) {
goToPrevious()
} else {
goToNext()
}
}
}
window.addEventListener('keydown', handleKeydown, true)
return () => {
window.removeEventListener('keydown', handleKeydown, true)
}
}, [closeSearch, goToNext, goToPrevious, isVisible, openSearch])
useEffect(() => {
if (!isWebviewReady) {
setIsVisible(false)
resetSearchState()
stopSearch()
return
}
}, [isWebviewReady, resetSearchState, stopSearch])
useEffect(() => {
if (!appId) return
if (lastAppIdRef.current === appId) return
lastAppIdRef.current = appId
setIsVisible(false)
resetSearchState()
stopSearch()
}, [appId, resetSearchState, stopSearch])
useEffect(() => {
return () => {
stopSearch()
if (focusFrameRef.current !== null) {
window.cancelAnimationFrame(focusFrameRef.current)
focusFrameRef.current = null
}
}
}, [stopSearch])
if (!isVisible) {
return null
}
const matchLabel = `${matchCount > 0 ? Math.max(activeIndex, 1) : 0}/${matchCount}`
const noResultTitle = matchCount === 0 && query ? t('common.no_results') : undefined
const disableNavigation = !query || matchCount === 0
return (
<div className="pointer-events-auto absolute top-3 right-3 z-50 flex items-center gap-2 rounded-xl border border-default-200 bg-background px-2 py-1 shadow-lg">
<Input
ref={inputRef}
autoFocus
value={query}
onValueChange={setQuery}
spellCheck={'false'}
placeholder={t('common.search')}
size="sm"
radius="sm"
variant="flat"
classNames={{
base: 'w-[240px]',
inputWrapper:
'h-8 bg-transparent border border-transparent shadow-none hover:border-transparent hover:bg-transparent focus:border-transparent data-[hover=true]:border-transparent data-[focus=true]:border-transparent data-[focus-visible=true]:outline-none data-[focus-visible=true]:ring-0',
input: 'text-small focus:outline-none focus-visible:outline-none',
innerWrapper: 'gap-0'
}}
/>
<span
className="min-w-[44px] text-center text-default-500 text-small tabular-nums"
title={noResultTitle}
role="status"
aria-live="polite"
aria-atomic="true">
{matchLabel}
</span>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToPrevious}
isDisabled={disableNavigation}
aria-label="Previous match"
className="text-default-500 hover:text-default-900">
<ChevronUp size={16} />
</Button>
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToNext}
isDisabled={disableNavigation}
aria-label="Next match"
className="text-default-500 hover:text-default-900">
<ChevronDown size={16} />
</Button>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={closeSearch}
aria-label={t('common.close')}
className="text-default-500 hover:text-default-900">
<X size={16} />
</Button>
</div>
)
}
export default WebviewSearch

View File

@@ -1,396 +0,0 @@
import type { WebviewKeyEvent } from '@shared/config/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { WebviewTag } from 'electron'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import WebviewSearch from '../WebviewSearch'
const translations: Record<string, string> = {
'common.close': 'Close',
'common.error': 'Error',
'common.no_results': 'No results',
'common.search': 'Search'
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => translations[key] ?? key
})
}))
const createWebviewMock = () => {
const listeners = new Map<string, Set<(event: Event & { result?: Electron.FoundInPageResult }) => void>>()
const findInPageMock = vi.fn()
const stopFindInPageMock = vi.fn()
const webview = {
addEventListener: vi.fn(
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
if (!listeners.has(type)) {
listeners.set(type, new Set())
}
listeners.get(type)!.add(listener)
}
),
removeEventListener: vi.fn(
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
listeners.get(type)?.delete(listener)
}
),
getWebContentsId: vi.fn(() => 1),
findInPage: findInPageMock as unknown as WebviewTag['findInPage'],
stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage']
} as unknown as WebviewTag
const emit = (type: string, result?: Electron.FoundInPageResult) => {
listeners.get(type)?.forEach((listener) => {
const event = new CustomEvent(type) as Event & { result?: Electron.FoundInPageResult }
event.result = result
listener(event)
})
}
return {
emit,
findInPageMock,
stopFindInPageMock,
webview
}
}
const openSearchOverlay = async () => {
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true }))
})
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
}
const originalRAF = window.requestAnimationFrame
const originalCAF = window.cancelAnimationFrame
const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
callback(0)
return 1
})
const cancelAnimationFrameMock = vi.fn()
beforeAll(() => {
Object.defineProperty(window, 'requestAnimationFrame', {
value: requestAnimationFrameMock,
writable: true
})
Object.defineProperty(window, 'cancelAnimationFrame', {
value: cancelAnimationFrameMock,
writable: true
})
})
afterAll(() => {
Object.defineProperty(window, 'requestAnimationFrame', {
value: originalRAF
})
Object.defineProperty(window, 'cancelAnimationFrame', {
value: originalCAF
})
})
describe('WebviewSearch', () => {
const toastMock = {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
addToast: vi.fn()
}
let removeFindShortcutListenerMock: ReturnType<typeof vi.fn>
let onFindShortcutMock: ReturnType<typeof vi.fn>
const invokeLatestShortcut = (payload: WebviewKeyEvent) => {
const handler = onFindShortcutMock.mock.calls.at(-1)?.[0] as ((args: WebviewKeyEvent) => void) | undefined
if (!handler) {
throw new Error('Shortcut handler not registered')
}
act(() => {
handler(payload)
})
}
beforeEach(() => {
removeFindShortcutListenerMock = vi.fn()
onFindShortcutMock = vi.fn(() => removeFindShortcutListenerMock)
Object.assign(window as any, {
api: {
webview: {
onFindShortcut: onFindShortcutMock
}
}
})
Object.assign(window, { toast: toastMock })
})
afterEach(() => {
vi.clearAllMocks()
Reflect.deleteProperty(window, 'api')
})
it('opens the search overlay with keyboard shortcut', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
await openSearchOverlay()
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
it('opens the search overlay when webview shortcut is forwarded', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
})
it('skips shortcut wiring when getWebContentsId throws', async () => {
const { webview } = createWebviewMock()
const error = new Error('not ready')
;(webview as any).getWebContentsId = vi.fn(() => {
throw error
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
expect(onFindShortcutMock).not.toHaveBeenCalled()
;(webview as any).getWebContentsId = vi.fn(() => 1)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
})
it('does not call stopFindInPage when webview is not ready', async () => {
const { stopFindInPageMock, webview } = createWebviewMock()
const error = new Error('loading')
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { rerender, unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
stopFindInPageMock.mockImplementation(() => {
throw new Error('should not be called')
})
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
expect(stopFindInPageMock).not.toHaveBeenCalled()
unmount()
expect(stopFindInPageMock).not.toHaveBeenCalled()
})
it('closes the search overlay when escape is forwarded from the webview', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
await waitFor(() => {
expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2)
})
invokeLatestShortcut({ webviewId: 1, key: 'escape', control: false, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
})
})
it('performs searches and navigates between results', async () => {
const { emit, findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
})
await act(async () => {
emit('found-in-page', {
requestId: 1,
matches: 3,
activeMatchOrdinal: 1,
selectionArea: undefined as unknown as Electron.Rectangle,
finalUpdate: false
} as Electron.FoundInPageResult)
})
const nextButton = screen.getByRole('button', { name: 'Next match' })
await waitFor(() => {
expect(nextButton).not.toBeDisabled()
})
await user.click(nextButton)
await waitFor(() => {
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: true, findNext: true })
})
const previousButton = screen.getByRole('button', { name: 'Previous match' })
await user.click(previousButton)
await waitFor(() => {
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: false, findNext: true })
})
})
it('navigates results when enter is forwarded from the webview', async () => {
const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
})
await waitFor(() => {
expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2)
})
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
})
findInPageMock.mockClear()
invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: false, alt: false })
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', { forward: true, findNext: true })
})
findInPageMock.mockClear()
invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: true, alt: false })
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalledWith('Cherry', { forward: false, findNext: true })
})
})
it('clears search state when appId changes', async () => {
const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(findInPageMock).toHaveBeenCalled()
})
await act(async () => {
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-2" />)
})
await waitFor(() => {
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
})
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
})
it('shows toast error when search fails', async () => {
const { findInPageMock, webview } = createWebviewMock()
findInPageMock.mockImplementation(() => {
throw new Error('findInPage failed')
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup()
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
const input = screen.getByRole('textbox')
await user.type(input, 'Cherry')
await waitFor(() => {
expect(toastMock.error).toHaveBeenCalledWith('Error')
})
})
it('stops search when component unmounts', async () => {
const { stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay()
stopFindInPageMock.mockClear()
unmount()
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
expect(removeFindShortcutListenerMock).toHaveBeenCalled()
})
it('ignores keyboard shortcut when webview is not ready', async () => {
const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
await act(async () => {
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
})
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
expect(findInPageMock).not.toHaveBeenCalled()
})
})

View File

@@ -385,25 +385,21 @@ const NotesPage: FC = () => {
}, [activeFilePath])
// 获取目标文件夹路径(选中文件夹或根目录)
const getTargetFolderPath = useCallback(
(targetFolderId?: string) => {
const folderId = targetFolderId || selectedFolderId
if (folderId) {
const selectedNode = findNode(notesTree, folderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
}
const getTargetFolderPath = useCallback(() => {
if (selectedFolderId) {
const selectedNode = findNode(notesTree, selectedFolderId)
if (selectedNode && selectedNode.type === 'folder') {
return selectedNode.externalPath
}
return notesPath // 默认返回根目录
},
[selectedFolderId, notesTree, notesPath]
)
}
return notesPath // 默认返回根目录
}, [selectedFolderId, notesTree, notesPath])
// 创建文件夹
const handleCreateFolder = useCallback(
async (name: string, targetFolderId?: string) => {
async (name: string) => {
try {
const targetPath = getTargetFolderPath(targetFolderId)
const targetPath = getTargetFolderPath()
if (!targetPath) {
throw new Error('No folder path selected')
}
@@ -419,11 +415,11 @@ const NotesPage: FC = () => {
// 创建笔记
const handleCreateNote = useCallback(
async (name: string, targetFolderId?: string) => {
async (name: string) => {
try {
isCreatingNoteRef.current = true
const targetPath = getTargetFolderPath(targetFolderId)
const targetPath = getTargetFolderPath()
if (!targetPath) {
throw new Error('No folder path selected')
}

View File

@@ -34,8 +34,8 @@ import { useSelector } from 'react-redux'
import styled from 'styled-components'
interface NotesSidebarProps {
onCreateFolder: (name: string, targetFolderId?: string) => void
onCreateNote: (name: string, targetFolderId?: string) => void
onCreateFolder: (name: string, parentId?: string) => void
onCreateNote: (name: string, parentId?: string) => void
onSelectNode: (node: NotesTreeNode) => void
onDeleteNode: (nodeId: string) => void
onRenameNode: (nodeId: string, newName: string) => void
@@ -71,8 +71,6 @@ interface TreeNodeProps {
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
onDragEnd: () => void
renderChildren?: boolean // 控制是否渲染子节点
openDropdownKey: string | null
onDropdownOpenChange: (key: string | null) => void
}
const TreeNode = memo<TreeNodeProps>(
@@ -96,9 +94,7 @@ const TreeNode = memo<TreeNodeProps>(
onDragLeave,
onDrop,
onDragEnd,
renderChildren = true,
openDropdownKey,
onDropdownOpenChange
renderChildren = true
}) => {
const { t } = useTranslation()
@@ -123,12 +119,8 @@ const TreeNode = memo<TreeNodeProps>(
return (
<div key={node.id}>
<Dropdown
menu={{ items: getMenuItems(node) }}
trigger={['contextMenu']}
open={openDropdownKey === node.id}
onOpenChange={(open) => onDropdownOpenChange(open ? node.id : null)}>
<div onContextMenu={(e) => e.stopPropagation()}>
<Dropdown menu={{ items: getMenuItems(node) }} trigger={['contextMenu']}>
<div>
<TreeNodeContainer
active={isActive}
depth={depth}
@@ -214,8 +206,6 @@ const TreeNode = memo<TreeNodeProps>(
onDrop={onDrop}
onDragEnd={onDragEnd}
renderChildren={renderChildren}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={onDropdownOpenChange}
/>
))}
</div>
@@ -254,7 +244,6 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
const [isShowSearch, setIsShowSearch] = useState(false)
const [searchKeyword, setSearchKeyword] = useState('')
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
const [openDropdownKey, setOpenDropdownKey] = useState<string | null>(null)
const dragNodeRef = useRef<HTMLDivElement | null>(null)
const scrollbarRef = useRef<any>(null)
@@ -599,28 +588,6 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
})
}
if (node.type === 'folder') {
baseMenuItems.push(
{
label: t('notes.new_note'),
key: 'new_note',
icon: <FilePlus size={14} />,
onClick: () => {
onCreateNote(t('notes.untitled_note'), node.id)
}
},
{
label: t('notes.new_folder'),
key: 'new_folder',
icon: <Folder size={14} />,
onClick: () => {
onCreateFolder(t('notes.untitled_folder'), node.id)
}
},
{ type: 'divider' }
)
}
baseMenuItems.push(
{
label: t('notes.rename'),
@@ -735,9 +702,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
handleDeleteNode,
renamingNodeIds,
handleAutoRename,
exportMenuOptions,
onCreateNote,
onCreateFolder
exportMenuOptions
]
)
@@ -818,23 +783,6 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
fileInput.click()
}, [onUploadFiles])
const getEmptyAreaMenuItems = useCallback((): MenuProps['items'] => {
return [
{
label: t('notes.new_note'),
key: 'new_note',
icon: <FilePlus size={14} />,
onClick: handleCreateNote
},
{
label: t('notes.new_folder'),
key: 'new_folder',
icon: <Folder size={14} />,
onClick: handleCreateFolder
}
]
}, [t, handleCreateNote, handleCreateFolder])
return (
<SidebarContainer
onDragOver={(e) => {
@@ -864,62 +812,120 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
<NotesTreeContainer>
{shouldUseVirtualization ? (
<Dropdown
menu={{ items: getEmptyAreaMenuItems() }}
trigger={['contextMenu']}
open={openDropdownKey === 'empty-area'}
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
<VirtualizedTreeContainer ref={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const { node, depth } = flattenedNodes[virtualItem.index]
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`
}}>
<div style={{ padding: '0 8px' }}>
<TreeNode
node={node}
depth={depth}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
renderChildren={false}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/>
</div>
<VirtualizedTreeContainer ref={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const { node, depth } = flattenedNodes[virtualItem.index]
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`
}}>
<div style={{ padding: '0 8px' }}>
<TreeNode
node={node}
depth={depth}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
renderChildren={false}
/>
</div>
)
})}
</div>
</div>
)
})}
</div>
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</VirtualizedTreeContainer>
) : (
<StyledScrollbar ref={scrollbarRef}>
<TreeContent>
{isShowStarred || isShowSearch
? filteredTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))
: notesTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
@@ -932,84 +938,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
</TreeNodeContainer>
</DropHintNode>
)}
</VirtualizedTreeContainer>
</Dropdown>
) : (
<Dropdown
menu={{ items: getEmptyAreaMenuItems() }}
trigger={['contextMenu']}
open={openDropdownKey === 'empty-area'}
onOpenChange={(open) => setOpenDropdownKey(open ? 'empty-area' : null)}>
<StyledScrollbar ref={scrollbarRef}>
<TreeContent>
{isShowStarred || isShowSearch
? filteredTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/>
))
: notesTree.map((node) => (
<TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
activeNodeId={activeNode?.id}
editingNodeId={editingNodeId}
renamingNodeIds={renamingNodeIds}
newlyRenamedNodeIds={newlyRenamedNodeIds}
draggedNodeId={draggedNodeId}
dragOverNodeId={dragOverNodeId}
dragPosition={dragPosition}
inPlaceEdit={inPlaceEdit}
getMenuItems={getMenuItems}
onSelectNode={onSelectNode}
onToggleExpanded={onToggleExpanded}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
openDropdownKey={openDropdownKey}
onDropdownOpenChange={setOpenDropdownKey}
/>
))}
{!isShowStarred && !isShowSearch && (
<DropHintNode>
<TreeNodeContainer active={false} depth={0}>
<TreeNodeContent>
<NodeIcon>
<FilePlus size={16} />
</NodeIcon>
<DropHintText onClick={handleClickToSelectFiles}>{t('notes.drop_markdown_hint')}</DropHintText>
</TreeNodeContent>
</TreeNodeContainer>
</DropHintNode>
)}
</TreeContent>
</StyledScrollbar>
</Dropdown>
</TreeContent>
</StyledScrollbar>
)}
</NotesTreeContainer>

View File

@@ -12,7 +12,6 @@ import { HStack, VStack } from '@renderer/components/Layout'
import Scrollbar from '@renderer/components/Scrollbar'
import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { getProviderLogo } from '@renderer/config/providers'
import { LanguagesEnum } from '@renderer/config/translate'
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings'
@@ -27,7 +26,7 @@ import { useAppDispatch } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import type { FileMetadata, Painting } from '@renderer/types'
import { getErrorMessage, uuid } from '@renderer/utils'
import { Avatar, Button, Input, InputNumber, Radio, Select, Slider, Switch, Tooltip } from 'antd'
import { Button, Input, InputNumber, Radio, Select, Slider, Switch, Tooltip } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { Info } from 'lucide-react'
import type { FC } from 'react'
@@ -389,16 +388,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
<ContentContainer id="content-container">
<LeftContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<Select value={providerOptions[2].value} onChange={handleProviderChange}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
<Select value={providerOptions[2].value} onChange={handleProviderChange} options={providerOptions} />
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('common.model')}</SettingTitle>
<Select value={painting.model} options={modelOptions} onChange={onSelectModel} />
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>{t('paintings.image.size')}</SettingTitle>
@@ -672,14 +662,4 @@ const StyledInputNumber = styled(InputNumber)`
width: 70px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const ProviderLogo = styled(Avatar)`
flex-shrink: 0;
`
export default SiliconPage

View File

@@ -1,21 +1,18 @@
import { GithubOutlined } from '@ant-design/icons'
import { useDisclosure } from '@heroui/react'
import IndicatorLight from '@renderer/components/IndicatorLight'
import { HStack } from '@renderer/components/Layout'
import UpdateDialog from '@renderer/components/UpdateDialog'
import { APP_NAME, AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { useAppDispatch } from '@renderer/store'
import { handleSaveData, useAppDispatch } from '@renderer/store'
import { setUpdateState } from '@renderer/store/runtime'
import { ThemeMode } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { UpgradeChannel } from '@shared/config/constant'
import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd'
import { UpdateInfo } from 'builder-util-runtime'
import { debounce } from 'lodash'
import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react'
import { BadgeQuestionMark } from 'lucide-react'
@@ -30,8 +27,6 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitl
const AboutSettings: FC = () => {
const [version, setVersion] = useState('')
const [isPortable, setIsPortable] = useState(false)
const [updateDialogInfo, setUpdateDialogInfo] = useState<UpdateInfo | null>(null)
const { isOpen, onOpen, onClose } = useDisclosure()
const { t } = useTranslation()
const { autoCheckUpdate, setAutoCheckUpdate, testPlan, setTestPlan, testChannel, setTestChannel } = useSettings()
const { theme } = useTheme()
@@ -46,9 +41,8 @@ const AboutSettings: FC = () => {
}
if (update.downloaded) {
// Open update dialog directly in renderer
setUpdateDialogInfo(update.info || null)
onOpen()
await handleSaveData()
window.api.showUpdateDialog()
return
}
@@ -347,9 +341,6 @@ const AboutSettings: FC = () => {
<Button onClick={debug}>{t('settings.about.debug.open')}</Button>
</SettingRow>
</SettingGroup>
{/* Update Dialog */}
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={updateDialogInfo} />
</SettingContainer>
)
}

View File

@@ -71,12 +71,12 @@ export const AccessibleDirsSetting: React.FC<AccessibleDirsSettingProps> = ({ ba
}>
{t('agent.session.accessible_paths.label')}
</SettingsTitle>
<ul className="flex flex-col gap-2">
<ul className="mt-2 flex flex-col gap-2 rounded-xl border p-2">
{base.accessible_paths.map((path) => (
<li
key={path}
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-2 py-1">
<span className="w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm" title={path}>
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-3 py-2">
<span className="truncate text-sm" title={path}>
{path}
</span>
<Button size="sm" variant="light" color="danger" onPress={() => removeAccessiblePath(path)}>

View File

@@ -18,10 +18,9 @@ import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
interface AgentEssentialSettingsProps {
agent: GetAgentResponse | undefined | null
update: ReturnType<typeof useUpdateAgent>['updateAgent']
showModelSetting?: boolean
}
const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update, showModelSetting = true }) => {
const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update }) => {
const { t } = useTranslation()
if (!agent) return null
@@ -37,7 +36,7 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
</SettingsItem>
<AvatarSetting agent={agent} update={update} />
<NameSetting base={agent} update={update} />
{showModelSetting && <ModelSetting base={agent} update={update} />}
<ModelSetting base={agent} update={update} />
<AccessibleDirsSetting base={agent} update={update} />
<DescriptionSetting base={agent} update={update} />
</SettingsContainer>

View File

@@ -25,7 +25,6 @@ export const NameSetting: React.FC<NameSettingsProps> = ({ base, update }) => {
<Input
placeholder={t('common.agent_one') + t('common.name')}
value={name}
size="sm"
onValueChange={(value) => setName(value)}
onBlur={() => {
if (name !== base.name) {

View File

@@ -172,22 +172,15 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
const onUpdateApiVersion = () => updateProvider({ apiVersion })
const openApiKeyList = async () => {
if (localApiKey !== provider.apiKey) {
updateProvider({ apiKey: formatApiKeys(localApiKey) })
await new Promise((resolve) => setTimeout(resolve, 0))
}
await ApiKeyListPopup.show({
providerId: provider.id,
title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}`,
providerType: 'llm'
title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}`
})
}
const onCheckApi = async () => {
const formattedLocalKey = formatApiKeys(localApiKey)
// 如果存在多个密钥,直接打开管理窗口
if (formattedLocalKey.includes(',')) {
if (provider.apiKey.includes(',')) {
await openApiKeyList()
return
}
@@ -211,7 +204,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
try {
setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: HealthStatus.NOT_CHECKED }))
await checkApi({ ...provider, apiHost, apiKey: formattedLocalKey }, model)
await checkApi({ ...provider, apiHost }, model)
window.toast.success({
timeout: 2000,

View File

@@ -1,5 +1,5 @@
import { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import { ProviderSpecificParams, WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
export default abstract class BaseWebSearchProvider {
// @ts-ignore this
@@ -16,7 +16,8 @@ export default abstract class BaseWebSearchProvider {
abstract search(
query: string,
websearch: WebSearchState,
httpOptions?: RequestInit
httpOptions?: RequestInit,
providerParams?: ProviderSpecificParams
): Promise<WebSearchProviderResponse>
public getApiHost() {

Some files were not shown because too many files have changed in this diff Show More