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 AIMessage = { role: string; content: string | AIMessagePart[] }
type AIChatDeltaHandler = (delta: string) => void
type AIRequestResult = {
data: string
finishReason?: string
@@ -34,6 +35,29 @@ interface OpenAIResponsePayload {
choices?: Array<{ message?: { content?: string }; finish_reason?: string }>
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
}
interface OpenAIStreamPayload {
error?: { message?: string }
choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
}
interface OpenAIResponsesPayload {
error?: { message?: string }
incomplete_details?: { reason?: string }
status?: string
output_text?: string
output?: Array<{
type?: string
content?: Array<{ type?: string; text?: string }>
}>
usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number }
}
interface OpenAIResponsesStreamPayload {
type?: string
delta?: string
message?: string
error?: { message?: string }
response?: OpenAIResponsesPayload
}
interface AnthropicResponsePayload {
error?: { message?: string }
content?: Array<{ type?: string; text?: string }>
@@ -234,7 +258,8 @@ export class AIProviderService {
async chat(
messages: Array<{ role: string; content: string }>,
options?: AIChatRequestOptions,
signal?: AbortSignal
signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<{
success: boolean
data?: string
@@ -242,7 +267,7 @@ export class AIProviderService {
error?: string
}> {
try {
return { success: true, ...(await this.request(messages, options, false, signal)) }
return { success: true, ...(await this.request(messages, options, false, signal, onDelta)) }
} catch (error) {
if (signal?.aborted) throw error
return { success: false, error: safeAIError(error) }
@@ -323,12 +348,13 @@ export class AIProviderService {
messages: AIMessage[],
options?: AIChatRequestOptions,
testing = false,
signal?: AbortSignal
signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<{
data: string
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
}> {
if (options?.apiKey) return this.requestLegacy(messages, options, signal)
if (options?.apiKey) return this.requestLegacy(messages, options, signal, onDelta)
const resolved = this.resolveProvider(options)
const provider = options?.timeoutMs
? {
@@ -336,7 +362,15 @@ export class AIProviderService {
advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs }
}
: resolved.provider
return requestProvider(provider, resolved.key, resolved.model, messages, testing, signal)
return requestProvider(
provider,
resolved.key,
resolved.model,
messages,
testing,
signal,
onDelta
)
}
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
@@ -359,7 +393,8 @@ export class AIProviderService {
private async requestLegacy(
messages: AIMessage[],
options: AIChatRequestOptions,
signal?: AbortSignal
signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> {
const provider = deepSeekProvider(options.baseURL, options.model)
return requestOpenAICompatible(
@@ -368,7 +403,8 @@ export class AIProviderService {
options.model || provider.defaultModel,
messages,
false,
signal
signal,
onDelta
)
}
@@ -542,6 +578,11 @@ function validateProvider(provider: AIProviderConfig): string | undefined {
return '默认模型不在模型列表中'
if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim())
return '请填写自定义认证字段'
if (
provider.advanced.apiProtocol &&
!['chat-completions', 'responses'].includes(provider.advanced.apiProtocol)
)
return 'OpenAI API 接口配置不正确'
return undefined
}
@@ -557,17 +598,26 @@ function buildHeaders(provider: AIProviderSummary, apiKey: string): Record<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 }> {
@@ -584,6 +634,46 @@ function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content:
}))
}
function toOpenAIResponsesRequest(messages: AIMessage[]): {
instructions?: string
input: Array<{ role: string; content: unknown[] }>
} {
const instructions = messages
.filter((message) => message.role === 'system')
.map((message) =>
typeof message.content === 'string'
? message.content
: message.content
.filter((part) => part.type === 'text')
.map((part) => (part.type === 'text' ? part.text : ''))
.join('\n')
)
.filter(Boolean)
.join('\n\n')
const input = messages
.filter((message) => message.role !== 'system')
.map((message) => ({
role: message.role === 'assistant' ? 'assistant' : 'user',
content:
typeof message.content === 'string'
? [
{
type: message.role === 'assistant' ? 'output_text' : 'input_text',
text: message.content
}
]
: message.content.map((part) =>
part.type === 'text'
? {
type: message.role === 'assistant' ? 'output_text' : 'input_text',
text: part.text
}
: { type: 'input_image', image_url: part.dataUrl }
)
}))
return { instructions: instructions || undefined, input }
}
function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
return messages
.filter((message) => message.role !== 'system')
@@ -603,24 +693,27 @@ function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; conte
}))
}
function modelMaxTokens(provider: AIProviderSummary, model: string): number {
return (
provider.models.find((item) => item.id === model)?.maxTokens ||
provider.advanced.maxTokens ||
4096
)
}
async function requestOpenAICompatible(
provider: AIProviderSummary,
apiKey: string,
model: string,
messages: AIMessage[],
testing = false,
signal?: AbortSignal
signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<AIRequestResult> {
const endpoint = provider.baseUrl.endsWith('/chat/completions')
? provider.baseUrl
: `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions`
let modelMaxTokens = 0
for (const m of provider.models) {
if (m.id === model && m.maxTokens) {
modelMaxTokens = m.maxTokens
}
}
const response = await fetchWithTimeout(
return fetchWithTimeout(
endpoint,
{
method: 'POST',
@@ -629,30 +722,85 @@ async function requestOpenAICompatible(
model,
messages: toOpenAIMessages(messages),
temperature: provider.advanced.temperature,
max_tokens: testing
? 8
: modelMaxTokens > 0
? modelMaxTokens
: provider.advanced.maxTokens || 4096
max_tokens: testing ? 8 : modelMaxTokens(provider, model),
...(provider.advanced.stream ? { stream: true } : {})
})
},
provider.advanced.timeoutMs,
signal
signal,
async (response) => {
if (!response.ok) {
const payload = await parseJsonResponse<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 {
data: String(payload.choices?.[0]?.message?.content || ''),
finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'),
usage: payload.usage
? {
input: payload.usage.prompt_tokens,
output: payload.usage.completion_tokens,
total: payload.usage.total_tokens,
estimated: false
}
: undefined
}
async function requestOpenAIResponses(
provider: AIProviderSummary,
apiKey: string,
model: string,
messages: AIMessage[],
testing = false,
signal?: AbortSignal,
onDelta?: AIChatDeltaHandler
): Promise<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 : 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(
@@ -680,13 +828,7 @@ async function requestAnthropic(
const endpoint = provider.baseUrl.endsWith('/messages')
? provider.baseUrl
: `${provider.baseUrl.replace(/\/+$/, '')}/messages`
let modelMaxTokens = 0
for (const m of provider.models) {
if (m.id === model && m.maxTokens) {
modelMaxTokens = m.maxTokens
}
}
const response = await fetchWithTimeout(
return fetchWithTimeout(
endpoint,
{
method: 'POST',
@@ -696,44 +838,44 @@ async function requestAnthropic(
system: system || undefined,
messages: anthropicMessages,
temperature: provider.advanced.temperature,
max_tokens: testing
? 8
: modelMaxTokens > 0
? modelMaxTokens
: provider.advanced.maxTokens || 4096
max_tokens: testing ? 8 : modelMaxTokens(provider, model)
})
},
provider.advanced.timeoutMs,
signal
signal,
async (response) => {
const payload = await parseJsonResponse<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,
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 =>
@@ -748,7 +890,8 @@ async function fetchWithTimeout(
Math.max(1_000, timeoutMs || 120_000)
)
try {
return await fetch(url, { ...init, signal: controller.signal })
const response = await fetch(url, { ...init, signal: controller.signal })
return await consume(response)
} catch (error) {
if (signal?.aborted) throw new DOMException('AI request cancelled', 'AbortError')
if (timedOut) throw new DOMException('AI request timed out', 'TimeoutError')
@@ -759,6 +902,218 @@ async function fetchWithTimeout(
}
}
async function parseOpenAIStream(
response: Response,
onDelta?: AIChatDeltaHandler
): Promise<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> {
const body = await response.text()
try {
@@ -145,6 +145,34 @@ const isAbortError = (error: unknown): boolean =>
(error instanceof DOMException && error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError')
const createDeltaBatcher = (
publish: (delta: string) => void,
intervalMs = 40
): { push: (delta: string) => void; close: (flushPending: boolean) => void } => {
let pending = ''
let timer: ReturnType<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
@@ -791,6 +819,23 @@ export class AiSearchPipelineService {
timings: snapshotTimings()
})
const aiGenerationStartedAt = Date.now()
const answerDeltaBatcher = createDeltaBatcher((answerDelta) =>
emit({
stage: 'ai_generating',
status: 'running',
message: '正在生成带来源的回答',
answerDelta,
plan,
modelName: aiConfig.modelName,
stats: {
matchedMessages: evidenceBuild.candidateCount,
evidenceCount: evidence.length,
contextEvidenceCount: evidence.length,
tokenEstimate
},
timings: snapshotTimings()
})
)
const answer = await this.chatForSearchRequest(
request.requestId,
aiConfig.providerId,
@@ -803,8 +848,9 @@ export class AiSearchPipelineService {
},
{ role: 'user', content: prompt }
],
signal
)
signal,
answerDeltaBatcher.push
).finally(() => answerDeltaBatcher.close(!signal.aborted))
signal.throwIfAborted()
timings.aiGenerationMs = Date.now() - aiGenerationStartedAt
if (!answer.success || !answer.data) {
@@ -1009,13 +1055,14 @@ export class AiSearchPipelineService {
providerId: string | undefined,
modelId: string,
messages: Array<{ role: string; content: string }>,
signal: AbortSignal
signal: AbortSignal,
onDelta?: (delta: string) => void
): ReturnType<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 {
@@ -144,7 +144,9 @@ export function AISearchWorkspace({
startSearch,
cancelSearch,
resetSearchRun
} = useAiSearchRun()
} = useAiSearchRun({
onAnswerDelta: (delta) => setAnswer((current) => current + delta)
})
const resetSearchResult = (): void => {
const reset = createSearchResultResetState()
@@ -520,6 +522,19 @@ export function AISearchWorkspace({
</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>
)
}
@@ -24,7 +24,11 @@ export type AiSearchRunOutcome =
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : '读取聊天记录失败'
export function useAiSearchRun(): {
export function useAiSearchRun({
onAnswerDelta
}: {
onAnswerDelta?: (delta: string) => void
} = {}): {
status: AiSearchRunStatus
requestId: string
result: AiSearchPipelineResult | null
@@ -44,6 +48,11 @@ export function useAiSearchRun(): {
const [progress, setProgress] = useState<SearchProgressByStage>({})
const [agentTrace, setAgentTrace] = useState<AiSearchAgentRun['trace']>([])
const requestIdRef = useRef('')
const onAnswerDeltaRef = useRef(onAnswerDelta)
useEffect(() => {
onAnswerDeltaRef.current = onAnswerDelta
}, [onAnswerDelta])
const createRequestId = (): string => globalThis.crypto?.randomUUID?.() || `search-${Date.now()}`
@@ -54,6 +63,7 @@ export function useAiSearchRun(): {
const unsubscribe = window.api.onAiSearchProgress((event: AiSearchProgressEvent) => {
if (!isCurrentRequest(event.requestId)) return
setProgress((current) => ({ ...current, [event.stage]: event }))
if (event.answerDelta) onAnswerDeltaRef.current?.(event.answerDelta)
if (event.agentTrace) {
setAgentTrace((current) =>
current.some((item) => item.sequence === event.agentTrace?.sequence)
@@ -332,6 +332,49 @@ export function AIProviderEditor({
}
/>
</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">
额外 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: {}
}
}
}
+24
View File
@@ -623,6 +623,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;
@@ -792,6 +813,9 @@
.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-evidence-focus-flash,
.ai-search-pipeline-step.active .ai-search-pipeline-mark,
.ai-search-sync-progress-track span {
+9
View File
@@ -1553,6 +1553,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;
+4
View File
@@ -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>
}
+1
View File
@@ -73,6 +73,7 @@ export interface AiSearchProgressEvent {
stage: AiSearchProgressStage
status: AiSearchProgressStatus
message: string
answerDelta?: string
plan?: AiSearchPlan
stats?: {
knowledgeMessageCount?: number