Files
new-api/web/src/hooks/playground/useApiRequest.js
t0ng7u 38e72e1af7 🎨 chore: integrate ESLint header automation with AGPL-3.0 notice
• Added `.eslintrc.cjs`
  - Enables `header` + `react-hooks` plugins
  - Inserts standardized AGPL-3.0 license banner for © 2025 QuantumNous
  - JS/JSX parsing & JSX support configured

• Installed dev-deps: `eslint`, `eslint-plugin-header`, `eslint-plugin-react-hooks`

• Updated `web/package.json` scripts
  - `eslint` → lint with cache
  - `eslint:fix` → auto-insert/repair license headers

• Executed `eslint --fix` to prepend license banner to all JS/JSX files

• Ignored runtime cache
  - Added `.eslintcache` to `.gitignore` & `.dockerignore`

Result: consistent AGPL-3.0 license headers, reproducible linting across local dev & CI.
2025-07-19 03:30:44 +08:00

429 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { SSE } from 'sse.js';
import {
API_ENDPOINTS,
MESSAGE_STATUS,
DEBUG_TABS
} from '../../constants/playground.constants';
import {
getUserIdFromLocalStorage,
handleApiError,
processThinkTags,
processIncompleteThinkTags
} from '../../helpers';
export const useApiRequest = (
setMessage,
setDebugData,
setActiveDebugTab,
sseSourceRef,
saveMessages
) => {
const { t } = useTranslation();
// 处理消息自动关闭逻辑的公共函数
const applyAutoCollapseLogic = useCallback((message, isThinkingComplete = true) => {
const shouldAutoCollapse = isThinkingComplete && !message.hasAutoCollapsed;
return {
isThinkingComplete,
hasAutoCollapsed: shouldAutoCollapse || message.hasAutoCollapsed,
isReasoningExpanded: shouldAutoCollapse ? false : message.isReasoningExpanded,
};
}, []);
// 流式消息更新
const streamMessageUpdate = useCallback((textChunk, type) => {
setMessage(prevMessage => {
const lastMessage = prevMessage[prevMessage.length - 1];
if (!lastMessage) return prevMessage;
if (lastMessage.role !== 'assistant') return prevMessage;
if (lastMessage.status === MESSAGE_STATUS.ERROR) {
return prevMessage;
}
if (lastMessage.status === MESSAGE_STATUS.LOADING ||
lastMessage.status === MESSAGE_STATUS.INCOMPLETE) {
let newMessage = { ...lastMessage };
if (type === 'reasoning') {
newMessage = {
...newMessage,
reasoningContent: (lastMessage.reasoningContent || '') + textChunk,
status: MESSAGE_STATUS.INCOMPLETE,
isThinkingComplete: false,
};
} else if (type === 'content') {
const shouldCollapseReasoning = !lastMessage.content && lastMessage.reasoningContent;
const newContent = (lastMessage.content || '') + textChunk;
let shouldCollapseFromThinkTag = false;
let thinkingCompleteFromTags = lastMessage.isThinkingComplete;
if (lastMessage.isReasoningExpanded && newContent.includes('</think>')) {
const thinkMatches = newContent.match(/<think>/g);
const thinkCloseMatches = newContent.match(/<\/think>/g);
if (thinkMatches && thinkCloseMatches &&
thinkCloseMatches.length >= thinkMatches.length) {
shouldCollapseFromThinkTag = true;
thinkingCompleteFromTags = true; // think标签闭合也标记思考完成
}
}
// 如果开始接收content内容且之前有reasoning内容或者think标签已闭合则标记思考完成
const isThinkingComplete = (lastMessage.reasoningContent && !lastMessage.isThinkingComplete) ||
thinkingCompleteFromTags;
const autoCollapseState = applyAutoCollapseLogic(lastMessage, isThinkingComplete);
newMessage = {
...newMessage,
content: newContent,
status: MESSAGE_STATUS.INCOMPLETE,
...autoCollapseState,
};
}
return [...prevMessage.slice(0, -1), newMessage];
}
return prevMessage;
});
}, [setMessage, applyAutoCollapseLogic]);
// 完成消息
const completeMessage = useCallback((status = MESSAGE_STATUS.COMPLETE) => {
setMessage(prevMessage => {
const lastMessage = prevMessage[prevMessage.length - 1];
if (lastMessage.status === MESSAGE_STATUS.COMPLETE ||
lastMessage.status === MESSAGE_STATUS.ERROR) {
return prevMessage;
}
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
const updatedMessages = [
...prevMessage.slice(0, -1),
{
...lastMessage,
status: status,
...autoCollapseState,
}
];
// 在消息完成时保存,传入更新后的消息列表
if (status === MESSAGE_STATUS.COMPLETE || status === MESSAGE_STATUS.ERROR) {
setTimeout(() => saveMessages(updatedMessages), 0);
}
return updatedMessages;
});
}, [setMessage, applyAutoCollapseLogic, saveMessages]);
// 非流式请求
const handleNonStreamRequest = useCallback(async (payload) => {
setDebugData(prev => ({
...prev,
request: payload,
timestamp: new Date().toISOString(),
response: null
}));
setActiveDebugTab(DEBUG_TABS.REQUEST);
try {
const response = await fetch(API_ENDPOINTS.CHAT_COMPLETIONS, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'New-Api-User': getUserIdFromLocalStorage(),
},
body: JSON.stringify(payload),
});
if (!response.ok) {
let errorBody = '';
try {
errorBody = await response.text();
} catch (e) {
errorBody = '无法读取错误响应体';
}
const errorInfo = handleApiError(
new Error(`HTTP error! status: ${response.status}, body: ${errorBody}`),
response
);
setDebugData(prev => ({
...prev,
response: JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
throw new Error(`HTTP error! status: ${response.status}, body: ${errorBody}`);
}
const data = await response.json();
setDebugData(prev => ({
...prev,
response: JSON.stringify(data, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
if (data.choices?.[0]) {
const choice = data.choices[0];
let content = choice.message?.content || '';
let reasoningContent = choice.message?.reasoning_content || '';
const processed = processThinkTags(content, reasoningContent);
setMessage(prevMessage => {
const newMessages = [...prevMessage];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage?.status === MESSAGE_STATUS.LOADING) {
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
newMessages[newMessages.length - 1] = {
...lastMessage,
content: processed.content,
reasoningContent: processed.reasoningContent,
status: MESSAGE_STATUS.COMPLETE,
...autoCollapseState,
};
}
return newMessages;
});
}
} catch (error) {
console.error('Non-stream request error:', error);
const errorInfo = handleApiError(error);
setDebugData(prev => ({
...prev,
response: JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
setMessage(prevMessage => {
const newMessages = [...prevMessage];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage?.status === MESSAGE_STATUS.LOADING) {
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
newMessages[newMessages.length - 1] = {
...lastMessage,
content: t('请求发生错误: ') + error.message,
status: MESSAGE_STATUS.ERROR,
...autoCollapseState,
};
}
return newMessages;
});
}
}, [setDebugData, setActiveDebugTab, setMessage, t, applyAutoCollapseLogic]);
// SSE请求
const handleSSE = useCallback((payload) => {
setDebugData(prev => ({
...prev,
request: payload,
timestamp: new Date().toISOString(),
response: null
}));
setActiveDebugTab(DEBUG_TABS.REQUEST);
const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
headers: {
'Content-Type': 'application/json',
'New-Api-User': getUserIdFromLocalStorage(),
},
method: 'POST',
payload: JSON.stringify(payload),
});
sseSourceRef.current = source;
let responseData = '';
let hasReceivedFirstResponse = false;
let isStreamComplete = false; // 添加标志位跟踪流是否正常完成
source.addEventListener('message', (e) => {
if (e.data === '[DONE]') {
isStreamComplete = true; // 标记流正常完成
source.close();
sseSourceRef.current = null;
setDebugData(prev => ({ ...prev, response: responseData }));
completeMessage();
return;
}
try {
const payload = JSON.parse(e.data);
responseData += e.data + '\n';
if (!hasReceivedFirstResponse) {
setActiveDebugTab(DEBUG_TABS.RESPONSE);
hasReceivedFirstResponse = true;
}
const delta = payload.choices?.[0]?.delta;
if (delta) {
if (delta.reasoning_content) {
streamMessageUpdate(delta.reasoning_content, 'reasoning');
}
if (delta.content) {
streamMessageUpdate(delta.content, 'content');
}
}
} catch (error) {
console.error('Failed to parse SSE message:', error);
const errorInfo = `解析错误: ${error.message}`;
setDebugData(prev => ({
...prev,
response: responseData + `\n\nError: ${errorInfo}`
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
streamMessageUpdate(t('解析响应数据时发生错误'), 'content');
completeMessage(MESSAGE_STATUS.ERROR);
}
});
source.addEventListener('error', (e) => {
// 只有在流没有正常完成且连接状态异常时才处理错误
if (!isStreamComplete && source.readyState !== 2) {
console.error('SSE Error:', e);
const errorMessage = e.data || t('请求发生错误');
const errorInfo = handleApiError(new Error(errorMessage));
errorInfo.readyState = source.readyState;
setDebugData(prev => ({
...prev,
response: responseData + '\n\nSSE Error:\n' + JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
streamMessageUpdate(errorMessage, 'content');
completeMessage(MESSAGE_STATUS.ERROR);
sseSourceRef.current = null;
source.close();
}
});
source.addEventListener('readystatechange', (e) => {
// 检查 HTTP 状态错误,但避免与正常关闭重复处理
if (e.readyState >= 2 && source.status !== undefined && source.status !== 200 && !isStreamComplete) {
const errorInfo = handleApiError(new Error('HTTP状态错误'));
errorInfo.status = source.status;
errorInfo.readyState = source.readyState;
setDebugData(prev => ({
...prev,
response: responseData + '\n\nHTTP Error:\n' + JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
source.close();
streamMessageUpdate(t('连接已断开'), 'content');
completeMessage(MESSAGE_STATUS.ERROR);
}
});
try {
source.stream();
} catch (error) {
console.error('Failed to start SSE stream:', error);
const errorInfo = handleApiError(error);
setDebugData(prev => ({
...prev,
response: 'Stream启动失败:\n' + JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
streamMessageUpdate(t('建立连接时发生错误'), 'content');
completeMessage(MESSAGE_STATUS.ERROR);
}
}, [setDebugData, setActiveDebugTab, streamMessageUpdate, completeMessage, t, applyAutoCollapseLogic]);
// 停止生成
const onStopGenerator = useCallback(() => {
// 如果仍有活动的 SSE 连接,首先关闭
if (sseSourceRef.current) {
sseSourceRef.current.close();
sseSourceRef.current = null;
}
// 无论是否存在 SSE 连接,都尝试处理最后一条正在生成的消息
setMessage(prevMessage => {
if (prevMessage.length === 0) return prevMessage;
const lastMessage = prevMessage[prevMessage.length - 1];
if (lastMessage.status === MESSAGE_STATUS.LOADING ||
lastMessage.status === MESSAGE_STATUS.INCOMPLETE) {
const processed = processIncompleteThinkTags(
lastMessage.content || '',
lastMessage.reasoningContent || ''
);
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
const updatedMessages = [
...prevMessage.slice(0, -1),
{
...lastMessage,
status: MESSAGE_STATUS.COMPLETE,
reasoningContent: processed.reasoningContent || null,
content: processed.content,
...autoCollapseState,
}
];
// 停止生成时也保存,传入更新后的消息列表
setTimeout(() => saveMessages(updatedMessages), 0);
return updatedMessages;
}
return prevMessage;
});
}, [setMessage, applyAutoCollapseLogic, saveMessages]);
// 发送请求
const sendRequest = useCallback((payload, isStream) => {
if (isStream) {
handleSSE(payload);
} else {
handleNonStreamRequest(payload);
}
}, [handleSSE, handleNonStreamRequest]);
return {
sendRequest,
onStopGenerator,
streamMessageUpdate,
completeMessage,
};
};