Merge pull request #24 from ZipperWang/main

添加response API支持,添加流式传输支持
This commit is contained in:
qingmao
2026-08-26 17:29:54 +08:00
committed by GitHub
10 changed files with 597 additions and 82 deletions
+430 -75
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
finishReason?: string finishReason?: string
@@ -34,6 +35,29 @@ interface OpenAIResponsePayload {
choices?: Array<{ message?: { content?: string }; finish_reason?: string }> choices?: Array<{ message?: { content?: string }; finish_reason?: 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 }; 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 { interface AnthropicResponsePayload {
error?: { message?: string } error?: { message?: string }
content?: Array<{ type?: string; text?: string }> content?: Array<{ type?: string; text?: string }>
@@ -234,7 +258,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
@@ -242,7 +267,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) }
@@ -323,12 +348,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
? { ? {
@@ -336,7 +362,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 }): {
@@ -359,7 +393,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(
@@ -368,7 +403,8 @@ export class AIProviderService {
options.model || provider.defaultModel, options.model || provider.defaultModel,
messages, messages,
false, false,
signal signal,
onDelta
) )
} }
@@ -542,6 +578,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
} }
@@ -557,17 +598,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 }> {
@@ -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 }> { function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
return messages return messages
.filter((message) => message.role !== 'system') .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( async function requestOpenAICompatible(
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> {
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`
let modelMaxTokens = 0 return fetchWithTimeout(
for (const m of provider.models) {
if (m.id === model && m.maxTokens) {
modelMaxTokens = m.maxTokens
}
}
const response = await fetchWithTimeout(
endpoint, endpoint,
{ {
method: 'POST', method: 'POST',
@@ -629,30 +722,85 @@ async function requestOpenAICompatible(
model, model,
messages: toOpenAIMessages(messages), messages: toOpenAIMessages(messages),
temperature: provider.advanced.temperature, temperature: provider.advanced.temperature,
max_tokens: testing max_tokens: testing ? 8 : modelMaxTokens(provider, model),
? 8 ...(provider.advanced.stream ? { stream: true } : {})
: modelMaxTokens > 0
? modelMaxTokens
: provider.advanced.maxTokens || 4096
}) })
}, },
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 || ''),
finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'),
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,
finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'), apiKey: string,
usage: payload.usage model: string,
? { messages: AIMessage[],
input: payload.usage.prompt_tokens, testing = false,
output: payload.usage.completion_tokens, signal?: AbortSignal,
total: payload.usage.total_tokens, onDelta?: AIChatDeltaHandler
estimated: false ): Promise<AIRequestResult> {
} const normalizedBaseUrl = provider.baseUrl.replace(/\/+$/, '')
: undefined 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<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),
finishReason: openAIResponsesFinishReason(payload) || 'unknown',
usage: toOpenAIResponsesUsage(payload.usage)
}
}
)
} }
async function requestAnthropic( async function requestAnthropic(
@@ -680,13 +828,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`
let modelMaxTokens = 0 return fetchWithTimeout(
for (const m of provider.models) {
if (m.id === model && m.maxTokens) {
modelMaxTokens = m.maxTokens
}
}
const response = await fetchWithTimeout(
endpoint, endpoint,
{ {
method: 'POST', method: 'POST',
@@ -696,44 +838,44 @@ async function requestAnthropic(
system: system || undefined, system: system || undefined,
messages: anthropicMessages, messages: anthropicMessages,
temperature: provider.advanced.temperature, temperature: provider.advanced.temperature,
max_tokens: testing max_tokens: testing ? 8 : modelMaxTokens(provider, model)
? 8
: modelMaxTokens > 0
? modelMaxTokens
: provider.advanced.maxTokens || 4096
}) })
}, },
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')
: '',
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<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')
: '',
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<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 =>
@@ -748,7 +890,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')
@@ -759,6 +902,218 @@ async function fetchWithTimeout(
} }
} }
async function parseOpenAIStream(
response: Response,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> {
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<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,
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<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 {
@@ -145,6 +145,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
@@ -791,6 +819,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,
@@ -803,8 +848,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) {
@@ -1009,13 +1055,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 {
@@ -144,7 +144,9 @@ export function AISearchWorkspace({
startSearch, startSearch,
cancelSearch, cancelSearch,
resetSearchRun resetSearchRun
} = useAiSearchRun() } = useAiSearchRun({
onAnswerDelta: (delta) => setAnswer((current) => current + delta)
})
const resetSearchResult = (): void => { const resetSearchResult = (): void => {
const reset = createSearchResultResetState() const reset = createSearchResultResetState()
@@ -520,6 +522,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>
) )
} }
@@ -24,7 +24,11 @@ export type AiSearchRunOutcome =
const errorMessage = (error: unknown): string => const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : '读取聊天记录失败' error instanceof Error ? error.message : '读取聊天记录失败'
export function useAiSearchRun(): { export function useAiSearchRun({
onAnswerDelta
}: {
onAnswerDelta?: (delta: string) => void
} = {}): {
status: AiSearchRunStatus status: AiSearchRunStatus
requestId: string requestId: string
result: AiSearchPipelineResult | null result: AiSearchPipelineResult | null
@@ -44,6 +48,11 @@ export function useAiSearchRun(): {
const [progress, setProgress] = useState<SearchProgressByStage>({}) const [progress, setProgress] = useState<SearchProgressByStage>({})
const [agentTrace, setAgentTrace] = useState<AiSearchAgentRun['trace']>([]) const [agentTrace, setAgentTrace] = useState<AiSearchAgentRun['trace']>([])
const requestIdRef = useRef('') const requestIdRef = useRef('')
const onAnswerDeltaRef = useRef(onAnswerDelta)
useEffect(() => {
onAnswerDeltaRef.current = onAnswerDelta
}, [onAnswerDelta])
const createRequestId = (): string => globalThis.crypto?.randomUUID?.() || `search-${Date.now()}` const createRequestId = (): string => globalThis.crypto?.randomUUID?.() || `search-${Date.now()}`
@@ -54,6 +63,7 @@ export function useAiSearchRun(): {
const unsubscribe = window.api.onAiSearchProgress((event: AiSearchProgressEvent) => { const unsubscribe = window.api.onAiSearchProgress((event: AiSearchProgressEvent) => {
if (!isCurrentRequest(event.requestId)) return if (!isCurrentRequest(event.requestId)) return
setProgress((current) => ({ ...current, [event.stage]: event })) setProgress((current) => ({ ...current, [event.stage]: event }))
if (event.answerDelta) onAnswerDeltaRef.current?.(event.answerDelta)
if (event.agentTrace) { if (event.agentTrace) {
setAgentTrace((current) => setAgentTrace((current) =>
current.some((item) => item.sequence === event.agentTrace?.sequence) current.some((item) => item.sequence === event.agentTrace?.sequence)
@@ -332,6 +332,49 @@ export function AIProviderEditor({
} }
/> />
</label> </label>
{provider.type !== 'anthropic-messages' && (
<>
<label className="wide">
OpenAI API 接口
<Select
value={provider.advanced.apiProtocol || 'chat-completions'}
onValueChange={(value) =>
patch({
advanced: {
...provider.advanced,
apiProtocol: value as AIProviderConfig['advanced']['apiProtocol']
}
})
}
>
<SelectTrigger aria-label="OpenAI API 接口">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="chat-completions">Chat Completions(兼容模式)</SelectItem>
<SelectItem value="responses">Responses API</SelectItem>
</SelectContent>
</Select>
<small>Codex 或仅支持 /responses 的中转服务请选择 Responses API。</small>
</label>
<label className="wide ai-provider-stream-toggle" htmlFor="ai-provider-stream">
<span className="ai-provider-stream-toggle-control">
<Checkbox
id="ai-provider-stream"
aria-label="启用流式响应"
checked={Boolean(provider.advanced.stream)}
onCheckedChange={(checked) =>
patch({
advanced: { ...provider.advanced, stream: checked === true }
})
}
/>
启用流式响应(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: {}
}
} }
} }
+24
View File
@@ -623,6 +623,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;
@@ -792,6 +813,9 @@
.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-history-popover,
.ai-search-evidence-focus-flash, .ai-search-evidence-focus-flash,
.ai-search-pipeline-step.active .ai-search-pipeline-mark, .ai-search-pipeline-step.active .ai-search-pipeline-mark,
.ai-search-sync-progress-track span { .ai-search-sync-progress-track span {
+9
View File
@@ -1553,6 +1553,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
@@ -73,6 +73,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