diff --git a/src/main/services/ai-provider-service.ts b/src/main/services/ai-provider-service.ts index 40abdb3..4c7201e 100644 --- a/src/main/services/ai-provider-service.ts +++ b/src/main/services/ai-provider-service.ts @@ -24,6 +24,7 @@ interface AIProviderMetadataFile { type AIMessagePart = { type: 'text'; text: string } | { type: 'image'; dataUrl: string } type AIMessage = { role: string; content: string | AIMessagePart[] } +type AIChatDeltaHandler = (delta: string) => void type AIRequestResult = { data: string finishReason?: string @@ -34,6 +35,29 @@ interface OpenAIResponsePayload { choices?: Array<{ message?: { content?: string }; finish_reason?: string }> usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } } +interface OpenAIStreamPayload { + error?: { message?: string } + choices?: Array<{ delta?: { content?: string }; finish_reason?: string }> + usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } +} +interface OpenAIResponsesPayload { + error?: { message?: string } + incomplete_details?: { reason?: string } + status?: string + output_text?: string + output?: Array<{ + type?: string + content?: Array<{ type?: string; text?: string }> + }> + usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number } +} +interface OpenAIResponsesStreamPayload { + type?: string + delta?: string + message?: string + error?: { message?: string } + response?: OpenAIResponsesPayload +} interface AnthropicResponsePayload { error?: { message?: string } content?: Array<{ type?: string; text?: string }> @@ -234,7 +258,8 @@ export class AIProviderService { async chat( messages: Array<{ role: string; content: string }>, options?: AIChatRequestOptions, - signal?: AbortSignal + signal?: AbortSignal, + onDelta?: AIChatDeltaHandler ): Promise<{ success: boolean data?: string @@ -242,7 +267,7 @@ export class AIProviderService { error?: string }> { try { - return { success: true, ...(await this.request(messages, options, false, signal)) } + return { success: true, ...(await this.request(messages, options, false, signal, onDelta)) } } catch (error) { if (signal?.aborted) throw error return { success: false, error: safeAIError(error) } @@ -323,12 +348,13 @@ export class AIProviderService { messages: AIMessage[], options?: AIChatRequestOptions, testing = false, - signal?: AbortSignal + signal?: AbortSignal, + onDelta?: AIChatDeltaHandler ): Promise<{ data: string usage?: { input?: number; output?: number; total?: number; estimated?: boolean } }> { - if (options?.apiKey) return this.requestLegacy(messages, options, signal) + if (options?.apiKey) return this.requestLegacy(messages, options, signal, onDelta) const resolved = this.resolveProvider(options) const provider = options?.timeoutMs ? { @@ -336,7 +362,15 @@ export class AIProviderService { advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs } } : resolved.provider - return requestProvider(provider, resolved.key, resolved.model, messages, testing, signal) + return requestProvider( + provider, + resolved.key, + resolved.model, + messages, + testing, + signal, + onDelta + ) } private resolveProvider(options?: { providerId?: string; modelId?: string }): { @@ -359,7 +393,8 @@ export class AIProviderService { private async requestLegacy( messages: AIMessage[], options: AIChatRequestOptions, - signal?: AbortSignal + signal?: AbortSignal, + onDelta?: AIChatDeltaHandler ): Promise { const provider = deepSeekProvider(options.baseURL, options.model) return requestOpenAICompatible( @@ -368,7 +403,8 @@ export class AIProviderService { options.model || provider.defaultModel, messages, false, - signal + signal, + onDelta ) } @@ -542,6 +578,11 @@ function validateProvider(provider: AIProviderConfig): string | undefined { return '默认模型不在模型列表中' if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim()) return '请填写自定义认证字段' + if ( + provider.advanced.apiProtocol && + !['chat-completions', 'responses'].includes(provider.advanced.apiProtocol) + ) + return 'OpenAI API 接口配置不正确' return undefined } @@ -557,17 +598,26 @@ function buildHeaders(provider: AIProviderSummary, apiKey: string): Record, name: string): boolean { + const normalizedName = name.toLowerCase() + return Object.keys(headers).some((header) => header.toLowerCase() === normalizedName) +} + function requestProvider( provider: AIProviderSummary, apiKey: string, model: string, messages: AIMessage[], testing = false, - signal?: AbortSignal + signal?: AbortSignal, + onDelta?: AIChatDeltaHandler ): Promise { - return provider.type === 'anthropic-messages' - ? requestAnthropic(provider, apiKey, model, messages, testing, signal) - : requestOpenAICompatible(provider, apiKey, model, messages, testing, signal) + if (provider.type === 'anthropic-messages') { + return requestAnthropic(provider, apiKey, model, messages, testing, signal) + } + return provider.advanced.apiProtocol === 'responses' + ? requestOpenAIResponses(provider, apiKey, model, messages, testing, signal, onDelta) + : requestOpenAICompatible(provider, apiKey, model, messages, testing, signal, onDelta) } function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> { @@ -584,6 +634,46 @@ function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: })) } +function toOpenAIResponsesRequest(messages: AIMessage[]): { + instructions?: string + input: Array<{ role: string; content: unknown[] }> +} { + const instructions = messages + .filter((message) => message.role === 'system') + .map((message) => + typeof message.content === 'string' + ? message.content + : message.content + .filter((part) => part.type === 'text') + .map((part) => (part.type === 'text' ? part.text : '')) + .join('\n') + ) + .filter(Boolean) + .join('\n\n') + const input = messages + .filter((message) => message.role !== 'system') + .map((message) => ({ + role: message.role === 'assistant' ? 'assistant' : 'user', + content: + typeof message.content === 'string' + ? [ + { + type: message.role === 'assistant' ? 'output_text' : 'input_text', + text: message.content + } + ] + : message.content.map((part) => + part.type === 'text' + ? { + type: message.role === 'assistant' ? 'output_text' : 'input_text', + text: part.text + } + : { type: 'input_image', image_url: part.dataUrl } + ) + })) + return { instructions: instructions || undefined, input } +} + function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> { return messages .filter((message) => message.role !== 'system') @@ -603,24 +693,27 @@ function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; conte })) } +function modelMaxTokens(provider: AIProviderSummary, model: string): number { + return ( + provider.models.find((item) => item.id === model)?.maxTokens || + provider.advanced.maxTokens || + 4096 + ) +} + async function requestOpenAICompatible( provider: AIProviderSummary, apiKey: string, model: string, messages: AIMessage[], testing = false, - signal?: AbortSignal + signal?: AbortSignal, + onDelta?: AIChatDeltaHandler ): Promise { const endpoint = provider.baseUrl.endsWith('/chat/completions') ? provider.baseUrl : `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions` - let modelMaxTokens = 0 - for (const m of provider.models) { - if (m.id === model && m.maxTokens) { - modelMaxTokens = m.maxTokens - } - } - const response = await fetchWithTimeout( + return fetchWithTimeout( endpoint, { method: 'POST', @@ -629,30 +722,85 @@ async function requestOpenAICompatible( model, messages: toOpenAIMessages(messages), temperature: provider.advanced.temperature, - max_tokens: testing - ? 8 - : modelMaxTokens > 0 - ? modelMaxTokens - : provider.advanced.maxTokens || 4096 + max_tokens: testing ? 8 : modelMaxTokens(provider, model), + ...(provider.advanced.stream ? { stream: true } : {}) }) }, provider.advanced.timeoutMs, - signal + signal, + async (response) => { + if (!response.ok) { + const payload = await parseJsonResponse(response) + throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`) + } + if ( + provider.advanced.stream && + !response.headers.get('content-type')?.toLowerCase().includes('application/json') + ) { + return parseOpenAIStream(response, onDelta) + } + const payload = await parseJsonResponse(response) + return { + data: String(payload.choices?.[0]?.message?.content || ''), + finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'), + usage: toOpenAIUsage(payload.usage) + } + } ) - const payload = await parseJsonResponse(response) - if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`) - return { - data: String(payload.choices?.[0]?.message?.content || ''), - finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'), - usage: payload.usage - ? { - input: payload.usage.prompt_tokens, - output: payload.usage.completion_tokens, - total: payload.usage.total_tokens, - estimated: false - } - : undefined +} + +async function requestOpenAIResponses( + provider: AIProviderSummary, + apiKey: string, + model: string, + messages: AIMessage[], + testing = false, + signal?: AbortSignal, + onDelta?: AIChatDeltaHandler +): Promise { + const normalizedBaseUrl = provider.baseUrl.replace(/\/+$/, '') + const endpoint = normalizedBaseUrl.endsWith('/responses') + ? normalizedBaseUrl + : normalizedBaseUrl.endsWith('/chat/completions') + ? `${normalizedBaseUrl.slice(0, -'/chat/completions'.length)}/responses` + : `${normalizedBaseUrl}/responses` + const request = toOpenAIResponsesRequest(messages) + const headers = buildHeaders(provider, apiKey) + if (provider.advanced.stream && !hasHeader(headers, 'accept')) { + headers.accept = 'text/event-stream' } + return fetchWithTimeout( + endpoint, + { + method: 'POST', + headers, + body: JSON.stringify({ + model, + instructions: request.instructions, + input: request.input, + temperature: provider.advanced.temperature, + max_output_tokens: testing ? 128 : modelMaxTokens(provider, model), + store: false, + ...(provider.advanced.stream ? { stream: true } : {}) + }) + }, + provider.advanced.timeoutMs, + signal, + async (response) => { + if (!response.ok) { + const payload = await parseJsonResponse(response) + throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`) + } + if (provider.advanced.stream) return parseOpenAIResponsesStream(response, onDelta) + const payload = await parseJsonResponse(response) + if (payload.error?.message) throw new Error(payload.error.message) + return { + data: extractOpenAIResponsesText(payload), + finishReason: openAIResponsesFinishReason(payload) || 'unknown', + usage: toOpenAIResponsesUsage(payload.usage) + } + } + ) } async function requestAnthropic( @@ -680,13 +828,7 @@ async function requestAnthropic( const endpoint = provider.baseUrl.endsWith('/messages') ? provider.baseUrl : `${provider.baseUrl.replace(/\/+$/, '')}/messages` - let modelMaxTokens = 0 - for (const m of provider.models) { - if (m.id === model && m.maxTokens) { - modelMaxTokens = m.maxTokens - } - } - const response = await fetchWithTimeout( + return fetchWithTimeout( endpoint, { method: 'POST', @@ -696,44 +838,44 @@ async function requestAnthropic( system: system || undefined, messages: anthropicMessages, temperature: provider.advanced.temperature, - max_tokens: testing - ? 8 - : modelMaxTokens > 0 - ? modelMaxTokens - : provider.advanced.maxTokens || 4096 + max_tokens: testing ? 8 : modelMaxTokens(provider, model) }) }, provider.advanced.timeoutMs, - signal + signal, + async (response) => { + const payload = await parseJsonResponse(response) + if (!response.ok) + throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`) + return { + data: Array.isArray(payload.content) + ? payload.content + .filter((item) => item.type === 'text') + .map((item) => item.text || '') + .join('\n') + : '', + finishReason: String(payload.stop_reason || 'unknown'), + usage: payload.usage + ? { + input: payload.usage.input_tokens, + output: payload.usage.output_tokens, + total: + Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0), + estimated: false + } + : undefined + } + } ) - const payload = await parseJsonResponse(response) - if (!response.ok) - throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`) - return { - data: Array.isArray(payload.content) - ? payload.content - .filter((item) => item.type === 'text') - .map((item) => item.text || '') - .join('\n') - : '', - finishReason: String(payload.stop_reason || 'unknown'), - usage: payload.usage - ? { - input: payload.usage.input_tokens, - output: payload.usage.output_tokens, - total: Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0), - estimated: false - } - : undefined - } } -async function fetchWithTimeout( +async function fetchWithTimeout( url: string, init: RequestInit, timeoutMs: number, - signal?: AbortSignal -): Promise { + signal: AbortSignal | undefined, + consume: (response: Response) => Promise +): Promise { const controller = new AbortController() let timedOut = false const abortFromCaller = (): void => @@ -748,7 +890,8 @@ async function fetchWithTimeout( Math.max(1_000, timeoutMs || 120_000) ) try { - return await fetch(url, { ...init, signal: controller.signal }) + const response = await fetch(url, { ...init, signal: controller.signal }) + return await consume(response) } catch (error) { if (signal?.aborted) throw new DOMException('AI request cancelled', 'AbortError') if (timedOut) throw new DOMException('AI request timed out', 'TimeoutError') @@ -759,6 +902,218 @@ async function fetchWithTimeout( } } +async function parseOpenAIStream( + response: Response, + onDelta?: AIChatDeltaHandler +): Promise { + let data = '' + let finishReason = 'unknown' + let usage: AIRequestResult['usage'] + const stream = await readSSE(response, (eventData) => { + let payload: OpenAIStreamPayload + try { + payload = JSON.parse(eventData) as OpenAIStreamPayload + } catch { + throw new Error('模型服务返回了无法解析的流式数据') + } + if (payload.error?.message) throw new Error(payload.error.message) + if (payload.usage) usage = toOpenAIUsage(payload.usage) + const choice = payload.choices?.[0] + if (choice?.finish_reason) finishReason = choice.finish_reason + const delta = choice?.delta?.content + if (typeof delta === 'string' && delta) { + data += delta + onDelta?.(delta) + } + }) + + if (!stream.sawData) { + let payload: OpenAIResponsePayload + try { + payload = JSON.parse(stream.rawBody) as OpenAIResponsePayload + } catch { + throw new Error('模型服务返回了无法解析的流式数据') + } + if (payload.error?.message) throw new Error(payload.error.message) + const content = String(payload.choices?.[0]?.message?.content || '') + if (content) onDelta?.(content) + return { + data: content, + finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'), + usage: toOpenAIUsage(payload.usage) + } + } + return { data, finishReason, usage } +} + +async function parseOpenAIResponsesStream( + response: Response, + onDelta?: AIChatDeltaHandler +): Promise { + let data = '' + let completedResponse: OpenAIResponsesPayload | undefined + let usage: AIRequestResult['usage'] + const stream = await readSSE(response, (eventData) => { + let payload: OpenAIResponsesStreamPayload + try { + payload = JSON.parse(eventData) as OpenAIResponsesStreamPayload + } catch { + throw new Error('模型服务返回了无法解析的 Responses 流式数据') + } + const errorMessage = payload.error?.message || payload.response?.error?.message + if (errorMessage) throw new Error(errorMessage) + if (payload.response?.usage) usage = toOpenAIResponsesUsage(payload.response.usage) + if (payload.type === 'response.output_text.delta' && typeof payload.delta === 'string') { + data += payload.delta + onDelta?.(payload.delta) + } + if (payload.type === 'response.completed') completedResponse = payload.response + if (payload.type === 'response.failed' || payload.type === 'response.incomplete') { + const reason = payload.response?.incomplete_details?.reason + throw new Error(reason ? `模型响应未完成:${reason}` : payload.message || '模型响应未完成') + } + }) + + if (!stream.sawData) { + let payload: OpenAIResponsesPayload + try { + payload = JSON.parse(stream.rawBody) as OpenAIResponsesPayload + } catch { + throw new Error('模型服务返回了无法解析的 Responses 流式数据') + } + if (payload.error?.message) throw new Error(payload.error.message) + const content = extractOpenAIResponsesText(payload) + if (content) onDelta?.(content) + return { + data: content, + finishReason: openAIResponsesFinishReason(payload) || 'unknown', + usage: toOpenAIResponsesUsage(payload.usage) + } + } + + if (!data && completedResponse) { + data = extractOpenAIResponsesText(completedResponse) + if (data) onDelta?.(data) + } + return { + data, + finishReason: completedResponse + ? openAIResponsesFinishReason(completedResponse) || 'unknown' + : 'unknown', + usage + } +} + +async function readSSE( + response: Response, + onEvent: (eventData: string) => void +): Promise<{ sawData: boolean; rawBody: string }> { + if (!response.body) throw new Error('模型服务未返回可读取的流式响应') + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let rawBody = '' + let sawData = false + let finished = false + + const consumeEvent = (event: string): boolean => { + const eventData = event + .split(/\r\n|\r|\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => { + const value = line.slice(5) + return value.startsWith(' ') ? value.slice(1) : value + }) + .join('\n') + if (!eventData) return false + sawData = true + rawBody = '' + if (eventData.trim() === '[DONE]') return true + onEvent(eventData) + return false + } + + const consumeBuffer = (): void => { + while (!finished) { + const boundary = /(?:\r\n|\r|\n){2}/.exec(buffer) + if (!boundary) return + const event = buffer.slice(0, boundary.index) + buffer = buffer.slice(boundary.index + boundary[0].length) + finished = consumeEvent(event) + } + } + + try { + while (!finished) { + const chunk = await reader.read() + if (chunk.done) { + const tail = decoder.decode() + buffer += tail + if (!sawData) rawBody += tail + consumeBuffer() + break + } + const text = decoder.decode(chunk.value, { stream: true }) + buffer += text + if (!sawData) rawBody += text + consumeBuffer() + } + if (!finished && buffer.trim()) finished = consumeEvent(buffer) + if (finished) await reader.cancel().catch(() => undefined) + } catch (error) { + await reader.cancel(error).catch(() => undefined) + throw error + } finally { + reader.releaseLock() + } + + return { sawData, rawBody } +} + +function extractOpenAIResponsesText(payload: OpenAIResponsesPayload): string { + if (typeof payload.output_text === 'string') return payload.output_text + return (payload.output || []) + .flatMap((item) => item.content || []) + .filter((item) => item.type === 'output_text') + .map((item) => item.text || '') + .join('') +} + +function openAIResponsesFinishReason(payload: OpenAIResponsesPayload): string | undefined { + const reason = payload.incomplete_details?.reason + if (reason === 'max_output_tokens') return 'max_tokens' + if (reason) return reason + if (payload.status === 'completed') return 'stop' + if (payload.status === 'failed') return 'error' + return undefined +} + +function toOpenAIResponsesUsage( + usage: OpenAIResponsesPayload['usage'] +): AIRequestResult['usage'] | undefined { + return usage + ? { + input: usage.input_tokens, + output: usage.output_tokens, + total: usage.total_tokens, + estimated: false + } + : undefined +} + +function toOpenAIUsage( + usage: OpenAIResponsePayload['usage'] +): AIRequestResult['usage'] | undefined { + return usage + ? { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + estimated: false + } + : undefined +} + async function parseJsonResponse(response: Response): Promise { const body = await response.text() try { diff --git a/src/main/services/ai-search-pipeline-service.ts b/src/main/services/ai-search-pipeline-service.ts index b66deec..1a4743c 100644 --- a/src/main/services/ai-search-pipeline-service.ts +++ b/src/main/services/ai-search-pipeline-service.ts @@ -145,6 +145,34 @@ const isAbortError = (error: unknown): boolean => (error instanceof DOMException && error.name === 'AbortError') || (error instanceof Error && error.name === 'AbortError') +const createDeltaBatcher = ( + publish: (delta: string) => void, + intervalMs = 40 +): { push: (delta: string) => void; close: (flushPending: boolean) => void } => { + let pending = '' + let timer: ReturnType | undefined + const flush = (): void => { + timer = undefined + if (!pending) return + const delta = pending + pending = '' + publish(delta) + } + return { + push: (delta: string): void => { + if (!delta) return + pending += delta + if (!timer) timer = setTimeout(flush, intervalMs) + }, + close: (flushPending: boolean): void => { + if (timer) clearTimeout(timer) + timer = undefined + if (flushPending) flush() + else pending = '' + } + } +} + /** * Main-process search orchestrator. It owns the only transition from raw * candidates to Final Evidence; the AI and Renderer never receive a wider @@ -791,6 +819,23 @@ export class AiSearchPipelineService { timings: snapshotTimings() }) const aiGenerationStartedAt = Date.now() + const answerDeltaBatcher = createDeltaBatcher((answerDelta) => + emit({ + stage: 'ai_generating', + status: 'running', + message: '正在生成带来源的回答', + answerDelta, + plan, + modelName: aiConfig.modelName, + stats: { + matchedMessages: evidenceBuild.candidateCount, + evidenceCount: evidence.length, + contextEvidenceCount: evidence.length, + tokenEstimate + }, + timings: snapshotTimings() + }) + ) const answer = await this.chatForSearchRequest( request.requestId, aiConfig.providerId, @@ -803,8 +848,9 @@ export class AiSearchPipelineService { }, { role: 'user', content: prompt } ], - signal - ) + signal, + answerDeltaBatcher.push + ).finally(() => answerDeltaBatcher.close(!signal.aborted)) signal.throwIfAborted() timings.aiGenerationMs = Date.now() - aiGenerationStartedAt if (!answer.success || !answer.data) { @@ -1009,13 +1055,14 @@ export class AiSearchPipelineService { providerId: string | undefined, modelId: string, messages: Array<{ role: string; content: string }>, - signal: AbortSignal + signal: AbortSignal, + onDelta?: (delta: string) => void ): ReturnType { if (!this.canUseAiForRequest(requestId, providerId)) { return { success: false, error: '当前搜索请求未授权向该 AI 服务发送内容' } } signal.throwIfAborted() - return this.aiProvider.chat(messages, { providerId, modelId }, signal) + return this.aiProvider.chat(messages, { providerId, modelId }, signal, onDelta) } private clearPendingAuthorization(requestId: string): void { diff --git a/src/renderer/src/components/search/AISearchWorkspace.tsx b/src/renderer/src/components/search/AISearchWorkspace.tsx index adcdd59..20eba7a 100644 --- a/src/renderer/src/components/search/AISearchWorkspace.tsx +++ b/src/renderer/src/components/search/AISearchWorkspace.tsx @@ -144,7 +144,9 @@ export function AISearchWorkspace({ startSearch, cancelSearch, resetSearchRun - } = useAiSearchRun() + } = useAiSearchRun({ + onAnswerDelta: (delta) => setAnswer((current) => current + delta) + }) const resetSearchResult = (): void => { const reset = createSearchResultResetState() @@ -520,6 +522,19 @@ export function AISearchWorkspace({ + {answer && ( +
+
+ + 回答生成中 +
+
{renderMarkdown(answer)}
+
+ )} ) } diff --git a/src/renderer/src/components/search/hooks/useAiSearchRun.ts b/src/renderer/src/components/search/hooks/useAiSearchRun.ts index 8d85a99..128a2cb 100644 --- a/src/renderer/src/components/search/hooks/useAiSearchRun.ts +++ b/src/renderer/src/components/search/hooks/useAiSearchRun.ts @@ -24,7 +24,11 @@ export type AiSearchRunOutcome = const errorMessage = (error: unknown): string => error instanceof Error ? error.message : '读取聊天记录失败' -export function useAiSearchRun(): { +export function useAiSearchRun({ + onAnswerDelta +}: { + onAnswerDelta?: (delta: string) => void +} = {}): { status: AiSearchRunStatus requestId: string result: AiSearchPipelineResult | null @@ -44,6 +48,11 @@ export function useAiSearchRun(): { const [progress, setProgress] = useState({}) const [agentTrace, setAgentTrace] = useState([]) const requestIdRef = useRef('') + const onAnswerDeltaRef = useRef(onAnswerDelta) + + useEffect(() => { + onAnswerDeltaRef.current = onAnswerDelta + }, [onAnswerDelta]) const createRequestId = (): string => globalThis.crypto?.randomUUID?.() || `search-${Date.now()}` @@ -54,6 +63,7 @@ export function useAiSearchRun(): { const unsubscribe = window.api.onAiSearchProgress((event: AiSearchProgressEvent) => { if (!isCurrentRequest(event.requestId)) return setProgress((current) => ({ ...current, [event.stage]: event })) + if (event.answerDelta) onAnswerDeltaRef.current?.(event.answerDelta) if (event.agentTrace) { setAgentTrace((current) => current.some((item) => item.sequence === event.agentTrace?.sequence) diff --git a/src/renderer/src/features/settings/ai-model/AIProviderEditor.tsx b/src/renderer/src/features/settings/ai-model/AIProviderEditor.tsx index 628d67b..f8ab050 100644 --- a/src/renderer/src/features/settings/ai-model/AIProviderEditor.tsx +++ b/src/renderer/src/features/settings/ai-model/AIProviderEditor.tsx @@ -332,6 +332,49 @@ export function AIProviderEditor({ } /> + {provider.type !== 'anthropic-messages' && ( + <> + + + + )}