- ChatArea: DeerFlow ai-elements annotations for accessibility - Conversation: remove unused Context, simplify message rendering - Delete dead modules: audit-logger.ts, gateway-reconnect.ts - Replace console.log with structured logger across components - Add idb dependency for IndexedDB persistence - Fix kernel-skills type safety improvements
769 lines
32 KiB
TypeScript
769 lines
32 KiB
TypeScript
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 是专为编程助手设计的优惠套餐,使用专用端点
|
||
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' },
|
||
{ id: 'openai', name: 'OpenAI', baseUrl: 'https://api.openai.com/v1' },
|
||
{ id: 'anthropic', name: 'Anthropic', baseUrl: 'https://api.anthropic.com' },
|
||
{ 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 {
|
||
// ignore
|
||
}
|
||
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 {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
// ignore
|
||
}
|
||
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 {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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',
|
||
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.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, glm-4-plus, glm-4.5"
|
||
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(免费), glm-4-plus, glm-4.5, glm-4.6
|
||
</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>
|
||
);
|
||
}
|