mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 13:54:10 +08:00
feat: 支持 OpenAI Responses API
This commit is contained in:
@@ -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)
|
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||||
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
|
||||||
return {
|
return {
|
||||||
data: String(payload.choices?.[0]?.message?.content || ''),
|
data: String(payload.choices?.[0]?.message?.content || ''),
|
||||||
usage: payload.usage
|
usage: toOpenAIUsage(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<AIRequestResult> {
|
||||||
|
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,8 +829,8 @@ async function requestAnthropic(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
provider.advanced.timeoutMs,
|
provider.advanced.timeoutMs,
|
||||||
signal
|
signal,
|
||||||
)
|
async (response) => {
|
||||||
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
|
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
||||||
@@ -697,19 +845,23 @@ async function requestAnthropic(
|
|||||||
? {
|
? {
|
||||||
input: payload.usage.input_tokens,
|
input: payload.usage.input_tokens,
|
||||||
output: payload.usage.output_tokens,
|
output: payload.usage.output_tokens,
|
||||||
total: Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0),
|
total:
|
||||||
|
Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0),
|
||||||
estimated: false
|
estimated: false
|
||||||
}
|
}
|
||||||
: undefined
|
: 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: {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user