This commit is contained in:
GeekMr 2025-12-18 22:23:26 +08:00 committed by GitHub
commit 37d7ae4a93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 740 additions and 9 deletions

View File

@ -32,6 +32,10 @@ export class AiSdkToChunkAdapter {
private firstTokenTimestamp: number | null = null
private hasTextContent = false
private getSessionWasCleared?: () => boolean
private idleTimeoutMs?: number
private idleAbortController?: AbortController
private idleTimeoutTimer: ReturnType<typeof setTimeout> | null = null
private idleTimeoutTriggered = false
constructor(
private onChunk: (chunk: Chunk) => void,
@ -39,13 +43,19 @@ export class AiSdkToChunkAdapter {
accumulate?: boolean,
enableWebSearch?: boolean,
onSessionUpdate?: (sessionId: string) => void,
getSessionWasCleared?: () => boolean
getSessionWasCleared?: () => boolean,
streamingConfig?: {
idleTimeoutMs: number
idleAbortController: AbortController
}
) {
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
this.accumulate = accumulate
this.enableWebSearch = enableWebSearch || false
this.onSessionUpdate = onSessionUpdate
this.getSessionWasCleared = getSessionWasCleared
this.idleTimeoutMs = streamingConfig?.idleTimeoutMs
this.idleAbortController = streamingConfig?.idleAbortController
}
private markFirstTokenIfNeeded() {
@ -59,6 +69,27 @@ export class AiSdkToChunkAdapter {
this.firstTokenTimestamp = null
}
private clearIdleTimeoutTimer() {
if (this.idleTimeoutTimer) {
clearTimeout(this.idleTimeoutTimer)
this.idleTimeoutTimer = null
}
}
private resetIdleTimeoutTimer() {
if (!this.idleTimeoutMs || this.idleTimeoutMs <= 0 || !this.idleAbortController) {
return
}
this.clearIdleTimeoutTimer()
this.idleTimeoutTimer = setTimeout(() => {
this.idleTimeoutTriggered = true
logger.warn('SSE idle timeout reached; aborting request', { idleTimeoutMs: this.idleTimeoutMs })
this.idleAbortController?.abort()
}, this.idleTimeoutMs)
}
/**
* AI SDK
* @param aiSdkResult AI SDK
@ -88,6 +119,8 @@ export class AiSdkToChunkAdapter {
}
this.resetTimingState()
this.responseStartTimestamp = Date.now()
this.idleTimeoutTriggered = false
this.resetIdleTimeoutTimer()
// Reset state at the start of stream
this.isFirstChunk = true
this.hasTextContent = false
@ -111,10 +144,12 @@ export class AiSdkToChunkAdapter {
break
}
this.resetIdleTimeoutTimer()
// 转换并发送 chunk
this.convertAndEmitChunk(value, final)
}
} finally {
this.clearIdleTimeoutTimer()
reader.releaseLock()
this.resetTimingState()
}
@ -380,7 +415,12 @@ export class AiSdkToChunkAdapter {
case 'abort':
this.onChunk({
type: ChunkType.ERROR,
error: new DOMException('Request was aborted', 'AbortError')
error: this.idleTimeoutTriggered
? new DOMException(
`SSE idle timeout after ${Math.round((this.idleTimeoutMs ?? 0) / 60000)} minutes`,
'TimeoutError'
)
: new DOMException('Request was aborted', 'AbortError')
})
break
case 'error':

View File

@ -42,6 +42,10 @@ export type ModernAiProviderConfig = AiSdkMiddlewareConfig & {
// topicId for tracing
topicId?: string
callType: string
streamingConfig?: {
idleTimeoutMs: number
idleAbortController: AbortController
}
}
export default class ModernAiProvider {
@ -330,7 +334,15 @@ export default class ModernAiProvider {
// 创建带有中间件的执行器
if (config.onChunk) {
const accumulate = this.model!.supported_text_delta !== false // true and undefined
const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate, config.enableWebSearch)
const adapter = new AiSdkToChunkAdapter(
config.onChunk,
config.mcpTools ?? [],
accumulate,
config.enableWebSearch,
undefined,
undefined,
config.streamingConfig
)
const streamResult = await executor.streamText({
...params,

View File

@ -0,0 +1,161 @@
import type { Assistant, Model, Provider } from '@renderer/types'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@renderer/services/AssistantService', () => ({
DEFAULT_ASSISTANT_SETTINGS: {
temperature: 0.7,
enableTemperature: true,
contextCount: 5,
enableMaxTokens: false,
maxTokens: 0,
streamOutput: true,
topP: 1,
enableTopP: false,
toolUseMode: 'function',
customParameters: []
},
getDefaultAssistant: vi.fn(() => ({
id: 'default',
name: 'Default Assistant',
prompt: '',
type: 'assistant',
topics: [],
settings: {
temperature: 0.7,
enableTemperature: true,
contextCount: 5,
enableMaxTokens: false,
maxTokens: 0,
streamOutput: true,
topP: 1,
enableTopP: false,
toolUseMode: 'function',
customParameters: []
}
})),
getDefaultModel: vi.fn(() => ({
id: 'gpt-4o',
provider: 'openai',
name: 'GPT-4o',
group: 'openai'
})),
getAssistantSettings: vi.fn((assistant: any) => assistant?.settings ?? {}),
getProviderByModel: vi.fn(() => ({
id: 'openai',
type: 'openai',
name: 'OpenAI',
apiKey: '',
apiHost: 'https://example.com/v1',
models: []
})),
getDefaultTopic: vi.fn(() => ({
id: 'topic-1',
assistantId: 'default',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
name: 'Default Topic',
messages: [],
isNameManuallyEdited: false
}))
}))
vi.mock('@renderer/store', () => ({
default: {
getState: vi.fn(() => ({
websearch: {
maxResults: 5,
excludeDomains: [],
searchWithTime: false
}
}))
}
}))
vi.mock('@renderer/utils/prompt', () => ({
replacePromptVariables: vi.fn(async (prompt: string) => prompt)
}))
vi.mock('../../utils/mcp', () => ({
setupToolsConfig: vi.fn(() => undefined)
}))
vi.mock('../../utils/options', () => ({
buildProviderOptions: vi.fn(() => ({
providerOptions: {},
standardParams: {}
}))
}))
import { buildStreamTextParams } from '../parameterBuilder'
const createModel = (): Model => ({
id: 'gpt-4o',
provider: 'openai',
name: 'GPT-4o',
group: 'openai'
})
const createAssistant = (model: Model): Assistant => ({
id: 'assistant-1',
name: 'Assistant',
prompt: '',
type: 'assistant',
topics: [],
model,
settings: {}
})
const createProvider = (model: Model, overrides: Partial<Provider> = {}): Provider => ({
id: 'openai-response',
type: 'openai-response',
name: 'OpenAI Responses',
apiKey: 'test',
apiHost: 'https://example.com/v1',
models: [model],
...overrides
})
describe('parameterBuilder.buildStreamTextParams', () => {
it('uses default max tool steps when unset', async () => {
const model = createModel()
const assistant = createAssistant(model)
const provider = createProvider(model)
const { params } = await buildStreamTextParams([], assistant, provider, {})
const stopWhen = params.stopWhen as any
expect(stopWhen({ steps: new Array(19) })).toBe(false)
expect(stopWhen({ steps: new Array(20) })).toBe(true)
})
it('uses provider.maxToolSteps when set', async () => {
const model = createModel()
const assistant = createAssistant(model)
const provider = createProvider(model, { maxToolSteps: 42 })
const { params } = await buildStreamTextParams([], assistant, provider, {})
const stopWhen = params.stopWhen as any
expect(stopWhen({ steps: new Array(41) })).toBe(false)
expect(stopWhen({ steps: new Array(42) })).toBe(true)
})
it('returns streamingConfig and abortSignal when SSE idle timeout is enabled', async () => {
const model = createModel()
const assistant = createAssistant(model)
const provider = createProvider(model, { sseIdleTimeoutMinutes: 10 })
const userAbortController = new AbortController()
const { params, streamingConfig } = await buildStreamTextParams([], assistant, provider, {
requestOptions: { signal: userAbortController.signal }
})
expect(streamingConfig?.idleTimeoutMs).toBe(10 * 60 * 1000)
expect(streamingConfig?.idleAbortController).toBeInstanceOf(AbortController)
expect(params.abortSignal).toBeDefined()
userAbortController.abort()
expect(params.abortSignal?.aborted).toBe(true)
})
})

View File

@ -40,6 +40,7 @@ import { stepCountIs } from 'ai'
import { getAiSdkProviderId } from '../provider/factory'
import { setupToolsConfig } from '../utils/mcp'
import { buildProviderOptions } from '../utils/options'
import { buildCombinedAbortSignal, normalizeMaxToolSteps, timeoutMinutesToMs } from '../utils/streamingTimeout'
import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch'
import { addAnthropicHeaders } from './header'
import { getMaxTokens, getTemperature, getTopP } from './modelParameters'
@ -95,6 +96,10 @@ export async function buildStreamTextParams(
enableUrlContext: boolean
}
webSearchPluginConfig?: WebSearchPluginConfig
streamingConfig?: {
idleTimeoutMs: number
idleAbortController: AbortController
}
}> {
const { mcpTools } = options
@ -218,6 +223,17 @@ export async function buildStreamTextParams(
// Note: standardParams (topK, frequencyPenalty, presencePenalty, stopSequences, seed)
// are extracted from custom parameters and passed directly to streamText()
// instead of being placed in providerOptions
const requestTimeoutMs = timeoutMinutesToMs(provider.requestTimeoutMinutes)
const idleTimeoutMs = timeoutMinutesToMs(provider.sseIdleTimeoutMinutes)
const idleAbortController = idleTimeoutMs ? new AbortController() : undefined
const abortSignal = buildCombinedAbortSignal([
options.requestOptions?.signal,
requestTimeoutMs ? AbortSignal.timeout(requestTimeoutMs) : undefined,
idleAbortController?.signal
])
const maxToolSteps = normalizeMaxToolSteps(provider.maxToolSteps)
const params: StreamTextParams = {
messages: sdkMessages,
maxOutputTokens: getMaxTokens(assistant, model),
@ -225,10 +241,10 @@ export async function buildStreamTextParams(
topP: getTopP(assistant, model),
// Include AI SDK standard params extracted from custom parameters
...standardParams,
abortSignal: options.requestOptions?.signal,
abortSignal,
headers,
providerOptions,
stopWhen: stepCountIs(20),
stopWhen: stepCountIs(maxToolSteps),
maxRetries: 0
}
@ -246,7 +262,14 @@ export async function buildStreamTextParams(
params,
modelId: model.id,
capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext },
webSearchPluginConfig
webSearchPluginConfig,
streamingConfig:
idleTimeoutMs && idleAbortController
? {
idleTimeoutMs,
idleAbortController
}
: undefined
}
}

View File

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
buildCombinedAbortSignal,
normalizeMaxToolSteps,
normalizeTimeoutMinutes,
timeoutMinutesToMs
} from '../streamingTimeout'
describe('streamingTimeout utils', () => {
it('normalizeTimeoutMinutes returns undefined for non-numbers', () => {
expect(normalizeTimeoutMinutes(undefined)).toBeUndefined()
expect(normalizeTimeoutMinutes(null)).toBeUndefined()
expect(normalizeTimeoutMinutes('10')).toBeUndefined()
})
it('normalizeTimeoutMinutes clamps to integer >= 0', () => {
expect(normalizeTimeoutMinutes(-1)).toBe(0)
expect(normalizeTimeoutMinutes(0)).toBe(0)
expect(normalizeTimeoutMinutes(1.9)).toBe(1)
})
it('timeoutMinutesToMs returns undefined for 0/undefined and converts minutes to ms', () => {
expect(timeoutMinutesToMs(undefined)).toBeUndefined()
expect(timeoutMinutesToMs(0)).toBeUndefined()
expect(timeoutMinutesToMs(2)).toBe(2 * 60 * 1000)
})
it('normalizeMaxToolSteps uses defaults and clamps', () => {
expect(normalizeMaxToolSteps(undefined, { defaultSteps: 20, maxSteps: 50 })).toBe(20)
expect(normalizeMaxToolSteps(-1, { defaultSteps: 20, maxSteps: 50 })).toBe(20)
expect(normalizeMaxToolSteps(10.2, { defaultSteps: 20, maxSteps: 50 })).toBe(10)
expect(normalizeMaxToolSteps(999, { defaultSteps: 20, maxSteps: 50 })).toBe(50)
})
it('buildCombinedAbortSignal returns undefined for empty and combines signals', () => {
expect(buildCombinedAbortSignal([])).toBeUndefined()
const controllerA = new AbortController()
const controllerB = new AbortController()
const single = buildCombinedAbortSignal([controllerA.signal])
expect(single).toBe(controllerA.signal)
const combined = buildCombinedAbortSignal([controllerA.signal, controllerB.signal])
expect(combined?.aborted).toBe(false)
controllerB.abort()
expect(combined?.aborted).toBe(true)
})
})

View File

@ -0,0 +1,40 @@
export const DEFAULT_MAX_TOOL_STEPS = 20
export const MAX_MAX_TOOL_STEPS = 500
export function normalizeTimeoutMinutes(value: unknown): number | undefined {
if (value === undefined || value === null) return undefined
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
const normalized = Math.max(0, Math.floor(value))
return normalized
}
export function timeoutMinutesToMs(minutes: unknown): number | undefined {
const normalized = normalizeTimeoutMinutes(minutes)
if (normalized === undefined || normalized <= 0) return undefined
return normalized * 60 * 1000
}
export function normalizeMaxToolSteps(
value: unknown,
options: {
defaultSteps?: number
maxSteps?: number
} = {}
): number {
const defaultSteps = options.defaultSteps ?? DEFAULT_MAX_TOOL_STEPS
const maxSteps = options.maxSteps ?? MAX_MAX_TOOL_STEPS
if (value === undefined || value === null) return defaultSteps
if (typeof value !== 'number' || !Number.isFinite(value)) return defaultSteps
const normalized = Math.floor(value)
if (normalized <= 0) return defaultSteps
return Math.min(normalized, maxSteps)
}
export function buildCombinedAbortSignal(signals: Array<AbortSignal | undefined | null>): AbortSignal | undefined {
const validSignals = signals.filter((s): s is AbortSignal => Boolean(s))
if (validSignals.length === 0) return undefined
if (validSignals.length === 1) return validSignals[0]
return AbortSignal.any(validSignals)
}

View File

@ -4579,6 +4579,24 @@
"remove_invalid_keys": "Remove Invalid Keys",
"search": "Search Providers...",
"search_placeholder": "Search model id or name",
"streaming": {
"description": "Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).",
"label": "Streaming & Timeouts",
"max_tool_steps": {
"help": "Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.",
"label": "Max tool steps"
},
"request_timeout": {
"help": "Abort the request after this time. Set to 0 to disable.",
"label": "Request hard timeout (minutes)"
},
"reset": "Reset to defaults",
"sse_idle_timeout": {
"help": "Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.",
"label": "SSE idle timeout (minutes)"
},
"title": "Streaming & Timeouts"
},
"title": "Model Provider",
"vertex_ai": {
"api_host_help": "The API host for Vertex AI, not recommended to fill in, generally applicable to reverse proxy",

View File

@ -4579,6 +4579,24 @@
"remove_invalid_keys": "删除无效密钥",
"search": "搜索模型平台...",
"search_placeholder": "搜索模型 ID 或名称",
"streaming": {
"description": "配置长时间流式请求(例如 OpenAI Responses SSE的客户端限制。",
"label": "流式与超时",
"max_tool_steps": {
"help": "AI SDK 的工具调用会多轮循环。该值用于防止无限循环;若工作流需要更多轮工具调用请调大。",
"label": "最大工具步数"
},
"request_timeout": {
"help": "超过该时间将由客户端中止请求。设为 0 表示不额外限制。",
"label": "请求硬超时(分钟)"
},
"reset": "恢复默认",
"sse_idle_timeout": {
"help": "在该时间内未收到任何流式事件则中止请求。长时间无输出/工具执行较慢时可调大;设为 0 关闭。",
"label": "SSE 空闲超时(分钟)"
},
"title": "流式与超时"
},
"title": "模型服务",
"vertex_ai": {
"api_host_help": "Vertex AI 的 API 地址,不建议填写,通常适用于反向代理",

View File

@ -4579,6 +4579,24 @@
"remove_invalid_keys": "刪除無效金鑰",
"search": "搜尋模型平臺...",
"search_placeholder": "搜尋模型 ID 或名稱",
"streaming": {
"description": "設定長時間串流請求(例如 OpenAI Responses SSE的用戶端限制。",
"label": "串流與逾時",
"max_tool_steps": {
"help": "AI SDK 的工具呼叫會多輪循環。此值用於避免無限循環;若工作流程需要更多輪工具呼叫請調大。",
"label": "最大工具步數"
},
"request_timeout": {
"help": "超過此時間用戶端將中止請求。設為 0 表示不額外限制。",
"label": "請求硬逾時(分鐘)"
},
"reset": "還原預設值",
"sse_idle_timeout": {
"help": "在此時間內未收到任何串流事件則中止請求。長時間無輸出/工具執行較慢時可調大;設為 0 關閉。",
"label": "SSE 閒置逾時(分鐘)"
},
"title": "串流與逾時"
},
"title": "模型提供者",
"vertex_ai": {
"api_host_help": "Vertex AI 的 API 位址,不建議填寫,通常適用於反向代理",

View File

@ -4579,6 +4579,24 @@
"remove_invalid_keys": "Ungültige Schlüssel löschen",
"search": "Modellplattform suchen...",
"search_placeholder": "Modell-ID oder Name suchen",
"streaming": {
"description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).",
"label": "[to be translated]:Streaming & Timeouts",
"max_tool_steps": {
"help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.",
"label": "[to be translated]:Max tool steps"
},
"request_timeout": {
"help": "[to be translated]:Abort the request after this time. Set to 0 to disable.",
"label": "[to be translated]:Request hard timeout (minutes)"
},
"reset": "[to be translated]:Reset to defaults",
"sse_idle_timeout": {
"help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.",
"label": "[to be translated]:SSE idle timeout (minutes)"
},
"title": "[to be translated]:Streaming & Timeouts"
},
"title": "Modelldienst",
"vertex_ai": {
"api_host_help": "Vertex AI-API-Adresse, nicht empfohlen, normalerweise für Reverse-Proxy geeignet",

View File

@ -4579,6 +4579,24 @@
"remove_invalid_keys": "Διαγραφή Ακυρωμένων Κλειδιών",
"search": "Αναζήτηση πλατφόρμας μονάδων...",
"search_placeholder": "Αναζήτηση ID ή ονόματος μονάδας",
"streaming": {
"description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).",
"label": "[to be translated]:Streaming & Timeouts",
"max_tool_steps": {
"help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.",
"label": "[to be translated]:Max tool steps"
},
"request_timeout": {
"help": "[to be translated]:Abort the request after this time. Set to 0 to disable.",
"label": "[to be translated]:Request hard timeout (minutes)"
},
"reset": "[to be translated]:Reset to defaults",
"sse_idle_timeout": {
"help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.",
"label": "[to be translated]:SSE idle timeout (minutes)"
},
"title": "[to be translated]:Streaming & Timeouts"
},
"title": "Υπηρεσία μονάδων",
"vertex_ai": {
"api_host_help": "Η διεύθυνση API του Vertex AI, δεν συνιστάται να συμπληρωθεί, συνήθως κατάλληλη για αντίστροφη διαμεσολάβηση",

View File

@ -4579,6 +4579,24 @@
"remove_invalid_keys": "Eliminar claves inválidas",
"search": "Buscar plataforma de modelos...",
"search_placeholder": "Buscar ID o nombre del modelo",
"streaming": {
"description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).",
"label": "[to be translated]:Streaming & Timeouts",
"max_tool_steps": {
"help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.",
"label": "[to be translated]:Max tool steps"
},
"request_timeout": {
"help": "[to be translated]:Abort the request after this time. Set to 0 to disable.",
"label": "[to be translated]:Request hard timeout (minutes)"
},
"reset": "[to be translated]:Reset to defaults",
"sse_idle_timeout": {
"help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.",
"label": "[to be translated]:SSE idle timeout (minutes)"
},
"title": "[to be translated]:Streaming & Timeouts"
},
"title": "Servicio de modelos",
"vertex_ai": {
"api_host_help": "Dirección de la API de Vertex AI, no se recomienda completar, normalmente aplicable al proxy inverso",

View File

@ -4579,6 +4579,24 @@
"remove_invalid_keys": "Supprimer les clés invalides",
"search": "Rechercher une plateforme de modèles...",
"search_placeholder": "Rechercher un ID ou un nom de modèle",
"streaming": {
"description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).",
"label": "[to be translated]:Streaming & Timeouts",
"max_tool_steps": {
"help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.",
"label": "[to be translated]:Max tool steps"
},
"request_timeout": {
"help": "[to be translated]:Abort the request after this time. Set to 0 to disable.",
"label": "[to be translated]:Request hard timeout (minutes)"
},
"reset": "[to be translated]:Reset to defaults",
"sse_idle_timeout": {
"help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.",
"label": "[to be translated]:SSE idle timeout (minutes)"
},
"title": "[to be translated]:Streaming & Timeouts"
},
"title": "Services de modèles",
"vertex_ai": {
"api_host_help": "Adresse API de Vertex AI, il n'est pas recommandé de la remplir, généralement utilisée pour un proxy inverse",

View File

@ -4579,6 +4579,24 @@
"remove_invalid_keys": "無効なキーを削除",
"search": "プロバイダーを検索...",
"search_placeholder": "モデルIDまたは名前を検索",
"streaming": {
"description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).",
"label": "[to be translated]:Streaming & Timeouts",
"max_tool_steps": {
"help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.",
"label": "[to be translated]:Max tool steps"
},
"request_timeout": {
"help": "[to be translated]:Abort the request after this time. Set to 0 to disable.",
"label": "[to be translated]:Request hard timeout (minutes)"
},
"reset": "[to be translated]:Reset to defaults",
"sse_idle_timeout": {
"help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.",
"label": "[to be translated]:SSE idle timeout (minutes)"
},
"title": "[to be translated]:Streaming & Timeouts"
},
"title": "モデルプロバイダー",
"vertex_ai": {
"api_host_help": "Vertex AIのAPIアドレス。逆プロキシに適しています。",

View File

@ -4579,6 +4579,24 @@
"remove_invalid_keys": "Remover chaves inválidas",
"search": "Procurar plataforma de modelos...",
"search_placeholder": "Procurar ID ou nome do modelo",
"streaming": {
"description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).",
"label": "[to be translated]:Streaming & Timeouts",
"max_tool_steps": {
"help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.",
"label": "[to be translated]:Max tool steps"
},
"request_timeout": {
"help": "[to be translated]:Abort the request after this time. Set to 0 to disable.",
"label": "[to be translated]:Request hard timeout (minutes)"
},
"reset": "[to be translated]:Reset to defaults",
"sse_idle_timeout": {
"help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.",
"label": "[to be translated]:SSE idle timeout (minutes)"
},
"title": "[to be translated]:Streaming & Timeouts"
},
"title": "Serviços de Modelos",
"vertex_ai": {
"api_host_help": "O endereço da API do Vertex AI, não é recomendado preencher, normalmente aplicável a proxy reverso",

View File

@ -4579,6 +4579,24 @@
"remove_invalid_keys": "Удалить недействительные ключи",
"search": "Поиск поставщиков...",
"search_placeholder": "Поиск по ID или имени модели",
"streaming": {
"description": "[to be translated]:Configure client-side limits for long-running streaming requests (e.g., OpenAI Responses SSE).",
"label": "[to be translated]:Streaming & Timeouts",
"max_tool_steps": {
"help": "[to be translated]:Maximum tool-calling steps for agent workflows. Increase this if your workflow requires more than 20 steps.",
"label": "[to be translated]:Max tool steps"
},
"request_timeout": {
"help": "[to be translated]:Abort the request after this time. Set to 0 to disable.",
"label": "[to be translated]:Request hard timeout (minutes)"
},
"reset": "[to be translated]:Reset to defaults",
"sse_idle_timeout": {
"help": "[to be translated]:Abort if no stream events are received for this time. Increase it for long silent reasoning/tool runs; set to 0 to disable.",
"label": "[to be translated]:SSE idle timeout (minutes)"
},
"title": "[to be translated]:Streaming & Timeouts"
},
"title": "Провайдеры моделей",
"vertex_ai": {
"api_host_help": "API-адрес Vertex AI, не рекомендуется заполнять, обычно применим к обратным прокси",

View File

@ -36,7 +36,7 @@ import {
import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link'
import { debounce, isEmpty } from 'lodash'
import { Bolt, Check, Settings2, SquareArrowOutUpRight, TriangleAlert } from 'lucide-react'
import { Bolt, Check, Settings2, SquareArrowOutUpRight, Timer, TriangleAlert } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -61,6 +61,7 @@ import LMStudioSettings from './LMStudioSettings'
import OVMSSettings from './OVMSSettings'
import ProviderOAuth from './ProviderOAuth'
import SelectProviderModelPopup from './SelectProviderModelPopup'
import StreamingSettingsPopup from './StreamingSettings/StreamingSettingsPopup'
import VertexAISettings from './VertexAISettings'
interface Props {
@ -395,6 +396,14 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
<Button type="text" size="small" icon={<SquareArrowOutUpRight size={14} />} />
</Link>
)}
<Tooltip title={t('settings.provider.streaming.label')}>
<Button
type="text"
icon={<Timer size={14} />}
size="small"
onClick={() => StreamingSettingsPopup.show({ providerId: provider.id })}
/>
</Tooltip>
{!isSystemProvider(provider) && (
<Tooltip title={t('settings.provider.api.options.label')}>
<Button
@ -418,6 +427,19 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
/>
</SettingTitle>
<Divider style={{ width: '100%', margin: '10px 0' }} />
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 5 }}>
<div className="flex items-center gap-1">{t('settings.provider.streaming.title')}</div>
<Tooltip title={t('settings.provider.streaming.label')} mouseEnterDelay={0.3}>
<Button
type="text"
onClick={() => StreamingSettingsPopup.show({ providerId: provider.id })}
icon={<Timer size={16} />}
/>
</Tooltip>
</SettingSubtitle>
<SettingHelpTextRow style={{ paddingTop: 0 }}>
<SettingHelpText>{t('settings.provider.streaming.description')}</SettingHelpText>
</SettingHelpTextRow>
{isProviderSupportAuth(provider) && <ProviderOAuth providerId={provider.id} />}
{provider.id === 'openai' && <OpenAIAlert />}
{provider.id === 'ovms' && <OVMSSettings />}

View File

@ -0,0 +1,116 @@
import { DEFAULT_MAX_TOOL_STEPS, MAX_MAX_TOOL_STEPS } from '@renderer/aiCore/utils/streamingTimeout'
import { HStack } from '@renderer/components/Layout'
import { InfoTooltip } from '@renderer/components/TooltipIcons'
import { useProvider } from '@renderer/hooks/useProvider'
import type { Provider } from '@renderer/types'
import { Button, Flex, InputNumber } from 'antd'
import { startTransition, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '../..'
type Props = {
providerId: string
}
const StreamingSettings = ({ providerId }: Props) => {
const { t } = useTranslation()
const { provider, updateProvider } = useProvider(providerId)
const updateProviderTransition = useCallback(
(updates: Partial<Provider>) => {
startTransition(() => {
updateProvider(updates)
})
},
[updateProvider]
)
const requestTimeoutMinutes = provider.requestTimeoutMinutes ?? 0
const sseIdleTimeoutMinutes = provider.sseIdleTimeoutMinutes ?? 0
const maxToolSteps = provider.maxToolSteps ?? DEFAULT_MAX_TOOL_STEPS
return (
<Flex vertical gap="middle">
<SettingSubtitle style={{ marginTop: 0 }}>{t('settings.provider.streaming.title')}</SettingSubtitle>
<SettingHelpTextRow style={{ paddingTop: 0 }}>
<SettingHelpText>{t('settings.provider.streaming.description')}</SettingHelpText>
</SettingHelpTextRow>
<HStack justifyContent="space-between" alignItems="center">
<HStack alignItems="center" gap={6}>
<label style={{ cursor: 'pointer' }} htmlFor="provider-request-timeout-minutes">
{t('settings.provider.streaming.request_timeout.label')}
</label>
<InfoTooltip title={t('settings.provider.streaming.request_timeout.help')}></InfoTooltip>
</HStack>
<InputNumber
id="provider-request-timeout-minutes"
min={0}
max={720}
step={1}
value={requestTimeoutMinutes}
onChange={(value) => {
updateProviderTransition({ requestTimeoutMinutes: value ?? 0 })
}}
style={{ width: 160 }}
/>
</HStack>
<HStack justifyContent="space-between" alignItems="center">
<HStack alignItems="center" gap={6}>
<label style={{ cursor: 'pointer' }} htmlFor="provider-sse-idle-timeout-minutes">
{t('settings.provider.streaming.sse_idle_timeout.label')}
</label>
<InfoTooltip title={t('settings.provider.streaming.sse_idle_timeout.help')}></InfoTooltip>
</HStack>
<InputNumber
id="provider-sse-idle-timeout-minutes"
min={0}
max={720}
step={1}
value={sseIdleTimeoutMinutes}
onChange={(value) => {
updateProviderTransition({ sseIdleTimeoutMinutes: value ?? 0 })
}}
style={{ width: 160 }}
/>
</HStack>
<HStack justifyContent="space-between" alignItems="center">
<HStack alignItems="center" gap={6}>
<label style={{ cursor: 'pointer' }} htmlFor="provider-max-tool-steps">
{t('settings.provider.streaming.max_tool_steps.label')}
</label>
<InfoTooltip title={t('settings.provider.streaming.max_tool_steps.help')}></InfoTooltip>
</HStack>
<InputNumber
id="provider-max-tool-steps"
min={1}
max={MAX_MAX_TOOL_STEPS}
step={1}
value={maxToolSteps}
onChange={(value) => {
updateProviderTransition({ maxToolSteps: value ?? DEFAULT_MAX_TOOL_STEPS })
}}
style={{ width: 160 }}
/>
</HStack>
<HStack justifyContent="flex-end">
<Button
onClick={() => {
updateProviderTransition({
requestTimeoutMinutes: 0,
sseIdleTimeoutMinutes: 0,
maxToolSteps: DEFAULT_MAX_TOOL_STEPS
})
}}>
{t('settings.provider.streaming.reset')}
</Button>
</HStack>
</Flex>
)
}
export default StreamingSettings

View File

@ -0,0 +1,66 @@
import { TopView } from '@renderer/components/TopView'
import { Modal } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import StreamingSettings from './StreamingSettings'
interface ShowParams {
providerId: string
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ providerId, resolve }) => {
const { t } = useTranslation()
const [open, setOpen] = useState(true)
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
StreamingSettingsPopup.hide = onCancel
return (
<Modal
title={t('settings.provider.streaming.title')}
open={open}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
styles={{ body: { padding: '20px 16px' } }}
footer={null}
centered>
<StreamingSettings providerId={providerId} />
</Modal>
)
}
const TopViewKey = 'StreamingSettingsPopup'
export default class StreamingSettingsPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -187,7 +187,8 @@ export async function fetchChatCompletion({
params: aiSdkParams,
modelId,
capabilities,
webSearchPluginConfig
webSearchPluginConfig,
streamingConfig
} = await buildStreamTextParams(messages, assistant, provider, {
mcpTools: mcpTools,
webSearchProviderId: assistant.webSearchProviderId,
@ -221,7 +222,8 @@ export async function fetchChatCompletion({
assistant,
topicId,
callType: 'chat',
uiMessages
uiMessages,
streamingConfig
})
}

View File

@ -114,6 +114,25 @@ export type Provider = {
serviceTier?: ServiceTier
verbosity?: OpenAIVerbosity
/**
* Client-side hard timeout for a single model request.
* - `0`/`undefined`: no additional client-enforced timeout (recommended when upstream supports long-running streams).
* - `> 0`: abort the request after N minutes.
*/
requestTimeoutMinutes?: number
/**
* Client-side idle timeout for SSE streaming.
* - `0`/`undefined`: disabled.
* - `> 0`: abort the request if no stream events are received for N minutes.
*/
sseIdleTimeoutMinutes?: number
/**
* Max tool/agent steps for AI SDK multi-step tool calling loop.
* - `undefined`: uses the app default (currently 20).
* - `> 0`: stop after N steps to avoid infinite loops.
*/
maxToolSteps?: number
/** @deprecated */
isNotSupportArrayContent?: boolean
/** @deprecated */