mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
feat: add streaming idle timeout
This commit is contained in:
parent
8c9b05a55a
commit
67d4665deb
@ -32,6 +32,10 @@ export class AiSdkToChunkAdapter {
|
|||||||
private firstTokenTimestamp: number | null = null
|
private firstTokenTimestamp: number | null = null
|
||||||
private hasTextContent = false
|
private hasTextContent = false
|
||||||
private getSessionWasCleared?: () => boolean
|
private getSessionWasCleared?: () => boolean
|
||||||
|
private idleTimeoutMs?: number
|
||||||
|
private idleAbortController?: AbortController
|
||||||
|
private idleTimeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private idleTimeoutTriggered = false
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private onChunk: (chunk: Chunk) => void,
|
private onChunk: (chunk: Chunk) => void,
|
||||||
@ -39,13 +43,19 @@ export class AiSdkToChunkAdapter {
|
|||||||
accumulate?: boolean,
|
accumulate?: boolean,
|
||||||
enableWebSearch?: boolean,
|
enableWebSearch?: boolean,
|
||||||
onSessionUpdate?: (sessionId: string) => void,
|
onSessionUpdate?: (sessionId: string) => void,
|
||||||
getSessionWasCleared?: () => boolean
|
getSessionWasCleared?: () => boolean,
|
||||||
|
streamingConfig?: {
|
||||||
|
idleTimeoutMs: number
|
||||||
|
idleAbortController: AbortController
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
||||||
this.accumulate = accumulate
|
this.accumulate = accumulate
|
||||||
this.enableWebSearch = enableWebSearch || false
|
this.enableWebSearch = enableWebSearch || false
|
||||||
this.onSessionUpdate = onSessionUpdate
|
this.onSessionUpdate = onSessionUpdate
|
||||||
this.getSessionWasCleared = getSessionWasCleared
|
this.getSessionWasCleared = getSessionWasCleared
|
||||||
|
this.idleTimeoutMs = streamingConfig?.idleTimeoutMs
|
||||||
|
this.idleAbortController = streamingConfig?.idleAbortController
|
||||||
}
|
}
|
||||||
|
|
||||||
private markFirstTokenIfNeeded() {
|
private markFirstTokenIfNeeded() {
|
||||||
@ -59,6 +69,27 @@ export class AiSdkToChunkAdapter {
|
|||||||
this.firstTokenTimestamp = null
|
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 流结果
|
* 处理 AI SDK 流结果
|
||||||
* @param aiSdkResult AI SDK 的流结果对象
|
* @param aiSdkResult AI SDK 的流结果对象
|
||||||
@ -88,6 +119,8 @@ export class AiSdkToChunkAdapter {
|
|||||||
}
|
}
|
||||||
this.resetTimingState()
|
this.resetTimingState()
|
||||||
this.responseStartTimestamp = Date.now()
|
this.responseStartTimestamp = Date.now()
|
||||||
|
this.idleTimeoutTriggered = false
|
||||||
|
this.resetIdleTimeoutTimer()
|
||||||
// Reset state at the start of stream
|
// Reset state at the start of stream
|
||||||
this.isFirstChunk = true
|
this.isFirstChunk = true
|
||||||
this.hasTextContent = false
|
this.hasTextContent = false
|
||||||
@ -111,10 +144,12 @@ export class AiSdkToChunkAdapter {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.resetIdleTimeoutTimer()
|
||||||
// 转换并发送 chunk
|
// 转换并发送 chunk
|
||||||
this.convertAndEmitChunk(value, final)
|
this.convertAndEmitChunk(value, final)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
this.clearIdleTimeoutTimer()
|
||||||
reader.releaseLock()
|
reader.releaseLock()
|
||||||
this.resetTimingState()
|
this.resetTimingState()
|
||||||
}
|
}
|
||||||
@ -380,7 +415,12 @@ export class AiSdkToChunkAdapter {
|
|||||||
case 'abort':
|
case 'abort':
|
||||||
this.onChunk({
|
this.onChunk({
|
||||||
type: ChunkType.ERROR,
|
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
|
break
|
||||||
case 'error':
|
case 'error':
|
||||||
|
|||||||
@ -42,6 +42,10 @@ export type ModernAiProviderConfig = AiSdkMiddlewareConfig & {
|
|||||||
// topicId for tracing
|
// topicId for tracing
|
||||||
topicId?: string
|
topicId?: string
|
||||||
callType: string
|
callType: string
|
||||||
|
streamingConfig?: {
|
||||||
|
idleTimeoutMs: number
|
||||||
|
idleAbortController: AbortController
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ModernAiProvider {
|
export default class ModernAiProvider {
|
||||||
@ -330,7 +334,15 @@ export default class ModernAiProvider {
|
|||||||
// 创建带有中间件的执行器
|
// 创建带有中间件的执行器
|
||||||
if (config.onChunk) {
|
if (config.onChunk) {
|
||||||
const accumulate = this.model!.supported_text_delta !== false // true and undefined
|
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({
|
const streamResult = await executor.streamText({
|
||||||
...params,
|
...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 { getAiSdkProviderId } from '../provider/factory'
|
||||||
import { setupToolsConfig } from '../utils/mcp'
|
import { setupToolsConfig } from '../utils/mcp'
|
||||||
import { buildProviderOptions } from '../utils/options'
|
import { buildProviderOptions } from '../utils/options'
|
||||||
|
import { buildCombinedAbortSignal, normalizeMaxToolSteps, timeoutMinutesToMs } from '../utils/streamingTimeout'
|
||||||
import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch'
|
import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch'
|
||||||
import { addAnthropicHeaders } from './header'
|
import { addAnthropicHeaders } from './header'
|
||||||
import { getMaxTokens, getTemperature, getTopP } from './modelParameters'
|
import { getMaxTokens, getTemperature, getTopP } from './modelParameters'
|
||||||
@ -95,6 +96,10 @@ export async function buildStreamTextParams(
|
|||||||
enableUrlContext: boolean
|
enableUrlContext: boolean
|
||||||
}
|
}
|
||||||
webSearchPluginConfig?: WebSearchPluginConfig
|
webSearchPluginConfig?: WebSearchPluginConfig
|
||||||
|
streamingConfig?: {
|
||||||
|
idleTimeoutMs: number
|
||||||
|
idleAbortController: AbortController
|
||||||
|
}
|
||||||
}> {
|
}> {
|
||||||
const { mcpTools } = options
|
const { mcpTools } = options
|
||||||
|
|
||||||
@ -218,6 +223,17 @@ export async function buildStreamTextParams(
|
|||||||
// Note: standardParams (topK, frequencyPenalty, presencePenalty, stopSequences, seed)
|
// Note: standardParams (topK, frequencyPenalty, presencePenalty, stopSequences, seed)
|
||||||
// are extracted from custom parameters and passed directly to streamText()
|
// are extracted from custom parameters and passed directly to streamText()
|
||||||
// instead of being placed in providerOptions
|
// 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 = {
|
const params: StreamTextParams = {
|
||||||
messages: sdkMessages,
|
messages: sdkMessages,
|
||||||
maxOutputTokens: getMaxTokens(assistant, model),
|
maxOutputTokens: getMaxTokens(assistant, model),
|
||||||
@ -225,10 +241,10 @@ export async function buildStreamTextParams(
|
|||||||
topP: getTopP(assistant, model),
|
topP: getTopP(assistant, model),
|
||||||
// Include AI SDK standard params extracted from custom parameters
|
// Include AI SDK standard params extracted from custom parameters
|
||||||
...standardParams,
|
...standardParams,
|
||||||
abortSignal: options.requestOptions?.signal,
|
abortSignal,
|
||||||
headers,
|
headers,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
stopWhen: stepCountIs(20),
|
stopWhen: stepCountIs(maxToolSteps),
|
||||||
maxRetries: 0
|
maxRetries: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,7 +262,14 @@ export async function buildStreamTextParams(
|
|||||||
params,
|
params,
|
||||||
modelId: model.id,
|
modelId: model.id,
|
||||||
capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext },
|
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)
|
||||||
|
}
|
||||||
@ -4564,6 +4564,24 @@
|
|||||||
"remove_invalid_keys": "Remove Invalid Keys",
|
"remove_invalid_keys": "Remove Invalid Keys",
|
||||||
"search": "Search Providers...",
|
"search": "Search Providers...",
|
||||||
"search_placeholder": "Search model id or name",
|
"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",
|
"title": "Model Provider",
|
||||||
"vertex_ai": {
|
"vertex_ai": {
|
||||||
"api_host_help": "The API host for Vertex AI, not recommended to fill in, generally applicable to reverse proxy",
|
"api_host_help": "The API host for Vertex AI, not recommended to fill in, generally applicable to reverse proxy",
|
||||||
|
|||||||
@ -4564,6 +4564,24 @@
|
|||||||
"remove_invalid_keys": "删除无效密钥",
|
"remove_invalid_keys": "删除无效密钥",
|
||||||
"search": "搜索模型平台...",
|
"search": "搜索模型平台...",
|
||||||
"search_placeholder": "搜索模型 ID 或名称",
|
"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": "模型服务",
|
"title": "模型服务",
|
||||||
"vertex_ai": {
|
"vertex_ai": {
|
||||||
"api_host_help": "Vertex AI 的 API 地址,不建议填写,通常适用于反向代理",
|
"api_host_help": "Vertex AI 的 API 地址,不建议填写,通常适用于反向代理",
|
||||||
|
|||||||
@ -4564,6 +4564,24 @@
|
|||||||
"remove_invalid_keys": "刪除無效金鑰",
|
"remove_invalid_keys": "刪除無效金鑰",
|
||||||
"search": "搜尋模型平臺...",
|
"search": "搜尋模型平臺...",
|
||||||
"search_placeholder": "搜尋模型 ID 或名稱",
|
"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": "模型提供者",
|
"title": "模型提供者",
|
||||||
"vertex_ai": {
|
"vertex_ai": {
|
||||||
"api_host_help": "Vertex AI 的 API 位址,不建議填寫,通常適用於反向代理",
|
"api_host_help": "Vertex AI 的 API 位址,不建議填寫,通常適用於反向代理",
|
||||||
|
|||||||
@ -4564,6 +4564,24 @@
|
|||||||
"remove_invalid_keys": "Ungültige Schlüssel löschen",
|
"remove_invalid_keys": "Ungültige Schlüssel löschen",
|
||||||
"search": "Modellplattform suchen...",
|
"search": "Modellplattform suchen...",
|
||||||
"search_placeholder": "Modell-ID oder Name 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",
|
"title": "Modelldienst",
|
||||||
"vertex_ai": {
|
"vertex_ai": {
|
||||||
"api_host_help": "Vertex AI-API-Adresse, nicht empfohlen, normalerweise für Reverse-Proxy geeignet",
|
"api_host_help": "Vertex AI-API-Adresse, nicht empfohlen, normalerweise für Reverse-Proxy geeignet",
|
||||||
|
|||||||
@ -4564,6 +4564,24 @@
|
|||||||
"remove_invalid_keys": "Διαγραφή Ακυρωμένων Κλειδιών",
|
"remove_invalid_keys": "Διαγραφή Ακυρωμένων Κλειδιών",
|
||||||
"search": "Αναζήτηση πλατφόρμας μονάδων...",
|
"search": "Αναζήτηση πλατφόρμας μονάδων...",
|
||||||
"search_placeholder": "Αναζήτηση ID ή ονόματος μονάδας",
|
"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": "Υπηρεσία μονάδων",
|
"title": "Υπηρεσία μονάδων",
|
||||||
"vertex_ai": {
|
"vertex_ai": {
|
||||||
"api_host_help": "Η διεύθυνση API του Vertex AI, δεν συνιστάται να συμπληρωθεί, συνήθως κατάλληλη για αντίστροφη διαμεσολάβηση",
|
"api_host_help": "Η διεύθυνση API του Vertex AI, δεν συνιστάται να συμπληρωθεί, συνήθως κατάλληλη για αντίστροφη διαμεσολάβηση",
|
||||||
|
|||||||
@ -4564,6 +4564,24 @@
|
|||||||
"remove_invalid_keys": "Eliminar claves inválidas",
|
"remove_invalid_keys": "Eliminar claves inválidas",
|
||||||
"search": "Buscar plataforma de modelos...",
|
"search": "Buscar plataforma de modelos...",
|
||||||
"search_placeholder": "Buscar ID o nombre del modelo",
|
"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",
|
"title": "Servicio de modelos",
|
||||||
"vertex_ai": {
|
"vertex_ai": {
|
||||||
"api_host_help": "Dirección de la API de Vertex AI, no se recomienda completar, normalmente aplicable al proxy inverso",
|
"api_host_help": "Dirección de la API de Vertex AI, no se recomienda completar, normalmente aplicable al proxy inverso",
|
||||||
|
|||||||
@ -4564,6 +4564,24 @@
|
|||||||
"remove_invalid_keys": "Supprimer les clés invalides",
|
"remove_invalid_keys": "Supprimer les clés invalides",
|
||||||
"search": "Rechercher une plateforme de modèles...",
|
"search": "Rechercher une plateforme de modèles...",
|
||||||
"search_placeholder": "Rechercher un ID ou un nom de modèle",
|
"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",
|
"title": "Services de modèles",
|
||||||
"vertex_ai": {
|
"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",
|
"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",
|
||||||
|
|||||||
@ -4564,6 +4564,24 @@
|
|||||||
"remove_invalid_keys": "無効なキーを削除",
|
"remove_invalid_keys": "無効なキーを削除",
|
||||||
"search": "プロバイダーを検索...",
|
"search": "プロバイダーを検索...",
|
||||||
"search_placeholder": "モデルIDまたは名前を検索",
|
"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": "モデルプロバイダー",
|
"title": "モデルプロバイダー",
|
||||||
"vertex_ai": {
|
"vertex_ai": {
|
||||||
"api_host_help": "Vertex AIのAPIアドレス。逆プロキシに適しています。",
|
"api_host_help": "Vertex AIのAPIアドレス。逆プロキシに適しています。",
|
||||||
|
|||||||
@ -4564,6 +4564,24 @@
|
|||||||
"remove_invalid_keys": "Remover chaves inválidas",
|
"remove_invalid_keys": "Remover chaves inválidas",
|
||||||
"search": "Procurar plataforma de modelos...",
|
"search": "Procurar plataforma de modelos...",
|
||||||
"search_placeholder": "Procurar ID ou nome do modelo",
|
"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",
|
"title": "Serviços de Modelos",
|
||||||
"vertex_ai": {
|
"vertex_ai": {
|
||||||
"api_host_help": "O endereço da API do Vertex AI, não é recomendado preencher, normalmente aplicável a proxy reverso",
|
"api_host_help": "O endereço da API do Vertex AI, não é recomendado preencher, normalmente aplicável a proxy reverso",
|
||||||
|
|||||||
@ -4564,6 +4564,24 @@
|
|||||||
"remove_invalid_keys": "Удалить недействительные ключи",
|
"remove_invalid_keys": "Удалить недействительные ключи",
|
||||||
"search": "Поиск поставщиков...",
|
"search": "Поиск поставщиков...",
|
||||||
"search_placeholder": "Поиск по ID или имени модели",
|
"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": "Провайдеры моделей",
|
"title": "Провайдеры моделей",
|
||||||
"vertex_ai": {
|
"vertex_ai": {
|
||||||
"api_host_help": "API-адрес 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 { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd'
|
||||||
import Link from 'antd/es/typography/Link'
|
import Link from 'antd/es/typography/Link'
|
||||||
import { debounce, isEmpty } from 'lodash'
|
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 type { FC } from 'react'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -61,6 +61,7 @@ import LMStudioSettings from './LMStudioSettings'
|
|||||||
import OVMSSettings from './OVMSSettings'
|
import OVMSSettings from './OVMSSettings'
|
||||||
import ProviderOAuth from './ProviderOAuth'
|
import ProviderOAuth from './ProviderOAuth'
|
||||||
import SelectProviderModelPopup from './SelectProviderModelPopup'
|
import SelectProviderModelPopup from './SelectProviderModelPopup'
|
||||||
|
import StreamingSettingsPopup from './StreamingSettings/StreamingSettingsPopup'
|
||||||
import VertexAISettings from './VertexAISettings'
|
import VertexAISettings from './VertexAISettings'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -394,6 +395,14 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
|||||||
<Button type="text" size="small" icon={<SquareArrowOutUpRight size={14} />} />
|
<Button type="text" size="small" icon={<SquareArrowOutUpRight size={14} />} />
|
||||||
</Link>
|
</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) && (
|
{!isSystemProvider(provider) && (
|
||||||
<Tooltip title={t('settings.provider.api.options.label')}>
|
<Tooltip title={t('settings.provider.api.options.label')}>
|
||||||
<Button
|
<Button
|
||||||
@ -417,6 +426,19 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
|||||||
/>
|
/>
|
||||||
</SettingTitle>
|
</SettingTitle>
|
||||||
<Divider style={{ width: '100%', margin: '10px 0' }} />
|
<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} />}
|
{isProviderSupportAuth(provider) && <ProviderOAuth providerId={provider.id} />}
|
||||||
{provider.id === 'openai' && <OpenAIAlert />}
|
{provider.id === 'openai' && <OpenAIAlert />}
|
||||||
{provider.id === 'ovms' && <OVMSSettings />}
|
{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
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -130,7 +130,8 @@ export async function fetchChatCompletion({
|
|||||||
params: aiSdkParams,
|
params: aiSdkParams,
|
||||||
modelId,
|
modelId,
|
||||||
capabilities,
|
capabilities,
|
||||||
webSearchPluginConfig
|
webSearchPluginConfig,
|
||||||
|
streamingConfig
|
||||||
} = await buildStreamTextParams(messages, assistant, provider, {
|
} = await buildStreamTextParams(messages, assistant, provider, {
|
||||||
mcpTools: mcpTools,
|
mcpTools: mcpTools,
|
||||||
webSearchProviderId: assistant.webSearchProviderId,
|
webSearchProviderId: assistant.webSearchProviderId,
|
||||||
@ -164,7 +165,8 @@ export async function fetchChatCompletion({
|
|||||||
assistant,
|
assistant,
|
||||||
topicId,
|
topicId,
|
||||||
callType: 'chat',
|
callType: 'chat',
|
||||||
uiMessages
|
uiMessages,
|
||||||
|
streamingConfig
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -114,6 +114,25 @@ export type Provider = {
|
|||||||
serviceTier?: ServiceTier
|
serviceTier?: ServiceTier
|
||||||
verbosity?: OpenAIVerbosity
|
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 */
|
/** @deprecated */
|
||||||
isNotSupportArrayContent?: boolean
|
isNotSupportArrayContent?: boolean
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user