diff --git a/README.md b/README.md index e372322f..ebe1c040 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,13 @@ To bring your own key with a custom model manually: Full worked example (custom model parameters + API key) and protocol details: [Configuration](./docs/configuration.md#bring-your-own-api-key). +## Slow Providers + +Provider responses are not subject to an application-level response timeout, so +local models may take as long as necessary to load or generate output. Provider +connections still have an internal connection-establishment deadline, and every +request can be cancelled by the user. + ## Docs - [Offline Installation](./docs/offline-installation.md) diff --git a/README.zh-Hans.md b/README.zh-Hans.md index f634be8d..0962f7c5 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -214,6 +214,11 @@ devo resume 完整示例(自定义模型参数 + API key)与协议说明见 [配置](./docs/configuration.zh-Hans.md#接入自有-api-key)。 +## 响应较慢的 Provider + +Provider 响应没有应用层总超时,因此本地模型可以按需要花费时间加载或生成输出。 +Provider 连接建立仍有内部期限,并且用户可以随时取消请求。 + ## Docs - [离线安装](./docs/offline-installation.zh-Hans.md) diff --git a/apps/desktop/src/main/native-stdio-client.test.ts b/apps/desktop/src/main/native-stdio-client.test.ts index 20eda813..b7033f90 100644 --- a/apps/desktop/src/main/native-stdio-client.test.ts +++ b/apps/desktop/src/main/native-stdio-client.test.ts @@ -182,10 +182,12 @@ describe("StdioNativeClient", () => { sessionList: requestTimeoutMsForMethod("session/list", 10_000), mcpTools: requestTimeoutMsForMethod("mcp/tools", 10_000), mcpSetEnabled: requestTimeoutMsForMethod("mcp/set_enabled", 5), + providerValidate: requestTimeoutMsForMethod("provider/validate", 10_000), }).toEqual({ sessionList: 10_000, mcpTools: 60_000, mcpSetEnabled: 60_000, + providerValidate: undefined, }) }) diff --git a/apps/desktop/src/main/native-stdio-client.ts b/apps/desktop/src/main/native-stdio-client.ts index cec5bad3..116434b5 100644 --- a/apps/desktop/src/main/native-stdio-client.ts +++ b/apps/desktop/src/main/native-stdio-client.ts @@ -29,14 +29,17 @@ export type JsonRpcId = number | string type PendingRequest = { resolve: (value: unknown) => void reject: (error: Error) => void - timer: ReturnType + timer?: ReturnType } const REQUEST_TIMEOUT_MS = 10_000 /** MCP admin RPCs may start a lazy server before listing tools. */ export const MCP_ADMIN_REQUEST_TIMEOUT_MS = 60_000 -export function requestTimeoutMsForMethod(method: string, fallbackMs: number): number { +export function requestTimeoutMsForMethod(method: string, fallbackMs: number): number | undefined { + if (method === "provider/validate") { + return undefined + } if (method === "mcp/tools" || method === "mcp/set_enabled") { return Math.max(fallbackMs, MCP_ADMIN_REQUEST_TIMEOUT_MS) } @@ -295,11 +298,17 @@ export class StdioNativeClient implements NativeTransport { const scopedParams = scopeRequestParams(method, params, directory) const payload = { jsonrpc: "2.0", id, method, params: scopedParams } const response = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - if (!this.pending.delete(id)) return - this.pendingMethods.delete(id) - reject(new Error(`${method} request ${id} timed out`)) - }, requestTimeoutMsForMethod(method, this.options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS)) + const timeoutMs = requestTimeoutMsForMethod( + method, + this.options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS, + ) + const timer = timeoutMs === undefined + ? undefined + : setTimeout(() => { + if (!this.pending.delete(id)) return + this.pendingMethods.delete(id) + reject(new Error(`${method} request ${id} timed out`)) + }, timeoutMs) this.pending.set(id, { resolve, reject, timer }) }) this.pendingMethods.set(id, method) @@ -315,7 +324,7 @@ export class StdioNativeClient implements NativeTransport { } catch (error) { const reason = toError(error) const pending = this.pending.get(id) - if (pending) clearTimeout(pending.timer) + if (pending?.timer !== undefined) clearTimeout(pending.timer) this.pending.delete(id) this.pendingMethods.delete(id) this.close(reason) @@ -399,7 +408,7 @@ export class StdioNativeClient implements NativeTransport { const pending = this.pending.get(routed.id) if (!pending) return this.pending.delete(routed.id) - clearTimeout(pending.timer) + if (pending.timer !== undefined) clearTimeout(pending.timer) const error = routed.message.error as { message?: string } | undefined if (error) { pending.reject(new Error(error.message ?? "Devo Native request failed")) @@ -485,7 +494,7 @@ export class StdioNativeClient implements NativeTransport { payload: { error: error.message }, }) for (const pending of this.pending.values()) { - clearTimeout(pending.timer) + if (pending.timer !== undefined) clearTimeout(pending.timer) pending.reject(error) } this.pending.clear() diff --git a/apps/desktop/src/renderer/components/settings/provider-settings.test.tsx b/apps/desktop/src/renderer/components/settings/provider-settings.test.tsx index b5e26998..d85c7335 100644 --- a/apps/desktop/src/renderer/components/settings/provider-settings.test.tsx +++ b/apps/desktop/src/renderer/components/settings/provider-settings.test.tsx @@ -196,6 +196,27 @@ describe("ProviderSettings", () => { expect(calls).toEqual(["validate"]) }) + test("cancelled validation does not upsert", async () => { + const calls: string[] = [] + const params = buildProviderUpsertParams(formValues, null) + const client = { + provider: { + validate: async () => { + calls.push("validate") + return { data: { reply_preview: "OK" } } + }, + upsert: async () => { + calls.push("upsert") + return { data: { provider_vendor: providerVendor } } + }, + }, + } + + const cancelled = true + await saveProviderVendor(client, params, () => !cancelled) + expect(calls).toEqual([]) + }) + test("provider dialog scrolls form body while keeping footer actions outside", () => { const queryClient = new QueryClient() const markup = renderToStaticMarkup( diff --git a/apps/desktop/src/renderer/components/settings/provider-vendor-dialog.tsx b/apps/desktop/src/renderer/components/settings/provider-vendor-dialog.tsx index ae5d116f..7e947876 100644 --- a/apps/desktop/src/renderer/components/settings/provider-vendor-dialog.tsx +++ b/apps/desktop/src/renderer/components/settings/provider-vendor-dialog.tsx @@ -38,7 +38,7 @@ import { Spinner } from "@devo/ui/components/spinner" import { Textarea } from "@devo/ui/components/textarea" import { useQueryClient } from "@tanstack/react-query" import { SaveIcon } from "lucide-react" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { queryKeys } from "../../hooks/use-devo-data" import { createLogger } from "../../lib/logger" import { getBaseClient, invalidateConfigOptionCaches } from "../../services/connection-manager" @@ -170,16 +170,19 @@ export function buildProviderUpsertParams( export async function saveProviderVendor( client: ProviderVendorClient, params: ProviderVendorUpsertParams, + shouldContinue: () => boolean = () => true, ) { if (!params.model_binding) { throw new Error("Model binding is required") } + if (!shouldContinue()) return const validateParams: ProviderValidateParams = { provider_vendor: params.provider_vendor, model_binding: params.model_binding, ...(params.api_key ? { api_key: params.api_key } : {}), } await client.provider.validate(validateParams) + if (!shouldContinue()) return return client.provider.upsert(params) } @@ -193,8 +196,10 @@ export function ProviderVendorDialog({ const [values, setValues] = useState(() => initialValues(providerVendor)) const [error, setError] = useState(null) const [saving, setSaving] = useState(false) + const saveAttemptRef = useRef(0) useEffect(() => { + saveAttemptRef.current += 1 if (!open) return setValues(initialValues(providerVendor)) setError(null) @@ -211,13 +216,15 @@ export function ProviderVendorDialog({ const handleSubmit = useCallback( async (event: React.FormEvent) => { event.preventDefault() + const saveAttempt = ++saveAttemptRef.current setSaving(true) setError(null) try { const client = getBaseClient() if (!client) throw new Error("Not connected to server") const params = buildProviderUpsertParams(values, providerVendor) - await saveProviderVendor(client, params) + await saveProviderVendor(client, params, () => saveAttemptRef.current === saveAttempt) + if (saveAttemptRef.current !== saveAttempt) return invalidateConfigOptionCaches() queryClient.invalidateQueries({ queryKey: queryKeys.providerVendors }) queryClient.invalidateQueries({ @@ -226,18 +233,32 @@ export function ProviderVendorDialog({ onSaved() onOpenChange(false) } catch (err) { + if (saveAttemptRef.current !== saveAttempt) return const message = err instanceof Error ? err.message : "Failed to save provider" log.error("Failed to save provider", { error: err }) setError(message) } finally { - setSaving(false) + if (saveAttemptRef.current === saveAttempt) setSaving(false) } }, [values, providerVendor, queryClient, onSaved, onOpenChange], ) + const handleDialogOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + // Closing while validation is pending invalidates the continuation so + // a late validation response cannot persist the provider. + saveAttemptRef.current += 1 + setSaving(false) + } + onOpenChange(nextOpen) + }, + [onOpenChange], + ) + return ( - +
@@ -399,7 +420,7 @@ export function ProviderVendorDialog({ className="shrink-0 bg-background px-6 py-4" data-testid="provider-dialog-footer" > -