mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
@@ -24,6 +24,7 @@ interface AIProviderMetadataFile {
|
|||||||
|
|
||||||
type AIMessagePart = { type: 'text'; text: string } | { type: 'image'; dataUrl: string }
|
type AIMessagePart = { type: 'text'; text: string } | { type: 'image'; dataUrl: string }
|
||||||
type AIMessage = { role: string; content: string | AIMessagePart[] }
|
type AIMessage = { role: string; content: string | AIMessagePart[] }
|
||||||
|
type AIChatDeltaHandler = (delta: string) => void
|
||||||
type AIRequestResult = {
|
type AIRequestResult = {
|
||||||
data: string
|
data: string
|
||||||
finishReason?: string
|
finishReason?: string
|
||||||
@@ -34,6 +35,29 @@ interface OpenAIResponsePayload {
|
|||||||
choices?: Array<{ message?: { content?: string }; finish_reason?: string }>
|
choices?: Array<{ message?: { content?: string }; finish_reason?: string }>
|
||||||
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
||||||
}
|
}
|
||||||
|
interface OpenAIStreamPayload {
|
||||||
|
error?: { message?: string }
|
||||||
|
choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>
|
||||||
|
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }
|
||||||
|
}
|
||||||
|
interface OpenAIResponsesPayload {
|
||||||
|
error?: { message?: string }
|
||||||
|
incomplete_details?: { reason?: string }
|
||||||
|
status?: string
|
||||||
|
output_text?: string
|
||||||
|
output?: Array<{
|
||||||
|
type?: string
|
||||||
|
content?: Array<{ type?: string; text?: string }>
|
||||||
|
}>
|
||||||
|
usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number }
|
||||||
|
}
|
||||||
|
interface OpenAIResponsesStreamPayload {
|
||||||
|
type?: string
|
||||||
|
delta?: string
|
||||||
|
message?: string
|
||||||
|
error?: { message?: string }
|
||||||
|
response?: OpenAIResponsesPayload
|
||||||
|
}
|
||||||
interface AnthropicResponsePayload {
|
interface AnthropicResponsePayload {
|
||||||
error?: { message?: string }
|
error?: { message?: string }
|
||||||
content?: Array<{ type?: string; text?: string }>
|
content?: Array<{ type?: string; text?: string }>
|
||||||
@@ -234,7 +258,8 @@ export class AIProviderService {
|
|||||||
async chat(
|
async chat(
|
||||||
messages: Array<{ role: string; content: string }>,
|
messages: Array<{ role: string; content: string }>,
|
||||||
options?: AIChatRequestOptions,
|
options?: AIChatRequestOptions,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
onDelta?: AIChatDeltaHandler
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: string
|
data?: string
|
||||||
@@ -242,7 +267,7 @@ export class AIProviderService {
|
|||||||
error?: string
|
error?: string
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
return { success: true, ...(await this.request(messages, options, false, signal)) }
|
return { success: true, ...(await this.request(messages, options, false, signal, onDelta)) }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal?.aborted) throw error
|
if (signal?.aborted) throw error
|
||||||
return { success: false, error: safeAIError(error) }
|
return { success: false, error: safeAIError(error) }
|
||||||
@@ -323,12 +348,13 @@ export class AIProviderService {
|
|||||||
messages: AIMessage[],
|
messages: AIMessage[],
|
||||||
options?: AIChatRequestOptions,
|
options?: AIChatRequestOptions,
|
||||||
testing = false,
|
testing = false,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
onDelta?: AIChatDeltaHandler
|
||||||
): Promise<{
|
): Promise<{
|
||||||
data: string
|
data: string
|
||||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||||
}> {
|
}> {
|
||||||
if (options?.apiKey) return this.requestLegacy(messages, options, signal)
|
if (options?.apiKey) return this.requestLegacy(messages, options, signal, onDelta)
|
||||||
const resolved = this.resolveProvider(options)
|
const resolved = this.resolveProvider(options)
|
||||||
const provider = options?.timeoutMs
|
const provider = options?.timeoutMs
|
||||||
? {
|
? {
|
||||||
@@ -336,7 +362,15 @@ export class AIProviderService {
|
|||||||
advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs }
|
advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs }
|
||||||
}
|
}
|
||||||
: resolved.provider
|
: resolved.provider
|
||||||
return requestProvider(provider, resolved.key, resolved.model, messages, testing, signal)
|
return requestProvider(
|
||||||
|
provider,
|
||||||
|
resolved.key,
|
||||||
|
resolved.model,
|
||||||
|
messages,
|
||||||
|
testing,
|
||||||
|
signal,
|
||||||
|
onDelta
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
|
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
|
||||||
@@ -359,7 +393,8 @@ export class AIProviderService {
|
|||||||
private async requestLegacy(
|
private async requestLegacy(
|
||||||
messages: AIMessage[],
|
messages: AIMessage[],
|
||||||
options: AIChatRequestOptions,
|
options: AIChatRequestOptions,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
onDelta?: AIChatDeltaHandler
|
||||||
): Promise<AIRequestResult> {
|
): Promise<AIRequestResult> {
|
||||||
const provider = deepSeekProvider(options.baseURL, options.model)
|
const provider = deepSeekProvider(options.baseURL, options.model)
|
||||||
return requestOpenAICompatible(
|
return requestOpenAICompatible(
|
||||||
@@ -368,7 +403,8 @@ export class AIProviderService {
|
|||||||
options.model || provider.defaultModel,
|
options.model || provider.defaultModel,
|
||||||
messages,
|
messages,
|
||||||
false,
|
false,
|
||||||
signal
|
signal,
|
||||||
|
onDelta
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +578,11 @@ function validateProvider(provider: AIProviderConfig): string | undefined {
|
|||||||
return '默认模型不在模型列表中'
|
return '默认模型不在模型列表中'
|
||||||
if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim())
|
if (provider.auth.type === 'custom-header' && !provider.auth.headerName?.trim())
|
||||||
return '请填写自定义认证字段'
|
return '请填写自定义认证字段'
|
||||||
|
if (
|
||||||
|
provider.advanced.apiProtocol &&
|
||||||
|
!['chat-completions', 'responses'].includes(provider.advanced.apiProtocol)
|
||||||
|
)
|
||||||
|
return 'OpenAI API 接口配置不正确'
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,17 +598,26 @@ function buildHeaders(provider: AIProviderSummary, apiKey: string): Record<strin
|
|||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
||||||
|
const normalizedName = name.toLowerCase()
|
||||||
|
return Object.keys(headers).some((header) => header.toLowerCase() === normalizedName)
|
||||||
|
}
|
||||||
|
|
||||||
function requestProvider(
|
function requestProvider(
|
||||||
provider: AIProviderSummary,
|
provider: AIProviderSummary,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
model: string,
|
model: string,
|
||||||
messages: AIMessage[],
|
messages: AIMessage[],
|
||||||
testing = false,
|
testing = false,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
onDelta?: AIChatDeltaHandler
|
||||||
): Promise<AIRequestResult> {
|
): Promise<AIRequestResult> {
|
||||||
return provider.type === 'anthropic-messages'
|
if (provider.type === 'anthropic-messages') {
|
||||||
? requestAnthropic(provider, apiKey, model, messages, testing, signal)
|
return requestAnthropic(provider, apiKey, model, messages, testing, signal)
|
||||||
: requestOpenAICompatible(provider, apiKey, model, messages, testing, signal)
|
}
|
||||||
|
return provider.advanced.apiProtocol === 'responses'
|
||||||
|
? requestOpenAIResponses(provider, apiKey, model, messages, testing, signal, onDelta)
|
||||||
|
: requestOpenAICompatible(provider, apiKey, model, messages, testing, signal, onDelta)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
|
function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
|
||||||
@@ -584,6 +634,46 @@ function toOpenAIMessages(messages: AIMessage[]): Array<{ role: string; content:
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toOpenAIResponsesRequest(messages: AIMessage[]): {
|
||||||
|
instructions?: string
|
||||||
|
input: Array<{ role: string; content: unknown[] }>
|
||||||
|
} {
|
||||||
|
const instructions = messages
|
||||||
|
.filter((message) => message.role === 'system')
|
||||||
|
.map((message) =>
|
||||||
|
typeof message.content === 'string'
|
||||||
|
? message.content
|
||||||
|
: message.content
|
||||||
|
.filter((part) => part.type === 'text')
|
||||||
|
.map((part) => (part.type === 'text' ? part.text : ''))
|
||||||
|
.join('\n')
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n')
|
||||||
|
const input = messages
|
||||||
|
.filter((message) => message.role !== 'system')
|
||||||
|
.map((message) => ({
|
||||||
|
role: message.role === 'assistant' ? 'assistant' : 'user',
|
||||||
|
content:
|
||||||
|
typeof message.content === 'string'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: message.role === 'assistant' ? 'output_text' : 'input_text',
|
||||||
|
text: message.content
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: message.content.map((part) =>
|
||||||
|
part.type === 'text'
|
||||||
|
? {
|
||||||
|
type: message.role === 'assistant' ? 'output_text' : 'input_text',
|
||||||
|
text: part.text
|
||||||
|
}
|
||||||
|
: { type: 'input_image', image_url: part.dataUrl }
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
return { instructions: instructions || undefined, input }
|
||||||
|
}
|
||||||
|
|
||||||
function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
|
function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; content: unknown }> {
|
||||||
return messages
|
return messages
|
||||||
.filter((message) => message.role !== 'system')
|
.filter((message) => message.role !== 'system')
|
||||||
@@ -603,24 +693,27 @@ function toAnthropicMessages(messages: AIMessage[]): Array<{ role: string; conte
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelMaxTokens(provider: AIProviderSummary, model: string): number {
|
||||||
|
return (
|
||||||
|
provider.models.find((item) => item.id === model)?.maxTokens ||
|
||||||
|
provider.advanced.maxTokens ||
|
||||||
|
4096
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function requestOpenAICompatible(
|
async function requestOpenAICompatible(
|
||||||
provider: AIProviderSummary,
|
provider: AIProviderSummary,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
model: string,
|
model: string,
|
||||||
messages: AIMessage[],
|
messages: AIMessage[],
|
||||||
testing = false,
|
testing = false,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
onDelta?: AIChatDeltaHandler
|
||||||
): Promise<AIRequestResult> {
|
): Promise<AIRequestResult> {
|
||||||
const endpoint = provider.baseUrl.endsWith('/chat/completions')
|
const endpoint = provider.baseUrl.endsWith('/chat/completions')
|
||||||
? provider.baseUrl
|
? provider.baseUrl
|
||||||
: `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions`
|
: `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions`
|
||||||
let modelMaxTokens = 0
|
return fetchWithTimeout(
|
||||||
for (const m of provider.models) {
|
|
||||||
if (m.id === model && m.maxTokens) {
|
|
||||||
modelMaxTokens = m.maxTokens
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const response = await fetchWithTimeout(
|
|
||||||
endpoint,
|
endpoint,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -629,30 +722,85 @@ async function requestOpenAICompatible(
|
|||||||
model,
|
model,
|
||||||
messages: toOpenAIMessages(messages),
|
messages: toOpenAIMessages(messages),
|
||||||
temperature: provider.advanced.temperature,
|
temperature: provider.advanced.temperature,
|
||||||
max_tokens: testing
|
max_tokens: testing ? 8 : modelMaxTokens(provider, model),
|
||||||
? 8
|
...(provider.advanced.stream ? { stream: true } : {})
|
||||||
: modelMaxTokens > 0
|
|
||||||
? modelMaxTokens
|
|
||||||
: provider.advanced.maxTokens || 4096
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
provider.advanced.timeoutMs,
|
provider.advanced.timeoutMs,
|
||||||
signal
|
signal,
|
||||||
|
async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||||
|
throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provider.advanced.stream &&
|
||||||
|
!response.headers.get('content-type')?.toLowerCase().includes('application/json')
|
||||||
|
) {
|
||||||
|
return parseOpenAIStream(response, onDelta)
|
||||||
|
}
|
||||||
|
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||||
|
return {
|
||||||
|
data: String(payload.choices?.[0]?.message?.content || ''),
|
||||||
|
finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'),
|
||||||
|
usage: toOpenAIUsage(payload.usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
}
|
||||||
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
|
||||||
return {
|
async function requestOpenAIResponses(
|
||||||
data: String(payload.choices?.[0]?.message?.content || ''),
|
provider: AIProviderSummary,
|
||||||
finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'),
|
apiKey: string,
|
||||||
usage: payload.usage
|
model: string,
|
||||||
? {
|
messages: AIMessage[],
|
||||||
input: payload.usage.prompt_tokens,
|
testing = false,
|
||||||
output: payload.usage.completion_tokens,
|
signal?: AbortSignal,
|
||||||
total: payload.usage.total_tokens,
|
onDelta?: AIChatDeltaHandler
|
||||||
estimated: false
|
): Promise<AIRequestResult> {
|
||||||
}
|
const normalizedBaseUrl = provider.baseUrl.replace(/\/+$/, '')
|
||||||
: undefined
|
const endpoint = normalizedBaseUrl.endsWith('/responses')
|
||||||
|
? normalizedBaseUrl
|
||||||
|
: normalizedBaseUrl.endsWith('/chat/completions')
|
||||||
|
? `${normalizedBaseUrl.slice(0, -'/chat/completions'.length)}/responses`
|
||||||
|
: `${normalizedBaseUrl}/responses`
|
||||||
|
const request = toOpenAIResponsesRequest(messages)
|
||||||
|
const headers = buildHeaders(provider, apiKey)
|
||||||
|
if (provider.advanced.stream && !hasHeader(headers, 'accept')) {
|
||||||
|
headers.accept = 'text/event-stream'
|
||||||
}
|
}
|
||||||
|
return fetchWithTimeout(
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
instructions: request.instructions,
|
||||||
|
input: request.input,
|
||||||
|
temperature: provider.advanced.temperature,
|
||||||
|
max_output_tokens: testing ? 128 : modelMaxTokens(provider, model),
|
||||||
|
store: false,
|
||||||
|
...(provider.advanced.stream ? { stream: true } : {})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
provider.advanced.timeoutMs,
|
||||||
|
signal,
|
||||||
|
async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await parseJsonResponse<OpenAIResponsesPayload>(response)
|
||||||
|
throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
if (provider.advanced.stream) return parseOpenAIResponsesStream(response, onDelta)
|
||||||
|
const payload = await parseJsonResponse<OpenAIResponsesPayload>(response)
|
||||||
|
if (payload.error?.message) throw new Error(payload.error.message)
|
||||||
|
return {
|
||||||
|
data: extractOpenAIResponsesText(payload),
|
||||||
|
finishReason: openAIResponsesFinishReason(payload) || 'unknown',
|
||||||
|
usage: toOpenAIResponsesUsage(payload.usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestAnthropic(
|
async function requestAnthropic(
|
||||||
@@ -680,13 +828,7 @@ async function requestAnthropic(
|
|||||||
const endpoint = provider.baseUrl.endsWith('/messages')
|
const endpoint = provider.baseUrl.endsWith('/messages')
|
||||||
? provider.baseUrl
|
? provider.baseUrl
|
||||||
: `${provider.baseUrl.replace(/\/+$/, '')}/messages`
|
: `${provider.baseUrl.replace(/\/+$/, '')}/messages`
|
||||||
let modelMaxTokens = 0
|
return fetchWithTimeout(
|
||||||
for (const m of provider.models) {
|
|
||||||
if (m.id === model && m.maxTokens) {
|
|
||||||
modelMaxTokens = m.maxTokens
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const response = await fetchWithTimeout(
|
|
||||||
endpoint,
|
endpoint,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -696,44 +838,44 @@ async function requestAnthropic(
|
|||||||
system: system || undefined,
|
system: system || undefined,
|
||||||
messages: anthropicMessages,
|
messages: anthropicMessages,
|
||||||
temperature: provider.advanced.temperature,
|
temperature: provider.advanced.temperature,
|
||||||
max_tokens: testing
|
max_tokens: testing ? 8 : modelMaxTokens(provider, model)
|
||||||
? 8
|
|
||||||
: modelMaxTokens > 0
|
|
||||||
? modelMaxTokens
|
|
||||||
: provider.advanced.maxTokens || 4096
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
provider.advanced.timeoutMs,
|
provider.advanced.timeoutMs,
|
||||||
signal
|
signal,
|
||||||
|
async (response) => {
|
||||||
|
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
||||||
|
return {
|
||||||
|
data: Array.isArray(payload.content)
|
||||||
|
? payload.content
|
||||||
|
.filter((item) => item.type === 'text')
|
||||||
|
.map((item) => item.text || '')
|
||||||
|
.join('\n')
|
||||||
|
: '',
|
||||||
|
finishReason: String(payload.stop_reason || 'unknown'),
|
||||||
|
usage: payload.usage
|
||||||
|
? {
|
||||||
|
input: payload.usage.input_tokens,
|
||||||
|
output: payload.usage.output_tokens,
|
||||||
|
total:
|
||||||
|
Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0),
|
||||||
|
estimated: false
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
|
|
||||||
if (!response.ok)
|
|
||||||
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
|
||||||
return {
|
|
||||||
data: Array.isArray(payload.content)
|
|
||||||
? payload.content
|
|
||||||
.filter((item) => item.type === 'text')
|
|
||||||
.map((item) => item.text || '')
|
|
||||||
.join('\n')
|
|
||||||
: '',
|
|
||||||
finishReason: String(payload.stop_reason || 'unknown'),
|
|
||||||
usage: payload.usage
|
|
||||||
? {
|
|
||||||
input: payload.usage.input_tokens,
|
|
||||||
output: payload.usage.output_tokens,
|
|
||||||
total: Number(payload.usage.input_tokens || 0) + Number(payload.usage.output_tokens || 0),
|
|
||||||
estimated: false
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWithTimeout(
|
async function fetchWithTimeout<T>(
|
||||||
url: string,
|
url: string,
|
||||||
init: RequestInit,
|
init: RequestInit,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
signal?: AbortSignal
|
signal: AbortSignal | undefined,
|
||||||
): Promise<Response> {
|
consume: (response: Response) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
let timedOut = false
|
let timedOut = false
|
||||||
const abortFromCaller = (): void =>
|
const abortFromCaller = (): void =>
|
||||||
@@ -748,7 +890,8 @@ async function fetchWithTimeout(
|
|||||||
Math.max(1_000, timeoutMs || 120_000)
|
Math.max(1_000, timeoutMs || 120_000)
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
return await fetch(url, { ...init, signal: controller.signal })
|
const response = await fetch(url, { ...init, signal: controller.signal })
|
||||||
|
return await consume(response)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal?.aborted) throw new DOMException('AI request cancelled', 'AbortError')
|
if (signal?.aborted) throw new DOMException('AI request cancelled', 'AbortError')
|
||||||
if (timedOut) throw new DOMException('AI request timed out', 'TimeoutError')
|
if (timedOut) throw new DOMException('AI request timed out', 'TimeoutError')
|
||||||
@@ -759,6 +902,218 @@ async function fetchWithTimeout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function parseOpenAIStream(
|
||||||
|
response: Response,
|
||||||
|
onDelta?: AIChatDeltaHandler
|
||||||
|
): Promise<AIRequestResult> {
|
||||||
|
let data = ''
|
||||||
|
let finishReason = 'unknown'
|
||||||
|
let usage: AIRequestResult['usage']
|
||||||
|
const stream = await readSSE(response, (eventData) => {
|
||||||
|
let payload: OpenAIStreamPayload
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(eventData) as OpenAIStreamPayload
|
||||||
|
} catch {
|
||||||
|
throw new Error('模型服务返回了无法解析的流式数据')
|
||||||
|
}
|
||||||
|
if (payload.error?.message) throw new Error(payload.error.message)
|
||||||
|
if (payload.usage) usage = toOpenAIUsage(payload.usage)
|
||||||
|
const choice = payload.choices?.[0]
|
||||||
|
if (choice?.finish_reason) finishReason = choice.finish_reason
|
||||||
|
const delta = choice?.delta?.content
|
||||||
|
if (typeof delta === 'string' && delta) {
|
||||||
|
data += delta
|
||||||
|
onDelta?.(delta)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!stream.sawData) {
|
||||||
|
let payload: OpenAIResponsePayload
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(stream.rawBody) as OpenAIResponsePayload
|
||||||
|
} catch {
|
||||||
|
throw new Error('模型服务返回了无法解析的流式数据')
|
||||||
|
}
|
||||||
|
if (payload.error?.message) throw new Error(payload.error.message)
|
||||||
|
const content = String(payload.choices?.[0]?.message?.content || '')
|
||||||
|
if (content) onDelta?.(content)
|
||||||
|
return {
|
||||||
|
data: content,
|
||||||
|
finishReason: String(payload.choices?.[0]?.finish_reason || 'unknown'),
|
||||||
|
usage: toOpenAIUsage(payload.usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { data, finishReason, usage }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseOpenAIResponsesStream(
|
||||||
|
response: Response,
|
||||||
|
onDelta?: AIChatDeltaHandler
|
||||||
|
): Promise<AIRequestResult> {
|
||||||
|
let data = ''
|
||||||
|
let completedResponse: OpenAIResponsesPayload | undefined
|
||||||
|
let usage: AIRequestResult['usage']
|
||||||
|
const stream = await readSSE(response, (eventData) => {
|
||||||
|
let payload: OpenAIResponsesStreamPayload
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(eventData) as OpenAIResponsesStreamPayload
|
||||||
|
} catch {
|
||||||
|
throw new Error('模型服务返回了无法解析的 Responses 流式数据')
|
||||||
|
}
|
||||||
|
const errorMessage = payload.error?.message || payload.response?.error?.message
|
||||||
|
if (errorMessage) throw new Error(errorMessage)
|
||||||
|
if (payload.response?.usage) usage = toOpenAIResponsesUsage(payload.response.usage)
|
||||||
|
if (payload.type === 'response.output_text.delta' && typeof payload.delta === 'string') {
|
||||||
|
data += payload.delta
|
||||||
|
onDelta?.(payload.delta)
|
||||||
|
}
|
||||||
|
if (payload.type === 'response.completed') completedResponse = payload.response
|
||||||
|
if (payload.type === 'response.failed' || payload.type === 'response.incomplete') {
|
||||||
|
const reason = payload.response?.incomplete_details?.reason
|
||||||
|
throw new Error(reason ? `模型响应未完成:${reason}` : payload.message || '模型响应未完成')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!stream.sawData) {
|
||||||
|
let payload: OpenAIResponsesPayload
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(stream.rawBody) as OpenAIResponsesPayload
|
||||||
|
} catch {
|
||||||
|
throw new Error('模型服务返回了无法解析的 Responses 流式数据')
|
||||||
|
}
|
||||||
|
if (payload.error?.message) throw new Error(payload.error.message)
|
||||||
|
const content = extractOpenAIResponsesText(payload)
|
||||||
|
if (content) onDelta?.(content)
|
||||||
|
return {
|
||||||
|
data: content,
|
||||||
|
finishReason: openAIResponsesFinishReason(payload) || 'unknown',
|
||||||
|
usage: toOpenAIResponsesUsage(payload.usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data && completedResponse) {
|
||||||
|
data = extractOpenAIResponsesText(completedResponse)
|
||||||
|
if (data) onDelta?.(data)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
finishReason: completedResponse
|
||||||
|
? openAIResponsesFinishReason(completedResponse) || 'unknown'
|
||||||
|
: 'unknown',
|
||||||
|
usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSSE(
|
||||||
|
response: Response,
|
||||||
|
onEvent: (eventData: string) => void
|
||||||
|
): Promise<{ sawData: boolean; rawBody: string }> {
|
||||||
|
if (!response.body) throw new Error('模型服务未返回可读取的流式响应')
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
let rawBody = ''
|
||||||
|
let sawData = false
|
||||||
|
let finished = false
|
||||||
|
|
||||||
|
const consumeEvent = (event: string): boolean => {
|
||||||
|
const eventData = event
|
||||||
|
.split(/\r\n|\r|\n/)
|
||||||
|
.filter((line) => line.startsWith('data:'))
|
||||||
|
.map((line) => {
|
||||||
|
const value = line.slice(5)
|
||||||
|
return value.startsWith(' ') ? value.slice(1) : value
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
if (!eventData) return false
|
||||||
|
sawData = true
|
||||||
|
rawBody = ''
|
||||||
|
if (eventData.trim() === '[DONE]') return true
|
||||||
|
onEvent(eventData)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumeBuffer = (): void => {
|
||||||
|
while (!finished) {
|
||||||
|
const boundary = /(?:\r\n|\r|\n){2}/.exec(buffer)
|
||||||
|
if (!boundary) return
|
||||||
|
const event = buffer.slice(0, boundary.index)
|
||||||
|
buffer = buffer.slice(boundary.index + boundary[0].length)
|
||||||
|
finished = consumeEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!finished) {
|
||||||
|
const chunk = await reader.read()
|
||||||
|
if (chunk.done) {
|
||||||
|
const tail = decoder.decode()
|
||||||
|
buffer += tail
|
||||||
|
if (!sawData) rawBody += tail
|
||||||
|
consumeBuffer()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const text = decoder.decode(chunk.value, { stream: true })
|
||||||
|
buffer += text
|
||||||
|
if (!sawData) rawBody += text
|
||||||
|
consumeBuffer()
|
||||||
|
}
|
||||||
|
if (!finished && buffer.trim()) finished = consumeEvent(buffer)
|
||||||
|
if (finished) await reader.cancel().catch(() => undefined)
|
||||||
|
} catch (error) {
|
||||||
|
await reader.cancel(error).catch(() => undefined)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sawData, rawBody }
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOpenAIResponsesText(payload: OpenAIResponsesPayload): string {
|
||||||
|
if (typeof payload.output_text === 'string') return payload.output_text
|
||||||
|
return (payload.output || [])
|
||||||
|
.flatMap((item) => item.content || [])
|
||||||
|
.filter((item) => item.type === 'output_text')
|
||||||
|
.map((item) => item.text || '')
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAIResponsesFinishReason(payload: OpenAIResponsesPayload): string | undefined {
|
||||||
|
const reason = payload.incomplete_details?.reason
|
||||||
|
if (reason === 'max_output_tokens') return 'max_tokens'
|
||||||
|
if (reason) return reason
|
||||||
|
if (payload.status === 'completed') return 'stop'
|
||||||
|
if (payload.status === 'failed') return 'error'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOpenAIResponsesUsage(
|
||||||
|
usage: OpenAIResponsesPayload['usage']
|
||||||
|
): AIRequestResult['usage'] | undefined {
|
||||||
|
return usage
|
||||||
|
? {
|
||||||
|
input: usage.input_tokens,
|
||||||
|
output: usage.output_tokens,
|
||||||
|
total: usage.total_tokens,
|
||||||
|
estimated: false
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOpenAIUsage(
|
||||||
|
usage: OpenAIResponsePayload['usage']
|
||||||
|
): AIRequestResult['usage'] | undefined {
|
||||||
|
return usage
|
||||||
|
? {
|
||||||
|
input: usage.prompt_tokens,
|
||||||
|
output: usage.completion_tokens,
|
||||||
|
total: usage.total_tokens,
|
||||||
|
estimated: false
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||||
const body = await response.text()
|
const body = await response.text()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -145,6 +145,34 @@ const isAbortError = (error: unknown): boolean =>
|
|||||||
(error instanceof DOMException && error.name === 'AbortError') ||
|
(error instanceof DOMException && error.name === 'AbortError') ||
|
||||||
(error instanceof Error && error.name === 'AbortError')
|
(error instanceof Error && error.name === 'AbortError')
|
||||||
|
|
||||||
|
const createDeltaBatcher = (
|
||||||
|
publish: (delta: string) => void,
|
||||||
|
intervalMs = 40
|
||||||
|
): { push: (delta: string) => void; close: (flushPending: boolean) => void } => {
|
||||||
|
let pending = ''
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const flush = (): void => {
|
||||||
|
timer = undefined
|
||||||
|
if (!pending) return
|
||||||
|
const delta = pending
|
||||||
|
pending = ''
|
||||||
|
publish(delta)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
push: (delta: string): void => {
|
||||||
|
if (!delta) return
|
||||||
|
pending += delta
|
||||||
|
if (!timer) timer = setTimeout(flush, intervalMs)
|
||||||
|
},
|
||||||
|
close: (flushPending: boolean): void => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
if (flushPending) flush()
|
||||||
|
else pending = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main-process search orchestrator. It owns the only transition from raw
|
* Main-process search orchestrator. It owns the only transition from raw
|
||||||
* candidates to Final Evidence; the AI and Renderer never receive a wider
|
* candidates to Final Evidence; the AI and Renderer never receive a wider
|
||||||
@@ -791,6 +819,23 @@ export class AiSearchPipelineService {
|
|||||||
timings: snapshotTimings()
|
timings: snapshotTimings()
|
||||||
})
|
})
|
||||||
const aiGenerationStartedAt = Date.now()
|
const aiGenerationStartedAt = Date.now()
|
||||||
|
const answerDeltaBatcher = createDeltaBatcher((answerDelta) =>
|
||||||
|
emit({
|
||||||
|
stage: 'ai_generating',
|
||||||
|
status: 'running',
|
||||||
|
message: '正在生成带来源的回答',
|
||||||
|
answerDelta,
|
||||||
|
plan,
|
||||||
|
modelName: aiConfig.modelName,
|
||||||
|
stats: {
|
||||||
|
matchedMessages: evidenceBuild.candidateCount,
|
||||||
|
evidenceCount: evidence.length,
|
||||||
|
contextEvidenceCount: evidence.length,
|
||||||
|
tokenEstimate
|
||||||
|
},
|
||||||
|
timings: snapshotTimings()
|
||||||
|
})
|
||||||
|
)
|
||||||
const answer = await this.chatForSearchRequest(
|
const answer = await this.chatForSearchRequest(
|
||||||
request.requestId,
|
request.requestId,
|
||||||
aiConfig.providerId,
|
aiConfig.providerId,
|
||||||
@@ -803,8 +848,9 @@ export class AiSearchPipelineService {
|
|||||||
},
|
},
|
||||||
{ role: 'user', content: prompt }
|
{ role: 'user', content: prompt }
|
||||||
],
|
],
|
||||||
signal
|
signal,
|
||||||
)
|
answerDeltaBatcher.push
|
||||||
|
).finally(() => answerDeltaBatcher.close(!signal.aborted))
|
||||||
signal.throwIfAborted()
|
signal.throwIfAborted()
|
||||||
timings.aiGenerationMs = Date.now() - aiGenerationStartedAt
|
timings.aiGenerationMs = Date.now() - aiGenerationStartedAt
|
||||||
if (!answer.success || !answer.data) {
|
if (!answer.success || !answer.data) {
|
||||||
@@ -1009,13 +1055,14 @@ export class AiSearchPipelineService {
|
|||||||
providerId: string | undefined,
|
providerId: string | undefined,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
messages: Array<{ role: string; content: string }>,
|
messages: Array<{ role: string; content: string }>,
|
||||||
signal: AbortSignal
|
signal: AbortSignal,
|
||||||
|
onDelta?: (delta: string) => void
|
||||||
): ReturnType<AIProviderService['chat']> {
|
): ReturnType<AIProviderService['chat']> {
|
||||||
if (!this.canUseAiForRequest(requestId, providerId)) {
|
if (!this.canUseAiForRequest(requestId, providerId)) {
|
||||||
return { success: false, error: '当前搜索请求未授权向该 AI 服务发送内容' }
|
return { success: false, error: '当前搜索请求未授权向该 AI 服务发送内容' }
|
||||||
}
|
}
|
||||||
signal.throwIfAborted()
|
signal.throwIfAborted()
|
||||||
return this.aiProvider.chat(messages, { providerId, modelId }, signal)
|
return this.aiProvider.chat(messages, { providerId, modelId }, signal, onDelta)
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearPendingAuthorization(requestId: string): void {
|
private clearPendingAuthorization(requestId: string): void {
|
||||||
|
|||||||
@@ -144,7 +144,9 @@ export function AISearchWorkspace({
|
|||||||
startSearch,
|
startSearch,
|
||||||
cancelSearch,
|
cancelSearch,
|
||||||
resetSearchRun
|
resetSearchRun
|
||||||
} = useAiSearchRun()
|
} = useAiSearchRun({
|
||||||
|
onAnswerDelta: (delta) => setAnswer((current) => current + delta)
|
||||||
|
})
|
||||||
|
|
||||||
const resetSearchResult = (): void => {
|
const resetSearchResult = (): void => {
|
||||||
const reset = createSearchResultResetState()
|
const reset = createSearchResultResetState()
|
||||||
@@ -520,6 +522,19 @@ export function AISearchWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
{answer && (
|
||||||
|
<section
|
||||||
|
className="ai-search-summary-block ai-search-streaming-answer"
|
||||||
|
aria-label="正在生成的回答"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="ai-search-section-heading">
|
||||||
|
<span />
|
||||||
|
回答生成中
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-answer">{renderMarkdown(answer)}</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ export type AiSearchRunOutcome =
|
|||||||
const errorMessage = (error: unknown): string =>
|
const errorMessage = (error: unknown): string =>
|
||||||
error instanceof Error ? error.message : '读取聊天记录失败'
|
error instanceof Error ? error.message : '读取聊天记录失败'
|
||||||
|
|
||||||
export function useAiSearchRun(): {
|
export function useAiSearchRun({
|
||||||
|
onAnswerDelta
|
||||||
|
}: {
|
||||||
|
onAnswerDelta?: (delta: string) => void
|
||||||
|
} = {}): {
|
||||||
status: AiSearchRunStatus
|
status: AiSearchRunStatus
|
||||||
requestId: string
|
requestId: string
|
||||||
result: AiSearchPipelineResult | null
|
result: AiSearchPipelineResult | null
|
||||||
@@ -44,6 +48,11 @@ export function useAiSearchRun(): {
|
|||||||
const [progress, setProgress] = useState<SearchProgressByStage>({})
|
const [progress, setProgress] = useState<SearchProgressByStage>({})
|
||||||
const [agentTrace, setAgentTrace] = useState<AiSearchAgentRun['trace']>([])
|
const [agentTrace, setAgentTrace] = useState<AiSearchAgentRun['trace']>([])
|
||||||
const requestIdRef = useRef('')
|
const requestIdRef = useRef('')
|
||||||
|
const onAnswerDeltaRef = useRef(onAnswerDelta)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onAnswerDeltaRef.current = onAnswerDelta
|
||||||
|
}, [onAnswerDelta])
|
||||||
|
|
||||||
const createRequestId = (): string => globalThis.crypto?.randomUUID?.() || `search-${Date.now()}`
|
const createRequestId = (): string => globalThis.crypto?.randomUUID?.() || `search-${Date.now()}`
|
||||||
|
|
||||||
@@ -54,6 +63,7 @@ export function useAiSearchRun(): {
|
|||||||
const unsubscribe = window.api.onAiSearchProgress((event: AiSearchProgressEvent) => {
|
const unsubscribe = window.api.onAiSearchProgress((event: AiSearchProgressEvent) => {
|
||||||
if (!isCurrentRequest(event.requestId)) return
|
if (!isCurrentRequest(event.requestId)) return
|
||||||
setProgress((current) => ({ ...current, [event.stage]: event }))
|
setProgress((current) => ({ ...current, [event.stage]: event }))
|
||||||
|
if (event.answerDelta) onAnswerDeltaRef.current?.(event.answerDelta)
|
||||||
if (event.agentTrace) {
|
if (event.agentTrace) {
|
||||||
setAgentTrace((current) =>
|
setAgentTrace((current) =>
|
||||||
current.some((item) => item.sequence === event.agentTrace?.sequence)
|
current.some((item) => item.sequence === event.agentTrace?.sequence)
|
||||||
|
|||||||
@@ -332,6 +332,49 @@ export function AIProviderEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{provider.type !== 'anthropic-messages' && (
|
||||||
|
<>
|
||||||
|
<label className="wide">
|
||||||
|
OpenAI API 接口
|
||||||
|
<Select
|
||||||
|
value={provider.advanced.apiProtocol || 'chat-completions'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patch({
|
||||||
|
advanced: {
|
||||||
|
...provider.advanced,
|
||||||
|
apiProtocol: value as AIProviderConfig['advanced']['apiProtocol']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="OpenAI API 接口">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="chat-completions">Chat Completions(兼容模式)</SelectItem>
|
||||||
|
<SelectItem value="responses">Responses API</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<small>Codex 或仅支持 /responses 的中转服务请选择 Responses API。</small>
|
||||||
|
</label>
|
||||||
|
<label className="wide ai-provider-stream-toggle" htmlFor="ai-provider-stream">
|
||||||
|
<span className="ai-provider-stream-toggle-control">
|
||||||
|
<Checkbox
|
||||||
|
id="ai-provider-stream"
|
||||||
|
aria-label="启用流式响应"
|
||||||
|
checked={Boolean(provider.advanced.stream)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
patch({
|
||||||
|
advanced: { ...provider.advanced, stream: checked === true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
启用流式响应(stream: true)
|
||||||
|
</span>
|
||||||
|
<small>问问微信会边生成边显示,其他功能仍会等待完整结果。</small>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<label className="wide">
|
<label className="wide">
|
||||||
额外 Headers(JSON)
|
额外 Headers(JSON)
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ export function createProviderFromPreset(presetId = 'deepseek'): AIProviderConfi
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
defaultModel: preset.model,
|
defaultModel: preset.model,
|
||||||
advanced: { timeoutMs: 120000, temperature: 0.7, maxTokens: 4096, extraHeaders: {} }
|
advanced: {
|
||||||
|
timeoutMs: 120000,
|
||||||
|
temperature: 0.7,
|
||||||
|
maxTokens: 4096,
|
||||||
|
stream: false,
|
||||||
|
apiProtocol: 'chat-completions',
|
||||||
|
extraHeaders: {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -623,6 +623,27 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-search-streaming-answer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-streaming-answer .ai-search-answer::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 3px;
|
||||||
|
background: var(--wxex-ai);
|
||||||
|
vertical-align: -0.12em;
|
||||||
|
animation: ai-search-stream-cursor 0.8s steps(2, start) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-search-stream-cursor {
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ai-search-answer-evidence,
|
.ai-search-answer-evidence,
|
||||||
.ai-search-trace {
|
.ai-search-trace {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -792,6 +813,9 @@
|
|||||||
.ai-search-result,
|
.ai-search-result,
|
||||||
.ai-search-summary-block,
|
.ai-search-summary-block,
|
||||||
.ai-search-answer > *,
|
.ai-search-answer > *,
|
||||||
|
.ai-search-streaming-answer .ai-search-answer::after,
|
||||||
|
.ai-search-evidence-card,
|
||||||
|
.ai-search-history-popover,
|
||||||
.ai-search-evidence-focus-flash,
|
.ai-search-evidence-focus-flash,
|
||||||
.ai-search-pipeline-step.active .ai-search-pipeline-mark,
|
.ai-search-pipeline-step.active .ai-search-pipeline-mark,
|
||||||
.ai-search-sync-progress-track span {
|
.ai-search-sync-progress-track span {
|
||||||
|
|||||||
@@ -1553,6 +1553,15 @@
|
|||||||
min-height: 90px;
|
min-height: 90px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
.ai-provider-stream-toggle-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.ai-provider-stream-toggle small {
|
||||||
|
color: #66706b;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
.ai-provider-preview pre {
|
.ai-provider-preview pre {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: 280px;
|
max-height: 280px;
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface AiSearchProgressEvent {
|
|||||||
stage: AiSearchProgressStage
|
stage: AiSearchProgressStage
|
||||||
status: AiSearchProgressStatus
|
status: AiSearchProgressStatus
|
||||||
message: string
|
message: string
|
||||||
|
answerDelta?: string
|
||||||
plan?: AiSearchPlan
|
plan?: AiSearchPlan
|
||||||
stats?: {
|
stats?: {
|
||||||
knowledgeMessageCount?: number
|
knowledgeMessageCount?: number
|
||||||
|
|||||||
Reference in New Issue
Block a user