| import { useCallback } from 'react' |
|
|
| import { getAttachedFileAccountIds } from './fileAccountBinding' |
|
|
| export function useChatStreamClient({ |
| t, |
| onMessage, |
| model, |
| message, |
| effectiveKey, |
| selectedAccount, |
| streamingMode, |
| attachedFiles, |
| abortControllerRef, |
| setLoading, |
| setIsStreaming, |
| setResponse, |
| setStreamingContent, |
| setStreamingThinking, |
| }) { |
| const stopGeneration = useCallback(() => { |
| if (abortControllerRef.current) { |
| abortControllerRef.current.abort() |
| abortControllerRef.current = null |
| } |
| setLoading(false) |
| setIsStreaming(false) |
| }, [abortControllerRef, setIsStreaming, setLoading]) |
|
|
| const extractErrorMessage = useCallback(async (res) => { |
| let raw = '' |
| try { |
| raw = await res.text() |
| } catch { |
| return t('apiTester.requestFailed') |
| } |
| if (!raw) { |
| return t('apiTester.requestFailed') |
| } |
| try { |
| const data = JSON.parse(raw) |
| const fromErrorObject = data?.error?.message |
| const fromErrorString = typeof data?.error === 'string' ? data.error : '' |
| const detail = typeof data?.detail === 'string' ? data.detail : '' |
| const msg = typeof data?.message === 'string' ? data.message : '' |
| return fromErrorObject || fromErrorString || detail || msg || t('apiTester.requestFailed') |
| } catch { |
| return raw.length > 240 ? `${raw.slice(0, 240)}...` : raw |
| } |
| }, [t]) |
|
|
| const resolveAttachmentAccount = useCallback(() => { |
| const ids = getAttachedFileAccountIds(attachedFiles) |
| if (ids.length > 1) { |
| return { |
| accountId: '', |
| error: t('apiTester.fileAccountConflict'), |
| } |
| } |
| return { |
| accountId: ids[0] || '', |
| error: '', |
| } |
| }, [attachedFiles, t]) |
|
|
| const extractStreamError = useCallback((json) => { |
| const error = json?.error |
| if (!error || typeof error !== 'object') { |
| return null |
| } |
|
|
| const message = typeof error.message === 'string' && error.message.trim() |
| ? error.message.trim() |
| : t('apiTester.requestFailed') |
| const rawStatus = Number(json?.status_code ?? error.status_code ?? error.http_status) |
| const statusCode = Number.isFinite(rawStatus) && rawStatus > 0 |
| ? rawStatus |
| : (error.code === 'content_filter' ? 400 : 429) |
|
|
| return { |
| message, |
| statusCode, |
| code: typeof error.code === 'string' ? error.code : '', |
| type: typeof error.type === 'string' ? error.type : '', |
| } |
| }, [t]) |
|
|
| const runTest = useCallback(async () => { |
| if (!effectiveKey) { |
| onMessage('error', t('apiTester.missingApiKey')) |
| return |
| } |
|
|
| const startedAt = Date.now() |
| setLoading(true) |
| setIsStreaming(true) |
| setResponse(null) |
| setStreamingContent('') |
| setStreamingThinking('') |
|
|
| abortControllerRef.current = new AbortController() |
|
|
| try { |
| const selectedAccountId = String(selectedAccount || '').trim() |
| const attachmentBinding = resolveAttachmentAccount() |
| if (attachmentBinding.error) { |
| setResponse({ success: false, error: attachmentBinding.error }) |
| onMessage('error', attachmentBinding.error) |
| setLoading(false) |
| setIsStreaming(false) |
| return |
| } |
| if (attachmentBinding.accountId && selectedAccountId && selectedAccountId !== attachmentBinding.accountId) { |
| const errorMsg = t('apiTester.fileAccountMismatch', { account: attachmentBinding.accountId }) |
| setResponse({ success: false, error: errorMsg }) |
| onMessage('error', errorMsg) |
| setLoading(false) |
| setIsStreaming(false) |
| return |
| } |
| const requestAccount = selectedAccountId || attachmentBinding.accountId |
|
|
| const headers = { |
| 'Content-Type': 'application/json', |
| 'Authorization': `Bearer ${effectiveKey}`, |
| } |
| if (requestAccount) { |
| headers['X-Ds2-Target-Account'] = requestAccount |
| } |
|
|
| const body = { |
| model, |
| messages: [{ role: 'user', content: message }], |
| stream: streamingMode, |
| } |
| |
| if (attachedFiles && attachedFiles.length > 0) { |
| body.file_ids = attachedFiles.map(f => f.id) |
| } |
|
|
| const endpoint = streamingMode ? '/v1/chat/completions' : '/v1/chat/completions?__go=1' |
| const res = await fetch(endpoint, { |
| method: 'POST', |
| headers, |
| body: JSON.stringify(body), |
| signal: abortControllerRef.current.signal, |
| }) |
|
|
| if (!res.ok) { |
| const errorMsg = await extractErrorMessage(res) |
| setResponse({ success: false, error: errorMsg }) |
| onMessage('error', errorMsg) |
| setLoading(false) |
| setIsStreaming(false) |
| return |
| } |
|
|
| if (streamingMode) { |
| setResponse({ success: true, status_code: res.status }) |
|
|
| const reader = res.body.getReader() |
| const decoder = new TextDecoder() |
| let buffer = '' |
| let accumulatedThinking = '' |
| let accumulatedContent = '' |
| let streamError = null |
|
|
| streamLoop: |
| while (true) { |
| const { done, value } = await reader.read() |
| if (done) break |
|
|
| buffer += decoder.decode(value, { stream: true }) |
| const lines = buffer.split('\n') |
| buffer = lines.pop() || '' |
|
|
| for (const line of lines) { |
| const trimmed = line.trim() |
| if (!trimmed || !trimmed.startsWith('data: ')) continue |
|
|
| const dataStr = trimmed.slice(6) |
| if (dataStr === '[DONE]') continue |
|
|
| try { |
| const json = JSON.parse(dataStr) |
| const errorPayload = extractStreamError(json) |
| if (errorPayload) { |
| streamError = errorPayload |
| break streamLoop |
| } |
| const choice = json.choices?.[0] |
| if (choice?.delta) { |
| const delta = choice.delta |
| if (delta.reasoning_content) { |
| accumulatedThinking += delta.reasoning_content |
| setStreamingThinking(prev => prev + delta.reasoning_content) |
| } |
| if (delta.content) { |
| accumulatedContent += delta.content |
| setStreamingContent(prev => prev + delta.content) |
| } |
| } |
| } catch (e) { |
| console.error('Invalid JSON hunk:', dataStr, e) |
| } |
| } |
| } |
|
|
| if (streamError) { |
| await reader.cancel().catch(() => {}) |
| setStreamingContent('') |
| setStreamingThinking('') |
| setResponse({ |
| success: false, |
| status_code: streamError.statusCode, |
| error: streamError.message, |
| code: streamError.code, |
| type: streamError.type, |
| }) |
| onMessage('error', streamError.message) |
| setLoading(false) |
| setIsStreaming(false) |
| return |
| } |
|
|
| setResponse({ |
| success: true, |
| status_code: res.status, |
| choices: [{ |
| finish_reason: 'stop', |
| index: 0, |
| message: { |
| role: 'assistant', |
| content: accumulatedContent, |
| reasoning_content: accumulatedThinking, |
| }, |
| }], |
| }) |
| onMessage('success', t('apiTester.requestSuccess', { account: requestAccount || selectedAccountId || 'Auto', time: Math.max(0, Date.now() - startedAt) })) |
| } else { |
| const data = await res.json() |
| setResponse({ success: true, status_code: res.status, ...data }) |
| const elapsed = Math.max(0, Date.now() - startedAt) |
| onMessage('success', t('apiTester.requestSuccess', { account: requestAccount || 'Auto', time: elapsed })) |
| } |
| } catch (e) { |
| if (e.name === 'AbortError') { |
| onMessage('info', t('messages.generationStopped')) |
| } else { |
| onMessage('error', t('apiTester.networkError', { error: e.message })) |
| setResponse({ error: e.message, success: false }) |
| } |
| } finally { |
| setLoading(false) |
| setIsStreaming(false) |
| abortControllerRef.current = null |
| } |
| }, [ |
| abortControllerRef, |
| attachedFiles, |
| effectiveKey, |
| extractErrorMessage, |
| extractStreamError, |
| message, |
| model, |
| onMessage, |
| resolveAttachmentAccount, |
| selectedAccount, |
| setIsStreaming, |
| setLoading, |
| setResponse, |
| setStreamingContent, |
| setStreamingThinking, |
| streamingMode, |
| t, |
| ]) |
|
|
| return { |
| runTest, |
| stopGeneration, |
| } |
| } |
|
|