diff --git a/src/components/terminal/ai/TerminalAIInput.tsx b/src/components/terminal/ai/TerminalAIInput.tsx index 5a0c4dbe5..401054027 100644 --- a/src/components/terminal/ai/TerminalAIInput.tsx +++ b/src/components/terminal/ai/TerminalAIInput.tsx @@ -6,10 +6,24 @@ * 参考 Waveterm 的 AIPanelInput 设计 */ -import React, { useRef, useCallback, useEffect } from "react"; +import React, { useRef, useCallback, useEffect, useMemo } from "react"; import { Send, Square, Paperclip } from "lucide-react"; import { cn } from "@/lib/utils"; +/** + * 简单的防抖函数 + */ +function debounce void>( + fn: T, + delay: number, +): T { + let timeoutId: ReturnType | null = null; + return ((...args: unknown[]) => { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn(...args), delay); + }) as T; +} + interface TerminalAIInputProps { /** 输入值 */ value: string; @@ -51,9 +65,17 @@ export const TerminalAIInput: React.FC = ({ textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; }, []); + /** + * 防抖的 resize 函数 + */ + const debouncedResize = useMemo( + () => debounce(resizeTextarea, 16), // ~60fps + [resizeTextarea], + ); + useEffect(() => { - resizeTextarea(); - }, [value, resizeTextarea]); + debouncedResize(); + }, [value, debouncedResize]); /** * 处理键盘事件 diff --git a/src/components/terminal/ai/TerminalAIModeSelector.tsx b/src/components/terminal/ai/TerminalAIModeSelector.tsx index 8f9700982..2f6d3d81c 100644 --- a/src/components/terminal/ai/TerminalAIModeSelector.tsx +++ b/src/components/terminal/ai/TerminalAIModeSelector.tsx @@ -25,6 +25,126 @@ import type { ProviderAliasConfig } from "@/lib/types/modelRegistry"; // 常量 // ============================================================================ +/** + * 从凭证类型提取支持的模型列表 + * 对应后端 orchestrator_cmd.rs 中的 extract_supported_models 函数 + */ +const extractSupportedModels = ( + providerType: string, + credentialType: string, + providerId?: string, // 添加 providerId 参数用于特殊判断 +): string[] => { + const type = providerType.toLowerCase(); + const credType = credentialType.toLowerCase(); + + // 特殊处理:DeepSeek(通过 providerId 判断) + if (providerId === "deepseek") { + return ["deepseek-chat", "deepseek-reasoner"]; + } + + // Claude 凭证 + if (type === "claude" || type === "claude_oauth") { + return [ + "claude-opus-4-5-20251101", + "claude-opus-4-20250514", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-20250514", + "claude-haiku-4-5-20251001", + "claude-3-7-sonnet-20250219", + "claude-3-5-haiku-20241022", + ]; + } + + // OpenAI 凭证 + if (type === "openai") { + return [ + "gpt-5.2-codex", + "gpt-5.2", + "gpt-5.1-codex-max", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1", + "gpt-5-codex", + "gpt-5-codex-mini", + "gpt-5", + "gpt-4o", + "gpt-4o-mini", + ]; + } + + // Gemini OAuth 凭证 + if (type === "gemini" && credType.includes("oauth")) { + return [ + "gemini-3-pro-preview", + "gemini-3-flash-preview", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ]; + } + + // Gemini API Key 凭证 + if ( + type === "gemini_api_key" || + (type === "gemini" && credType.includes("key")) + ) { + return [ + "gemini-3-pro-preview", + "gemini-3-flash-preview", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ]; + } + + // Kiro OAuth 凭证 + if (type === "kiro") { + return [ + "claude-opus-4-5", + "claude-opus-4-5-20251101", + "claude-haiku-4-5", + "claude-sonnet-4-5", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-20250514", + "claude-3-7-sonnet-20250219", + ]; + } + + // Codex OAuth 凭证 + if (type === "codex") { + return ["codex-mini-latest"]; + } + + // Qwen OAuth 凭证 + if (type === "qwen") { + return ["qwen3-coder-plus", "qwen3-coder-flash"]; + } + + // Antigravity OAuth 凭证 + if (type === "antigravity") { + return [ + // Max 等级 + "gemini-3-pro-preview", + "gemini-3-pro-image-preview", + "gemini-claude-opus-4-5-thinking", + // Pro 等级 + "gemini-2.5-flash", + "gemini-2.5-computer-use-preview-10-2025", + "gemini-claude-sonnet-4-5", + "gemini-claude-sonnet-4-5-thinking", + // Mini 等级 + "gemini-3-flash-preview", + ]; + } + + // iFlow 凭证(DeepSeek 代理) + if (type === "iflow") { + return ["deepseek-chat", "deepseek-reasoner"]; + } + + return []; +}; + /** Provider type 到 registry ID 的映射 */ const getRegistryIdFromType = (providerType: string): string => { const typeMap: Record = { @@ -37,7 +157,7 @@ const getRegistryIdFromType = (providerType: string): string => { qwen: "alibaba", codex: "openai", antigravity: "antigravity", - iflow: "openai", + iflow: "iflowcn", gemini_api_key: "google", }; return typeMap[providerType.toLowerCase()] || providerType.toLowerCase(); @@ -77,6 +197,8 @@ interface ConfiguredProvider { registryId: string; fallbackRegistryId?: string; type: string; + credentialType: string; // 添加凭证类型字段 + providerId?: string; // 添加 providerId 字段用于特殊判断(如 DeepSeek) } interface TerminalAIModeSelectorProps { @@ -121,12 +243,17 @@ export const TerminalAIModeSelector: React.FC = ({ oauthCredentials.forEach((overview) => { if (overview.credentials.length > 0) { const key = overview.provider_type; + // 获取第一个凭证的类型作为代表 + const firstCredential = overview.credentials[0]; + const credentialType = firstCredential.credential_type || key; + if (!providerMap.has(key)) { providerMap.set(key, { key, label: getProviderLabel(key), registryId: getRegistryIdFromType(key), type: key, + credentialType, }); } } @@ -153,6 +280,8 @@ export const TerminalAIModeSelector: React.FC = ({ registryId, fallbackRegistryId: registryId, type: provider.type, + credentialType: `${provider.type}_key`, // API Key 类型 + providerId: provider.id, // 保存原始 provider.id }); } }); @@ -185,7 +314,18 @@ export const TerminalAIModeSelector: React.FC = ({ return aliasConfig.models; } - // 从 model_registry 获取 + // 优先使用凭证池中的模型列表(从后端 extract_supported_models 逻辑) + const credentialModels = extractSupportedModels( + selectedProvider.type, + selectedProvider.credentialType, + selectedProvider.providerId, // 传递 providerId + ); + + if (credentialModels.length > 0) { + return credentialModels; + } + + // 降级:从 model_registry 获取 let models = registryModels.filter( (m) => m.provider_id === selectedProvider.registryId, ); diff --git a/src/i18n/I18nPatchProvider.tsx b/src/i18n/I18nPatchProvider.tsx index 8d5a29c73..38ed8109a 100644 --- a/src/i18n/I18nPatchProvider.tsx +++ b/src/i18n/I18nPatchProvider.tsx @@ -83,9 +83,40 @@ export function I18nPatchProvider({ window.__I18N_METRICS__.languageChanges++; } - // Set up MutationObserver for dynamic content - const observer = new MutationObserver(() => { - replaceTextInDOM(language); + // Set up MutationObserver for dynamic content with debouncing + let timeoutId: number | null = null; + const observer = new MutationObserver((mutations) => { + // 忽略输入框的变化(避免输入卡顿) + const shouldIgnore = mutations.every((mutation) => { + const target = mutation.target as HTMLElement; + // 忽略 input、textarea 内部的变化 + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.closest("input, textarea") + ) { + return true; + } + // 忽略已经打过补丁的节点 + if ( + target instanceof Element && + target.hasAttribute("data-i18n-patched") + ) { + return true; + } + return false; + }); + + if (shouldIgnore) return; + + // 防抖:延迟 300ms 执行,避免频繁触发 + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + timeoutId = window.setTimeout(() => { + replaceTextInDOM(language); + timeoutId = null; + }, 300); }); observer.observe(document.body, { @@ -93,7 +124,12 @@ export function I18nPatchProvider({ subtree: true, }); - return () => observer.disconnect(); + return () => { + observer.disconnect(); + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + }; }, [language]); return ( diff --git a/src/i18n/dom-replacer.ts b/src/i18n/dom-replacer.ts index 71d8a333b..55a87f139 100644 --- a/src/i18n/dom-replacer.ts +++ b/src/i18n/dom-replacer.ts @@ -54,6 +54,17 @@ export function replaceTextInDOM(language: Language): void { ) { return NodeFilter.FILTER_REJECT; } + + // 跳过输入框和文本域(避免影响用户输入) + if (tagName === "INPUT" || tagName === "TEXTAREA") { + return NodeFilter.FILTER_REJECT; + } + + // 跳过可编辑元素 + if (parent.isContentEditable) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; }, },