mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-29 14:31:35 +08:00
Merge branch 'main' into feat/proxy-api-server
This commit is contained in:
commit
2ba6c1101d
@ -162,7 +162,7 @@
|
||||
"@langchain/core": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
|
||||
"@langchain/openai": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@mistralai/mistralai": "^1.7.5",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@openrouter/ai-sdk-provider": "^1.2.8",
|
||||
@ -207,6 +207,7 @@
|
||||
"@types/content-type": "^1.1.9",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/diff": "^7",
|
||||
"@types/dotenv": "^8.2.3",
|
||||
"@types/express": "^5",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/he": "^1",
|
||||
|
||||
@ -26,6 +26,23 @@ export function withoutTrailingSlash<T extends string>(url: T): T {
|
||||
return url.replace(/\/$/, '') as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the trailing '#' from a URL string if it exists.
|
||||
*
|
||||
* @template T - The string type to preserve type safety
|
||||
* @param {T} url - The URL string to process
|
||||
* @returns {T} The URL string without a trailing '#'
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* withoutTrailingSharp('https://example.com#') // 'https://example.com'
|
||||
* withoutTrailingSharp('https://example.com') // 'https://example.com'
|
||||
* ```
|
||||
*/
|
||||
export function withoutTrailingSharp<T extends string>(url: T): T {
|
||||
return url.replace(/#$/, '') as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a version segment in a path that starts with `/v<number>` and optionally
|
||||
* continues with `alpha` or `beta`. The segment may be followed by `/` or the end
|
||||
@ -93,12 +110,12 @@ export function formatVertexApiHost(
|
||||
* @param apiVersion - The API version to append if needed. Defaults to `'v1'`.
|
||||
*
|
||||
* @returns The formatted API host URL. If the host is empty after normalization, returns an empty string.
|
||||
* If the host ends with '#', API version is not supported, or the host already contains a version, returns the normalized host as-is.
|
||||
* If the host ends with '#', API version is not supported, or the host already contains a version, returns the normalized host with trailing '#' removed.
|
||||
* Otherwise, returns the host with the API version appended.
|
||||
*
|
||||
* @example
|
||||
* formatApiHost('https://api.example.com/') // Returns 'https://api.example.com/v1'
|
||||
* formatApiHost('https://api.example.com#') // Returns 'https://api.example.com#'
|
||||
* formatApiHost('https://api.example.com#') // Returns 'https://api.example.com'
|
||||
* formatApiHost('https://api.example.com/v2', true, 'v1') // Returns 'https://api.example.com/v2'
|
||||
*/
|
||||
export function formatApiHost(host?: string, supportApiVersion: boolean = true, apiVersion: string = 'v1'): string {
|
||||
@ -107,10 +124,13 @@ export function formatApiHost(host?: string, supportApiVersion: boolean = true,
|
||||
return ''
|
||||
}
|
||||
|
||||
if (normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost)) {
|
||||
return normalizedHost
|
||||
const shouldAppendApiVersion = !(normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost))
|
||||
|
||||
if (shouldAppendApiVersion) {
|
||||
return `${normalizedHost}/${apiVersion}`
|
||||
} else {
|
||||
return withoutTrailingSharp(normalizedHost)
|
||||
}
|
||||
return `${normalizedHost}/${apiVersion}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -12,6 +12,7 @@ import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import type { SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
import type { StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import {
|
||||
StreamableHTTPClientTransport,
|
||||
@ -42,11 +43,14 @@ import {
|
||||
type MCPPrompt,
|
||||
type MCPResource,
|
||||
type MCPServer,
|
||||
type MCPTool
|
||||
type MCPTool,
|
||||
MCPToolInputSchema,
|
||||
MCPToolOutputSchema
|
||||
} from '@types'
|
||||
import { app, net } from 'electron'
|
||||
import { EventEmitter } from 'events'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
import DxtService from './DxtService'
|
||||
@ -343,7 +347,7 @@ class McpService {
|
||||
removeEnvProxy(loginShellEnv)
|
||||
}
|
||||
|
||||
const transportOptions: any = {
|
||||
const transportOptions: StdioServerParameters = {
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
@ -620,6 +624,8 @@ class McpService {
|
||||
tools.map((tool: SDKTool) => {
|
||||
const serverTool: MCPTool = {
|
||||
...tool,
|
||||
inputSchema: z.parse(MCPToolInputSchema, tool.inputSchema),
|
||||
outputSchema: tool.outputSchema ? z.parse(MCPToolOutputSchema, tool.outputSchema) : undefined,
|
||||
id: buildFunctionCallToolName(server.name, tool.name, server.id),
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
|
||||
@ -27,6 +27,7 @@ import { buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
|
||||
import { buildPlugins } from './plugins/PluginBuilder'
|
||||
import { createAiSdkProvider } from './provider/factory'
|
||||
import {
|
||||
adaptProvider,
|
||||
getActualProvider,
|
||||
isModernSdkSupported,
|
||||
prepareSpecialProviderConfig,
|
||||
@ -64,12 +65,11 @@ export default class ModernAiProvider {
|
||||
* - URL will be automatically formatted via `formatProviderApiHost`, adding version suffixes like `/v1`
|
||||
*
|
||||
* 2. When called with `(model, provider)`:
|
||||
* - **Directly uses the provided provider WITHOUT going through `getActualProvider`**
|
||||
* - **URL will NOT be automatically formatted, `/v1` suffix will NOT be added**
|
||||
* - This is legacy behavior kept for backward compatibility
|
||||
* - The provided provider will be adapted via `adaptProvider`
|
||||
* - URL formatting behavior depends on the adapted result
|
||||
*
|
||||
* 3. When called with `(provider)`:
|
||||
* - Directly uses the provider without requiring a model
|
||||
* - The provider will be adapted via `adaptProvider`
|
||||
* - Used for operations that don't need a model (e.g., fetchModels)
|
||||
*
|
||||
* @example
|
||||
@ -77,7 +77,7 @@ export default class ModernAiProvider {
|
||||
* // Recommended: Auto-format URL
|
||||
* const ai = new ModernAiProvider(model)
|
||||
*
|
||||
* // Not recommended: Skip URL formatting (only for special cases)
|
||||
* // Provider will be adapted
|
||||
* const ai = new ModernAiProvider(model, customProvider)
|
||||
*
|
||||
* // For operations that don't need a model
|
||||
@ -91,12 +91,12 @@ export default class ModernAiProvider {
|
||||
if (this.isModel(modelOrProvider)) {
|
||||
// 传入的是 Model
|
||||
this.model = modelOrProvider
|
||||
this.actualProvider = provider || getActualProvider(modelOrProvider)
|
||||
this.actualProvider = provider ? adaptProvider({ provider }) : getActualProvider(modelOrProvider)
|
||||
// 只保存配置,不预先创建executor
|
||||
this.config = providerToAiSdkConfig(this.actualProvider, modelOrProvider)
|
||||
} else {
|
||||
// 传入的是 Provider
|
||||
this.actualProvider = modelOrProvider
|
||||
this.actualProvider = adaptProvider({ provider: modelOrProvider })
|
||||
// model为可选,某些操作(如fetchModels)不需要model
|
||||
}
|
||||
|
||||
|
||||
@ -177,8 +177,12 @@ export async function buildStreamTextParams(
|
||||
let headers: Record<string, string | undefined> = options.requestOptions?.headers ?? {}
|
||||
|
||||
if (isAnthropicModel(model) && !isAwsBedrockProvider(provider)) {
|
||||
const newBetaHeaders = { 'anthropic-beta': addAnthropicHeaders(assistant, model).join(',') }
|
||||
headers = combineHeaders(headers, newBetaHeaders)
|
||||
const betaHeaders = addAnthropicHeaders(assistant, model)
|
||||
// Only add the anthropic-beta header if there are actual beta headers to include
|
||||
if (betaHeaders.length > 0) {
|
||||
const newBetaHeaders = { 'anthropic-beta': betaHeaders.join(',') }
|
||||
headers = combineHeaders(headers, newBetaHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建基础参数
|
||||
|
||||
@ -94,25 +94,52 @@ function getRendererFormatContext(): ProviderFormatContext {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format and normalize the API host URL for a provider.
|
||||
* Handles provider-specific URL formatting rules (e.g., appending version paths, Azure formatting).
|
||||
*
|
||||
* @param provider - The provider whose API host is to be formatted.
|
||||
* @returns A new provider instance with the formatted API host.
|
||||
*/
|
||||
function formatProviderApiHost(provider: Provider): Provider {
|
||||
return sharedFormatProviderApiHost(provider, getRendererFormatContext())
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实际的Provider配置
|
||||
* 简化版:将逻辑分解为小函数
|
||||
* Retrieve the effective Provider configuration for the given model.
|
||||
* Applies all necessary transformations (special-provider handling, URL formatting, etc.).
|
||||
*
|
||||
* @param model - The model whose provider is to be resolved.
|
||||
* @returns A new Provider instance with all adaptations applied.
|
||||
*/
|
||||
export function getActualProvider(model: Model): Provider {
|
||||
const baseProvider = getProviderByModel(model)
|
||||
|
||||
// 按顺序处理各种转换
|
||||
let actualProvider = cloneDeep(baseProvider)
|
||||
actualProvider = resolveActualProvider(actualProvider, model, {
|
||||
isSystemProvider
|
||||
}) as Provider
|
||||
actualProvider = formatProviderApiHost(actualProvider)
|
||||
return adaptProvider({ provider: baseProvider, model })
|
||||
}
|
||||
|
||||
return actualProvider
|
||||
/**
|
||||
* Transforms a provider configuration by applying model-specific adaptations and normalizing its API host.
|
||||
* The transformations are applied in the following order:
|
||||
* 1. Model-specific provider handling (e.g., New-API, system providers, Azure OpenAI)
|
||||
* 2. API host formatting (provider-specific URL normalization)
|
||||
*
|
||||
* @param provider - The base provider configuration to transform.
|
||||
* @param model - The model associated with the provider; optional but required for special-provider handling.
|
||||
* @returns A new Provider instance with all transformations applied.
|
||||
*/
|
||||
export function adaptProvider({ provider, model }: { provider: Provider; model?: Model }): Provider {
|
||||
let adaptedProvider = cloneDeep(provider)
|
||||
|
||||
// Apply transformations in order
|
||||
if (model) {
|
||||
adaptedProvider = resolveActualProvider(adaptedProvider, model, {
|
||||
isSystemProvider
|
||||
})
|
||||
}
|
||||
adaptedProvider = formatProviderApiHost(adaptedProvider)
|
||||
|
||||
return adaptedProvider
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Preview: {{url}}",
|
||||
"reset": "Reset",
|
||||
"tip": "ending with # forces use of input address"
|
||||
"tip": "Add # at the end to disable the automatically appended API version."
|
||||
}
|
||||
},
|
||||
"api_host": "API Host",
|
||||
|
||||
@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "预览: {{url}}",
|
||||
"reset": "重置",
|
||||
"tip": "# 结尾强制使用输入地址"
|
||||
"tip": "在末尾添加 # 以禁用自动附加的API版本。"
|
||||
}
|
||||
},
|
||||
"api_host": "API 地址",
|
||||
|
||||
@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "預覽:{{url}}",
|
||||
"reset": "重設",
|
||||
"tip": "# 結尾強制使用輸入位址"
|
||||
"tip": "在末尾添加 # 以停用自動附加的 API 版本。"
|
||||
}
|
||||
},
|
||||
"api_host": "API 主機地址",
|
||||
|
||||
@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Vorschau: {{url}}",
|
||||
"reset": "Zurücksetzen",
|
||||
"tip": "# am Ende erzwingt die Verwendung der Eingabe-Adresse"
|
||||
"tip": "Fügen Sie am Ende ein # hinzu, um die automatisch angehängte API-Version zu deaktivieren."
|
||||
}
|
||||
},
|
||||
"api_host": "API-Adresse",
|
||||
|
||||
@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Προεπισκόπηση: {{url}}",
|
||||
"reset": "Επαναφορά",
|
||||
"tip": "#τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
|
||||
"tip": "Προσθέστε το σύμβολο # στο τέλος για να απενεργοποιήσετε την αυτόματα προστιθέμενη έκδοση API."
|
||||
}
|
||||
},
|
||||
"api_host": "Διεύθυνση API",
|
||||
|
||||
@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Vista previa: {{url}}",
|
||||
"reset": "Restablecer",
|
||||
"tip": "forzar uso de dirección de entrada con # al final"
|
||||
"tip": "Añada # al final para deshabilitar la versión de la API que se añade automáticamente."
|
||||
}
|
||||
},
|
||||
"api_host": "Dirección API",
|
||||
|
||||
@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Aperçu : {{url}}",
|
||||
"reset": "Réinitialiser",
|
||||
"tip": "forcer l'utilisation de l'adresse d'entrée si terminé par #"
|
||||
"tip": "Ajoutez # à la fin pour désactiver la version d'API ajoutée automatiquement."
|
||||
}
|
||||
},
|
||||
"api_host": "Adresse API",
|
||||
|
||||
@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "プレビュー: {{url}}",
|
||||
"reset": "リセット",
|
||||
"tip": "#で終わる場合、入力されたアドレスを強制的に使用します"
|
||||
"tip": "自動的に付加されるAPIバージョンを無効にするには、末尾に#を追加します。"
|
||||
}
|
||||
},
|
||||
"api_host": "APIホスト",
|
||||
|
||||
@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Pré-visualização: {{url}}",
|
||||
"reset": "Redefinir",
|
||||
"tip": "e forçar o uso do endereço original quando terminar com '#'"
|
||||
"tip": "Adicione # no final para desativar a versão da API adicionada automaticamente."
|
||||
}
|
||||
},
|
||||
"api_host": "Endereço API",
|
||||
|
||||
@ -4372,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Предпросмотр: {{url}}",
|
||||
"reset": "Сброс",
|
||||
"tip": "заканчивая на # принудительно использует введенный адрес"
|
||||
"tip": "Добавьте # в конце, чтобы отключить автоматически добавляемую версию API."
|
||||
}
|
||||
},
|
||||
"api_host": "Хост API",
|
||||
|
||||
@ -7,6 +7,7 @@ import { useMCPServer, useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { useMCPServerTrust } from '@renderer/hooks/useMCPServerTrust'
|
||||
import MCPDescription from '@renderer/pages/settings/MCPSettings/McpDescription'
|
||||
import type { MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types'
|
||||
import { parseKeyValueString } from '@renderer/utils/env'
|
||||
import { formatMcpError } from '@renderer/utils/error'
|
||||
import type { TabsProps } from 'antd'
|
||||
import { Badge, Button, Flex, Form, Input, Radio, Select, Switch, Tabs } from 'antd'
|
||||
@ -63,21 +64,6 @@ const PipRegistry: Registry[] = [
|
||||
|
||||
type TabKey = 'settings' | 'description' | 'tools' | 'prompts' | 'resources'
|
||||
|
||||
const parseKeyValueString = (str: string): Record<string, string> => {
|
||||
const result: Record<string, string> = {}
|
||||
str.split('\n').forEach((line) => {
|
||||
if (line.trim()) {
|
||||
const [key, ...value] = line.split('=')
|
||||
const formatValue = value.join('=').trim()
|
||||
const formatKey = key.trim()
|
||||
if (formatKey && formatValue) {
|
||||
result[formatKey] = formatValue
|
||||
}
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const McpSettings: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { serverId } = useParams<{ serverId: string }>()
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { adaptProvider } from '@renderer/aiCore/provider/providerConfig'
|
||||
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { HelpTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import { PROVIDER_URLS } from '@renderer/config/providers'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
@ -19,14 +21,7 @@ import type { SystemProviderId } from '@renderer/types'
|
||||
import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types'
|
||||
import type { ApiKeyConnectivity } from '@renderer/types/healthCheck'
|
||||
import { HealthStatus } from '@renderer/types/healthCheck'
|
||||
import {
|
||||
formatApiHost,
|
||||
formatApiKeys,
|
||||
formatAzureOpenAIApiHost,
|
||||
formatVertexApiHost,
|
||||
getFancyProviderName,
|
||||
validateApiHost
|
||||
} from '@renderer/utils'
|
||||
import { formatApiHost, formatApiKeys, getFancyProviderName, validateApiHost } from '@renderer/utils'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import {
|
||||
isAIGatewayProvider,
|
||||
@ -36,7 +31,6 @@ import {
|
||||
isNewApiProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
isOpenAIProvider,
|
||||
isSupportAPIVersionProvider,
|
||||
isVertexProvider
|
||||
} from '@renderer/utils/provider'
|
||||
import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd'
|
||||
@ -282,12 +276,10 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
}, [configuredApiHost, apiHost])
|
||||
|
||||
const hostPreview = () => {
|
||||
if (apiHost.endsWith('#')) {
|
||||
return apiHost.replace('#', '')
|
||||
}
|
||||
const formattedApiHost = adaptProvider({ provider: { ...provider, apiHost } }).apiHost
|
||||
|
||||
if (isOpenAICompatibleProvider(provider)) {
|
||||
return formatApiHost(apiHost, isSupportAPIVersionProvider(provider)) + '/chat/completions'
|
||||
return formattedApiHost + '/chat/completions'
|
||||
}
|
||||
|
||||
if (isAzureOpenAIProvider(provider)) {
|
||||
@ -295,29 +287,26 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
const path = !['preview', 'v1'].includes(apiVersion)
|
||||
? `/v1/chat/completion?apiVersion=v1`
|
||||
: `/v1/responses?apiVersion=v1`
|
||||
return formatAzureOpenAIApiHost(apiHost) + path
|
||||
return formattedApiHost + path
|
||||
}
|
||||
|
||||
if (isAnthropicProvider(provider)) {
|
||||
// AI SDK uses the baseURL with /v1, then appends /messages
|
||||
// formatApiHost adds /v1 automatically if not present
|
||||
const normalizedHost = formatApiHost(apiHost)
|
||||
return normalizedHost + '/messages'
|
||||
return formattedApiHost + '/messages'
|
||||
}
|
||||
|
||||
if (isGeminiProvider(provider)) {
|
||||
return formatApiHost(apiHost, true, 'v1beta') + '/models'
|
||||
return formattedApiHost + '/models'
|
||||
}
|
||||
if (isOpenAIProvider(provider)) {
|
||||
return formatApiHost(apiHost) + '/responses'
|
||||
return formattedApiHost + '/responses'
|
||||
}
|
||||
if (isVertexProvider(provider)) {
|
||||
return formatVertexApiHost(provider) + '/publishers/google'
|
||||
return formattedApiHost + '/publishers/google'
|
||||
}
|
||||
if (isAIGatewayProvider(provider)) {
|
||||
return formatApiHost(apiHost) + '/language-model'
|
||||
return formattedApiHost + '/language-model'
|
||||
}
|
||||
return formatApiHost(apiHost)
|
||||
return formattedApiHost
|
||||
}
|
||||
|
||||
// API key 连通性检查状态指示器,目前仅在失败时显示
|
||||
@ -495,16 +484,21 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
{!isDmxapi && (
|
||||
<>
|
||||
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Tooltip title={hostSelectorTooltip} mouseEnterDelay={0.3}>
|
||||
<Selector
|
||||
size={14}
|
||||
value={activeHostField}
|
||||
onChange={(value) => setActiveHostField(value as HostField)}
|
||||
options={hostSelectorOptions}
|
||||
style={{ paddingLeft: 1, fontWeight: 'bold' }}
|
||||
placement="bottomLeft"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip title={hostSelectorTooltip} mouseEnterDelay={0.3}>
|
||||
<div>
|
||||
<Selector
|
||||
size={14}
|
||||
value={activeHostField}
|
||||
onChange={(value) => setActiveHostField(value as HostField)}
|
||||
options={hostSelectorOptions}
|
||||
style={{ paddingLeft: 1, fontWeight: 'bold' }}
|
||||
placement="bottomLeft"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<HelpTooltip title={t('settings.provider.api.url.tip')}></HelpTooltip>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Button
|
||||
type="text"
|
||||
|
||||
@ -34,6 +34,15 @@ export const MCPToolInputSchema = z
|
||||
required: z.array(z.string()).optional()
|
||||
})
|
||||
.loose()
|
||||
.transform((schema) => {
|
||||
if (!schema.properties) {
|
||||
schema.properties = {}
|
||||
}
|
||||
if (!schema.required) {
|
||||
schema.required = []
|
||||
}
|
||||
return schema
|
||||
})
|
||||
|
||||
export interface BuiltinTool extends BaseTool {
|
||||
inputSchema: z.infer<typeof MCPToolInputSchema>
|
||||
|
||||
@ -13,7 +13,8 @@ import {
|
||||
routeToEndpoint,
|
||||
splitApiKeyString,
|
||||
validateApiHost,
|
||||
withoutTrailingApiVersion
|
||||
withoutTrailingApiVersion,
|
||||
withoutTrailingSharp
|
||||
} from '../api'
|
||||
|
||||
vi.mock('@renderer/store', () => {
|
||||
@ -81,6 +82,27 @@ describe('api', () => {
|
||||
it('keeps host untouched when api version unsupported', () => {
|
||||
expect(formatApiHost('https://api.example.com', false)).toBe('https://api.example.com')
|
||||
})
|
||||
|
||||
it('removes trailing # and does not append api version when host ends with #', () => {
|
||||
expect(formatApiHost('https://api.example.com#')).toBe('https://api.example.com')
|
||||
expect(formatApiHost('http://localhost:5173/#')).toBe('http://localhost:5173/')
|
||||
expect(formatApiHost(' https://api.openai.com/# ')).toBe('https://api.openai.com/')
|
||||
})
|
||||
|
||||
it('handles trailing # with custom api version settings', () => {
|
||||
expect(formatApiHost('https://api.example.com#', true, 'v2')).toBe('https://api.example.com')
|
||||
expect(formatApiHost('https://api.example.com#', false, 'v2')).toBe('https://api.example.com')
|
||||
})
|
||||
|
||||
it('handles host with both trailing # and existing api version', () => {
|
||||
expect(formatApiHost('https://api.example.com/v2#')).toBe('https://api.example.com/v2')
|
||||
expect(formatApiHost('https://api.example.com/v3beta#')).toBe('https://api.example.com/v3beta')
|
||||
})
|
||||
|
||||
it('trims whitespace before processing trailing #', () => {
|
||||
expect(formatApiHost(' https://api.example.com# ')).toBe('https://api.example.com')
|
||||
expect(formatApiHost('\thttps://api.example.com#\n')).toBe('https://api.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAPIVersion', () => {
|
||||
@ -393,4 +415,56 @@ describe('api', () => {
|
||||
expect(withoutTrailingApiVersion('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withoutTrailingSharp', () => {
|
||||
it('removes trailing # from URL', () => {
|
||||
expect(withoutTrailingSharp('https://api.example.com#')).toBe('https://api.example.com')
|
||||
expect(withoutTrailingSharp('http://localhost:3000#')).toBe('http://localhost:3000')
|
||||
})
|
||||
|
||||
it('returns URL unchanged when no trailing #', () => {
|
||||
expect(withoutTrailingSharp('https://api.example.com')).toBe('https://api.example.com')
|
||||
expect(withoutTrailingSharp('http://localhost:3000')).toBe('http://localhost:3000')
|
||||
})
|
||||
|
||||
it('handles URLs with multiple # characters but only removes trailing one', () => {
|
||||
expect(withoutTrailingSharp('https://api.example.com#path#')).toBe('https://api.example.com#path')
|
||||
})
|
||||
|
||||
it('handles URLs with # in the middle (not trailing)', () => {
|
||||
expect(withoutTrailingSharp('https://api.example.com#section/path')).toBe('https://api.example.com#section/path')
|
||||
expect(withoutTrailingSharp('https://api.example.com/v1/chat/completions#')).toBe(
|
||||
'https://api.example.com/v1/chat/completions'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(withoutTrailingSharp('')).toBe('')
|
||||
})
|
||||
|
||||
it('handles single character #', () => {
|
||||
expect(withoutTrailingSharp('#')).toBe('')
|
||||
})
|
||||
|
||||
it('preserves whitespace around the URL (pure function)', () => {
|
||||
expect(withoutTrailingSharp(' https://api.example.com# ')).toBe(' https://api.example.com# ')
|
||||
expect(withoutTrailingSharp('\thttps://api.example.com#\n')).toBe('\thttps://api.example.com#\n')
|
||||
})
|
||||
|
||||
it('only removes exact trailing # character', () => {
|
||||
expect(withoutTrailingSharp('https://api.example.com# ')).toBe('https://api.example.com# ')
|
||||
expect(withoutTrailingSharp(' https://api.example.com#')).toBe(' https://api.example.com')
|
||||
expect(withoutTrailingSharp('https://api.example.com#\t')).toBe('https://api.example.com#\t')
|
||||
})
|
||||
|
||||
it('handles URLs ending with multiple # characters', () => {
|
||||
expect(withoutTrailingSharp('https://api.example.com##')).toBe('https://api.example.com#')
|
||||
expect(withoutTrailingSharp('https://api.example.com###')).toBe('https://api.example.com##')
|
||||
})
|
||||
|
||||
it('preserves URL with trailing # and other content', () => {
|
||||
expect(withoutTrailingSharp('https://api.example.com/v1#')).toBe('https://api.example.com/v1')
|
||||
expect(withoutTrailingSharp('https://api.example.com/v2beta#')).toBe('https://api.example.com/v2beta')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
98
src/renderer/src/utils/__tests__/env.test.ts
Normal file
98
src/renderer/src/utils/__tests__/env.test.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseKeyValueString } from '../env'
|
||||
|
||||
describe('parseKeyValueString', () => {
|
||||
it('should parse empty string', () => {
|
||||
expect(parseKeyValueString('')).toEqual({})
|
||||
})
|
||||
|
||||
it('should parse single key-value pair', () => {
|
||||
expect(parseKeyValueString('KEY=value')).toEqual({ KEY: 'value' })
|
||||
})
|
||||
|
||||
it('should parse multiple key-value pairs', () => {
|
||||
const input = `KEY1=value1
|
||||
KEY2=value2
|
||||
KEY3=value3`
|
||||
expect(parseKeyValueString(input)).toEqual({
|
||||
KEY1: 'value1',
|
||||
KEY2: 'value2',
|
||||
KEY3: 'value3'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle quoted values', () => {
|
||||
expect(parseKeyValueString('KEY="quoted value"')).toEqual({ KEY: 'quoted value' })
|
||||
})
|
||||
|
||||
it('should handle single quoted values', () => {
|
||||
expect(parseKeyValueString("KEY='single quoted'")).toEqual({ KEY: 'single quoted' })
|
||||
})
|
||||
|
||||
it('should handle values with equals signs', () => {
|
||||
expect(parseKeyValueString('URL=https://example.com?param=value')).toEqual({
|
||||
URL: 'https://example.com?param=value'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty values', () => {
|
||||
expect(parseKeyValueString('KEY=')).toEqual({ KEY: '' })
|
||||
})
|
||||
|
||||
it('should handle comments', () => {
|
||||
const input = `KEY=value
|
||||
# This is a comment
|
||||
ANOTHER_KEY=another_value`
|
||||
expect(parseKeyValueString(input)).toEqual({
|
||||
KEY: 'value',
|
||||
ANOTHER_KEY: 'another_value'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle whitespace around key-value pairs', () => {
|
||||
expect(parseKeyValueString(' KEY=value \n ANOTHER=another ')).toEqual({
|
||||
KEY: 'value',
|
||||
ANOTHER: 'another'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle special characters in values', () => {
|
||||
expect(parseKeyValueString('KEY=value with spaces & symbols!')).toEqual({
|
||||
KEY: 'value with spaces & symbols!'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle multiline values', () => {
|
||||
const input = `KEY="value
|
||||
with
|
||||
multiple
|
||||
lines"`
|
||||
expect(parseKeyValueString(input)).toEqual({
|
||||
KEY: 'value\nwith\nmultiple\nlines'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle invalid lines gracefully', () => {
|
||||
const input = `KEY=value
|
||||
invalid line without equals
|
||||
ANOTHER_KEY=another_value`
|
||||
expect(parseKeyValueString(input)).toEqual({
|
||||
KEY: 'value',
|
||||
ANOTHER_KEY: 'another_value'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle duplicate keys (last one wins)', () => {
|
||||
const input = `KEY=first
|
||||
KEY=second
|
||||
KEY=third`
|
||||
expect(parseKeyValueString(input)).toEqual({ KEY: 'third' })
|
||||
})
|
||||
|
||||
it('should handle keys and values with special characters', () => {
|
||||
expect(parseKeyValueString('API-URL_123=https://api.example.com/v1/users')).toEqual({
|
||||
'API-URL_123': 'https://api.example.com/v1/users'
|
||||
})
|
||||
})
|
||||
})
|
||||
5
src/renderer/src/utils/env.ts
Normal file
5
src/renderer/src/utils/env.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { parse } from 'dotenv'
|
||||
|
||||
export const parseKeyValueString = (str: string): Record<string, string> => {
|
||||
return parse(str)
|
||||
}
|
||||
@ -136,7 +136,10 @@ export async function callMCPTool(
|
||||
topicId?: string,
|
||||
modelName?: string
|
||||
): Promise<MCPCallToolResponse> {
|
||||
logger.info(`Calling Tool: ${toolResponse.tool.serverName} ${toolResponse.tool.name}`, toolResponse.tool)
|
||||
logger.info(
|
||||
`Calling Tool: ${toolResponse.id} ${toolResponse.tool.serverName} ${toolResponse.tool.name}`,
|
||||
toolResponse.tool
|
||||
)
|
||||
try {
|
||||
const server = getMcpServerByTool(toolResponse.tool)
|
||||
|
||||
|
||||
78
yarn.lock
78
yarn.lock
@ -4747,11 +4747,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@modelcontextprotocol/sdk@npm:^1.17.5":
|
||||
version: 1.17.5
|
||||
resolution: "@modelcontextprotocol/sdk@npm:1.17.5"
|
||||
"@modelcontextprotocol/sdk@npm:^1.23.0":
|
||||
version: 1.23.0
|
||||
resolution: "@modelcontextprotocol/sdk@npm:1.23.0"
|
||||
dependencies:
|
||||
ajv: "npm:^6.12.6"
|
||||
ajv: "npm:^8.17.1"
|
||||
ajv-formats: "npm:^3.0.1"
|
||||
content-type: "npm:^1.0.5"
|
||||
cors: "npm:^2.8.5"
|
||||
cross-spawn: "npm:^7.0.5"
|
||||
@ -4761,9 +4762,17 @@ __metadata:
|
||||
express-rate-limit: "npm:^7.5.0"
|
||||
pkce-challenge: "npm:^5.0.0"
|
||||
raw-body: "npm:^3.0.0"
|
||||
zod: "npm:^3.23.8"
|
||||
zod-to-json-schema: "npm:^3.24.1"
|
||||
checksum: 10c0/182b92b5e7c07da428fd23c6de22021c4f9a91f799c02a8ef15def07e4f9361d0fc22303548658fec2a700623535fd44a9dc4d010fb5d803a8f80e3c6c64a45e
|
||||
zod: "npm:^3.25 || ^4.0"
|
||||
zod-to-json-schema: "npm:^3.25.0"
|
||||
peerDependencies:
|
||||
"@cfworker/json-schema": ^4.1.1
|
||||
zod: ^3.25 || ^4.0
|
||||
peerDependenciesMeta:
|
||||
"@cfworker/json-schema":
|
||||
optional: true
|
||||
zod:
|
||||
optional: false
|
||||
checksum: 10c0/b0291f921ad9bda06bbf1a61b1bb61ceca1173da5d74d39a411c40428d6ca50a95f0de3a1631f25a44b439220b15c30c1306600bf48bef665ab7ad118d528260
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -8524,6 +8533,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/dotenv@npm:^8.2.3":
|
||||
version: 8.2.3
|
||||
resolution: "@types/dotenv@npm:8.2.3"
|
||||
dependencies:
|
||||
dotenv: "npm:*"
|
||||
checksum: 10c0/af9178da617959cddc8259aaa3f16c474523ead469f4a03490de2f2d1cafc8615c5d0d1ed3fad837096218126421c38cd46b4065548bb5aee3cc002c518b69f7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/estree-jsx@npm:^1.0.0":
|
||||
version: 1.0.5
|
||||
resolution: "@types/estree-jsx@npm:1.0.5"
|
||||
@ -10046,7 +10064,7 @@ __metadata:
|
||||
"@libsql/client": "npm:0.14.0"
|
||||
"@libsql/win32-x64-msvc": "npm:^0.4.7"
|
||||
"@mistralai/mistralai": "npm:^1.7.5"
|
||||
"@modelcontextprotocol/sdk": "npm:^1.17.5"
|
||||
"@modelcontextprotocol/sdk": "npm:^1.23.0"
|
||||
"@mozilla/readability": "npm:^0.6.0"
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch"
|
||||
"@notionhq/client": "npm:^2.2.15"
|
||||
@ -10094,6 +10112,7 @@ __metadata:
|
||||
"@types/content-type": "npm:^1.1.9"
|
||||
"@types/cors": "npm:^2.8.19"
|
||||
"@types/diff": "npm:^7"
|
||||
"@types/dotenv": "npm:^8.2.3"
|
||||
"@types/express": "npm:^5"
|
||||
"@types/fs-extra": "npm:^11"
|
||||
"@types/he": "npm:^1"
|
||||
@ -10403,6 +10422,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ajv-formats@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "ajv-formats@npm:3.0.1"
|
||||
dependencies:
|
||||
ajv: "npm:^8.0.0"
|
||||
peerDependencies:
|
||||
ajv: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
checksum: 10c0/168d6bca1ea9f163b41c8147bae537e67bd963357a5488a1eaf3abe8baa8eec806d4e45f15b10767e6020679315c7e1e5e6803088dfb84efa2b4e9353b83dd0a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ajv-keywords@npm:^3.4.1":
|
||||
version: 3.5.2
|
||||
resolution: "ajv-keywords@npm:3.5.2"
|
||||
@ -10412,7 +10445,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ajv@npm:^6.10.0, ajv@npm:^6.12.0, ajv@npm:^6.12.4, ajv@npm:^6.12.6":
|
||||
"ajv@npm:^6.10.0, ajv@npm:^6.12.0, ajv@npm:^6.12.4":
|
||||
version: 6.12.6
|
||||
resolution: "ajv@npm:6.12.6"
|
||||
dependencies:
|
||||
@ -10424,7 +10457,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ajv@npm:^8.0.0, ajv@npm:^8.6.3":
|
||||
"ajv@npm:^8.0.0, ajv@npm:^8.17.1, ajv@npm:^8.6.3":
|
||||
version: 8.17.1
|
||||
resolution: "ajv@npm:8.17.1"
|
||||
dependencies:
|
||||
@ -13425,6 +13458,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dotenv@npm:*":
|
||||
version: 17.2.3
|
||||
resolution: "dotenv@npm:17.2.3"
|
||||
checksum: 10c0/c884403209f713214a1b64d4d1defa4934c2aa5b0002f5a670ae298a51e3c3ad3ba79dfee2f8df49f01ae74290fcd9acdb1ab1d09c7bfb42b539036108bb2ba0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dotenv@npm:^16.1.4, dotenv@npm:^16.3.0, dotenv@npm:^16.3.1, dotenv@npm:^16.4.5":
|
||||
version: 16.6.1
|
||||
resolution: "dotenv@npm:16.6.1"
|
||||
@ -26353,6 +26393,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zod-to-json-schema@npm:^3.25.0":
|
||||
version: 3.25.0
|
||||
resolution: "zod-to-json-schema@npm:3.25.0"
|
||||
peerDependencies:
|
||||
zod: ^3.25 || ^4
|
||||
checksum: 10c0/2d2cf6ca49752bf3dc5fb37bc8f275eddbbc4020e7958d9c198ea88cd197a5f527459118188a0081b889da6a6474d64c4134cd60951fa70178c125138761c680
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zod-validation-error@npm:^3.4.0":
|
||||
version: 3.4.0
|
||||
resolution: "zod-validation-error@npm:3.4.0"
|
||||
@ -26362,13 +26411,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zod@npm:^3.22.4, zod@npm:^3.23.8, zod@npm:^3.24.1":
|
||||
"zod@npm:^3.22.4, zod@npm:^3.24.1":
|
||||
version: 3.25.56
|
||||
resolution: "zod@npm:3.25.56"
|
||||
checksum: 10c0/3800f01d4b1df932b91354eb1e648f69cc7e5561549e6d2bf83827d930a5f33bbf92926099445f6fc1ebb64ca9c6513ef9ae5e5409cfef6325f354bcf6fc9a24
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zod@npm:^3.25 || ^4.0":
|
||||
version: 4.1.13
|
||||
resolution: "zod@npm:4.1.13"
|
||||
checksum: 10c0/d7e74e82dba81a91ffc3239cd85bc034abe193a28f7087a94ab258a3e48e9a7ca4141920cac979a0d781495b48fc547777394149f26be04c3dc642f58bbc3941
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zod@npm:^3.25.0 || ^4.0.0, zod@npm:^3.25.76 || ^4":
|
||||
version: 4.1.12
|
||||
resolution: "zod@npm:4.1.12"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user