Files
zclaw_openfang/desktop/src/components/Settings/ModelsAPI.tsx
iven 9772d6ec94
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(ui): 空catch块添加日志 + ErrorBoundary覆盖高风险组件
空catch块修复 (12处, 6文件):
- ModelsAPI: 4处 localStorage 配置读写添加 console.warn
- VikingPanel: 2处 viking 操作添加日志
- Workspace/MCPServices/SaaSStatus/TOTPSettings: 各1-3处

ErrorBoundary新增覆盖:
- ChatArea: 两种UI模式均包裹(防白屏)
- RightPanel: 两种UI模式均包裹
- AuditLogsPanel/HeartbeatConfig/VikingPanel: 设置页包裹
2026-04-11 00:26:24 +08:00

771 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { getStoredGatewayToken, getStoredGatewayUrl } from '../../lib/gateway-client';
import { useConnectionStore } from '../../store/connectionStore';
import { useConfigStore } from '../../store/configStore';
import { useConversationStore } from '../../store/chat/conversationStore';
import { silentErrorHandler } from '../../lib/error-utils';
import { secureStorage } from '../../lib/secure-storage';
import { Plus, Pencil, Trash2, Star, Eye, EyeOff, AlertCircle, X, Zap, Check } from 'lucide-react';
// 自定义模型数据结构
interface CustomModel {
id: string;
name: string;
provider: string;
apiKey?: string;
apiProtocol: 'openai' | 'anthropic' | 'custom';
baseUrl?: string;
isDefault?: boolean;
createdAt: string;
}
// Embedding 配置数据结构
interface EmbeddingConfig {
provider: string;
model: string;
apiKey: string;
endpoint: string;
enabled: boolean;
}
interface EmbeddingProvider {
id: string;
name: string;
defaultModel: string;
dimensions: number;
}
// 可用的 Provider 列表
// 注意: Coding Plan 是专为编程助手设计的优惠套餐,使用专用端点
// P2-21: 外国模型 (OpenAI, Anthropic, Gemini) 暂停支持,标记为 suspended
const AVAILABLE_PROVIDERS = [
// === Coding Plan 专用端点 (推荐用于编程场景) ===
{ id: 'kimi-coding', name: 'Kimi Coding Plan', baseUrl: 'https://api.kimi.com/coding/v1' },
{ id: 'qwen-coding', name: '百炼 Coding Plan', baseUrl: 'https://coding.dashscope.aliyuncs.com/v1' },
{ id: 'zhipu-coding', name: '智谱 GLM Coding Plan', baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4' },
// === 标准 API 端点 (国内) ===
{ id: 'kimi', name: 'Kimi (标准 API)', baseUrl: 'https://api.moonshot.cn/v1' },
{ id: 'zhipu', name: '智谱 (标准 API)', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' },
{ id: 'qwen', name: '百炼/通义千问 (标准)', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
{ id: 'deepseek', name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1' },
// === 暂停支持 (P2-21: 前期不使用非国内大模型) ===
{ id: 'openai', name: 'OpenAI (暂停支持)', baseUrl: 'https://api.openai.com/v1', suspended: true },
{ id: 'anthropic', name: 'Anthropic (暂停支持)', baseUrl: 'https://api.anthropic.com', suspended: true },
{ id: 'custom', name: '自定义', baseUrl: '' },
];
const STORAGE_KEY = 'zclaw-custom-models';
const MODEL_KEY_SECURE_PREFIX = 'zclaw-secure-model-key:';
const EMBEDDING_STORAGE_KEY = 'zclaw-embedding-config';
const EMBEDDING_KEY_SECURE = 'zclaw-secure-embedding-apikey';
const DEFAULT_EMBEDDING_PROVIDERS: EmbeddingProvider[] = [
{ id: 'local', name: '本地 TF-IDF (无需 API)', defaultModel: 'tfidf', dimensions: 0 },
{ id: 'openai', name: 'OpenAI', defaultModel: 'text-embedding-3-small', dimensions: 1536 },
{ id: 'zhipu', name: '智谱 AI', defaultModel: 'embedding-3', dimensions: 1024 },
{ id: 'doubao', name: '火山引擎 (Doubao)', defaultModel: 'doubao-embedding', dimensions: 1024 },
{ id: 'qwen', name: '百炼/通义千问', defaultModel: 'text-embedding-v3', dimensions: 1024 },
{ id: 'deepseek', name: 'DeepSeek', defaultModel: 'deepseek-embedding', dimensions: 1536 },
];
/**
* Load embedding config from localStorage. apiKey will be empty here;
* call loadEmbeddingApiKey() to retrieve it from secure storage.
*/
function loadEmbeddingConfigBase(): Omit<EmbeddingConfig, 'apiKey'> & { apiKey: string } {
try {
const stored = localStorage.getItem(EMBEDDING_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return { ...parsed, apiKey: '' };
}
} catch (e) {
console.warn('[ModelsAPI] Failed to load embedding config:', e);
}
return {
provider: 'local',
model: 'tfidf',
apiKey: '',
endpoint: '',
enabled: false,
};
}
/**
* Save embedding config to localStorage. API key is NOT saved here;
* use saveEmbeddingApiKey() separately.
*/
function saveEmbeddingConfigBase(config: Omit<EmbeddingConfig, 'apiKey'>): void {
try {
localStorage.setItem(EMBEDDING_STORAGE_KEY, JSON.stringify(config));
} catch (e) {
console.warn('[ModelsAPI] Failed to save embedding config:', e);
}
}
/**
* Save embedding API key to secure storage.
*/
async function saveEmbeddingApiKey(apiKey: string): Promise<void> {
if (!apiKey.trim()) {
await secureStorage.delete(EMBEDDING_KEY_SECURE);
return;
}
await secureStorage.set(EMBEDDING_KEY_SECURE, apiKey.trim());
}
/**
* Load embedding API key from secure storage.
*/
async function loadEmbeddingApiKey(): Promise<string | null> {
return secureStorage.get(EMBEDDING_KEY_SECURE);
}
// 从 localStorage 加载自定义模型 (apiKeys are stripped from localStorage)
function loadCustomModelsBase(): CustomModel[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('[ModelsAPI] Failed to load model config:', e);
}
return [];
}
// 保存自定义模型到 localStorage (apiKeys are stripped before saving)
function saveCustomModelsBase(models: CustomModel[]): void {
try {
const sanitized = models.map(m => {
const { apiKey: _, ...rest } = m;
return rest;
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(sanitized));
} catch (e) {
console.warn('[ModelsAPI] Failed to save model config:', e);
}
}
/**
* Async load: fetches models from localStorage and merges apiKeys from secure storage.
*/
async function loadCustomModelsWithKeys(): Promise<CustomModel[]> {
const models = loadCustomModelsBase();
const modelsWithKeys = await Promise.all(
models.map(async (model) => {
const apiKey = await secureStorage.get(MODEL_KEY_SECURE_PREFIX + model.id);
return { ...model, apiKey: apiKey || undefined };
})
);
return modelsWithKeys;
}
export function ModelsAPI() {
const connectionState = useConnectionStore((s) => s.connectionState);
const connect = useConnectionStore((s) => s.connect);
const disconnect = useConnectionStore((s) => s.disconnect);
const quickConfig = useConfigStore((s) => s.quickConfig);
const loadModels = useConfigStore((s) => s.loadModels);
const currentModel = useConversationStore((s) => s.currentModel);
const setCurrentModel = useConversationStore((s) => s.setCurrentModel);
const [gatewayUrl, setGatewayUrl] = useState(getStoredGatewayUrl());
const [gatewayToken, setGatewayToken] = useState(quickConfig.gatewayToken || getStoredGatewayToken());
// 自定义模型状态
const [customModels, setCustomModels] = useState<CustomModel[]>([]);
const [showAddModal, setShowAddModal] = useState(false);
const [editingModel, setEditingModel] = useState<CustomModel | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
// Embedding 配置状态
const [embeddingConfig, setEmbeddingConfig] = useState<EmbeddingConfig>(loadEmbeddingConfigBase);
const [showEmbeddingApiKey, setShowEmbeddingApiKey] = useState(false);
const [testingEmbedding, setTestingEmbedding] = useState(false);
const [embeddingTestResult, setEmbeddingTestResult] = useState<{ success: boolean; message: string } | null>(null);
// 表单状态
const [formData, setFormData] = useState({
provider: 'zhipu',
modelId: 'glm-4-flash-250414',
displayName: '',
apiKey: '',
apiProtocol: 'openai' as 'openai' | 'anthropic' | 'custom',
baseUrl: '',
});
const connected = connectionState === 'connected';
const connecting = connectionState === 'connecting' || connectionState === 'reconnecting';
// 加载自定义模型和 embedding API key (async for secure storage)
useEffect(() => {
const loadAll = async () => {
// Load custom models with their secure apiKeys
const modelsWithKeys = await loadCustomModelsWithKeys();
setCustomModels(modelsWithKeys);
// Load embedding apiKey from secure storage
const embApiKey = await loadEmbeddingApiKey();
if (embApiKey) {
setEmbeddingConfig(prev => ({ ...prev, apiKey: embApiKey }));
}
};
loadAll().catch(silentErrorHandler('ModelsAPI'));
}, []);
useEffect(() => {
setGatewayUrl(quickConfig.gatewayUrl || getStoredGatewayUrl());
setGatewayToken(quickConfig.gatewayToken || getStoredGatewayToken());
}, [quickConfig.gatewayToken, quickConfig.gatewayUrl]);
const handleReconnect = () => {
disconnect();
setTimeout(() => connect(
gatewayUrl || quickConfig.gatewayUrl || 'ws://127.0.0.1:50051/ws',
gatewayToken || quickConfig.gatewayToken || getStoredGatewayToken()
).catch(silentErrorHandler('ModelsAPI')), 500);
};
// 打开添加模型弹窗
const handleOpenAddModal = () => {
setFormData({
provider: 'zhipu',
modelId: '',
displayName: '',
apiKey: '',
apiProtocol: 'openai',
baseUrl: AVAILABLE_PROVIDERS[0].baseUrl,
});
setEditingModel(null);
setShowAddModal(true);
};
// 打开编辑模型弹窗
const handleOpenEditModal = (model: CustomModel) => {
setFormData({
provider: model.provider,
modelId: model.id,
displayName: model.name,
apiKey: model.apiKey || '',
apiProtocol: model.apiProtocol,
baseUrl: model.baseUrl || '',
});
setEditingModel(model);
setShowAddModal(true);
};
// 保存模型
const handleSaveModel = async () => {
if (!formData.modelId.trim()) return;
const newModel: CustomModel = {
id: formData.modelId.trim(),
name: formData.displayName.trim() || formData.modelId.trim(),
provider: formData.provider,
apiKey: formData.apiKey.trim() || undefined,
apiProtocol: formData.apiProtocol,
baseUrl: formData.baseUrl.trim() || AVAILABLE_PROVIDERS.find(p => p.id === formData.provider)?.baseUrl,
createdAt: editingModel?.createdAt || new Date().toISOString(),
};
let updatedModels: CustomModel[];
if (editingModel) {
// 编辑模式
updatedModels = customModels.map(m => m.id === editingModel.id ? newModel : m);
} else {
// 添加模式
updatedModels = [...customModels, newModel];
}
// Save apiKey to secure storage
if (newModel.apiKey) {
await secureStorage.set(MODEL_KEY_SECURE_PREFIX + newModel.id, newModel.apiKey);
} else {
await secureStorage.delete(MODEL_KEY_SECURE_PREFIX + newModel.id);
}
setCustomModels(updatedModels);
saveCustomModelsBase(updatedModels);
setShowAddModal(false);
setEditingModel(null);
// 刷新模型列表
loadModels();
};
// 删除模型
const handleDeleteModel = async (modelId: string) => {
const updatedModels = customModels.filter(m => m.id !== modelId);
setCustomModels(updatedModels);
saveCustomModelsBase(updatedModels);
// Also remove apiKey from secure storage
await secureStorage.delete(MODEL_KEY_SECURE_PREFIX + modelId);
};
// 设为默认模型
const handleSetDefault = (modelId: string) => {
setCurrentModel(modelId);
// 更新自定义模型的默认状态
const updatedModels = customModels.map(m => ({
...m,
isDefault: m.id === modelId,
}));
setCustomModels(updatedModels);
saveCustomModelsBase(updatedModels);
};
// Provider 变更时更新 baseUrl
const handleProviderChange = (providerId: string) => {
const provider = AVAILABLE_PROVIDERS.find(p => p.id === providerId);
setFormData({
...formData,
provider: providerId,
baseUrl: provider?.baseUrl || '',
});
};
// Embedding Provider 变更
const handleEmbeddingProviderChange = (providerId: string) => {
const provider = DEFAULT_EMBEDDING_PROVIDERS.find(p => p.id === providerId);
setEmbeddingConfig(prev => ({
...prev,
provider: providerId,
model: provider?.defaultModel || 'tfidf',
}));
setEmbeddingTestResult(null);
};
// 保存 Embedding 配置
const handleSaveEmbeddingConfig = async () => {
const configToSave = {
...embeddingConfig,
enabled: embeddingConfig.provider !== 'local' && embeddingConfig.apiKey.trim() !== '',
};
setEmbeddingConfig(configToSave);
// Save apiKey to secure storage, rest to localStorage
await saveEmbeddingApiKey(configToSave.apiKey);
saveEmbeddingConfigBase(configToSave);
// Push config to Rust backend for semantic memory search
if (configToSave.enabled) {
try {
await invoke('viking_configure_embedding', {
provider: configToSave.provider,
apiKey: configToSave.apiKey,
model: configToSave.model || undefined,
endpoint: configToSave.endpoint || undefined,
});
setEmbeddingTestResult({ success: true, message: 'Embedding 配置已应用到语义记忆搜索' });
} catch (error) {
setEmbeddingTestResult({ success: false, message: `配置保存成功但应用失败: ${error}` });
}
} else {
setEmbeddingTestResult(null);
}
};
// 测试 Embedding API
const handleTestEmbedding = async () => {
if (embeddingConfig.provider === 'local') {
setEmbeddingTestResult({ success: true, message: '本地 TF-IDF 模式无需测试' });
return;
}
if (!embeddingConfig.apiKey.trim()) {
setEmbeddingTestResult({ success: false, message: '请先填写 API Key' });
return;
}
setTestingEmbedding(true);
setEmbeddingTestResult(null);
try {
const result = await invoke<{ embedding: number[]; model: string }>('embedding_create', {
provider: embeddingConfig.provider,
apiKey: embeddingConfig.apiKey,
text: '测试文本',
model: embeddingConfig.model || undefined,
endpoint: embeddingConfig.endpoint || undefined,
});
setEmbeddingTestResult({
success: true,
message: `成功!向量维度: ${result.embedding.length}`,
});
} catch (error) {
setEmbeddingTestResult({
success: false,
message: String(error),
});
} finally {
setTestingEmbedding(false);
}
};
return (
<div className="max-w-3xl">
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-bold text-gray-900 dark:text-white"> API</h1>
<button
onClick={handleReconnect}
disabled={connecting}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 px-3 py-1.5 border border-gray-200 dark:border-gray-700 rounded-lg transition-colors disabled:opacity-50"
>
{connecting ? '连接中...' : '重新连接'}
</button>
</div>
{/* Gateway 连接状态 */}
<div className="mb-6">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider">Gateway </h3>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400"></span>
<span className={`text-sm ${connected ? 'text-green-600' : connecting ? 'text-yellow-600' : 'text-gray-400'}`}>
{connected ? '已连接' : connecting ? '连接中...' : '未连接'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400"></span>
<span className="text-sm font-medium text-orange-600">{currentModel || '未选择'}</span>
</div>
</div>
</div>
{/* 内置模型 */}
<div className="mb-6">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider"></h3>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700 dark:text-gray-300">ZCLAW </span>
<span className="text-xs text-gray-400"> Gateway </span>
</div>
</div>
</div>
{/* 自定义模型 */}
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider"></h3>
<button
onClick={handleOpenAddModal}
className="text-xs text-orange-600 hover:text-orange-700 flex items-center gap-1"
>
<Plus className="w-3 h-3" />
</button>
</div>
{customModels.length === 0 ? (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm text-center">
<p className="text-sm text-gray-500 dark:text-gray-400"></p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1"></p>
</div>
) : (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl divide-y divide-gray-100 dark:divide-gray-700 shadow-sm">
{customModels.map((model) => (
<div
key={model.id}
className={`flex justify-between items-center p-4 ${currentModel === model.id ? 'bg-orange-50/50 dark:bg-orange-900/10' : ''}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{model.name}</span>
{currentModel === model.id && (
<span className="px-1.5 py-0.5 text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded"></span>
)}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{AVAILABLE_PROVIDERS.find(p => p.id === model.provider)?.name || model.provider}
{model.apiKey ? ' · 已配置 API Key' : ' · 未配置 API Key'}
</div>
</div>
<div className="flex items-center gap-2 text-xs">
{currentModel !== model.id && (
<button
onClick={() => handleSetDefault(model.id)}
className="text-orange-600 hover:underline flex items-center gap-1"
>
<Star className="w-3 h-3" />
</button>
)}
<button
onClick={() => handleOpenEditModal(model)}
className="text-gray-500 dark:text-gray-400 hover:underline flex items-center gap-1"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={() => handleDeleteModel(model.id)}
className="text-red-500 hover:underline flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Embedding 模型配置 */}
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider flex items-center gap-2">
<Zap className="w-3.5 h-3.5" />
Embedding
</h3>
<span className={`text-xs px-2 py-0.5 rounded ${embeddingConfig.enabled ? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-500'}`}>
{embeddingConfig.enabled ? '已启用' : '使用 TF-IDF'}
</span>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 shadow-sm space-y-4">
{/* Provider 选择 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"></label>
<select
value={embeddingConfig.provider}
onChange={(e) => handleEmbeddingProviderChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
>
{DEFAULT_EMBEDDING_PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.dimensions > 0 ? `(${p.dimensions}D)` : ''}
</option>
))}
</select>
</div>
{/* 模型 ID */}
{embeddingConfig.provider !== 'local' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> ID</label>
<input
type="text"
value={embeddingConfig.model}
onChange={(e) => setEmbeddingConfig(prev => ({ ...prev, model: e.target.value }))}
placeholder="text-embedding-3-small"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<p className="text-xs text-gray-400 mt-1">
: {DEFAULT_EMBEDDING_PROVIDERS.find(p => p.id === embeddingConfig.provider)?.defaultModel}
</p>
</div>
)}
{/* API Key */}
{embeddingConfig.provider !== 'local' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API Key</label>
<div className="relative">
<input
type={showEmbeddingApiKey ? 'text' : 'password'}
value={embeddingConfig.apiKey}
onChange={(e) => setEmbeddingConfig(prev => ({ ...prev, apiKey: e.target.value }))}
placeholder="请填写 API Key"
className="w-full px-3 py-2 pr-10 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<button
type="button"
onClick={() => setShowEmbeddingApiKey(!showEmbeddingApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showEmbeddingApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
)}
{/* 自定义 Endpoint */}
{embeddingConfig.provider !== 'local' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Endpoint <span className="text-gray-400">()</span>
</label>
<input
type="text"
value={embeddingConfig.endpoint}
onChange={(e) => setEmbeddingConfig(prev => ({ ...prev, endpoint: e.target.value }))}
placeholder="留空使用默认端点"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
)}
{/* 测试结果 */}
{embeddingTestResult && (
<div className={`flex items-center gap-2 p-3 rounded-lg text-sm ${embeddingTestResult.success ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300' : 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300'}`}>
{embeddingTestResult.success ? <Check className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{embeddingTestResult.message}
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSaveEmbeddingConfig}
className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm hover:bg-orange-600 transition-colors"
>
</button>
{embeddingConfig.provider !== 'local' && (
<button
onClick={handleTestEmbedding}
disabled={testingEmbedding || !embeddingConfig.apiKey.trim()}
className="px-4 py-2 border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{testingEmbedding ? '测试中...' : '测试连接'}
</button>
)}
</div>
{/* 说明 */}
<div className="text-xs text-gray-400 dark:text-gray-500 pt-2 border-t border-gray-100 dark:border-gray-700">
<p>Embedding </p>
<p className="mt-1"> TF-IDF API使 API Key</p>
</div>
</div>
</div>
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={() => setShowAddModal(false)} />
<div className="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
{/* 弹窗头部 */}
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700 p-6 flex justify-between items-center z-10">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
{editingModel ? '编辑模型' : '添加模型'}
</h3>
<button
onClick={() => setShowAddModal(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 弹窗内容 */}
<div className="p-6 space-y-4">
{/* 警告提示 */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-100 dark:border-yellow-800 rounded-lg p-3 text-xs text-yellow-800 dark:text-yellow-200 flex items-start gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>使</span>
</div>
{/* 服务商 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">* </label>
<select
value={formData.provider}
onChange={(e) => handleProviderChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
>
{AVAILABLE_PROVIDERS.filter((p) => !(p as any).suspended).map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* 模型 ID */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">* ID</label>
<input
type="text"
value={formData.modelId}
onChange={(e) => setFormData({ ...formData, modelId: e.target.value })}
placeholder="如glm-4-flash-250414, glm-4-plus, glm-4.7"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<p className="text-xs text-gray-400 mt-1">
智谱: glm-4-flash-250414(), glm-4-plus, glm-4.7, glm-z1-flash()
</p>
</div>
{/* 显示名称 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"></label>
<input
type="text"
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
placeholder="如GLM-4-Plus"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
{/* API Key */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API Key</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="请填写 API Key"
className="w-full px-3 py-2 pr-10 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* API 协议 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API </label>
<select
value={formData.apiProtocol}
onChange={(e) => setFormData({ ...formData, apiProtocol: e.target.value as 'openai' | 'anthropic' | 'custom' })}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="custom"></option>
</select>
</div>
{/* Base URL */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Base URL</label>
<input
type="text"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.example.com/v1"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
</div>
{/* 弹窗底部 */}
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700 p-6 flex justify-end gap-3">
<button
onClick={() => setShowAddModal(false)}
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-sm"
>
</button>
<button
onClick={handleSaveModel}
disabled={!formData.modelId.trim()}
className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingModel ? '保存' : '添加'}
</button>
</div>
</div>
</div>
)}
</div>
);
}