Skip to content

Commit 8145337

Browse files
authored
refactor: extract shared AI API logic into useAIFetch composable (#1439)
1 parent 172958c commit 8145337

File tree

6 files changed

+219
-272
lines changed

6 files changed

+219
-272
lines changed

apps/web/src/components/ai/chat-box/AIAssistantPanel.vue

Lines changed: 27 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
DropdownMenuTrigger,
3030
} from '@/components/ui/dropdown-menu'
3131
import { Textarea } from '@/components/ui/textarea'
32+
import { buildAIHeaders, resolveEndpointUrl, useAIFetch } from '@/composables/useAIFetch'
3233
import useAIConfigStore from '@/stores/aiConfig'
3334
import { useEditorStore } from '@/stores/editor'
3435
import { useQuickCommands } from '@/stores/quickCommands'
@@ -57,8 +58,7 @@ const inputHistory = ref<string[]>([])
5758
const historyIndex = ref<number | null>(null)
5859
5960
const configVisible = ref(false)
60-
const loading = ref(false)
61-
const fetchController = ref<AbortController | null>(null)
61+
const { loading, abort: abortFetch, fetchSSE } = useAIFetch()
6262
const copiedIndex = ref<number | null>(null)
6363
const insertedIndex = ref<number | null>(null)
6464
const memoryKey = `ai_memory_context`
@@ -273,10 +273,7 @@ function insertToDocument(text: string, index: number) {
273273
}
274274
275275
async function resetMessages() {
276-
if (fetchController.value) {
277-
fetchController.value.abort()
278-
fetchController.value = null
279-
}
276+
abortFetch()
280277
281278
if (currentConversationId.value) {
282279
conversationList.value = conversationList.value.filter(c => c.id !== currentConversationId.value)
@@ -292,11 +289,7 @@ async function resetMessages() {
292289
}
293290
294291
function pauseStreaming() {
295-
if (fetchController.value) {
296-
fetchController.value.abort()
297-
fetchController.value = null
298-
}
299-
loading.value = false
292+
abortFetch()
300293
const last = messages.value[messages.value.length - 1]
301294
if (last?.role === `assistant`)
302295
last.done = true
@@ -386,78 +379,42 @@ async function streamResponse(replyMessageProxy: ChatMessage) {
386379
max_tokens: maxToken.value,
387380
stream: true,
388381
}
389-
const headers: Record<string, string> = { 'Content-Type': `application/json` }
390-
if (apiKey.value && type.value !== `default`)
391-
headers.Authorization = `Bearer ${apiKey.value}`
392-
393-
fetchController.value = new AbortController()
394-
const signal = fetchController.value.signal
382+
const headers = buildAIHeaders(apiKey.value, type.value)
383+
const url = resolveEndpointUrl(endpoint.value, `chat`)
395384
396385
try {
397-
const url = new URL(endpoint.value)
398-
if (!url.pathname.endsWith(`/chat/completions`))
399-
url.pathname = url.pathname.replace(/\/?$/, `/chat/completions`)
400-
401-
const res = await window.fetch(url.toString(), {
402-
method: `POST`,
403-
headers,
404-
body: JSON.stringify(payload),
405-
signal,
406-
})
407-
if (!res.ok || !res.body)
408-
throw new Error(`响应错误:${res.status} ${res.statusText}`)
409-
410-
const reader = res.body.getReader()
411-
const decoder = new TextDecoder(`utf-8`)
412-
let buffer = ``
413-
414-
while (true) {
415-
const { value, done } = await reader.read()
416-
if (done) {
386+
await fetchSSE(url, headers, payload, {
387+
onDelta(content) {
388+
const last = messages.value[messages.value.length - 1]
389+
if (last !== replyMessageProxy)
390+
return
391+
last.content += content
392+
scrollToBottom()
393+
},
394+
onReasoningDelta(reasoning) {
395+
const last = messages.value[messages.value.length - 1]
396+
if (last !== replyMessageProxy)
397+
return
398+
last.reasoning = (last.reasoning || ``) + reasoning
399+
scrollToBottom()
400+
},
401+
onDone() {
417402
const last = messages.value[messages.value.length - 1]
418403
if (last.role === `assistant`) {
419404
last.done = true
420-
await scrollToBottom(true)
421-
}
422-
break
423-
}
424-
425-
buffer += decoder.decode(value, { stream: true })
426-
const lines = buffer.split(`\n`)
427-
buffer = lines.pop() || ``
428-
429-
for (const line of lines) {
430-
if (!line.trim() || line.trim() === `data: [DONE]`)
431-
continue
432-
try {
433-
const json = JSON.parse(line.replace(/^data: /, ``))
434-
const delta = json.choices?.[0]?.delta || {}
435-
const last = messages.value[messages.value.length - 1]
436-
if (last !== replyMessageProxy)
437-
return
438-
if (delta.content)
439-
last.content += delta.content
440-
else if (delta.reasoning_content)
441-
last.reasoning = (last.reasoning || ``) + delta.reasoning_content
442-
await scrollToBottom()
443-
}
444-
catch {
405+
scrollToBottom(true)
445406
}
446-
}
447-
}
407+
},
408+
})
448409
}
449410
catch (e) {
450-
if ((e as Error).name !== `AbortError`) {
451-
messages.value[messages.value.length - 1].content
452-
= `❌ 请求失败: ${(e as Error).message}`
453-
}
411+
messages.value[messages.value.length - 1].content
412+
= `❌ 请求失败: ${(e as Error).message}`
454413
await scrollToBottom(true)
455414
}
456415
finally {
457416
await store.setJSON(memoryKey, messages.value)
458417
await autoSaveCurrentConversation()
459-
loading.value = false
460-
fetchController.value = null
461418
}
462419
}
463420

apps/web/src/components/ai/chat-box/AIConfig.vue

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { serviceOptions } from '@md/shared/configs'
33
import { DEFAULT_SERVICE_TYPE } from '@md/shared/constants'
44
import { Info } from 'lucide-vue-next'
55
import { PasswordInput } from '@/components/ui/password-input'
6+
import { buildAIHeaders, resolveEndpointUrl, useAIFetch } from '@/composables/useAIFetch'
67
import useAIConfigStore from '@/stores/aiConfig'
78
89
/* -------------------------- 基础数据 -------------------------- */
@@ -13,7 +14,7 @@ const AIConfigStore = useAIConfigStore()
1314
const { type, endpoint, model, apiKey, temperature, maxToken } = storeToRefs(AIConfigStore)
1415
1516
/** UI 状态 */
16-
const loading = ref(false)
17+
const { loading, fetchJSON } = useAIFetch()
1718
const testResult = ref(``)
1819
1920
/** 当前服务信息 */
@@ -51,14 +52,10 @@ async function testConnection() {
5152
testResult.value = ``
5253
loading.value = true
5354
54-
const headers: Record<string, string> = { 'Content-Type': `application/json` }
55-
if (apiKey.value && type.value !== DEFAULT_SERVICE_TYPE)
56-
headers.Authorization = `Bearer ${apiKey.value}`
55+
const headers = buildAIHeaders(apiKey.value, type.value)
5756
5857
try {
59-
const url = new URL(endpoint.value)
60-
if (!url.pathname.endsWith(`/chat/completions`))
61-
url.pathname = url.pathname.replace(/\/?$/, `/chat/completions`)
58+
const url = resolveEndpointUrl(endpoint.value, `chat`)
6259
6360
const payload = {
6461
model: model.value,
@@ -68,20 +65,15 @@ async function testConnection() {
6865
stream: false,
6966
}
7067
71-
const res = await window.fetch(url.toString(), {
72-
method: `POST`,
73-
headers,
74-
body: JSON.stringify(payload),
75-
})
68+
const res = await fetchJSON(url, headers, payload)
7669
7770
if (res.ok) {
7871
testResult.value = `✅ 测试成功,/chat/completions 可用`
7972
saveConfig(false)
8073
}
8174
else {
82-
const text = await res.text()
8375
try {
84-
const { error } = JSON.parse(text)
76+
const { error } = JSON.parse(res.errorText)
8577
if (
8678
res.status === 404
8779
&& (error?.code === `ModelNotOpen`
@@ -93,7 +85,7 @@ async function testConnection() {
9385
}
9486
}
9587
catch {}
96-
testResult.value = `❌ 测试失败:${res.status} ${res.statusText},${text}`
88+
testResult.value = `❌ 测试失败:${res.status} ${res.statusText},${res.errorText}`
9789
}
9890
}
9991
catch (err) {

apps/web/src/components/ai/image-generator/AIImageConfig.vue

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
SelectTrigger,
1313
SelectValue,
1414
} from '@/components/ui/select'
15+
import { buildAIHeaders, resolveEndpointUrl, useAIFetch } from '@/composables/useAIFetch'
1516
import useAIImageConfigStore from '@/stores/aiImageConfig'
1617
1718
/* -------------------------- 基础数据 -------------------------- */
@@ -22,7 +23,7 @@ const AIImageConfigStore = useAIImageConfigStore()
2223
const { type, endpoint, model, apiKey, size, quality, style } = storeToRefs(AIImageConfigStore)
2324
2425
/** UI 状态 */
25-
const loading = ref(false)
26+
const { loading, fetchJSON } = useAIFetch()
2627
const testResult = ref(``)
2728
2829
/** 当前服务信息 */
@@ -82,15 +83,10 @@ async function testConnection() {
8283
testResult.value = ``
8384
loading.value = true
8485
85-
const headers: Record<string, string> = { 'Content-Type': `application/json` }
86-
if (apiKey.value && type.value !== DEFAULT_SERVICE_TYPE)
87-
headers.Authorization = `Bearer ${apiKey.value}`
86+
const headers = buildAIHeaders(apiKey.value, type.value)
8887
8988
try {
90-
const url = new URL(endpoint.value)
91-
if (!url.pathname.includes(`/images/`) && !url.pathname.endsWith(`/images/generations`)) {
92-
url.pathname = url.pathname.replace(/\/?$/, `/images/generations`)
93-
}
89+
const url = resolveEndpointUrl(endpoint.value, `image`)
9490
9591
const payload = {
9692
model: model.value,
@@ -101,18 +97,13 @@ async function testConnection() {
10197
n: 1,
10298
}
10399
104-
const res = await window.fetch(url.toString(), {
105-
method: `POST`,
106-
headers,
107-
body: JSON.stringify(payload),
108-
})
100+
const res = await fetchJSON(url, headers, payload)
109101
110102
if (res.ok) {
111103
testResult.value = `✅ 连接成功`
112104
}
113105
else {
114-
const errorText = await res.text()
115-
testResult.value = `❌ 连接失败:${res.status} ${errorText}`
106+
testResult.value = `❌ 连接失败:${res.status} ${res.errorText}`
116107
}
117108
}
118109
catch (error) {

0 commit comments

Comments
 (0)