diff --git a/.github/workflows/build-windows-x64.yml b/.github/workflows/build-windows-x64.yml new file mode 100644 index 000000000..062f5a37f --- /dev/null +++ b/.github/workflows/build-windows-x64.yml @@ -0,0 +1,97 @@ +name: Build Windows x64 + +permissions: + contents: read + actions: read + +on: + push: + branches: + - main + paths: + - 'app/**' + - 'packages/**' + - '.github/workflows/build-windows-x64.yml' + workflow_dispatch: + inputs: + app_version: + type: string + description: 'App Version (optional)' + required: false + default: '0.0.1' + +jobs: + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Cache Rust + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + packages/backend + packages/backend-server + key: ${{ runner.os }}-rust-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo-about binary + id: cache-cargo-about + uses: actions/cache@v4 + with: + path: ~/.cargo/bin/cargo-about + key: ${{ runner.os }}-cargo-about-0.8.2 + + - name: Install cargo-about + if: steps.cache-cargo-about.outputs.cache-hit != 'true' + run: cargo install cargo-about --version 0.8.2 + + - name: Install dependencies + run: | + yarn config set network-timeout 300000 + yarn install --frozen-lockfile --network-timeout 300000 + + - name: Build Windows x64 + run: yarn build:desktop:win:x64 + env: + APP_VERSION: ${{ inputs.app_version || '0.0.1' }} + M_VITE_APP_VERSION: ${{ inputs.app_version || '0.0.1' }} + P_VITE_APP_VERSION: ${{ inputs.app_version || '0.0.1' }} + R_VITE_APP_VERSION: ${{ inputs.app_version || '0.0.1' }} + + HUSKY: 0 + NODE_OPTIONS: --max_old_space_size=8192 + + PRODUCT_NAME: 'Surf' + M_VITE_PRODUCT_NAME: 'Surf' + BUILD_RESOURCES_DIR: build/resources/prod + + - name: Upload Windows Installer + uses: actions/upload-artifact@v4 + with: + name: surf-windows-x64-installer + path: | + app/dist/*.exe + app/dist/*.yml + retention-days: 30 + + - name: Output download info + run: | + echo "::notice::构建完成!请到 Actions 页面下载构建产物" + echo "::notice::Artifact 名称: surf-windows-x64-installer" + echo "::notice::保留时间: 30 天" diff --git a/BOCHA_SEARCH_API_CONFIG.md b/BOCHA_SEARCH_API_CONFIG.md new file mode 100644 index 000000000..485271b52 --- /dev/null +++ b/BOCHA_SEARCH_API_CONFIG.md @@ -0,0 +1,96 @@ +# 博查 Web Search API 配置指南 + +## 概述 + +本系统已添加对博查 Web Search API 的支持,该 API 兼容 Bing Search API 格式。 + +## API 配置 + +### 1. 搜索引擎选择 + +在 `WebSearch.svelte` 组件中,可以通过修改 `searchEngineConfig` 来切换搜索引擎: + +```typescript +const searchEngineConfig: SearchEngineConfig = { + engine: 'duckduckgo', // 可改为 'bocha' + apiKey: undefined +} +``` + +### 2. 使用博查 API + +要启用博查搜索,将配置修改为: + +```typescript +const searchEngineConfig: SearchEngineConfig = { + engine: 'bocha', + apiKey: 'YOUR_BOCHA_API_KEY' // 替换为实际的 API Key +} +``` + +### 3. 自定义配置 + +博查 API 支持以下配置选项: + +- `apiKey`: 博查 API 密钥(必需) +- `baseUrl`: API 端点地址(默认:`https://api.bochaai.com/api/v1/web-search`) +- `country`: 国家/地区代码(默认:`CN`) +- `language`: 语言代码(默认:`zh-CN`) + +完整配置示例: + +```typescript +const bochaConfig: BochaSearchConfig = { + apiKey: 'YOUR_BOCHA_API_KEY', + baseUrl: 'https://api.bochaai.com/api/v1/web-search', + country: 'CN', + language: 'zh-CN' +} + +const searchAPI = new BochaSearchAPI(bochaConfig) +``` + +## API 返回格式 + +博查 API 返回的数据格式应如下: + +```json +{ + "data": { + "webPages": { + "value": [ + { + "name": "结果标题", + "url": "https://example.com", + "snippet": "结果摘要" + } + ] + } + } +} +``` + +或者兼容格式: + +```json +{ + "results": [ + { + "title": "结果标题", + "url": "https://example.com" + } + ] +} +``` + +## 实现文件 + +- `/workspace/packages/web-parser/src/search/bocha.ts` - 博查 API 实现 +- `/workspace/packages/web-parser/src/search/index.ts` - 导出配置 +- `/workspace/app/src/renderer/Resource/components/WebSearch.svelte` - 搜索引擎使用 + +## 注意事项 + +1. 需要有效的博查 API 密钥才能使用 +2. API 密钥应通过安全的方式存储和传递,不要硬编码在源代码中 +3. 建议在生产环境中使用环境变量或配置文件管理 API 密钥 diff --git a/app/electron.vite.config.ts b/app/electron.vite.config.ts index d65ca9078..9d8c30e61 100644 --- a/app/electron.vite.config.ts +++ b/app/electron.vite.config.ts @@ -8,9 +8,36 @@ import { esbuildConsolidatePreloads } from './plugins/merge-chunks' import { nodePolyfills } from 'vite-plugin-node-polyfills' import { createConcatLicensesPlugin, createLicensePlugin } from './plugins/license' import { createRustLicensePlugin } from './plugins/rust-license' +import { copyFileSync, mkdirSync, existsSync } from 'fs' +import { join } from 'path' const IS_DEV = process.env.NODE_ENV === 'development' +// Plugin to copy i18n files to output directory +function copyI18nPlugin() { + return { + name: 'copy-i18n', + closeBundle() { + const srcDir = resolve(__dirname, 'src/main/i18n') + const destDir = resolve(__dirname, 'out/main/i18n') + + if (existsSync(srcDir)) { + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }) + } + + const files = ['en-US.json', 'zh-CN.json'] + for (const file of files) { + const srcFile = join(srcDir, file) + const destFile = join(destDir, file) + copyFileSync(srcFile, destFile) + } + console.log('Copied i18n files to out/main/i18n/') + } + } + } +} + // TODO: actually fix the warnings in the code const silenceWarnings = IS_DEV || process.env.SILENCE_WARNINGS === 'true' @@ -40,7 +67,7 @@ const cssConfig = silenceWarnings export default defineConfig({ main: { envPrefix: 'M_VITE_', - plugins: [externalizeDepsPlugin(), createLicensePlugin('main')], + plugins: [externalizeDepsPlugin(), createLicensePlugin('main'), copyI18nPlugin()], build: { rollupOptions: { input: { diff --git a/app/src/main/appMenu.ts b/app/src/main/appMenu.ts index d14f7737b..6edae91a2 100644 --- a/app/src/main/appMenu.ts +++ b/app/src/main/appMenu.ts @@ -9,6 +9,7 @@ import { execFile } from 'child_process' import { promisify } from 'util' import { importFiles } from './importer' import { useLogScope } from '@deta/utils' +import { t, getLocale } from './i18n' const log = useLogScope('Main App Menu') const execFileAsync = promisify(execFile) @@ -107,7 +108,7 @@ class AppMenu { private createDataLocationMenuItem(): MenuConfig { const userDataPath = app.getPath('userData') const surfDataPath = join(userDataPath, 'sffs_backend') - const label = isMac() ? 'Show Surf Data in Finder' : 'Show Surf Data in File Manager' + const label = isMac() ? t('menu.showSurfDataInFinder') : t('menu.showSurfDataInFileManager') return { label, @@ -118,10 +119,10 @@ class AppMenu { private getSurfMenu(isMacApp = isMac()): MenuConfig { const surfItems = [ ...(isMacApp - ? ([{ label: 'About Surf', role: 'about' }, { type: 'separator' }] as MenuConfig[]) + ? ([{ label: t('menu.aboutSurf'), role: 'about' }, { type: 'separator' }] as MenuConfig[]) : []), { - label: 'Preferences', + label: t('menu.preferences'), accelerator: 'CmdOrCtrl+,', click: () => createSettingsWindow() }, @@ -134,43 +135,43 @@ class AppMenu { ...(isMacApp ? [ { type: 'separator' }, - { role: 'services', label: 'Services' }, + { role: 'services', label: t('menu.services') }, { type: 'separator' }, { - label: 'Hide Surf', + label: t('menu.hideSurf'), accelerator: 'CmdOrCtrl+H', role: 'hide' }, { - label: 'Hide Others', + label: t('menu.hideOthers'), accelerator: 'CmdOrCtrl+Shift+H', role: 'hideOthers' }, - { label: 'Show All', role: 'unhide' } + { label: t('menu.showAll'), role: 'unhide' } ] : []), { type: 'separator' }, - { label: 'Quit Surf', role: 'quit' } + { label: t('menu.quitSurf'), role: 'quit' } ] return { - label: isMacApp ? app.name : 'Surf', + label: isMacApp ? app.name : t('menu.surf'), submenu: surfItems as MenuConfig[] } } private getFileMenu(): MenuConfig { return { - label: 'File', + label: t('menu.file'), submenu: [ ...(isMac() ? ([{ role: 'close', accelerator: 'CmdOrCtrl+Shift+W' }] as MenuConfig[]) : []), { - label: 'New Tab', + label: t('menu.newTab'), accelerator: 'CmdOrCtrl+T', click: () => ipcSenders.createNewTab() }, { - label: 'Close Tab', + label: t('menu.closeTab'), accelerator: 'CmdOrCtrl+W', click: () => ipcSenders.closeActiveTab() }, @@ -182,7 +183,7 @@ class AppMenu { // }, { id: 'importFiles', - label: 'Import Files', + label: t('menu.importFiles'), click: () => importFiles() }, // { @@ -199,7 +200,7 @@ class AppMenu { private getEditMenu(): MenuConfig { return { - label: 'Edit', + label: t('menu.edit'), submenu: [ { role: 'cut' }, { role: 'copy' }, @@ -208,7 +209,7 @@ class AppMenu { { role: 'selectAll' }, { type: 'separator' }, { - label: 'Copy URL', + label: t('menu.copyURL'), accelerator: 'CmdOrCtrl+Shift+C', click: () => ipcSenders.copyActiveTabURL() } @@ -218,11 +219,11 @@ class AppMenu { private getViewMenu(): MenuConfig { return { - label: 'View', + label: t('menu.view'), submenu: [ { id: 'showTabsInSidebar', - label: 'Show Tabs in Sidebar', + label: t('menu.showTabsInSidebar'), type: 'checkbox', accelerator: 'CmdOrCtrl+O', click: () => ipcSenders.toggleTabsPosition() @@ -230,7 +231,7 @@ class AppMenu { { type: 'separator' }, { role: 'togglefullscreen' }, { - label: 'Toggle Developer Tools', + label: t('menu.toggleDevTools'), accelerator: isMac() ? 'Cmd+Option+I' : 'Ctrl+Shift+I', click: () => ipcSenders.openDevTools() } @@ -240,7 +241,7 @@ class AppMenu { private getNavigateMenu(): MenuConfig { return { - label: 'Navigate', + label: t('menu.navigate'), submenu: [ // { // label: 'My Stuff', @@ -254,12 +255,12 @@ class AppMenu { // }, // { type: 'separator' }, { - label: 'Reload Tab', + label: t('menu.reloadTab'), accelerator: 'CmdOrCtrl+R', click: () => ipcSenders.reloadActiveTab() }, { - label: 'Force Reload Tab', + label: t('menu.forceReloadTab'), accelerator: 'CmdOrCtrl+Shift+R', click: () => ipcSenders.reloadActiveTab(true) } @@ -269,28 +270,28 @@ class AppMenu { private getToolsMenu(): MenuConfig { return { - label: 'Tools', + label: t('menu.tools'), submenu: [ { id: 'adblocker', // this will automatically change to the correct label on startup // based on the previous stored state when the adblocker is initialized - label: 'Enable Adblocker', + label: t('menu.enableAdblocker'), click: () => toggleAdblocker('persist:horizon') }, { type: 'separator' }, { - label: 'Reload App', + label: t('menu.reloadApp'), role: 'reload', accelerator: 'CmdOrCtrl+Alt+R' }, { - label: 'Force Reload App', + label: t('menu.forceReloadApp'), role: 'forceReload', accelerator: 'CmdOrCtrl+Alt+Shift+R' }, { - label: 'Toggle Developer Tools for Surf', + label: t('menu.toggleDevToolsForSurf'), accelerator: isMac() ? 'Cmd+Shift+I' : 'Option+Shift+I', role: 'toggleDevTools' } @@ -300,7 +301,7 @@ class AppMenu { private getWindowMenu(): MenuConfig { return { - label: 'Window', + label: t('menu.window'), submenu: [ { role: 'minimize' }, { role: 'zoom' }, @@ -368,6 +369,10 @@ export const updateTabOrientationMenuItem = (): void => { appMenu?.updateTabOrientationMenuItem() } +export const rebuildAppMenu = (): void => { + appMenu?.buildMenu() +} + const checkForChangeWithTimeout = async ( checkFn: () => Promise, interval: number, diff --git a/app/src/main/config.ts b/app/src/main/config.ts index a29409a41..d3d06af0f 100644 --- a/app/src/main/config.ts +++ b/app/src/main/config.ts @@ -4,6 +4,7 @@ import path from 'path' import { type UserConfig } from '@deta/types' import { BUILT_IN_MODELS, BuiltInModelIDs, DEFAULT_AI_MODEL } from '@deta/types/src/ai.types' import { useLogScope } from '@deta/utils' +import { type SupportedLocale, DEFAULT_LOCALE, initI18n } from './i18n' const log = useLogScope('Config') @@ -81,6 +82,7 @@ export const getUserConfig = (path?: string) => { embedding_model: 'multilingual_small', tabs_orientation: 'vertical', app_style: 'light', + language: DEFAULT_LOCALE, use_semantic_search: false, save_to_user_downloads: true, automatic_chat_prompt_generation: true, @@ -126,6 +128,7 @@ export const getUserConfig = (path?: string) => { enable_custom_prompts: true } setUserConfig(storedConfig as UserConfig) + initI18n(DEFAULT_LOCALE) } let changedConfig = false @@ -259,6 +262,14 @@ export const getUserConfig = (path?: string) => { changedConfig = true } + if (storedConfig.settings.language === undefined) { + storedConfig.settings.language = DEFAULT_LOCALE + changedConfig = true + } + + // Initialize i18n with stored locale + initI18n(storedConfig.settings.language as SupportedLocale) + // "Migration" for late april settings cleanup if (storedConfig.settings.show_annotations_in_oasis === undefined) { storedConfig.settings.show_annotations_in_oasis = false diff --git a/app/src/main/i18n/en-US.json b/app/src/main/i18n/en-US.json new file mode 100644 index 000000000..0141ecaf7 --- /dev/null +++ b/app/src/main/i18n/en-US.json @@ -0,0 +1,65 @@ +{ + "menu": { + "surf": "Surf", + "aboutSurf": "About Surf", + "preferences": "Preferences", + "showSurfDataInFinder": "Show Surf Data in Finder", + "showSurfDataInFileManager": "Show Surf Data in File Manager", + "services": "Services", + "hideSurf": "Hide Surf", + "hideOthers": "Hide Others", + "showAll": "Show All", + "quitSurf": "Quit Surf", + "file": "File", + "newTab": "New Tab", + "closeTab": "Close Tab", + "importFiles": "Import Files", + "edit": "Edit", + "copyURL": "Copy URL", + "view": "View", + "showTabsInSidebar": "Show Tabs in Sidebar", + "toggleFullscreen": "Toggle Full Screen", + "toggleDevTools": "Toggle Developer Tools", + "navigate": "Navigate", + "reloadTab": "Reload Tab", + "forceReloadTab": "Force Reload Tab", + "tools": "Tools", + "enableAdblocker": "Enable Adblocker", + "disableAdblocker": "Disable Adblocker", + "reloadApp": "Reload App", + "forceReloadApp": "Force Reload App", + "toggleDevToolsForSurf": "Toggle Developer Tools for Surf", + "window": "Window", + "minimize": "Minimize", + "zoom": "Zoom", + "bringAllToFront": "Bring All to Front", + "help": "Help", + "openCheatSheet": "Open Cheat Sheet", + "openChangelog": "Open Changelog", + "giveFeedback": "Give Feedback", + "keyboardShortcuts": "Keyboard Shortcuts" + }, + "settings": { + "general": "General", + "ai": "AI", + "appearance": "Appearance", + "advanced": "Advanced", + "darkMode": "Dark Mode", + "darkModeDescription": "Enable dark appearance for the application.", + "language": "Language", + "languageDescription": "Select the display language for the application.", + "english": "English", + "chinese": "中文 (Chinese)", + "searchEngine": "Search Engine", + "teletypeDefaultAction": "Teletype Default Action", + "openSourceLicenses": "Open Source Licenses And Notices", + "miscellaneous": "Miscellaneous" + }, + "common": { + "cancel": "Cancel", + "accept": "Accept", + "restart": "Restart", + "restartRequired": "Restart Required", + "restartMessage": "The application needs to restart to apply the language change." + } +} diff --git a/app/src/main/i18n/index.ts b/app/src/main/i18n/index.ts new file mode 100644 index 000000000..59582963e --- /dev/null +++ b/app/src/main/i18n/index.ts @@ -0,0 +1,88 @@ +import fs from 'fs' +import path from 'path' +import { app } from 'electron' +import { useLogScope } from '@deta/utils' + +const log = useLogScope('i18n') + +export type SupportedLocale = 'en-US' | 'zh-CN' + +export const DEFAULT_LOCALE: SupportedLocale = 'en-US' + +const SUPPORTED_LOCALES: SupportedLocale[] = ['en-US', 'zh-CN'] + +let translations: Record = {} +let currentLocale: SupportedLocale = DEFAULT_LOCALE + +export function setLocale(locale: SupportedLocale): void { + if (SUPPORTED_LOCALES.includes(locale)) { + currentLocale = locale + loadTranslations(locale) + log.info(`Locale set to: ${locale}`) + } else { + log.warn(`Unsupported locale: ${locale}, falling back to ${DEFAULT_LOCALE}`) + currentLocale = DEFAULT_LOCALE + loadTranslations(DEFAULT_LOCALE) + } +} + +export function getLocale(): SupportedLocale { + return currentLocale +} + +export function t(key: string): string { + const keys = key.split('.') + let value: any = translations + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = value[k] + } else { + log.warn(`Translation key not found: ${key}`) + return key + } + } + + return typeof value === 'string' ? value : key +} + +function loadTranslations(locale: SupportedLocale): void { + try { + const translationsPath = path.join(__dirname, 'i18n') + const translationFile = path.join(translationsPath, `${locale}.json`) + + if (fs.existsSync(translationFile)) { + const raw = fs.readFileSync(translationFile, 'utf8') + translations = JSON.parse(raw) + log.info(`Loaded translations for: ${locale}`) + } else { + log.warn(`Translation file not found: ${translationFile}`) + translations = {} + } + } catch (error) { + log.error('Error loading translations:', error) + translations = {} + } +} + +export function initI18n(locale?: SupportedLocale): void { + const targetLocale = locale || detectSystemLocale() + setLocale(targetLocale) +} + +function detectSystemLocale(): SupportedLocale { + const appLocale = app.getLocale() + + if (appLocale.startsWith('zh')) { + return 'zh-CN' + } + + return DEFAULT_LOCALE +} + +export function getAvailableLocales(): { code: SupportedLocale; name: string }[] { + return [ + { code: 'en-US', name: 'English' }, + { code: 'zh-CN', name: '中文 (Chinese)' } + ] +} diff --git a/app/src/main/i18n/zh-CN.json b/app/src/main/i18n/zh-CN.json new file mode 100644 index 000000000..1e6ad2f8c --- /dev/null +++ b/app/src/main/i18n/zh-CN.json @@ -0,0 +1,65 @@ +{ + "menu": { + "surf": "Surf", + "aboutSurf": "关于 Surf", + "preferences": "偏好设置", + "showSurfDataInFinder": "在访达中显示 Surf 数据", + "showSurfDataInFileManager": "在文件管理器中显示 Surf 数据", + "services": "服务", + "hideSurf": "隐藏 Surf", + "hideOthers": "隐藏其他", + "showAll": "显示全部", + "quitSurf": "退出 Surf", + "file": "文件", + "newTab": "新建标签页", + "closeTab": "关闭标签页", + "importFiles": "导入文件", + "edit": "编辑", + "copyURL": "复制链接", + "view": "显示", + "showTabsInSidebar": "在侧边栏显示标签页", + "toggleFullscreen": "切换全屏", + "toggleDevTools": "切换开发者工具", + "navigate": "导航", + "reloadTab": "重新加载标签页", + "forceReloadTab": "强制重新加载标签页", + "tools": "工具", + "enableAdblocker": "启用广告拦截", + "disableAdblocker": "禁用广告拦截", + "reloadApp": "重新加载应用", + "forceReloadApp": "强制重新加载应用", + "toggleDevToolsForSurf": "切换 Surf 的开发者工具", + "window": "窗口", + "minimize": "最小化", + "zoom": "缩放", + "bringAllToFront": "全部置于顶层", + "help": "帮助", + "openCheatSheet": "打开速查表", + "openChangelog": "打开更新日志", + "giveFeedback": "提供反馈", + "keyboardShortcuts": "键盘快捷键" + }, + "settings": { + "general": "通用", + "ai": "AI", + "appearance": "外观", + "advanced": "高级", + "darkMode": "深色模式", + "darkModeDescription": "启用应用程序的深色外观。", + "language": "语言", + "languageDescription": "选择应用程序的显示语言。", + "english": "English (英文)", + "chinese": "中文 (Chinese)", + "searchEngine": "搜索引擎", + "teletypeDefaultAction": "Teletype 默认操作", + "openSourceLicenses": "开源许可证和声明", + "miscellaneous": "其他" + }, + "common": { + "cancel": "取消", + "accept": "接受", + "restart": "重启", + "restartRequired": "需要重启", + "restartMessage": "应用程序需要重启以应用语言更改。" + } +} diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 90d9c2ffd..810ac5c3a 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -18,6 +18,7 @@ import { CrashHandler } from './crashHandler' import { surfProtocolExternalURLHandler } from './surfProtocolHandlers' import { useLogScope } from '@deta/utils' import { initializeSFFSMain } from './sffs' +import { initI18n, getLocale } from './i18n' const log = useLogScope('Main') @@ -206,6 +207,11 @@ const initializeApp = async () => { markAppAsSetup() await setupAdblocker() + + // Initialize i18n before setting app menu + const userConfig = getUserConfig() + initI18n(userConfig.settings.language as any) + setAppMenu() createWindow() diff --git a/app/src/main/ipcHandlers.ts b/app/src/main/ipcHandlers.ts index 87004e4a3..fea43f5c7 100644 --- a/app/src/main/ipcHandlers.ts +++ b/app/src/main/ipcHandlers.ts @@ -13,8 +13,9 @@ import { UserSettings } from '@deta/types' import { getPlatform, isPathSafe, isDefaultBrowser } from './utils' -import { updateTabOrientationMenuItem } from './appMenu' +import { updateTabOrientationMenuItem, rebuildAppMenu } from './appMenu' import { createSettingsWindow, getSettingsWindow } from './settingsWindow' +import { setLocale, type SupportedLocale } from './i18n' import { IPC_EVENTS_MAIN, NewWindowRequest } from '@deta/services/ipc' import { exportResource, openResourceAsFile } from './downloadManager' @@ -216,6 +217,12 @@ function setupIpcHandlers(backendRootPath: string) { updateTabOrientationMenuItem() } + // Rebuild menu if language changed + if (settings.language) { + setLocale(settings.language as SupportedLocale) + rebuildAppMenu() + } + // notify other windows of the change ipcSenders.userConfigSettingsChange(updatedSettings) }) diff --git a/app/src/renderer/Resource/components/WebSearch.svelte b/app/src/renderer/Resource/components/WebSearch.svelte index 59731b6b7..cc07a661d 100644 --- a/app/src/renderer/Resource/components/WebSearch.svelte +++ b/app/src/renderer/Resource/components/WebSearch.svelte @@ -3,7 +3,7 @@ import { writable, type Writable } from 'svelte/store' import { useLogScope } from '@deta/utils/io' import { getHostname } from '@deta/utils' - import { DuckDuckGoAPI } from '@deta/web-parser' + import { DuckDuckGoAPI, BochaSearchAPI, type BochaSearchConfig } from '@deta/web-parser' import HeadlessCitationItem from './HeadlessCitationItem.svelte' import { Icon } from '@deta/icons' import type { LinkClickHandler } from '@deta/editor/src/lib/extensions/Link/helpers/clickHandler' @@ -26,7 +26,33 @@ export let limit: number = 5 const log = useLogScope('WebSearch Component') - const searchAPI = new DuckDuckGoAPI() + + // 搜索引擎配置 - 默认使用 DuckDuckGo,可配置为博查 API + interface SearchEngineConfig { + engine: 'duckduckgo' | 'bocha' + apiKey?: string + } + + // 从环境变量或配置中读取搜索引擎设置 + const searchEngineConfig: SearchEngineConfig = { + engine: 'duckduckgo', // 默认使用 DuckDuckGo + apiKey: undefined + } + + // 根据配置初始化搜索引擎 + let searchAPI: DuckDuckGoAPI | BochaSearchAPI + + if (searchEngineConfig.engine === 'bocha') { + const bochaConfig: BochaSearchConfig = { + apiKey: searchEngineConfig.apiKey || '', + baseUrl: 'https://api.bochaai.com/api/v1/web-search', + country: 'CN', + language: 'zh-CN' + } + searchAPI = new BochaSearchAPI(bochaConfig) + } else { + searchAPI = new DuckDuckGoAPI() + } type ErrorType = 'search_error' | 'initialization' | 'network' diff --git a/app/src/renderer/Settings/Settings.svelte b/app/src/renderer/Settings/Settings.svelte index 7de4b6479..535ec2696 100644 --- a/app/src/renderer/Settings/Settings.svelte +++ b/app/src/renderer/Settings/Settings.svelte @@ -14,6 +14,7 @@ } from '@deta/types' import SettingsOption from './components/SettingsOption.svelte' import DefaultSearchEnginePicker from './components/DefaultSearchEnginePicker.svelte' + import LanguagePicker from './components/LanguagePicker.svelte' import TeletypeDefaultActionPicker from './components/TeletypeDefaultActionPicker.svelte' import AppStylePicker from './components/AppStylePicker.svelte' import ModelSettings, { type ModelUpdate } from './components/ModelSettings.svelte' @@ -303,17 +304,24 @@

{#if userConfigSettings} -
- -
+
+ +
+ +
+ handleSettingsUpdate()} + /> +
-
+
handleSettingsUpdate()} @@ -784,7 +792,8 @@ .dev-wrapper, .search-wrapper, .teletype-wrapper, - .dark-mode-wrapper { + .dark-mode-wrapper, + .language-wrapper { width: 100%; display: flex; align-items: center; diff --git a/app/src/renderer/Settings/components/LanguagePicker.svelte b/app/src/renderer/Settings/components/LanguagePicker.svelte new file mode 100644 index 000000000..408c4eff8 --- /dev/null +++ b/app/src/renderer/Settings/components/LanguagePicker.svelte @@ -0,0 +1,63 @@ + + +
+
+

Language

+

Select the display language for the application.

+
+ +
+ + diff --git a/packages/types/src/config.types.ts b/packages/types/src/config.types.ts index 9e2aaa309..f02f3ba45 100644 --- a/packages/types/src/config.types.ts +++ b/packages/types/src/config.types.ts @@ -11,6 +11,7 @@ export type UserSettings = { embedding_model: 'english_small' | 'english_large' | 'multilingual_small' | 'multilingual_large' tabs_orientation: 'vertical' | 'horizontal' app_style: 'light' | 'dark' // Note intentionally used app_style as "app_theme" would be themes in the future? + language: 'en-US' | 'zh-CN' use_semantic_search: boolean save_to_user_downloads: boolean automatic_chat_prompt_generation: boolean diff --git a/packages/web-parser/src/search/bocha.ts b/packages/web-parser/src/search/bocha.ts new file mode 100644 index 000000000..9d5abd05b --- /dev/null +++ b/packages/web-parser/src/search/bocha.ts @@ -0,0 +1,129 @@ +import { WebViewExtractor } from '../extractors' + +export type SearchResultLink = { + title: string + url: string +} + +export interface BochaSearchConfig { + apiKey: string + baseUrl?: string + country?: string + language?: string +} + +/** + * 博查 Web Search API - 兼容 Bing Search API + * 文档参考:https://docs.bochaai.com/ + */ +export class BochaSearchAPI { + private apiKey: string + private baseUrl: string + private country: string + private language: string + + constructor(config?: BochaSearchConfig) { + this.apiKey = config?.apiKey || '' + this.baseUrl = config?.baseUrl || 'https://api.bochaai.com/api/v1/web-search' + this.country = config?.country || 'CN' + this.language = config?.language || 'zh-CN' + } + + /** + * 调用博查搜索 API 进行搜索 + * @param query 搜索查询 + * @param limit 返回结果数量限制 + * @returns 搜索结果数组 + */ + async search(query: string, limit = 5): Promise { + const url = new URL(this.baseUrl) + url.searchParams.append('query', query) + url.searchParams.append('count', limit.toString()) + url.searchParams.append('country', this.country) + url.searchParams.append('language', this.language) + + try { + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify({ + query: query, + count: limit, + country: this.country, + language: this.language + }) + }) + + if (!response.ok) { + throw new Error(`Search API request failed with status: ${response.status}`) + } + + const data = await response.json() + + // 解析博查 API 返回的搜索结果 + const results: SearchResultLink[] = [] + + if (data.data && Array.isArray(data.data.webPages)) { + for (const page of data.data.webPages.value || data.data.webPages) { + results.push({ + title: page.name || page.title || '', + url: page.url || page.link || '' + }) + } + } else if (data.results && Array.isArray(data.results)) { + // 兼容不同的返回格式 + for (const item of data.results) { + results.push({ + title: item.title || item.name || '', + url: item.url || item.link || '' + }) + } + } + + return results.slice(0, limit) + } catch (error: any) { + console.error('Bocha Search API error:', error) + throw new Error(`Search failed: ${error.message}`) + } + } + + /** + * 使用 HTML 解析方式提取搜索结果(备用方案) + * @param html 搜索结果的 HTML 内容 + * @returns 搜索结果数组 + */ + async extractSearchResults(html: string): Promise { + const parser = new DOMParser() + const doc = parser.parseFromString(html, 'text/html') + + // 博查搜索结果的 CSS 选择器(根据实际情况调整) + const links = doc.querySelectorAll('a.result-title, a[data-title], h3 a') + + const results: SearchResultLink[] = [] + + links.forEach((link) => { + const title = link.textContent?.trim() || '' + const url = (link as HTMLAnchorElement).href || '' + + if (title && url) { + results.push({ title, url }) + } + }) + + return results + } + + /** + * 创建博查搜索 API 实例的工厂方法 + * @param config 配置选项 + * @returns BochaSearchAPI 实例 + */ + static createBochaSearchAPI(config?: BochaSearchConfig) { + return new BochaSearchAPI(config) + } +} + +export const createBochaSearchAPI = BochaSearchAPI.createBochaSearchAPI diff --git a/packages/web-parser/src/search/index.ts b/packages/web-parser/src/search/index.ts index e2c7e81fa..1f4d6487d 100644 --- a/packages/web-parser/src/search/index.ts +++ b/packages/web-parser/src/search/index.ts @@ -1,2 +1,3 @@ export * from './wikipedia' export * from './duckduckgo' +export * from './bocha'