From a3d6f1d0d120a064d76e1c3eee98bbf43cb993a9 Mon Sep 17 00:00:00 2001 From: "610037874@qq.com" <> Date: Tue, 31 Mar 2026 16:58:46 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E7=BB=99skills=E6=8F=90=E4=BE=9B=E4=BA=86?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=E5=8A=9F=E8=83=BD=20=E4=B8=AD=E5=9B=BD?= =?UTF-8?q?=E5=8F=8B=E5=A5=BD=EF=BC=9A=E4=BE=9D=E8=B5=96=20MyMemory=20API?= =?UTF-8?q?=EF=BC=8C=E4=BD=86=E6=98=AF=E4=BC=9A=E6=9C=89=E6=AF=8F=E6=97=A5?= =?UTF-8?q?500=E7=9A=84=E4=B8=8A=E9=99=90=20=E9=AB=98=E6=95=88=E7=BC=93?= =?UTF-8?q?=E5=AD=98=EF=BC=9A=EF=BC=88=E5=89=8D=E7=AB=AF=E5=86=85=E5=AD=98?= =?UTF-8?q?=20=E2=86=92=20=E5=90=8E=E7=AB=AF=E5=86=85=E5=AD=98=20=E2=86=92?= =?UTF-8?q?=20=E7=A3=81=E7=9B=98=E6=96=87=E4=BB=B6=EF=BC=89=E4=B8=89?= =?UTF-8?q?=E7=BA=A7=E7=BC=93=E5=AD=98=E9=81=BF=E5=85=8D=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=EF=BC=8C=E5=A4=A7=E5=B9=85=E5=87=8F=E5=B0=91?= =?UTF-8?q?=20API=20=E8=B0=83=E7=94=A8=20=E5=8F=AF=E9=9D=A0=E6=B8=85?= =?UTF-8?q?=E9=99=A4=EF=BC=9A=E4=B8=80=E9=94=AE=E6=B8=85=E9=99=A4=E6=89=80?= =?UTF-8?q?=E6=9C=89=E5=B1=82=E7=BA=A7=E7=BC=93=E5=AD=98=EF=BC=8CUI=20?= =?UTF-8?q?=E5=8D=B3=E6=97=B6=E5=93=8D=E5=BA=94=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=EF=BC=9A=E9=98=B2=E6=8A=96=E3=80=81=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=E5=B1=8F=E3=80=81=E5=8E=9F=E6=96=87=20Tooltip?= =?UTF-8?q?=E3=80=81=E5=8F=8C=E8=AF=AD=E8=A8=80=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 + electron/main/index.ts | 2 + electron/main/ipc-handlers.ts | 19 ++ electron/main/translation-service.ts | 377 +++++++++++++++++++++++++++ electron/preload/index.ts | 7 + src/components/TranslatedText.tsx | 170 ++++++++++++ src/hooks/useTranslation.ts | 189 ++++++++++++++ src/hooks/useTranslationSearch.ts | 108 ++++++++ src/pages/SkillsPage.tsx | 108 ++++++-- src/types/electron.d.ts | 10 + 10 files changed, 968 insertions(+), 25 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 electron/main/translation-service.ts create mode 100644 src/components/TranslatedText.tsx create mode 100644 src/hooks/useTranslation.ts create mode 100644 src/hooks/useTranslationSearch.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0491ef6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "Lingma.localMachineModel": false +} \ No newline at end of file diff --git a/electron/main/index.ts b/electron/main/index.ts index e7f0f77..0652aec 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -15,6 +15,7 @@ import { tryNormalizeProcessCwd } from './runtime-working-directory' import { revealWindow, showOrCreateWindow } from './window-lifecycle' import { reloadGatewayForConfigChange } from './gateway-lifecycle-controller' import { sanitizeNodeOptionsForElectron } from './node-options' +import { initTranslationService } from './translation-service' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const OPEN_CONTACT_MODAL_CHANNEL = 'app:open-contact-modal' @@ -265,6 +266,7 @@ function createTray() { } app.whenReady().then(() => { + initTranslationService() registerIpcHandlers() createWindow() createTray() diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index d6d29d4..bed9d34 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -208,6 +208,7 @@ import { } from './skills-uninstall-safety' import { removeManagedSkillLocally, removeSkillDirectoryLocally } from './skills-managed-uninstall' import { withExclusiveSkillMutation } from './skill-mutation-guard' +import { translateText, needsTranslation, containsChinese, clearTranslationCache } from './translation-service' const { randomUUID } = process.getBuiltinModule('node:crypto') as typeof import('node:crypto') const { appendFile, readFile, rm } = process.getBuiltinModule('node:fs/promises') as typeof import('node:fs/promises') @@ -1167,6 +1168,24 @@ export function registerIpcHandlers() { return { installed: usrLocalResult.ok } }) + // Translation service + ipcMain.handle('translate:text', async (_e, text: string, source?: string, target?: string) => { + return translateText({ text, source, target }) + }) + + ipcMain.handle('translate:needs', async (_e, text: string) => { + return needsTranslation(text) + }) + + ipcMain.handle('translate:contains-chinese', async (_e, text: string) => { + return containsChinese(text) + }) + + ipcMain.handle('translate:clear-cache', async () => { + clearTranslationCache() + return { ok: true } + }) + ipcMain.handle('deps:installBrew', async () => { // 先检查是否已安装(包括 /opt/homebrew/bin/brew) const checkResult = await runShell('brew', ['--version'], 10_000, 'env-setup') diff --git a/electron/main/translation-service.ts b/electron/main/translation-service.ts new file mode 100644 index 0000000..9f8ba77 --- /dev/null +++ b/electron/main/translation-service.ts @@ -0,0 +1,377 @@ +/** + * Translation service using MyMemory API + * MyMemory is free, requires no API key, works reliably in China, and provides complete translations + * https://mymemory.translated.net/doc/spec.php + */ + +import { app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' + +interface TranslationResult { + ok: boolean + translatedText: string + error?: string +} + +interface TranslateRequest { + text: string + source?: string + target?: string +} + +interface TranslationCacheData { + version: number + translations: Record +} + +// In-memory cache for translations +const translationCache = new Map() + +// Persistent cache file path +let cacheFilePath: string | null = null +let saveTimeout: NodeJS.Timeout | null = null +let isCacheLoaded = false + +const CACHE_VERSION = 1 +const SAVE_DELAY_MS = 2000 // Save after 2 seconds of inactivity + +// Request queue with rate limiting +interface QueuedRequest { + text: string + source: string + target: string + resolve: (result: TranslationResult) => void + reject: (error: Error) => void +} + +const requestQueue: QueuedRequest[] = [] +let isProcessingQueue = false +let lastRequestTime = 0 +const REQUEST_INTERVAL_MS = 500 // Wait 500ms between requests (2 requests/second) +const CONCURRENT_LIMIT = 3 // Max 3 concurrent requests + +/** + * Detect if text contains Chinese characters + */ +export function containsChinese(text: string): boolean { + return /[\u4e00-\u9fa5]/.test(text) +} + +/** + * Check if text needs translation (is English and not too short) + */ +export function needsTranslation(text: string): boolean { + if (!text || text.length < 2) return false + if (containsChinese(text)) return false + // Check if text is primarily English (letters, numbers, common punctuation) + const englishPattern = /^[A-Za-z0-9\s.,!?;:'"()\[\]{}@#$%^&*\-_+=<>\/\\|`~]+$/ + return englishPattern.test(text) +} + +/** + * Safe console.log that catches EPIPE errors + */ +function safeLog(message: string, ...args: any[]): void { + try { + console.log(message, ...args) + } catch { + // Ignore EPIPE errors when process is shutting down + } +} + +function safeError(message: string, ...args: any[]): void { + try { + console.error(message, ...args) + } catch { + // Ignore EPIPE errors when process is shutting down + } +} + +/** + * Get the cache file path + */ +function getCacheFilePath(): string { + if (!cacheFilePath) { + const userDataPath = app.getPath('userData') + cacheFilePath = path.join(userDataPath, 'translation-cache.json') + } + return cacheFilePath +} + +/** + * Load translation cache from disk + */ +export function loadTranslationCache(): void { + if (isCacheLoaded) return + + try { + const filePath = getCacheFilePath() + if (fs.existsSync(filePath)) { + const data = fs.readFileSync(filePath, 'utf-8') + const cacheData = JSON.parse(data) as TranslationCacheData + + if (cacheData.version === CACHE_VERSION && cacheData.translations) { + Object.entries(cacheData.translations).forEach(([key, value]) => { + translationCache.set(key, value) + }) + safeLog(`[Translation] Loaded ${translationCache.size} cached translations from disk`) + } + } + } catch (error) { + safeError('[Translation] Failed to load cache:', error) + } finally { + isCacheLoaded = true + } +} + +/** + * Clear translation cache (both memory and disk) + */ +export function clearTranslationCache(): void { + try { + // Clear in-memory cache + const size = translationCache.size + translationCache.clear() + + // Delete cache file + const filePath = getCacheFilePath() + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } + + safeLog(`[Translation] Cleared ${size} cached translations`) + } catch (error) { + safeError('[Translation] Failed to clear cache:', error) + } +} + +/** + * Save translation cache to disk (debounced) + */ +function saveCacheToDisk(): void { + if (saveTimeout) { + clearTimeout(saveTimeout) + } + + saveTimeout = setTimeout(() => { + try { + const filePath = getCacheFilePath() + const cacheData: TranslationCacheData = { + version: CACHE_VERSION, + translations: Object.fromEntries(translationCache), + } + fs.writeFileSync(filePath, JSON.stringify(cacheData, null, 2), 'utf-8') + safeLog(`[Translation] Saved ${translationCache.size} translations to disk`) + } catch (error) { + safeError('[Translation] Failed to save cache:', error) + } + }, SAVE_DELAY_MS) +} + +/** + * Initialize translation service - call on app ready + */ +export function initTranslationService(): void { + loadTranslationCache() + + // Save cache on app quit + app.on('will-quit', () => { + if (saveTimeout) { + clearTimeout(saveTimeout) + } + // Synchronous save on quit + try { + const filePath = getCacheFilePath() + const cacheData: TranslationCacheData = { + version: CACHE_VERSION, + translations: Object.fromEntries(translationCache), + } + fs.writeFileSync(filePath, JSON.stringify(cacheData), 'utf-8') + } catch { + // Ignore errors during quit + } + }) +} + +/** + * Translate using MyMemory API (free, no API key required) + * https://mymemory.translated.net/doc/spec.php + * This is the only translation service used as it works reliably in China + */ +async function translateWithMyMemory( + text: string, + source: string, + target: string +): Promise { + try { + const langPair = `${source}|${target}` + const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${langPair}` + + // Create abort controller for timeout + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 15000) // 15 second timeout (increased for slow networks) + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + return { ok: false, translatedText: text, error: `HTTP ${response.status}` } + } + + const data = (await response.json()) as { + responseStatus?: number + responseData?: { translatedText?: string } + matches?: Array<{ translation?: string; quality?: number }> + quotaFinished?: boolean + } + + // Check if quota is finished + if (data.quotaFinished) { + return { ok: false, translatedText: text, error: 'API quota exceeded' } + } + + // Check response status + if (data.responseStatus !== 200) { + return { ok: false, translatedText: text, error: 'Translation failed' } + } + + // Get the best translation + let translatedText = data.responseData?.translatedText + + // If we have matches, use the highest quality one + if (data.matches && data.matches.length > 0) { + const bestMatch = data.matches.reduce((best, current) => { + return (current.quality || 0) > (best.quality || 0) ? current : best + }) + if (bestMatch.translation) { + translatedText = bestMatch.translation + } + } + + if (translatedText) { + return { ok: true, translatedText } + } + + return { ok: false, translatedText: text, error: 'No translation returned' } + } catch (error) { + // Don't log errors during process shutdown to avoid EPIPE + const errorMessage = error instanceof Error ? error.message : 'Network error' + + // Ignore abort errors and return original text + if (errorMessage.includes('abort') || errorMessage.includes('EPIPE')) { + return { ok: false, translatedText: text, error: 'Request cancelled' } + } + + return { + ok: false, + translatedText: text, + error: errorMessage, + } + } +} + +/** + * Internal translation function without queue (used by queue processor) + * Uses MyMemory API only as it's reliable in China and provides complete translations + */ +async function doTranslateInternal(text: string, source: string, target: string): Promise { + // Check cache + const cacheKey = `${source}:${target}:${text}` + const cached = translationCache.get(cacheKey) + if (cached) { + return { ok: true, translatedText: cached } + } + + // Use MyMemory (works reliably in China, provides complete translations) + const result = await translateWithMyMemory(text, source, target) + if (result.ok && result.translatedText !== text) { + safeLog(`[Translation] Success: "${result.translatedText.substring(0, 50)}${result.translatedText.length > 50 ? '...' : ''}"`) + translationCache.set(cacheKey, result.translatedText) + saveCacheToDisk() + return result + } + + safeError(`[Translation] Failed:`, result.error) + + // Translation failed, return original text + return { + ok: false, + translatedText: text, + error: result.error || 'Translation service unavailable', + } +} + +/** + * Translate text using multiple translation APIs with fallback (with rate limiting) + */ +export async function translateText(request: TranslateRequest): Promise { + const { text, source = 'en', target = 'zh' } = request + + safeLog(`[Translation] Request: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}" (${source} → ${target})`) + + // Return original text if it contains Chinese + if (containsChinese(text)) { + safeLog(`[Translation] Already contains Chinese, skipping`) + return { ok: true, translatedText: text } + } + + // Check cache first + const cacheKey = `${source}:${target}:${text}` + const cached = translationCache.get(cacheKey) + if (cached) { + safeLog(`[Translation] Cache hit`) + return { ok: true, translatedText: cached } + } + + safeLog(`[Translation] Cache miss, queuing request...`) + + // Add to queue and wait for result + return new Promise((resolve, reject) => { + requestQueue.push({ + text, + source, + target, + resolve, + reject, + }) + + // Start processing queue + processQueue().catch((error) => { + safeError('[Translation] Queue processing error:', error) + }) + }) +} + +/** + * Batch translate multiple texts + */ +export async function translateBatch( + texts: string[], + options?: { source?: string; target?: string } +): Promise { + return Promise.all( + texts.map((text) => + translateText({ + text, + source: options?.source, + target: options?.target, + }) + ) + ) +} + +/** + * Get cache size + */ +export function getTranslationCacheSize(): number { + return translationCache.size +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 13f92b3..99e07f6 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -290,6 +290,13 @@ export const api = { onDepsInstallLog: (listener: (msg: string) => void) => subscribeToChannel('deps:install:log', listener), depsCheckBrew: () => ipcRenderer.invoke('deps:checkBrew'), depsInstallBrew: () => ipcRenderer.invoke('deps:installBrew'), + + // Translation + translateText: (text: string, source?: string, target?: string) => + ipcRenderer.invoke('translate:text', text, source, target), + needsTranslation: (text: string) => ipcRenderer.invoke('translate:needs', text), + containsChinese: (text: string) => ipcRenderer.invoke('translate:contains-chinese', text), + clearTranslationCache: () => ipcRenderer.invoke('translate:clear-cache'), } contextBridge.exposeInMainWorld('api', api) diff --git a/src/components/TranslatedText.tsx b/src/components/TranslatedText.tsx new file mode 100644 index 0000000..9d36fc2 --- /dev/null +++ b/src/components/TranslatedText.tsx @@ -0,0 +1,170 @@ +import { Text, Tooltip, Skeleton, Group, Badge } from '@mantine/core' +import { useTranslation } from '@/hooks/useTranslation' + +interface TranslatedTextProps { + /** Text to translate */ + text: string + /** Text size */ + size?: 'xs' | 'sm' | 'md' | 'lg' + /** Additional class name */ + className?: string + /** Additional style */ + style?: React.CSSProperties + /** Enable translation (default: true) */ + enableTranslation?: boolean + /** Show tooltip with original text when translated */ + showTooltip?: boolean + /** Show loading skeleton */ + showLoading?: boolean + /** Text color */ + c?: string + /** Font weight */ + fw?: number | string + /** Truncate text */ + truncate?: boolean | 'end' | 'start' + /** Show translation badge */ + showBadge?: boolean +} + +/** + * Component that displays translated text with optional tooltip showing original text + */ +export function TranslatedText({ + text, + size = 'sm', + className, + style, + enableTranslation = true, + showTooltip = true, + showLoading = true, + c, + fw, + truncate, + showBadge = false, +}: TranslatedTextProps) { + const { translated, loading, original, isTranslated } = useTranslation(text, { + enabled: enableTranslation, + }) + + // Show skeleton while loading + if (loading && showLoading) { + return + } + + const content = ( + + {translated} + {showBadge && isTranslated && ( + + 已译 + + )} + + ) + + // Show tooltip with original text if translated + if (showTooltip && isTranslated && translated !== original) { + return ( + + + 原文: + + {original} + + } + withArrow + multiline + maw={320} + > + {content} + + ) + } + + return content +} + +interface TranslatedTextWithLabelProps extends TranslatedTextProps { + /** Label to show before the text */ + label?: string + /** Label size */ + labelSize?: 'xs' | 'sm' | 'md' + /** Label color */ + labelC?: string + /** Label font weight */ + labelFw?: number | string +} + +/** + * Component that displays a label followed by translated text + */ +export function TranslatedTextWithLabel({ + text, + label, + labelSize = 'sm', + labelC = 'dimmed', + labelFw = 500, + size = 'sm', + enableTranslation = true, + showTooltip = true, + className, + style, +}: TranslatedTextWithLabelProps) { + return ( +
+ {label && ( + + {label} + + )} + +
+ ) +} + +/** + * Component that displays translated text with original in a collapsible section + */ +export function TranslatedTextWithOriginal({ + text, + size = 'sm', + enableTranslation = true, + className, + style, +}: TranslatedTextProps) { + const { translated, loading, original, isTranslated } = useTranslation(text, { + enabled: enableTranslation, + }) + + if (loading) { + return + } + + return ( +
+ {translated} + {isTranslated && translated !== original && ( + + 原文: {original} + + )} +
+ ) +} diff --git a/src/hooks/useTranslation.ts b/src/hooks/useTranslation.ts new file mode 100644 index 0000000..14207eb --- /dev/null +++ b/src/hooks/useTranslation.ts @@ -0,0 +1,189 @@ +import { useState, useEffect, useCallback, useRef } from 'react' + +interface TranslationResult { + ok: boolean + translatedText: string + error?: string +} + +interface UseTranslationOptions { + enabled?: boolean + source?: string + target?: string + debounceMs?: number +} + +interface UseTranslationReturn { + translated: string + loading: boolean + error: string | null + original: string + isTranslated: boolean + retranslate: () => void +} + +// Local cache for translations (persists across hook instances) +const localCache = new Map() + +// Callbacks to notify when cache is cleared +const clearCallbacks = new Set<() => void>() + +/** + * Hook for translating text using LibreTranslate API + */ +export function useTranslation( + text: string, + options: UseTranslationOptions = {} +): UseTranslationReturn { + const { + enabled = true, + source = 'en', + target = 'zh', + debounceMs = 300, + } = options + + const [translated, setTranslated] = useState(text) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const debounceTimerRef = useRef(null) + const abortControllerRef = useRef(null) + const cacheVersionRef = useRef(0) + + const cacheKey = `${source}:${target}:${text}` + const isTranslated = translated !== text && !loading + + // Register for cache clear notifications + useEffect(() => { + const onCacheClear = () => { + // Reset translated text to force re-translation + setTranslated(text) + cacheVersionRef.current++ + } + + clearCallbacks.add(onCacheClear) + + return () => { + clearCallbacks.delete(onCacheClear) + } + }, [text]) + + const doTranslate = useCallback(async () => { + if (!enabled || !text) { + setTranslated(text) + return + } + + console.log(`[useTranslation] Translating: "${text.substring(0, 30)}${text.length > 30 ? '...' : ''}"`) + + // Check if text contains Chinese (no translation needed) + const hasChinese = /[\u4e00-\u9fa5]/.test(text) + if (hasChinese) { + console.log(`[useTranslation] Already contains Chinese`) + setTranslated(text) + return + } + + // Check local cache first + const cached = localCache.get(cacheKey) + if (cached) { + console.log(`[useTranslation] Local cache hit`) + setTranslated(cached) + return + } + + // Abort previous request + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + abortControllerRef.current = new AbortController() + + setLoading(true) + setError(null) + + try { + console.log(`[useTranslation] Calling API...`) + const result = (await window.api.translateText( + text, + source, + target + )) as TranslationResult + + console.log(`[useTranslation] API result:`, result) + + if (result.ok && result.translatedText) { + localCache.set(cacheKey, result.translatedText) + setTranslated(result.translatedText) + } else { + // Translation failed, use original text + console.log(`[useTranslation] Translation failed:`, result.error) + setTranslated(text) + if (result.error) { + setError(result.error) + } + } + } catch (err) { + // Error occurred, use original text + console.error(`[useTranslation] Error:`, err) + setTranslated(text) + setError(err instanceof Error ? err.message : 'Translation failed') + } finally { + setLoading(false) + } + }, [text, enabled, source, target, cacheKey]) + + const retranslate = useCallback(() => { + localCache.delete(cacheKey) + void doTranslate() + }, [cacheKey, doTranslate]) + + useEffect(() => { + if (!enabled) { + setTranslated(text) + return + } + + // Clear previous timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + // Debounce the translation + debounceTimerRef.current = setTimeout(() => { + void doTranslate() + }, debounceMs) + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } + }, [enabled, text, debounceMs, doTranslate]) + + return { + translated, + loading, + error, + original: text, + isTranslated, + retranslate, + } +} + +/** + * Clear local translation cache and trigger re-translation + */ +export function clearLocalTranslationCache(): void { + localCache.clear() + // Notify all registered callbacks to re-translate + clearCallbacks.forEach((callback) => callback()) +} + +/** + * Get local cache size + */ +export function getLocalTranslationCacheSize(): number { + return localCache.size +} diff --git a/src/hooks/useTranslationSearch.ts b/src/hooks/useTranslationSearch.ts new file mode 100644 index 0000000..3c4feba --- /dev/null +++ b/src/hooks/useTranslationSearch.ts @@ -0,0 +1,108 @@ +import { useState, useEffect, useMemo, useRef } from 'react' + +interface TranslationResult { + ok: boolean + translatedText: string + error?: string +} + +/** + * Hook for searching items with translation support + * Pre-translates all descriptions to support Chinese search + */ +export function useTranslationSearch( + items: T[], + getText: (item: T) => string, + searchQuery: string +): { filteredItems: T[]; isTranslating: boolean; translatedMap: Map } { + const [translatedMap, setTranslatedMap] = useState>(new Map()) + const [isTranslating, setIsTranslating] = useState(false) + const abortControllerRef = useRef(null) + + // Translate all unique texts + useEffect(() => { + const uniqueTexts = [...new Set(items.map(getText).filter(Boolean))] + + // Filter out already translated texts or texts with Chinese + const textsToTranslate = uniqueTexts.filter(text => { + if (translatedMap.has(text)) return false + // Already contains Chinese, no translation needed + if (/[\u4e00-\u9fa5]/.test(text)) { + return false + } + return true + }) + + if (textsToTranslate.length === 0) return + + // Abort previous translations + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + abortControllerRef.current = new AbortController() + + const translateAll = async () => { + setIsTranslating(true) + + try { + // Batch translate with concurrency limit + const batchSize = 5 + const results = new Map() + + for (let i = 0; i < textsToTranslate.length; i += batchSize) { + if (abortControllerRef.current?.signal.aborted) break + + const batch = textsToTranslate.slice(i, i + batchSize) + const translations = await Promise.all( + batch.map(async (text) => { + try { + const result = await window.api.translateText(text, 'en', 'zh') as TranslationResult + return { text, translated: result.ok ? result.translatedText : text } + } catch { + return { text, translated: text } + } + }) + ) + + translations.forEach(({ text, translated }) => { + results.set(text, translated) + }) + } + + setTranslatedMap(prev => new Map([...prev, ...results])) + } catch (error) { + // Ignore abort errors + if (error instanceof Error && error.message.includes('abort')) return + console.error('Translation batch failed:', error) + } finally { + setIsTranslating(false) + } + } + + void translateAll() + + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } + }, [items, getText, translatedMap]) + + // Filter items based on search query + const filteredItems = useMemo(() => { + if (!searchQuery.trim()) return items + + const query = searchQuery.toLowerCase() + + return items.filter(item => { + const text = getText(item) + const original = text.toLowerCase() + const translated = translatedMap.get(text)?.toLowerCase() || original + + // Match against both original and translated text + return original.includes(query) || translated.includes(query) + }) + }, [items, getText, searchQuery, translatedMap]) + + return { filteredItems, isTranslating, translatedMap } +} diff --git a/src/pages/SkillsPage.tsx b/src/pages/SkillsPage.tsx index 7b5fdef..74d7327 100644 --- a/src/pages/SkillsPage.tsx +++ b/src/pages/SkillsPage.tsx @@ -1,4 +1,6 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslationSearch } from '@/hooks/useTranslationSearch' +import { clearLocalTranslationCache } from '@/hooks/useTranslation' import { Badge, Modal, @@ -35,6 +37,7 @@ import { import { createPageDataCache } from '../lib/page-data-cache' import { toUserFacingCliFailureMessage, toUserFacingUnknownErrorMessage } from '../lib/user-facing-cli-feedback' import tooltips from '@/constants/tooltips.json' +import { TranslatedText } from '@/components/TranslatedText' interface SkillMissing { bins: string[] @@ -168,6 +171,7 @@ export default function SkillsPage() { const [installingBrew, setInstallingBrew] = useState(false) const [refreshing, setRefreshing] = useState(false) const [scanDirectoriesCollapsed, setScanDirectoriesCollapsed] = useState(true) + const [clearingCache, setClearingCache] = useState(false) const skillInstallUiLocked = Boolean(installing) || cancelingInstall // Collapsed state for each source @@ -560,35 +564,46 @@ export default function SkillsPage() { setShowDetailModal(true) } - // Filter skills - const filteredSkills = useMemo(() => { + // Filter skills with translation support for description search + const { filteredItems: descriptionFilteredSkills, translatedMap } = useTranslationSearch( + skills, + (s) => s.description, + deferredSearch + ) + + // Final filter: match name OR skillKey OR description (including translated) + const finalFilteredSkills = useMemo(() => { if (!deferredSearch.trim()) return skills const query = deferredSearch.toLowerCase() - return skills.filter( - (s) => - s.name.toLowerCase().includes(query) || - s.skillKey.toLowerCase().includes(query) || - s.description.toLowerCase().includes(query) - ) - }, [skills, deferredSearch]) + return skills.filter((s) => { + // Match name or skillKey directly + if (s.name.toLowerCase().includes(query) || s.skillKey.toLowerCase().includes(query)) { + return true + } + // Match description (original or translated) + const originalDesc = s.description.toLowerCase() + const translatedDesc = translatedMap.get(s.description)?.toLowerCase() || originalDesc + return originalDesc.includes(query) || translatedDesc.includes(query) + }) + }, [skills, deferredSearch, translatedMap]) // Group by source const groupedSkills = useMemo(() => { const groups: Record = {} - filteredSkills.forEach((skill) => { + finalFilteredSkills.forEach((skill) => { if (!groups[skill.source]) groups[skill.source] = [] groups[skill.source].push(skill) }) return groups - }, [filteredSkills]) + }, [finalFilteredSkills]) // Statistics const stats = useMemo(() => { - const total = filteredSkills.length - const available = filteredSkills.filter((s) => s.eligible && !s.disabled && !hasMissing(s)).length - const disabled = filteredSkills.filter((s) => s.disabled || hasMissing(s)).length + const total = finalFilteredSkills.length + const available = finalFilteredSkills.filter((s) => s.eligible && !s.disabled && !hasMissing(s)).length + const disabled = finalFilteredSkills.filter((s) => s.disabled || hasMissing(s)).length return { total, available, disabled } - }, [filteredSkills]) + }, [finalFilteredSkills]) const toggleSource = (source: string) => { setCollapsedSources((prev) => ({ ...prev, [source]: !prev[source] })) @@ -692,6 +707,30 @@ export default function SkillsPage() { 本页面安装 Skill 时会优先走官方 `openclaw skills install`,不可用时再回退到 ClawHub,并优先安装到当前 OpenClaw workspace。 + @@ -775,7 +814,14 @@ export default function SkillsPage() { {item.slug} {item.score.toFixed(1)} - {item.name} + {alreadyInstalled ? ( 已安装 @@ -861,7 +907,7 @@ export default function SkillsPage() { )} {/* Empty state: search no results */} - {skills.length > 0 && filteredSkills.length === 0 && ( + {skills.length > 0 && finalFilteredSkills.length === 0 && (
🔍 未找到匹配 "{deferredSearch}" 的 Skills