fix: 修复全局输入卡顿和终端 AI 模型列表问题

- 优化 i18n MutationObserver 性能
  - 添加输入框变化过滤(INPUT、TEXTAREA)
  - 添加 300ms 防抖避免频繁触发
  - 跳过已打补丁的节点
  - 跳过可编辑元素

- 优化 DOM 文本替换性能
  - 跳过输入框和文本域
  - 跳过 contentEditable 元素

- 优化终端 AI 输入组件
  - 添加防抖函数(16ms ~60fps)
  - 使用 useMemo 缓存防抖函数

- 修复终端 AI 模型列表问题
  - 添加 extractSupportedModels 函数
  - 优先使用凭证池中的模型列表
  - 特殊处理 DeepSeek(显示 deepseek-chat/reasoner)
  - 修复 iFlow provider ID 映射(openai -> iflowcn)
  - 降级到 model registry(如果凭证模型为空)

修复后:
- 所有输入框流畅无卡顿
- 终端 AI 显示正确的提供商模型列表
This commit is contained in:
Chiron
2026-01-10 11:19:11 +08:00
parent d67ca77a5f
commit d82b07c1fb
4 changed files with 218 additions and 9 deletions
+25 -3
View File
@@ -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<T extends (...args: unknown[]) => void>(
fn: T,
delay: number,
): T {
let timeoutId: ReturnType<typeof setTimeout> | 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<TerminalAIInputProps> = ({
textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
}, []);
/**
* 防抖的 resize 函数
*/
const debouncedResize = useMemo(
() => debounce(resizeTextarea, 16), // ~60fps
[resizeTextarea],
);
useEffect(() => {
resizeTextarea();
}, [value, resizeTextarea]);
debouncedResize();
}, [value, debouncedResize]);
/**
* 处理键盘事件
@@ -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<string, string> = {
@@ -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<TerminalAIModeSelectorProps> = ({
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<TerminalAIModeSelectorProps> = ({
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<TerminalAIModeSelectorProps> = ({
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,
);
+40 -4
View File
@@ -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 (
+11
View File
@@ -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;
},
},