feat: 支持 OpenAI Responses API

This commit is contained in:
Zipper_Wang
2026-08-25 17:15:07 +08:00
parent 8e5b9790f6
commit 5ff67edaef
9 changed files with 539 additions and 57 deletions
+391 -52
View File
@@ -24,6 +24,7 @@ interface AIProviderMetadataFile {
type AIMessagePart = { type: 'text'; text: string } | { type: 'image'; dataUrl: string } type AIMessagePart = { type: 'text'; text: string } | { type: 'image'; dataUrl: string }
type AIMessage = { role: string; content: string | AIMessagePart[] } type AIMessage = { role: string; content: string | AIMessagePart[] }
type AIChatDeltaHandler = (delta: string) => void
type AIRequestResult = { type AIRequestResult = {
data: string data: string
usage?: { input?: number; output?: number; total?: number; estimated?: boolean } usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
@@ -33,6 +34,28 @@ interface OpenAIResponsePayload {
choices?: Array<{ message?: { content?: string } }> choices?: Array<{ message?: { content?: string } }>
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
} }
interface OpenAIStreamPayload {
error?: { message?: string }
choices?: Array<{ delta?: { content?: string } }>
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
}
interface OpenAIResponsesPayload {
error?: { message?: string }
incomplete_details?: { reason?: 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 { interface AnthropicResponsePayload {
error?: { message?: string } error?: { message?: string }
content?: Array<{ type?: string; text?: string }> content?: Array<{ type?: string; text?: string }>
@@ -232,7 +255,8 @@ export class AIProviderService {
async chat( async chat(
messages: Array<{ role: string; content: string }>, messages: Array<{ role: string; content: string }>,
options?: AIChatRequestOptions, options?: AIChatRequestOptions,
signal?: AbortSignal signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<{ ): Promise<{
success: boolean success: boolean
data?: string data?: string
@@ -240,7 +264,7 @@ export class AIProviderService {
error?: string error?: string
}> { }> {
try { try {
return { success: true, ...(await this.request(messages, options, false, signal)) } return { success: true, ...(await this.request(messages, options, false, signal, onDelta)) }
} catch (error) { } catch (error) {
if (signal?.aborted) throw error if (signal?.aborted) throw error
return { success: false, error: safeAIError(error) } return { success: false, error: safeAIError(error) }
@@ -321,12 +345,13 @@ export class AIProviderService {
messages: AIMessage[], messages: AIMessage[],
options?: AIChatRequestOptions, options?: AIChatRequestOptions,
testing = false, testing = false,
signal?: AbortSignal signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<{ ): Promise<{
data: string data: string
usage?: { input?: number; output?: number; total?: number; estimated?: boolean } 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 resolved = this.resolveProvider(options)
const provider = options?.timeoutMs const provider = options?.timeoutMs
? { ? {
@@ -334,7 +359,15 @@ export class AIProviderService {
advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs } advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs }
} }
: resolved.provider : 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 }): { private resolveProvider(options?: { providerId?: string; modelId?: string }): {
@@ -357,7 +390,8 @@ export class AIProviderService {
private async requestLegacy( private async requestLegacy(
messages: AIMessage[], messages: AIMessage[],
options: AIChatRequestOptions, options: AIChatRequestOptions,
signal?: AbortSignal signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> { ): Promise<AIRequestResult> {
const provider = deepSeekProvider(options.baseURL, options.model) const provider = deepSeekProvider(options.baseURL, options.model)
return requestOpenAICompatible( return requestOpenAICompatible(
@@ -366,7 +400,8 @@ export class AIProviderService {
options.model || provider.defaultModel, options.model || provider.defaultModel,
messages, messages,
false, false,
signal signal,
onDelta
) )
} }
@@ -540,6 +575,11 @@ function validateProvider(provider: AIProviderConfig): string | undefined {
return '默认模型不在模型列表中' return '默认模型不在模型列表中'
if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim()) if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim())
return '请填写自定义认证字段' return '请填写自定义认证字段'
if (
provider.advanced.apiProtocol &&
!['chat-completions', 'responses'].includes(provider.advanced.apiProtocol)
)
return 'OpenAI API 接口配置不正确'
return undefined return undefined
} }
@@ -555,17 +595,26 @@ function buildHeaders(provider: AIProviderSummary, apiKey: string): Record<strin
return headers return headers
} }
function hasHeader(headers: Record<string, string>, name: string): boolean {
const normalizedName = name.toLowerCase()
return Object.keys(headers).some((header) => header.toLowerCase() === normalizedName)
}
function requestProvider( function requestProvider(
provider: AIProviderSummary, provider: AIProviderSummary,
apiKey: string, apiKey: string,
model: string, model: string,
messages: AIMessage[], messages: AIMessage[],
testing = false, testing = false,
signal?: AbortSignal signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> { ): Promise<AIRequestResult> {
return provider.type === 'anthropic-messages' if (provider.type === 'anthropic-messages') {
? requestAnthropic(provider, apiKey, model, messages, testing, signal) return requestAnthropic(provider, apiKey, model, messages, testing, signal)
: requestOpenAICompatible(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 }> { function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
@@ -582,6 +631,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 }> { function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
return messages return messages
.filter((message) => message.role !== 'system') .filter((message) => message.role !== 'system')
@@ -607,12 +696,13 @@ async function requestOpenAICompatible(
model: string, model: string,
messages: AIMessage[], messages: AIMessage[],
testing = false, testing = false,
signal?: AbortSignal signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> { ): Promise<AIRequestResult> {
const endpoint = provider.baseUrl.endsWith('/chat/completions') const endpoint = provider.baseUrl.endsWith('/chat/completions')
? provider.baseUrl ? provider.baseUrl
: `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions` : `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions`
const response = await fetchWithTimeout( return fetchWithTimeout(
endpoint, endpoint,
{ {
method: 'POST', method: 'POST',
@@ -621,25 +711,83 @@ async function requestOpenAICompatible(
model, model,
messages: toOpenAIMessages(messages), messages: toOpenAIMessages(messages),
temperature: provider.advanced.temperature, temperature: provider.advanced.temperature,
max_tokens: testing ? 8 : provider.advanced.maxTokens max_tokens: testing ? 8 : provider.advanced.maxTokens,
...(provider.advanced.stream ? { stream: true } : {})
}) })
}, },
provider.advanced.timeoutMs, provider.advanced.timeoutMs,
signal signal,
async (response) => {
if (!response.ok) {
const payload = await parseJsonResponse<OpenAIResponsePayload>(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<OpenAIResponsePayload>(response)
return {
data: String(payload.choices?.[0]?.message?.content || ''),
usage: toOpenAIUsage(payload.usage)
}
}
) )
const payload = await parseJsonResponse<OpenAIResponsePayload>(response) }
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
return { async function requestOpenAIResponses(
data: String(payload.choices?.[0]?.message?.content || ''), provider: AIProviderSummary,
usage: payload.usage apiKey: string,
? { model: string,
input: payload.usage.prompt_tokens, messages: AIMessage[],
output: payload.usage.completion_tokens, testing = false,
total: payload.usage.total_tokens, signal?: AbortSignal,
estimated: false onDelta?: AIChatDeltaHandler
} ): Promise<AIRequestResult> {
: undefined 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 : provider.advanced.maxTokens,
store: false,
...(provider.advanced.stream ? { stream: true } : {})
})
},
provider.advanced.timeoutMs,
signal,
async (response) => {
if (!response.ok) {
const payload = await parseJsonResponse<OpenAIResponsesPayload>(response)
throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
}
if (provider.advanced.stream) return parseOpenAIResponsesStream(response, onDelta)
const payload = await parseJsonResponse<OpenAIResponsesPayload>(response)
if (payload.error?.message) throw new Error(payload.error.message)
return {
data: extractOpenAIResponsesText(payload),
usage: toOpenAIResponsesUsage(payload.usage)
}
}
)
} }
async function requestAnthropic( async function requestAnthropic(
@@ -667,7 +815,7 @@ async function requestAnthropic(
const endpoint = provider.baseUrl.endsWith('/messages') const endpoint = provider.baseUrl.endsWith('/messages')
? provider.baseUrl ? provider.baseUrl
: `${provider.baseUrl.replace(/\/+$/, '')}/messages` : `${provider.baseUrl.replace(/\/+$/, '')}/messages`
const response = await fetchWithTimeout( return fetchWithTimeout(
endpoint, endpoint,
{ {
method: 'POST', method: 'POST',
@@ -681,35 +829,39 @@ async function requestAnthropic(
}) })
}, },
provider.advanced.timeoutMs, provider.advanced.timeoutMs,
signal signal,
async (response) => {
const payload = await parseJsonResponse<AnthropicResponsePayload>(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')
: '',
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<AnthropicResponsePayload>(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')
: '',
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<T>(
url: string, url: string,
init: RequestInit, init: RequestInit,
timeoutMs: number, timeoutMs: number,
signal?: AbortSignal signal: AbortSignal | undefined,
): Promise<Response> { consume: (response: Response) => Promise<T>
): Promise<T> {
const controller = new AbortController() const controller = new AbortController()
let timedOut = false let timedOut = false
const abortFromCaller = (): void => const abortFromCaller = (): void =>
@@ -724,7 +876,8 @@ async function fetchWithTimeout(
Math.max(1_000, timeoutMs || 120_000) Math.max(1_000, timeoutMs || 120_000)
) )
try { try {
return await fetch(url, { ...init, signal: controller.signal }) const response = await fetch(url, { ...init, signal: controller.signal })
return await consume(response)
} catch (error) { } catch (error) {
if (signal?.aborted) throw new DOMException('AI request cancelled', 'AbortError') if (signal?.aborted) throw new DOMException('AI request cancelled', 'AbortError')
if (timedOut) throw new DOMException('AI request timed out', 'TimeoutError') if (timedOut) throw new DOMException('AI request timed out', 'TimeoutError')
@@ -735,6 +888,192 @@ async function fetchWithTimeout(
} }
} }
async function parseOpenAIStream(
response: Response,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> {
let data = ''
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 delta = payload.choices?.[0]?.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, usage: toOpenAIUsage(payload.usage) }
}
return { data, usage }
}
async function parseOpenAIResponsesStream(
response: Response,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> {
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, usage: toOpenAIResponsesUsage(payload.usage) }
}
if (!data && completedResponse) {
data = extractOpenAIResponsesText(completedResponse)
if (data) onDelta?.(data)
}
return { data, 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 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<T>(response: Response): Promise<T> { async function parseJsonResponse<T>(response: Response): Promise<T> {
const body = await response.text() const body = await response.text()
try { try {
@@ -128,6 +128,34 @@ const isAbortError = (error: unknown): boolean =>
(error instanceof DOMException && error.name === 'AbortError') || (error instanceof DOMException && error.name === 'AbortError') ||
(error instanceof Error && 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<typeof setTimeout> | 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 * Main-process search orchestrator. It owns the only transition from raw
* candidates to Final Evidence; the AI and Renderer never receive a wider * candidates to Final Evidence; the AI and Renderer never receive a wider
@@ -748,6 +776,23 @@ export class AiSearchPipelineService {
timings: snapshotTimings() timings: snapshotTimings()
}) })
const aiGenerationStartedAt = Date.now() 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( const answer = await this.chatForSearchRequest(
request.requestId, request.requestId,
aiConfig.providerId, aiConfig.providerId,
@@ -760,8 +805,9 @@ export class AiSearchPipelineService {
}, },
{ role: 'user', content: prompt } { role: 'user', content: prompt }
], ],
signal signal,
) answerDeltaBatcher.push
).finally(() => answerDeltaBatcher.close(!signal.aborted))
signal.throwIfAborted() signal.throwIfAborted()
timings.aiGenerationMs = Date.now() - aiGenerationStartedAt timings.aiGenerationMs = Date.now() - aiGenerationStartedAt
if (!answer.success || !answer.data) { if (!answer.success || !answer.data) {
@@ -964,13 +1010,14 @@ export class AiSearchPipelineService {
providerId: string | undefined, providerId: string | undefined,
modelId: string, modelId: string,
messages: Array<{ role: string; content: string }>, messages: Array<{ role: string; content: string }>,
signal: AbortSignal signal: AbortSignal,
onDelta?: (delta: string) => void
): ReturnType<AIProviderService['chat']> { ): ReturnType<AIProviderService['chat']> {
if (!this.canUseAiForRequest(requestId, providerId)) { if (!this.canUseAiForRequest(requestId, providerId)) {
return { success: false, error: '当前搜索请求未授权向该 AI 服务发送内容' } return { success: false, error: '当前搜索请求未授权向该 AI 服务发送内容' }
} }
signal.throwIfAborted() signal.throwIfAborted()
return this.aiProvider.chat(messages, { providerId, modelId }, signal) return this.aiProvider.chat(messages, { providerId, modelId }, signal, onDelta)
} }
private clearPendingAuthorization(requestId: string): void { private clearPendingAuthorization(requestId: string): void {
@@ -261,6 +261,8 @@ export function AISearchWorkspace({
window.api.onAiSearchProgress((progress) => { window.api.onAiSearchProgress((progress) => {
if (progress.requestId !== searchRequestIdRef.current) return if (progress.requestId !== searchRequestIdRef.current) return
setSearchProgress((current) => ({ ...current, [progress.stage]: progress })) setSearchProgress((current) => ({ ...current, [progress.stage]: progress }))
const answerDelta = progress.answerDelta
if (answerDelta) setAnswer((current) => current + answerDelta)
if (progress.agentTrace) { if (progress.agentTrace) {
setAgentTrace((current) => setAgentTrace((current) =>
current.some((item) => item.sequence === progress.agentTrace?.sequence) current.some((item) => item.sequence === progress.agentTrace?.sequence)
@@ -846,6 +848,19 @@ export function AISearchWorkspace({
</div> </div>
</section> </section>
</div> </div>
{answer && (
<section
className="ai-search-summary-block ai-search-streaming-answer"
aria-label="正在生成的回答"
aria-live="polite"
>
<div className="ai-search-section-heading">
<span />
回答生成中
</div>
<div className="ai-search-answer">{renderMarkdown(answer)}</div>
</section>
)}
</div> </div>
) )
} }
@@ -288,6 +288,44 @@ export function AIProviderEditor({
} }
/> />
</label> </label>
{provider.type !== 'anthropic-messages' && (
<>
<label className="wide">
OpenAI API 接口
<select
value={provider.advanced.apiProtocol || 'chat-completions'}
onChange={(event) =>
patch({
advanced: {
...provider.advanced,
apiProtocol: event.target
.value as AIProviderConfig['advanced']['apiProtocol']
}
})
}
>
<option value="chat-completions">Chat Completions(兼容模式)</option>
<option value="responses">Responses API</option>
</select>
<small>Codex 或仅支持 /responses 的中转服务请选择 Responses API。</small>
</label>
<label className="wide ai-provider-stream-toggle">
<span className="ai-provider-stream-toggle-control">
<input
type="checkbox"
checked={Boolean(provider.advanced.stream)}
onChange={(event) =>
patch({
advanced: { ...provider.advanced, stream: event.target.checked }
})
}
/>
启用流式响应(stream: true)
</span>
<small>问问微信会边生成边显示,其他功能仍会等待完整结果。</small>
</label>
</>
)}
<label className="wide"> <label className="wide">
额外 Headers(JSON) 额外 Headers(JSON)
<textarea <textarea
@@ -92,6 +92,13 @@ export function createProviderFromPreset(presetId = 'deepseek'): AIProviderConfi
} }
], ],
defaultModel: preset.model, defaultModel: preset.model,
advanced: { timeoutMs: 120000, temperature: 0.7, maxTokens: 4096, extraHeaders: {} } advanced: {
timeoutMs: 120000,
temperature: 0.7,
maxTokens: 4096,
stream: false,
apiProtocol: 'chat-completions',
extraHeaders: {}
}
} }
} }
+22
View File
@@ -1076,6 +1076,27 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
.ai-search-streaming-answer {
width: 100%;
}
.ai-search-streaming-answer .ai-search-answer::after {
content: '';
display: inline-block;
width: 2px;
height: 1em;
margin-left: 3px;
background: var(--wxex-ai);
vertical-align: -0.12em;
animation: ai-search-stream-cursor 0.8s steps(2, start) infinite;
}
@keyframes ai-search-stream-cursor {
50% {
opacity: 0;
}
}
.ai-search-answer-evidence, .ai-search-answer-evidence,
.ai-search-trace { .ai-search-trace {
display: flex; display: flex;
@@ -1577,6 +1598,7 @@
.ai-search-result, .ai-search-result,
.ai-search-summary-block, .ai-search-summary-block,
.ai-search-answer > *, .ai-search-answer > *,
.ai-search-streaming-answer .ai-search-answer::after,
.ai-search-evidence-card, .ai-search-evidence-card,
.ai-search-history-popover, .ai-search-history-popover,
.ai-search-pipeline-step.active .ai-search-pipeline-mark, .ai-search-pipeline-step.active .ai-search-pipeline-mark,
+9
View File
@@ -1807,6 +1807,15 @@
min-height: 90px; min-height: 90px;
resize: vertical; resize: vertical;
} }
.ai-provider-stream-toggle-control {
display: flex;
align-items: center;
gap: 7px;
}
.ai-provider-stream-toggle small {
color: #66706b;
font-weight: 400;
}
.ai-provider-preview pre { .ai-provider-preview pre {
overflow: auto; overflow: auto;
max-height: 280px; max-height: 280px;
+4
View File
@@ -7,6 +7,8 @@ export type AIProviderType =
export type AIAuthType = 'bearer' | 'x-api-key' | 'custom-header' | 'none' export type AIAuthType = 'bearer' | 'x-api-key' | 'custom-header' | 'none'
export type AIOpenAIProtocol = 'chat-completions' | 'responses'
export interface AIProviderAuth { export interface AIProviderAuth {
type: AIAuthType type: AIAuthType
headerName?: string headerName?: string
@@ -34,6 +36,8 @@ export interface AIProviderAdvancedSettings {
timeoutMs: number timeoutMs: number
temperature?: number temperature?: number
maxTokens?: number maxTokens?: number
stream?: boolean
apiProtocol?: AIOpenAIProtocol
extraHeaders: Record<string, string> extraHeaders: Record<string, string>
} }
+1
View File
@@ -71,6 +71,7 @@ export interface AiSearchProgressEvent {
stage: AiSearchProgressStage stage: AiSearchProgressStage
status: AiSearchProgressStatus status: AiSearchProgressStatus
message: string message: string
answerDelta?: string
plan?: AiSearchPlan plan?: AiSearchPlan
stats?: { stats?: {
knowledgeMessageCount?: number knowledgeMessageCount?: number