diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index 2a5f4fa..04fe5b8 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -207,8 +207,11 @@ export default { // Models Section ApiProvider: { label: 'Api provider:', + labelDescription: 'Select the LLM provider you want to use. Multiple providers can be configured, and API keys are securely stored locally', useCustomBaseUrl: 'Use custom base url', + useCustomBaseUrlDescription: 'Use custom API endpoint URL for this provider', enterApiKey: 'Enter your api key', + enterApiKeyDescription: 'API Key can be obtained from their official website{provider_api_url}', enterCustomUrl: 'Enter your custom api endpoint url', }, Models: { diff --git a/src/lang/locale/zh-cn.ts b/src/lang/locale/zh-cn.ts index 6ca5b6d..a3619de 100644 --- a/src/lang/locale/zh-cn.ts +++ b/src/lang/locale/zh-cn.ts @@ -208,14 +208,20 @@ export default { // 模型设置部分 ApiProvider: { label: 'LLM 提供商:', + labelDescription: '选择您想要使用的 LLM 提供商,支持配置多个提供商,API 密钥将安全保存在本地', useCustomBaseUrl: '使用自定义基础 URL', + useCustomBaseUrlDescription: '为该提供商使用自定义的API端点URL', enterApiKey: '输入您的 API 密钥', + enterApiKeyDescription: 'API Key 可以从官方网站{provider_api_url}获取', enterCustomUrl: '输入您的自定义 API 端点 URL', }, Models: { chatModel: '聊天模型:', + chatModelDescription: '用于日常对话和问答的模型,处理大部分聊天交互', autocompleteModel: '自动补全模型:', + autocompleteModelDescription: '用于代码和文本自动补全的模型,提供智能写作建议', embeddingModel: '嵌入模型:', + embeddingModelDescription: '用于文档向量化和语义搜索的模型,支持 RAG 功能', }, // 模型参数部分 diff --git a/src/settings/components/FormComponents.tsx b/src/settings/components/FormComponents.tsx index 8576cde..8a9ef77 100644 --- a/src/settings/components/FormComponents.tsx +++ b/src/settings/components/FormComponents.tsx @@ -91,6 +91,7 @@ export const TextComponent: React.FC = ({ export type ToggleComponentProps = { name: string; + description?: string; value: boolean; onChange: (value: boolean) => void; disabled?: boolean; @@ -98,19 +99,633 @@ export type ToggleComponentProps = { export const ToggleComponent: React.FC = ({ name, + description, value, onChange, disabled = false, }) => (
- +
+
+
{name}
+ {description &&
{description}
} +
+ +
+ +
); + +export type ApiKeyComponentProps = { + name: React.ReactNode; + description?: React.ReactNode; + placeholder: string; + value: string; + onChange: (value: string) => void; + onTest?: () => Promise; +} + +export const ApiKeyComponent: React.FC = ({ + name, + description, + placeholder, + value, + onChange, + onTest, +}) => { + const [localValue, setLocalValue] = useState(value); + const [isVisible, setIsVisible] = useState(false); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [testResult, setTestResult] = useState<'success' | 'error' | null>(null); + + // Update local value when prop value changes (e.g., provider change) + useEffect(() => { + setLocalValue(value); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + setLocalValue(e.target.value); + // Clear test result when user changes the key + setTestResult(null); + }; + + const handleBlur = () => { + if (localValue !== value) { + onChange(localValue); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } + }; + + const toggleVisibility = () => { + setIsVisible(!isVisible); + }; + + const handleTest = async () => { + if (!onTest || !localValue.trim()) return; + + setIsTestingConnection(true); + setTestResult(null); + + try { + await onTest(); + setTestResult('success'); + } catch (error) { + setTestResult('error'); + } finally { + setIsTestingConnection(false); + } + }; + + return ( +
+
+
{name}
+ {description &&
{description}
} +
+
+
+ + +
+ + {onTest && ( + + )} +
+ + +
+ ); +}; + +export type CustomUrlComponentProps = { + name: string; + placeholder: string; + useCustomUrl: boolean; + baseUrl: string; + onToggleCustomUrl: (value: boolean) => void; + onChangeBaseUrl: (value: string) => void; +} + +export const CustomUrlComponent: React.FC = ({ + name, + placeholder, + useCustomUrl, + baseUrl, + onToggleCustomUrl, + onChangeBaseUrl, +}) => { + const [localValue, setLocalValue] = useState(baseUrl); + + // Update local value when prop value changes (e.g., provider change) + useEffect(() => { + setLocalValue(baseUrl); + }, [baseUrl]); + + const handleUrlChange = (e: React.ChangeEvent) => { + setLocalValue(e.target.value); + }; + + const handleUrlBlur = () => { + if (localValue !== baseUrl) { + onChangeBaseUrl(localValue); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } + }; + + return ( +
+
+
{name}
+ +
+ + {useCustomUrl && ( +
+ +
+ )} + + +
+ ); +}; diff --git a/src/settings/components/ModelProviderSettings.tsx b/src/settings/components/ModelProviderSettings.tsx index e53c540..0a57d56 100644 --- a/src/settings/components/ModelProviderSettings.tsx +++ b/src/settings/components/ModelProviderSettings.tsx @@ -1,12 +1,13 @@ -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { t } from '../../lang/helpers'; import InfioPlugin from "../../main"; import { ApiProvider } from '../../types/llm/model'; import { InfioSettings } from '../../types/settings'; import { GetAllProviders } from '../../utils/api'; +import { getProviderApiUrl } from '../../utils/provider-urls'; -import { DropdownComponent, TextComponent, ToggleComponent } from './FormComponents'; +import { ApiKeyComponent, CustomUrlComponent } from './FormComponents'; import { ComboBoxComponent } from './ProviderModelsPicker'; type CustomProviderSettingsProps = { @@ -49,31 +50,17 @@ const getProviderSettingKey = (provider: ApiProvider): ProviderSettingKey => { const CustomProviderSettings: React.FC = ({ plugin, onSettingsUpdate }) => { const settings = plugin.settings; - const [currProvider, setCurrProvider] = useState(settings.defaultProvider); + const [activeTab, setActiveTab] = useState(ApiProvider.Infio); const handleSettingsUpdate = async (newSettings: InfioSettings) => { await plugin.setSettings(newSettings); - // Use the callback function passed from the parent component to refresh the entire container onSettingsUpdate?.(); }; - const providerSetting = useMemo(() => { - const providerKey = getProviderSettingKey(currProvider); - return settings[providerKey] || {}; - }, [currProvider, settings]); - const providers = GetAllProviders(); - const updateProvider = (provider: ApiProvider) => { - setCurrProvider(provider); - handleSettingsUpdate({ - ...settings, - defaultProvider: provider - }); - }; - - const updateProviderApiKey = (value: string) => { - const providerKey = getProviderSettingKey(currProvider); + const updateProviderApiKey = (provider: ApiProvider, value: string) => { + const providerKey = getProviderSettingKey(provider); const providerSettings = settings[providerKey]; handleSettingsUpdate({ @@ -85,8 +72,8 @@ const CustomProviderSettings: React.FC = ({ plugin, }); }; - const updateProviderUseCustomUrl = (value: boolean) => { - const providerKey = getProviderSettingKey(currProvider); + const updateProviderUseCustomUrl = (provider: ApiProvider, value: boolean) => { + const providerKey = getProviderSettingKey(provider); const providerSettings = settings[providerKey]; handleSettingsUpdate({ @@ -98,8 +85,8 @@ const CustomProviderSettings: React.FC = ({ plugin, }); }; - const updateProviderBaseUrl = (value: string) => { - const providerKey = getProviderSettingKey(currProvider); + const updateProviderBaseUrl = (provider: ApiProvider, value: string) => { + const providerKey = getProviderSettingKey(provider); const providerSettings = settings[providerKey]; handleSettingsUpdate({ @@ -111,6 +98,25 @@ const CustomProviderSettings: React.FC = ({ plugin, }); }; + const testApiConnection = async (provider: ApiProvider) => { + // TODO: 实现API连接测试逻辑 + // 这里应该根据provider类型调用对应的API测试接口 + console.log(`Testing connection for ${provider}...`); + + // 模拟延迟 + await new Promise(resolve => setTimeout(resolve, 1000)); + + // 模拟随机成功/失败(用于演示) + if (Math.random() > 0.5) { + throw new Error('Connection test failed'); + } + }; + + const getProviderSetting = (provider: ApiProvider) => { + const providerKey = getProviderSettingKey(provider); + return settings[providerKey] || {}; + }; + const updateChatModelId = (provider: ApiProvider, modelId: string) => { handleSettingsUpdate({ ...settings, @@ -135,66 +141,288 @@ const CustomProviderSettings: React.FC = ({ plugin, }); }; - return ( -
- -
- {currProvider !== ApiProvider.Ollama && ( - - )} -
- - {providerSetting.useCustomUrl && ( - - )} + // 生成包含链接的API Key描述 + const generateApiKeyDescription = (provider: ApiProvider): React.ReactNode => { + const apiUrl = getProviderApiUrl(provider); + const baseDescription = t("settings.ApiProvider.enterApiKeyDescription"); + + if (!apiUrl) { + // 如果没有URL,直接移除占位符 + return baseDescription.replace('{provider_api_url}', ''); + } -
-
- -
- -
- -
-
+ // 将占位符替换为实际的链接元素 + const parts = baseDescription.split('{provider_api_url}'); + if (parts.length !== 2) { + return baseDescription; + } + + return ( + <> + {parts[0]} + + {apiUrl} + + {parts[1]} + + ); + }; + + const renderProviderConfig = (provider: ApiProvider) => { + const providerSetting = getProviderSetting(provider); + + return ( +
+ {provider !== ApiProvider.Ollama && ( + + 设置 {provider} API Key + + } + placeholder={t("settings.ApiProvider.enterApiKey")} + description={generateApiKeyDescription(provider)} + value={providerSetting.apiKey || ''} + onChange={(value) => updateProviderApiKey(provider, value)} + onTest={() => testApiConnection(provider)} + /> + )} + + updateProviderUseCustomUrl(provider, value)} + onChangeBaseUrl={(value) => updateProviderBaseUrl(provider, value)} + /> +
+ ); + }; + + return ( +
+ {/* 提供商配置区域 */} +
+

{t("settings.ApiProvider.label")}

+

{t("settings.ApiProvider.labelDescription")}

+ {/* 提供商标签页 */} +
+ {providers.map((provider) => ( + + ))} +
+ + {/* 当前选中提供商的配置 */} +
+ {renderProviderConfig(activeTab)} +
+
+ + {/* 模型选择区域 */} +
+

模型选择

+ +
+ + + + + +
+
+ +
); }; diff --git a/src/settings/components/ProviderModelsPicker.tsx b/src/settings/components/ProviderModelsPicker.tsx index 42da37b..94aef50 100644 --- a/src/settings/components/ProviderModelsPicker.tsx +++ b/src/settings/components/ProviderModelsPicker.tsx @@ -152,6 +152,7 @@ export type ComboBoxComponentProps = { modelId: string; settings?: InfioSettings | null; isEmbedding?: boolean, + description?: string; updateModel: (provider: ApiProvider, modelId: string) => void; }; @@ -161,6 +162,7 @@ export const ComboBoxComponent: React.FC = ({ modelId, settings = null, isEmbedding = false, + description, updateModel, }) => { // provider state @@ -198,25 +200,37 @@ export const ComboBoxComponent: React.FC = ({ const fuse: Fuse = useMemo(() => { return new Fuse(searchableItems, { keys: ["html"], - threshold: 0.6, + threshold: 1, shouldSort: true, isCaseSensitive: false, ignoreLocation: false, includeMatches: true, - minMatchCharLength: 1, + minMatchCharLength: 4, }) }, [searchableItems]) // 根据 searchTerm 得到过滤后的数据列表 const filteredOptions = useMemo(() => { - const results: HighlightedItem[] = searchTerm + let results: HighlightedItem[] = searchTerm ? highlight(fuse.search(searchTerm)) : searchableItems.map(item => ({ ...item, html: typeof item.html === 'string' ? [{ text: item.html, isHighlighted: false }] : item.html })) + + // 如果有搜索词,添加自定义选项(如果不存在完全匹配的话) + if (searchTerm && searchTerm.trim()) { + const exactMatch = searchableItems.some(item => item.id === searchTerm); + if (!exactMatch) { + results.unshift({ + id: searchTerm, + html: [{ text: `${modelIds.length > 0 ? '自定义: ' : ''}${searchTerm}`, isHighlighted: false }] + }); + } + } + return results - }, [searchableItems, searchTerm, fuse]) + }, [searchableItems, searchTerm, fuse, modelIds.length]) const listRef = useRef(null); const itemRefs = useRef>([]); @@ -231,116 +245,379 @@ export const ComboBoxComponent: React.FC = ({ } }, [selectedIndex]); + // Handle provider change + const handleProviderChange = (newProvider: string) => { + // Use proper type checking without type assertion + const availableProviders = providers; + const isValidProvider = (value: string): value is ApiProvider => { + // @ts-ignore + return (availableProviders as readonly string[]).includes(value); + }; + + if (isValidProvider(newProvider)) { + setModelProvider(newProvider); + } + }; + return (
{name}
- - -
- [{modelProvider}] {modelId} -
-
- -
-
- handleProviderChange(e.target.value)} + > + {providers.map((providerOption) => ( + - ))} - - {modelIds.length > 0 ? ( - { - setSearchTerm(e.target.value); - setSelectedIndex(0); - }} - onKeyDown={(e) => { - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - setSelectedIndex((prev) => - Math.min(prev + 1, filteredOptions.length - 1) - ); - break; - case "ArrowUp": - e.preventDefault(); - setSelectedIndex((prev) => Math.max(prev - 1, 0)); - break; - case "Enter": { - e.preventDefault(); - const selectedOption = filteredOptions[selectedIndex]; - if (selectedOption) { - updateModel(modelProvider, selectedOption.id); - setSearchTerm(""); - setIsOpen(false); - } - break; - } - case "Escape": - e.preventDefault(); - setIsOpen(false); - setSearchTerm(""); - break; - } - }} - /> - ) : ( + {providerOption} + + ))} + +
+ + {/* Model Selection */} +
+ + + + + + +
+
0 ? "搜索或输入模型名称..." : "输入自定义模型名称"} value={searchTerm} onChange={(e) => { setSearchTerm(e.target.value); + setSelectedIndex(0); }} onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - updateModel(modelProvider, searchTerm); - setIsOpen(false); + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => + Math.min(prev + 1, filteredOptions.length - 1) + ); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + break; + case "Enter": { + e.preventDefault(); + if (filteredOptions.length > 0) { + const selectedOption = filteredOptions[selectedIndex]; + if (selectedOption) { + updateModel(modelProvider, selectedOption.id); + } + } else if (searchTerm.trim()) { + // 如果没有选项但有输入内容,直接使用输入内容 + updateModel(modelProvider, searchTerm.trim()); + } + setSearchTerm(""); + setIsOpen(false); + break; + } + case "Escape": + e.preventDefault(); + setIsOpen(false); + setSearchTerm(""); + break; } }} /> - )} -
- {filteredOptions.map((option, index) => ( - -
(itemRefs.current[index] = el)} - onMouseEnter={() => setSelectedIndex(index)} - onClick={() => { - updateModel(modelProvider, option.id); - setSearchTerm(""); - setIsOpen(false); - }} - className={`infio-llm-setting-combobox-option ${index === selectedIndex ? 'is-selected' : ''}`} - > -
-
- ))} -
-
-
+ {filteredOptions.length > 0 ? ( +
+ {filteredOptions.map((option, index) => ( + +
(itemRefs.current[index] = el)} + onMouseEnter={() => setSelectedIndex(index)} + onClick={() => { + updateModel(modelProvider, option.id); + setSearchTerm(""); + setIsOpen(false); + }} + className={`infio-llm-setting-combobox-option ${index === selectedIndex ? 'is-selected' : ''}`} + > + +
+
+ ))} +
+ ) : null} +
+ + +
+
+
); }; diff --git a/src/types/settings.ts b/src/types/settings.ts index 4eaf54b..6ca4c78 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -218,7 +218,7 @@ export const InfioSettingsSchema = z.object({ version: z.literal(SETTINGS_SCHEMA_VERSION).catch(SETTINGS_SCHEMA_VERSION), // Provider - defaultProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.OpenRouter), + defaultProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio), infioProvider: InfioProviderSchema, openrouterProvider: OpenRouterProviderSchema, siliconflowProvider: SiliconFlowProviderSchema, @@ -242,15 +242,15 @@ export const InfioSettingsSchema = z.object({ })).catch([]), // Chat Model - chatModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.OpenRouter), + chatModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio), chatModelId: z.string().catch(''), // Apply Model - applyModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.OpenRouter), + applyModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio), applyModelId: z.string().catch(''), // Embedding Model - embeddingModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Google), + embeddingModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio), embeddingModelId: z.string().catch(''), // fuzzyMatchThreshold diff --git a/src/utils/provider-urls.ts b/src/utils/provider-urls.ts new file mode 100644 index 0000000..fe9879f --- /dev/null +++ b/src/utils/provider-urls.ts @@ -0,0 +1,22 @@ +import { ApiProvider } from '../types/llm/model'; + +// Provider API Key获取地址映射 +export const providerApiUrls: Record = { + [ApiProvider.Infio]: 'https://platform.infio.app/home', + [ApiProvider.OpenRouter]: 'https://openrouter.ai/settings/keys', + [ApiProvider.SiliconFlow]: 'https://cloud.siliconflow.cn/account/ak', + [ApiProvider.AlibabaQwen]: 'https://help.aliyun.com/zh/dashscope/developer-reference/activate-dashscope-and-create-an-api-key', + [ApiProvider.Anthropic]: 'https://console.anthropic.com/settings/keys', + [ApiProvider.Deepseek]: 'https://platform.deepseek.com/api_keys/', + [ApiProvider.OpenAI]: 'https://platform.openai.com/api-keys', + [ApiProvider.Google]: 'https://aistudio.google.com/apikey', + [ApiProvider.Groq]: 'https://console.groq.com/keys', + [ApiProvider.Grok]: 'https://console.x.ai/', + [ApiProvider.Ollama]: '', // Ollama 不需要API Key + [ApiProvider.OpenAICompatible]: '', // 自定义兼容API,无固定URL +}; + +// 获取指定provider的API Key获取URL +export function getProviderApiUrl(provider: ApiProvider): string { + return providerApiUrls[provider] || ''; +} diff --git a/styles.css b/styles.css index 588ba74..3d9df4c 100644 --- a/styles.css +++ b/styles.css @@ -1453,10 +1453,7 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { * Highlight styles */ .infio-llm-setting-model-item-highlight { - background-color: var(--text-highlight-bg); color: var(--text-normal); - border-radius: var(--radius-s); - padding: 0 2px; } .infio-llm-setting-item-control::placeholder {