mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
Merge 67d4665deb into a6ba5d34e0
This commit is contained in:
commit
37d7ae4a93
@ -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':
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
40
src/renderer/src/aiCore/utils/streamingTimeout.ts
Normal file
40
src/renderer/src/aiCore/utils/streamingTimeout.ts
Normal 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)
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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 地址,不建议填写,通常适用于反向代理",
|
||||
|
||||
@ -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 位址,不建議填寫,通常適用於反向代理",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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, δεν συνιστάται να συμπληρωθεί, συνήθως κατάλληλη για αντίστροφη διαμεσολάβηση",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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アドレス。逆プロキシに適しています。",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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, не рекомендуется заполнять, обычно применим к обратным прокси",
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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 */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user