mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +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 AIMessage = { role: string; content: string | AIMessagePart[] }
|
||||
type AIChatDeltaHandler = (delta: string) => void
|
||||
type AIRequestResult = {
|
||||
data: string
|
||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||
@@ -33,6 +34,28 @@ interface OpenAIResponsePayload {
|
||||
choices?: Array<{ message?: { content?: string } }>
|
||||
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 {
|
||||
error?: { message?: string }
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
@@ -232,7 +255,8 @@ export class AIProviderService {
|
||||
async chat(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options?: AIChatRequestOptions,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
onDelta?: AIChatDeltaHandler
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
@@ -240,7 +264,7 @@ export class AIProviderService {
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
return { success: true, ...(await this.request(messages, options, false, signal)) }
|
||||
return { success: true, ...(await this.request(messages, options, false, signal, onDelta)) }
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error
|
||||
return { success: false, error: safeAIError(error) }
|
||||
@@ -321,12 +345,13 @@ export class AIProviderService {
|
||||
messages: AIMessage[],
|
||||
options?: AIChatRequestOptions,
|
||||
testing = false,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
onDelta?: AIChatDeltaHandler
|
||||
): Promise<{
|
||||
data: string
|
||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||
}> {
|
||||
if (options?.apiKey) return this.requestLegacy(messages, options, signal)
|
||||
if (options?.apiKey) return this.requestLegacy(messages, options, signal, onDelta)
|
||||
const resolved = this.resolveProvider(options)
|
||||
const provider = options?.timeoutMs
|
||||
? {
|
||||
@@ -334,7 +359,15 @@ export class AIProviderService {
|
||||
advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs }
|
||||
}
|
||||
: resolved.provider
|
||||
return requestProvider(provider, resolved.key, resolved.model, messages, testing, signal)
|
||||
return requestProvider(
|
||||
provider,
|
||||
resolved.key,
|
||||
resolved.model,
|
||||
messages,
|
||||
testing,
|
||||
signal,
|
||||
onDelta
|
||||
)
|
||||
}
|
||||
|
||||
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
|
||||
@@ -357,7 +390,8 @@ export class AIProviderService {
|
||||
private async requestLegacy(
|
||||
messages: AIMessage[],
|
||||
options: AIChatRequestOptions,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
onDelta?: AIChatDeltaHandler
|
||||
): Promise<AIRequestResult> {
|
||||
const provider = deepSeekProvider(options.baseURL, options.model)
|
||||
return requestOpenAICompatible(
|
||||
@@ -366,7 +400,8 @@ export class AIProviderService {
|
||||
options.model || provider.defaultModel,
|
||||
messages,
|
||||
false,
|
||||
signal
|
||||
signal,
|
||||
onDelta
|
||||
)
|
||||
}
|
||||
|
||||
@@ -540,6 +575,11 @@ function validateProvider(provider: AIProviderConfig): string | undefined {
|
||||
return '默认模型不在模型列表中'
|
||||
if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim())
|
||||
return '请填写自定义认证字段'
|
||||
if (
|
||||
provider.advanced.apiProtocol &&
|
||||
!['chat-completions', 'responses'].includes(provider.advanced.apiProtocol)
|
||||
)
|
||||
return 'OpenAI API 接口配置不正确'
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -555,17 +595,26 @@ function buildHeaders(provider: AIProviderSummary, apiKey: string): Record<strin
|
||||
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(
|
||||
provider: AIProviderSummary,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: AIMessage[],
|
||||
testing = false,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
onDelta?: AIChatDeltaHandler
|
||||
): Promise<AIRequestResult> {
|
||||
return provider.type === 'anthropic-messages'
|
||||
? requestAnthropic(provider, apiKey, model, messages, testing, signal)
|
||||
: requestOpenAICompatible(provider, apiKey, model, messages, testing, signal)
|
||||
if (provider.type === 'anthropic-messages') {
|
||||
return requestAnthropic(provider, apiKey, model, messages, testing, signal)
|
||||
}
|
||||
return provider.advanced.apiProtocol === 'responses'
|
||||
? requestOpenAIResponses(provider, apiKey, model, messages, testing, signal, onDelta)
|
||||
: requestOpenAICompatible(provider, apiKey, model, messages, testing, signal, onDelta)
|
||||
}
|
||||
|
||||
function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
|
||||
@@ -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 }> {
|
||||
return messages
|
||||
.filter((message) => message.role !== 'system')
|
||||
@@ -607,12 +696,13 @@ async function requestOpenAICompatible(
|
||||
model: string,
|
||||
messages: AIMessage[],
|
||||
testing = false,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
onDelta?: AIChatDeltaHandler
|
||||
): Promise<AIRequestResult> {
|
||||
const endpoint = provider.baseUrl.endsWith('/chat/completions')
|
||||
? provider.baseUrl
|
||||
: `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions`
|
||||
const response = await fetchWithTimeout(
|
||||
return fetchWithTimeout(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -621,25 +711,83 @@ async function requestOpenAICompatible(
|
||||
model,
|
||||
messages: toOpenAIMessages(messages),
|
||||
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,
|
||||
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 {
|
||||
data: String(payload.choices?.[0]?.message?.content || ''),
|
||||
usage: payload.usage
|
||||
? {
|
||||
input: payload.usage.prompt_tokens,
|
||||
output: payload.usage.completion_tokens,
|
||||
total: payload.usage.total_tokens,
|
||||
estimated: false
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
async function requestOpenAIResponses(
|
||||
provider: AIProviderSummary,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: AIMessage[],
|
||||
testing = false,
|
||||
signal?: AbortSignal,
|
||||
onDelta?: AIChatDeltaHandler
|
||||
): Promise<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(
|
||||
@@ -667,7 +815,7 @@ async function requestAnthropic(
|
||||
const endpoint = provider.baseUrl.endsWith('/messages')
|
||||
? provider.baseUrl
|
||||
: `${provider.baseUrl.replace(/\/+$/, '')}/messages`
|
||||
const response = await fetchWithTimeout(
|
||||
return fetchWithTimeout(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -681,35 +829,39 @@ async function requestAnthropic(
|
||||
})
|
||||
},
|
||||
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,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<Response> {
|
||||
signal: AbortSignal | undefined,
|
||||
consume: (response: Response) => Promise<T>
|
||||
): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
let timedOut = false
|
||||
const abortFromCaller = (): void =>
|
||||
@@ -724,7 +876,8 @@ async function fetchWithTimeout(
|
||||
Math.max(1_000, timeoutMs || 120_000)
|
||||
)
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal })
|
||||
const response = await fetch(url, { ...init, signal: controller.signal })
|
||||
return await consume(response)
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw new DOMException('AI request cancelled', 'AbortError')
|
||||
if (timedOut) throw new DOMException('AI request timed out', 'TimeoutError')
|
||||
@@ -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> {
|
||||
const body = await response.text()
|
||||
try {
|
||||
|
||||
@@ -128,6 +128,34 @@ const isAbortError = (error: unknown): boolean =>
|
||||
(error instanceof DOMException && error.name === 'AbortError') ||
|
||||
(error instanceof Error && error.name === 'AbortError')
|
||||
|
||||
const createDeltaBatcher = (
|
||||
publish: (delta: string) => void,
|
||||
intervalMs = 40
|
||||
): { push: (delta: string) => void; close: (flushPending: boolean) => void } => {
|
||||
let pending = ''
|
||||
let timer: ReturnType<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
|
||||
* candidates to Final Evidence; the AI and Renderer never receive a wider
|
||||
@@ -748,6 +776,23 @@ export class AiSearchPipelineService {
|
||||
timings: snapshotTimings()
|
||||
})
|
||||
const aiGenerationStartedAt = Date.now()
|
||||
const answerDeltaBatcher = createDeltaBatcher((answerDelta) =>
|
||||
emit({
|
||||
stage: 'ai_generating',
|
||||
status: 'running',
|
||||
message: '正在生成带来源的回答',
|
||||
answerDelta,
|
||||
plan,
|
||||
modelName: aiConfig.modelName,
|
||||
stats: {
|
||||
matchedMessages: evidenceBuild.candidateCount,
|
||||
evidenceCount: evidence.length,
|
||||
contextEvidenceCount: evidence.length,
|
||||
tokenEstimate
|
||||
},
|
||||
timings: snapshotTimings()
|
||||
})
|
||||
)
|
||||
const answer = await this.chatForSearchRequest(
|
||||
request.requestId,
|
||||
aiConfig.providerId,
|
||||
@@ -760,8 +805,9 @@ export class AiSearchPipelineService {
|
||||
},
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
signal
|
||||
)
|
||||
signal,
|
||||
answerDeltaBatcher.push
|
||||
).finally(() => answerDeltaBatcher.close(!signal.aborted))
|
||||
signal.throwIfAborted()
|
||||
timings.aiGenerationMs = Date.now() - aiGenerationStartedAt
|
||||
if (!answer.success || !answer.data) {
|
||||
@@ -964,13 +1010,14 @@ export class AiSearchPipelineService {
|
||||
providerId: string | undefined,
|
||||
modelId: string,
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
onDelta?: (delta: string) => void
|
||||
): ReturnType<AIProviderService['chat']> {
|
||||
if (!this.canUseAiForRequest(requestId, providerId)) {
|
||||
return { success: false, error: '当前搜索请求未授权向该 AI 服务发送内容' }
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
return this.aiProvider.chat(messages, { providerId, modelId }, signal)
|
||||
return this.aiProvider.chat(messages, { providerId, modelId }, signal, onDelta)
|
||||
}
|
||||
|
||||
private clearPendingAuthorization(requestId: string): void {
|
||||
|
||||
@@ -261,6 +261,8 @@ export function AISearchWorkspace({
|
||||
window.api.onAiSearchProgress((progress) => {
|
||||
if (progress.requestId !== searchRequestIdRef.current) return
|
||||
setSearchProgress((current) => ({ ...current, [progress.stage]: progress }))
|
||||
const answerDelta = progress.answerDelta
|
||||
if (answerDelta) setAnswer((current) => current + answerDelta)
|
||||
if (progress.agentTrace) {
|
||||
setAgentTrace((current) =>
|
||||
current.some((item) => item.sequence === progress.agentTrace?.sequence)
|
||||
@@ -846,6 +848,19 @@ export function AISearchWorkspace({
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -288,6 +288,44 @@ export function AIProviderEditor({
|
||||
}
|
||||
/>
|
||||
</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">
|
||||
额外 Headers(JSON)
|
||||
<textarea
|
||||
|
||||
@@ -92,6 +92,13 @@ export function createProviderFromPreset(presetId = 'deepseek'): AIProviderConfi
|
||||
}
|
||||
],
|
||||
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;
|
||||
}
|
||||
|
||||
.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-trace {
|
||||
display: flex;
|
||||
@@ -1577,6 +1598,7 @@
|
||||
.ai-search-result,
|
||||
.ai-search-summary-block,
|
||||
.ai-search-answer > *,
|
||||
.ai-search-streaming-answer .ai-search-answer::after,
|
||||
.ai-search-evidence-card,
|
||||
.ai-search-history-popover,
|
||||
.ai-search-pipeline-step.active .ai-search-pipeline-mark,
|
||||
|
||||
@@ -1807,6 +1807,15 @@
|
||||
min-height: 90px;
|
||||
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 {
|
||||
overflow: auto;
|
||||
max-height: 280px;
|
||||
|
||||
@@ -7,6 +7,8 @@ export type AIProviderType =
|
||||
|
||||
export type AIAuthType = 'bearer' | 'x-api-key' | 'custom-header' | 'none'
|
||||
|
||||
export type AIOpenAIProtocol = 'chat-completions' | 'responses'
|
||||
|
||||
export interface AIProviderAuth {
|
||||
type: AIAuthType
|
||||
headerName?: string
|
||||
@@ -34,6 +36,8 @@ export interface AIProviderAdvancedSettings {
|
||||
timeoutMs: number
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
stream?: boolean
|
||||
apiProtocol?: AIOpenAIProtocol
|
||||
extraHeaders: Record<string, string>
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface AiSearchProgressEvent {
|
||||
stage: AiSearchProgressStage
|
||||
status: AiSearchProgressStatus
|
||||
message: string
|
||||
answerDelta?: string
|
||||
plan?: AiSearchPlan
|
||||
stats?: {
|
||||
knowledgeMessageCount?: number
|
||||
|
||||
Reference in New Issue
Block a user