diff --git a/dto/openai_request.go b/dto/openai_request.go index 78706f9c..bda1bb17 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -43,7 +43,7 @@ type GeneralOpenAIRequest struct { ResponseFormat *ResponseFormat `json:"response_format,omitempty"` EncodingFormat any `json:"encoding_format,omitempty"` Seed float64 `json:"seed,omitempty"` - ParallelTooCalls bool `json:"parallel_tool_calls,omitempty"` + ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` Tools []ToolCallRequest `json:"tools,omitempty"` ToolChoice any `json:"tool_choice,omitempty"` User string `json:"user,omitempty"` diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 03eff9cf..da8d4e14 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -122,11 +122,13 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http var pingerWg sync.WaitGroup if info.IsStream { helper.SetEventStreamHeaders(c) - pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second - var pingerCtx context.Context - pingerCtx, stopPinger = context.WithCancel(c.Request.Context()) if pingEnabled { + pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second + var pingerCtx context.Context + pingerCtx, stopPinger = context.WithCancel(c.Request.Context()) + // 退出时清理 pingerCtx 防止泄露 + defer stopPinger() pingerWg.Add(1) gopool.Go(func() { defer pingerWg.Done() @@ -166,9 +168,8 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http } resp, err := client.Do(req) - // request结束后停止ping + // request结束后等待 ping goroutine 完成 if info.IsStream && pingEnabled { - stopPinger() pingerWg.Wait() } if err != nil { diff --git a/relay/relay-image.go b/relay/relay-image.go index daed3d80..36b4b9f8 100644 --- a/relay/relay-image.go +++ b/relay/relay-image.go @@ -46,11 +46,23 @@ func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto. if err != nil { return nil, err } + + if imageRequest.Model == "" { + imageRequest.Model = "dall-e-3" + } + + if strings.Contains(imageRequest.Size, "×") { + return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'") + } + // Not "256x256", "512x512", or "1024x1024" if imageRequest.Model == "dall-e-2" || imageRequest.Model == "dall-e" { if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" { return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024 for dall-e-2 or dall-e") } + if imageRequest.Size == "" { + imageRequest.Size = "1024x1024" + } } else if imageRequest.Model == "dall-e-3" { if imageRequest.Size != "" && imageRequest.Size != "1024x1024" && imageRequest.Size != "1024x1792" && imageRequest.Size != "1792x1024" { return nil, errors.New("size must be one of 1024x1024, 1024x1792 or 1792x1024 for dall-e-3") @@ -58,74 +70,24 @@ func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto. if imageRequest.Quality == "" { imageRequest.Quality = "standard" } - // N should between 1 and 10 - //if imageRequest.N != 0 && (imageRequest.N < 1 || imageRequest.N > 10) { - // return service.OpenAIErrorWrapper(errors.New("n must be between 1 and 10"), "invalid_field_value", http.StatusBadRequest) - //} + if imageRequest.Size == "" { + imageRequest.Size = "1024x1024" + } + } else if imageRequest.Model == "gpt-image-1" { + if imageRequest.Quality == "" { + imageRequest.Quality = "auto" + } + } + + if imageRequest.Prompt == "" { + return nil, errors.New("prompt is required") + } + + if imageRequest.N == 0 { + imageRequest.N = 1 } } - if imageRequest.Prompt == "" { - return nil, errors.New("prompt is required") - } - - if imageRequest.Model == "" { - imageRequest.Model = "dall-e-2" - } - if strings.Contains(imageRequest.Size, "×") { - return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'") - } - if imageRequest.N == 0 { - imageRequest.N = 1 - } - if imageRequest.Size == "" { - imageRequest.Size = "1024x1024" - } - - err := common.UnmarshalBodyReusable(c, imageRequest) - if err != nil { - return nil, err - } - if imageRequest.Prompt == "" { - return nil, errors.New("prompt is required") - } - if strings.Contains(imageRequest.Size, "×") { - return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'") - } - if imageRequest.N == 0 { - imageRequest.N = 1 - } - if imageRequest.Size == "" { - imageRequest.Size = "1024x1024" - } - if imageRequest.Model == "" { - imageRequest.Model = "dall-e-2" - } - // x.ai grok-2-image not support size, quality or style - if imageRequest.Size == "empty" { - imageRequest.Size = "" - } - - // Not "256x256", "512x512", or "1024x1024" - if imageRequest.Model == "dall-e-2" || imageRequest.Model == "dall-e" { - if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" { - return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024, dall-e-3 1024x1792 or 1792x1024") - } - } else if imageRequest.Model == "dall-e-3" { - if imageRequest.Size != "" && imageRequest.Size != "1024x1024" && imageRequest.Size != "1024x1792" && imageRequest.Size != "1792x1024" { - return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024, dall-e-3 1024x1792 or 1792x1024") - } - if imageRequest.Quality == "" { - imageRequest.Quality = "standard" - } - //if imageRequest.N != 1 { - // return nil, errors.New("n must be 1") - //} - } - // N should between 1 and 10 - //if imageRequest.N != 0 && (imageRequest.N < 1 || imageRequest.N > 10) { - // return service.OpenAIErrorWrapper(errors.New("n must be between 1 and 10"), "invalid_field_value", http.StatusBadRequest) - //} if setting.ShouldCheckPromptSensitive() { words, err := service.CheckSensitiveInput(imageRequest.Prompt) if err != nil { @@ -229,6 +191,10 @@ func ImageHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode { requestBody = bytes.NewBuffer(jsonData) } + if common.DebugEnabled { + println(fmt.Sprintf("image request body: %s", requestBody)) + } + statusCodeMappingStr := c.GetString("status_code_mapping") resp, err := adaptor.DoRequest(c, relayInfo, requestBody) diff --git a/web/src/index.css b/web/src/index.css index 68dc4bb3..d362dc0f 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -98,17 +98,6 @@ body { display: revert; } -.semi-chat { - padding-top: 0 !important; - padding-bottom: 0 !important; - height: 100%; -} - -.semi-chat-chatBox-content { - min-width: auto; - word-break: break-word; -} - .tableHiddle { display: none !important; } @@ -158,4 +147,114 @@ code { .semi-tabs-content { padding: 0 !important; +} + +/* 聊天 */ +.semi-chat { + padding-top: 0 !important; + padding-bottom: 0 !important; + height: 100%; + max-width: 100% !important; + width: 100% !important; + overflow: hidden !important; +} + +.semi-chat-chatBox { + max-width: 100% !important; + overflow: hidden !important; +} + +.semi-chat-chatBox-wrap { + max-width: 100% !important; + overflow: hidden !important; +} + +.semi-chat-chatBox-content { + min-width: auto; + word-break: break-word; + max-width: 100% !important; + overflow-wrap: break-word !important; +} + +.semi-chat-content { + max-width: 100% !important; + overflow: hidden !important; +} + +.semi-chat-list { + max-width: 100% !important; + overflow-x: hidden !important; +} + +.semi-chat-container { + overflow-x: hidden !important; +} + +.semi-chat-container::-webkit-scrollbar { + width: 4px; +} + +.semi-chat-container::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + border-radius: 2px; +} + +.semi-chat-container::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.15); +} + +.semi-chat-container::-webkit-scrollbar-track { + background: transparent; +} + +/* 隐藏模型设置区域的滚动条 */ +.model-settings-scroll::-webkit-scrollbar { + display: none; +} + +.model-settings-scroll { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* 调试面板代码样式 */ +.debug-code { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 11px; + line-height: 1.4; +} + +/* 调试面板标签样式 */ +.semi-tabs-content { + height: calc(100% - 40px) !important; + flex: 1 !important; +} + +.semi-tabs-content .semi-tabs-pane { + height: 100% !important; + overflow: hidden !important; +} + +.semi-tabs-content .semi-tabs-pane>div { + height: 100% !important; +} + +/* 调试面板特定样式 */ +.debug-panel .semi-tabs { + height: 100% !important; + display: flex !important; + flex-direction: column !important; +} + +.debug-panel .semi-tabs-bar { + flex-shrink: 0 !important; +} + +.debug-panel .semi-tabs-content { + flex: 1 !important; + overflow: hidden !important; +} + +.semi-chat-chatBox-action { + column-gap: 0 !important; } \ No newline at end of file diff --git a/web/src/pages/Playground/Playground.js b/web/src/pages/Playground/Playground.js index 3021e106..045c747f 100644 --- a/web/src/pages/Playground/Playground.js +++ b/web/src/pages/Playground/Playground.js @@ -19,12 +19,47 @@ import { Button, MarkdownRender, Tag, + Tabs, + TabPane, + Toast, + Tooltip, + Modal, } from '@douyinfe/semi-ui'; import { SSE } from 'sse'; -import { IconSetting, IconSpin, IconChevronRight, IconChevronUp } from '@douyinfe/semi-icons'; +import { + Settings, + Sparkles, + ChevronRight, + ChevronUp, + Brain, + Zap, + MessageSquare, + SlidersHorizontal, + Hash, + Thermometer, + Type, + Users, + Loader2, + Target, + Repeat, + Ban, + Shuffle, + ToggleLeft, + Code, + Eye, + EyeOff, + FileText, + Clock, + Check, + X, + Copy, + RefreshCw, + Trash2, +} from 'lucide-react'; import { StyleContext } from '../../context/Style/index.js'; import { useTranslation } from 'react-i18next'; import { renderGroupOption, truncateText, stringToColor } from '../../helpers/render.js'; +import { IconSend } from '@douyinfe/semi-icons'; let id = 4; function getId() { @@ -89,6 +124,19 @@ const Playground = () => { group: '', max_tokens: 0, temperature: 0, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + seed: null, + stream: true, + }); + const [parameterEnabled, setParameterEnabled] = useState({ + max_tokens: true, + temperature: true, + top_p: false, + frequency_penalty: false, + presence_penalty: false, + seed: false, }); const [searchParams, setSearchParams] = useSearchParams(); const [status, setStatus] = useState({}); @@ -99,6 +147,13 @@ const Playground = () => { const [models, setModels] = useState([]); const [groups, setGroups] = useState([]); const [showSettings, setShowSettings] = useState(true); + const [showDebugPanel, setShowDebugPanel] = useState(true); + const [debugData, setDebugData] = useState({ + request: null, + response: null, + timestamp: null + }); + const [activeDebugTab, setActiveDebugTab] = useState('request'); const [styleState, styleDispatch] = useContext(StyleContext); const sseSourceRef = useRef(null); @@ -106,6 +161,13 @@ const Playground = () => { setInputs((inputs) => ({ ...inputs, [name]: value })); }; + const handleParameterToggle = (paramName) => { + setParameterEnabled(prev => ({ + ...prev, + [paramName]: !prev[paramName] + })); + }; + useEffect(() => { if (searchParams.get('expired')) { showError(t('未登录或登录已过期,请重新登录!')); @@ -128,7 +190,6 @@ const Playground = () => { value: model, })); setModels(localModelOptions); - // if default model is not in the list, set the first one as default const hasDefault = localModelOptions.some(option => option.value === defaultModel); if (!hasDefault && localModelOptions.length > 0) { setInputs((inputs) => ({ ...inputs, model: localModelOptions[0].value })); @@ -184,12 +245,6 @@ const Playground = () => { } }; - const commonOuterStyle = { - border: '1px solid var(--semi-color-border)', - borderRadius: '16px', - margin: '0px 8px', - }; - const getSystemMessage = () => { if (systemPrompt !== '') { return { @@ -201,7 +256,152 @@ const Playground = () => { } }; + let handleNonStreamRequest = async (payload) => { + setDebugData(prev => ({ + ...prev, + request: payload, + timestamp: new Date().toISOString(), + response: null + })); + setActiveDebugTab('request'); + + try { + const response = await fetch('/pg/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 = { + error: 'HTTP错误', + status: response.status, + statusText: response.statusText, + body: errorBody, + timestamp: new Date().toISOString() + }; + + setDebugData(prev => ({ + ...prev, + response: JSON.stringify(errorInfo, null, 2) + })); + setActiveDebugTab('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('response'); + + if (data.choices && data.choices[0]) { + const choice = data.choices[0]; + let content = choice.message?.content || ''; + let reasoningContent = choice.message?.reasoning_content || ''; + + if (content.includes('')) { + const thinkTagRegex = /([\s\S]*?)<\/think>/g; + let thoughts = []; + let replyParts = []; + let lastIndex = 0; + let match; + + thinkTagRegex.lastIndex = 0; + while ((match = thinkTagRegex.exec(content)) !== null) { + replyParts.push(content.substring(lastIndex, match.index)); + thoughts.push(match[1]); + lastIndex = match.index + match[0].length; + } + replyParts.push(content.substring(lastIndex)); + + content = replyParts.join(''); + if (thoughts.length > 0) { + if (reasoningContent) { + reasoningContent += '\n\n---\n\n' + thoughts.join('\n\n---\n\n'); + } else { + reasoningContent = thoughts.join('\n\n---\n\n'); + } + } + } + + content = content.replace(/<\/?think>/g, '').trim(); + + setMessage((prevMessage) => { + const newMessages = [...prevMessage]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage && lastMessage.status === 'loading') { + newMessages[newMessages.length - 1] = { + ...lastMessage, + content: content, + reasoningContent: reasoningContent, + status: 'complete', + isReasoningExpanded: false + }; + } + return newMessages; + }); + } + } catch (error) { + console.error('Non-stream request error:', error); + + const errorInfo = { + error: '非流式请求错误', + message: error.message, + timestamp: new Date().toISOString(), + stack: error.stack + }; + + if (error.message.includes('HTTP error')) { + errorInfo.details = '服务器返回了错误状态码'; + } else if (error.message.includes('Failed to fetch')) { + errorInfo.details = '网络连接失败或服务器无响应'; + } + + setDebugData(prev => ({ + ...prev, + response: JSON.stringify(errorInfo, null, 2) + })); + setActiveDebugTab('response'); + + setMessage((prevMessage) => { + const newMessages = [...prevMessage]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage && lastMessage.status === 'loading') { + newMessages[newMessages.length - 1] = { + ...lastMessage, + content: t('请求发生错误: ') + error.message, + status: 'error', + isReasoningExpanded: false + }; + } + return newMessages; + }); + } + }; + let handleSSE = (payload) => { + setDebugData(prev => ({ + ...prev, + request: payload, + timestamp: new Date().toISOString(), + response: null + })); + setActiveDebugTab('request'); + let source = new SSE('/pg/chat/completions', { headers: { 'Content-Type': 'application/json', @@ -211,19 +411,32 @@ const Playground = () => { payload: JSON.stringify(payload), }); - // 保存 source 引用以便后续停止生成 sseSourceRef.current = source; + let responseData = ''; + let hasReceivedFirstResponse = false; + source.addEventListener('message', (e) => { if (e.data === '[DONE]') { source.close(); sseSourceRef.current = null; + setDebugData(prev => ({ + ...prev, + response: responseData + })); completeMessage(); return; } try { let payload = JSON.parse(e.data); + responseData += e.data + '\n'; + + if (!hasReceivedFirstResponse) { + setActiveDebugTab('response'); + hasReceivedFirstResponse = true; + } + const delta = payload.choices?.[0]?.delta; if (delta) { if (delta.reasoning_content) { @@ -235,6 +448,14 @@ const Playground = () => { } } catch (error) { console.error('Failed to parse SSE message:', error); + const errorInfo = `解析错误: ${error.message}`; + + setDebugData(prev => ({ + ...prev, + response: responseData + `\n\nError: ${errorInfo}` + })); + setActiveDebugTab('response'); + streamMessageUpdate(t('解析响应数据时发生错误'), 'content'); completeMessage('error'); } @@ -243,6 +464,21 @@ const Playground = () => { source.addEventListener('error', (e) => { console.error('SSE Error:', e); const errorMessage = e.data || t('请求发生错误'); + + const errorInfo = { + error: 'SSE连接错误', + message: errorMessage, + status: source.status, + readyState: source.readyState, + timestamp: new Date().toISOString() + }; + + setDebugData(prev => ({ + ...prev, + response: responseData + '\n\nSSE Error:\n' + JSON.stringify(errorInfo, null, 2) + })); + setActiveDebugTab('response'); + streamMessageUpdate(errorMessage, 'content'); completeMessage('error'); sseSourceRef.current = null; @@ -252,6 +488,19 @@ const Playground = () => { source.addEventListener('readystatechange', (e) => { if (e.readyState >= 2) { if (source.status !== undefined && source.status !== 200) { + const errorInfo = { + error: 'HTTP状态错误', + status: source.status, + readyState: source.readyState, + timestamp: new Date().toISOString() + }; + + setDebugData(prev => ({ + ...prev, + response: responseData + '\n\nHTTP Error:\n' + JSON.stringify(errorInfo, null, 2) + })); + setActiveDebugTab('response'); + source.close(); streamMessageUpdate(t('连接已断开'), 'content'); completeMessage('error'); @@ -263,6 +512,18 @@ const Playground = () => { source.stream(); } catch (error) { console.error('Failed to start SSE stream:', error); + const errorInfo = { + error: '启动SSE流失败', + message: error.message, + timestamp: new Date().toISOString() + }; + + setDebugData(prev => ({ + ...prev, + response: 'Stream启动失败:\n' + JSON.stringify(errorInfo, null, 2) + })); + setActiveDebugTab('response'); + streamMessageUpdate(t('建立连接时发生错误'), 'content'); completeMessage('error'); } @@ -293,17 +554,43 @@ const Playground = () => { if (systemMessage) { messages.unshift(systemMessage); } - return { + const payload = { messages: messages, - stream: true, + stream: inputs.stream, model: inputs.model, group: inputs.group, - max_tokens: parseInt(inputs.max_tokens), - temperature: inputs.temperature, }; + + if (parameterEnabled.max_tokens && inputs.max_tokens > 0) { + payload.max_tokens = parseInt(inputs.max_tokens); + } + if (parameterEnabled.temperature) { + payload.temperature = inputs.temperature; + } + if (parameterEnabled.top_p) { + payload.top_p = inputs.top_p; + } + if (parameterEnabled.frequency_penalty) { + payload.frequency_penalty = inputs.frequency_penalty; + } + if (parameterEnabled.presence_penalty) { + payload.presence_penalty = inputs.presence_penalty; + } + if (parameterEnabled.seed && inputs.seed !== null && inputs.seed !== '') { + payload.seed = parseInt(inputs.seed); + } + + return payload; }; - handleSSE(getPayload()); + const payload = getPayload(); + + if (inputs.stream) { + handleSSE(payload); + } else { + handleNonStreamRequest(payload); + } + newMessage.push({ role: 'assistant', content: '', @@ -334,7 +621,6 @@ const Playground = () => { const lastMessage = prevMessage[prevMessage.length - 1]; let newMessage = { ...lastMessage }; - // 如果消息已经是错误状态,保持错误状态 if (lastMessage.status === 'error') { return prevMessage; } @@ -347,10 +633,23 @@ const Playground = () => { status: 'incomplete', }; } else if (type === 'content') { + const shouldCollapseReasoning = !lastMessage.content && lastMessage.reasoningContent; + const newContent = (lastMessage.content || '') + textChunk; + + let shouldCollapseFromThinkTag = false; + if (lastMessage.isReasoningExpanded && newContent.includes('')) { + const thinkMatches = newContent.match(//g); + const thinkCloseMatches = newContent.match(/<\/think>/g); + if (thinkMatches && thinkCloseMatches && thinkCloseMatches.length >= thinkMatches.length) { + shouldCollapseFromThinkTag = true; + } + } + newMessage = { ...newMessage, - content: (lastMessage.content || '') + textChunk, + content: newContent, status: 'incomplete', + isReasoningExpanded: (shouldCollapseReasoning || shouldCollapseFromThinkTag) ? false : lastMessage.isReasoningExpanded, }; } } @@ -358,6 +657,143 @@ const Playground = () => { }); }, [setMessage]); + const handleMessageCopy = useCallback((message) => { + if (!message.content) return; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(message.content).then(() => { + Toast.success({ + content: t('消息已复制到剪贴板'), + duration: 2, + }); + }).catch(err => { + console.error('Clipboard API 复制失败:', err); + fallbackCopyToClipboard(message.content); + }); + } else { + fallbackCopyToClipboard(message.content); + } + }, [t]); + + const fallbackCopyToClipboard = useCallback((text) => { + try { + if (!document.execCommand) { + throw new Error('execCommand not supported'); + } + + const textArea = document.createElement('textarea'); + textArea.value = text; + + textArea.style.position = 'fixed'; + textArea.style.top = '-9999px'; + textArea.style.left = '-9999px'; + textArea.style.opacity = '0'; + textArea.style.pointerEvents = 'none'; + textArea.style.zIndex = '-1'; + textArea.setAttribute('readonly', ''); + + document.body.appendChild(textArea); + + if (textArea.select) { + textArea.select(); + } + if (textArea.setSelectionRange) { + textArea.setSelectionRange(0, text.length); + } + + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + if (successful) { + Toast.success({ + content: t('消息已复制到剪贴板'), + duration: 2, + }); + } else { + throw new Error('execCommand copy failed'); + } + } catch (err) { + console.error('回退复制方案也失败:', err); + + let errorMessage = t('复制失败,请手动选择文本复制'); + + if (window.location.protocol === 'http:' && window.location.hostname !== 'localhost') { + errorMessage = t('复制功能需要 HTTPS 环境,请手动复制'); + } else if (!navigator.clipboard && !document.execCommand) { + errorMessage = t('浏览器不支持复制功能,请手动复制'); + } + + Toast.error({ + content: errorMessage, + duration: 4, + }); + } + }, [t]); + + const handleMessageReset = useCallback((targetMessage) => { + setMessage(prevMessages => { + const messageIndex = prevMessages.findIndex(msg => msg.id === targetMessage.id); + if (messageIndex === -1) return prevMessages; + + if (targetMessage.role === 'user') { + const newMessages = prevMessages.slice(0, messageIndex); + setTimeout(() => { + onMessageSend(targetMessage.content); + }, 100); + return newMessages; + } else if (targetMessage.role === 'assistant') { + let userMessageIndex = messageIndex - 1; + while (userMessageIndex >= 0 && prevMessages[userMessageIndex].role !== 'user') { + userMessageIndex--; + } + if (userMessageIndex >= 0) { + const userMessage = prevMessages[userMessageIndex]; + const newMessages = prevMessages.slice(0, userMessageIndex); + setTimeout(() => { + onMessageSend(userMessage.content); + }, 100); + return newMessages; + } + } + return prevMessages; + }); + }, [onMessageSend]); + + const handleMessageDelete = useCallback((targetMessage) => { + Modal.confirm({ + title: t('确认删除'), + content: t('确定要删除这条消息吗?'), + okText: t('确定'), + cancelText: t('取消'), + okButtonProps: { + type: 'danger', + }, + onOk: () => { + setMessage(prevMessages => { + const messageIndex = prevMessages.findIndex(msg => msg.id === targetMessage.id); + if (messageIndex === -1) return prevMessages; + + if (targetMessage.role === 'user' && messageIndex < prevMessages.length - 1) { + const nextMessage = prevMessages[messageIndex + 1]; + if (nextMessage.role === 'assistant') { + Toast.success({ + content: t('已删除消息及其回复'), + duration: 2, + }); + return prevMessages.filter((_, index) => index !== messageIndex && index !== messageIndex + 1); + } + } + + Toast.success({ + content: t('消息已删除'), + duration: 2, + }); + return prevMessages.filter(msg => msg.id !== targetMessage.id); + }); + }, + }); + }, [setMessage, t]); + const onStopGenerator = useCallback(() => { if (sseSourceRef.current) { sseSourceRef.current.close(); @@ -365,37 +801,58 @@ const Playground = () => { setMessage((prevMessage) => { const lastMessage = prevMessage[prevMessage.length - 1]; if (lastMessage.status === 'loading' || lastMessage.status === 'incomplete') { - let content = lastMessage.content || ''; - let reasoningContent = lastMessage.reasoningContent || ''; + let currentContent = lastMessage.content || ''; + let currentReasoningContent = lastMessage.reasoningContent || ''; - // 处理 标签格式的思维链 - if (content.includes('')) { - const thinkTagRegex = /([\s\S]*?)(?:<\/think>|$)/g; - let thoughts = []; + if (currentContent.includes('')) { + const thinkTagRegex = /([\s\S]*?)<\/think>/g; + let match; + let thoughtsFromPairedTags = []; let replyParts = []; let lastIndex = 0; - let match; - while ((match = thinkTagRegex.exec(content)) !== null) { - replyParts.push(content.substring(lastIndex, match.index)); - thoughts.push(match[1]); + while ((match = thinkTagRegex.exec(currentContent)) !== null) { + replyParts.push(currentContent.substring(lastIndex, match.index)); + thoughtsFromPairedTags.push(match[1]); lastIndex = match.index + match[0].length; } - replyParts.push(content.substring(lastIndex)); + replyParts.push(currentContent.substring(lastIndex)); - // 更新内容和思维链 - content = replyParts.join('').trim(); - if (thoughts.length > 0) { - reasoningContent = thoughts.join('\n\n---\n\n'); + if (thoughtsFromPairedTags.length > 0) { + const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n'); + if (currentReasoningContent) { + currentReasoningContent += '\n\n---\n\n' + pairedThoughtsStr; + } else { + currentReasoningContent = pairedThoughtsStr; + } + } + currentContent = replyParts.join(''); + } + + const lastOpenThinkIndex = currentContent.lastIndexOf(''); + if (lastOpenThinkIndex !== -1) { + const fragmentAfterLastOpen = currentContent.substring(lastOpenThinkIndex); + if (!fragmentAfterLastOpen.includes('')) { + const unclosedThought = fragmentAfterLastOpen.substring(''.length).trim(); + if (unclosedThought) { + if (currentReasoningContent) { + currentReasoningContent += '\n\n---\n\n' + unclosedThought; + } else { + currentReasoningContent = unclosedThought; + } + } + currentContent = currentContent.substring(0, lastOpenThinkIndex); } } + currentContent = currentContent.replace(/<\/?think>/g, '').trim(); + return [...prevMessage.slice(0, -1), { ...lastMessage, status: 'complete', - reasoningContent: reasoningContent, - content: content, - isReasoningExpanded: false // 停止时折叠思维链面板 + reasoningContent: currentReasoningContent || null, + content: currentContent, + isReasoningExpanded: false }]; } return prevMessage; @@ -403,11 +860,26 @@ const Playground = () => { } }, [setMessage]); + const DebugToggle = () => { + return ( + + ); + }; + const SettingsToggle = () => { if (!styleState.isMobile) return null; return ( + ); } @@ -455,17 +931,66 @@ const Playground = () => { return ; }, []); + const renderChatBoxAction = useCallback((props) => { + const { message } = props; + + const isLoading = message.status === 'loading' || message.status === 'incomplete'; + + return ( +
+ {!isLoading && ( + +
+ ); + }, [handleMessageReset, handleMessageCopy, handleMessageDelete, t]); + const renderCustomChatContent = useCallback( ({ message, className }) => { if (message.status === 'error') { return ( -
- {message.content || t('请求发生错误')} +
+ + {message.content || t('请求发生错误')} +
); } @@ -486,56 +1011,62 @@ const Playground = () => { let thinkingSource = null; if (message.role === 'assistant') { + let baseContentForDisplay = message.content || ""; + let combinedThinkingContent = ""; + if (message.reasoningContent) { - currentExtractedThinkingContent = message.reasoningContent; + combinedThinkingContent = message.reasoningContent; thinkingSource = 'reasoningContent'; - } else if (message.content && message.content.includes('')) { const thinkTagRegex = /([\s\S]*?)<\/think>/g; let match; + let thoughtsFromPairedTags = []; + let replyParts = []; + let lastIndex = 0; - thinkTagRegex.lastIndex = 0; - while ((match = thinkTagRegex.exec(fullContent)) !== null) { - replyParts.push(fullContent.substring(lastIndex, match.index)); - thoughts.push(match[1]); + while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) { + replyParts.push(baseContentForDisplay.substring(lastIndex, match.index)); + thoughtsFromPairedTags.push(match[1]); lastIndex = match.index + match[0].length; } - replyParts.push(fullContent.substring(lastIndex)); + replyParts.push(baseContentForDisplay.substring(lastIndex)); - currentDisplayableFinalContent = replyParts.join('').trim(); - - if (thoughts.length > 0) { - currentExtractedThinkingContent = thoughts.join('\n\n---\n\n'); - thinkingSource = ' tags'; + if (thoughtsFromPairedTags.length > 0) { + const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n'); + if (combinedThinkingContent) { + combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr; + } else { + combinedThinkingContent = pairedThoughtsStr; + } + thinkingSource = thinkingSource ? thinkingSource + ' & tags' : ' tags'; } - if (isThinkingStatus && currentDisplayableFinalContent.includes(''); - if (lastOpenThinkIndex !== -1) { - const fragmentAfterLastOpen = currentDisplayableFinalContent.substring(lastOpenThinkIndex); - if (!fragmentAfterLastOpen.substring("".length).includes('')) { - const unclosedThought = fragmentAfterLastOpen.substring("".length); - if (currentExtractedThinkingContent) { - currentExtractedThinkingContent += (currentExtractedThinkingContent ? '\n\n---\n\n' : '') + unclosedThought; + baseContentForDisplay = replyParts.join(''); + } + + if (isThinkingStatus) { + const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf(''); + if (lastOpenThinkIndex !== -1) { + const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex); + if (!fragmentAfterLastOpen.includes('')) { + const unclosedThought = fragmentAfterLastOpen.substring(''.length).trim(); + if (unclosedThought) { + if (combinedThinkingContent) { + combinedThinkingContent += '\n\n---\n\n' + unclosedThought; } else { - currentExtractedThinkingContent = unclosedThought; + combinedThinkingContent = unclosedThought; } - if (!thinkingSource && unclosedThought) thinkingSource = ' tags (streaming)'; - currentDisplayableFinalContent = currentDisplayableFinalContent.substring(0, lastOpenThinkIndex).trim(); + thinkingSource = thinkingSource ? thinkingSource + ' + streaming ' : 'streaming '; } + baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex); } } } - if (typeof currentDisplayableFinalContent === 'string' && currentDisplayableFinalContent.trim().startsWith("")) { - const startsWithCompleteThinkTagRegex = /^[\s\S]*?<\/think>/; - if (!startsWithCompleteThinkTagRegex.test(currentDisplayableFinalContent.trim())) { - currentDisplayableFinalContent = ""; - } - } + currentExtractedThinkingContent = combinedThinkingContent || null; + currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim(); } const headerText = isThinkingStatus ? t('思考中...') : t('思考过程'); @@ -547,9 +1078,18 @@ const Playground = () => { !finalExtractedThinkingContent && (!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) { return ( -
- - {t('正在思考...')} +
+
+ +
+
+ + {t('正在思考...')} + + + AI 正在分析您的问题 + +
); } @@ -557,56 +1097,66 @@ const Playground = () => { return (
{message.role === 'assistant' && finalExtractedThinkingContent && ( -
+
toggleReasoningExpansion(message.id)} > -
- {headerText} - {thinkingSource && ( - - {thinkingSource} - - )} +
+
+ +
+
+ + {headerText} + + {thinkingSource && ( + + 来源: {thinkingSource} + + )} +
-
- {isThinkingStatus && } - {!isThinkingStatus && (message.isReasoningExpanded ? : )} +
+ {isThinkingStatus && ( +
+ + + 思考中 + +
+ )} + {!isThinkingStatus && ( +
+ {message.isReasoningExpanded ? + : + + } +
+ )}
- + {message.isReasoningExpanded && ( +
+
+
+ +
+
+
+ )}
)} {(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && ( - - )} - {!(finalExtractedThinkingContent) && !(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && message.role === 'assistant' && ( -
+
+ +
)}
); @@ -615,115 +1165,460 @@ const Playground = () => { ); return ( - - {(showSettings || !styleState.isMobile) && ( - - -
- {t('分组')}: -
- { - handleInputChange('model', value); - }} - value={inputs.model} - autoComplete='new-password' - optionList={models} - /> -
- Temperature: -
- { - handleInputChange('temperature', value); - }} - /> -
- MaxTokens: -
- { - handleInputChange('max_tokens', value); - }} - /> +
+ + {(showSettings || !styleState.isMobile) && ( + + +
+
+
+ +
+ + {t('模型设置')} + +
+ +
-
- System: +
+ {/* 分组选择 */} +
+
+ + + {t('分组')} + +
+ handleInputChange('model', value)} + value={inputs.model} + autoComplete='new-password' + optionList={models} + className="!rounded-lg" + /> +
+ + {/* Temperature */} +
+
+
+ + + Temperature + + + {inputs.temperature} + +
+
+ + 控制输出的随机性和创造性 + + handleInputChange('temperature', value)} + className="mt-2" + disabled={!parameterEnabled.temperature} + /> +
+ + {/* Top P */} +
+
+
+ + + Top P + + + {inputs.top_p} + +
+
+ + 核采样,控制词汇选择的多样性 + + handleInputChange('top_p', value)} + className="mt-2" + disabled={!parameterEnabled.top_p} + /> +
+ + {/* Frequency Penalty */} +
+
+
+ + + Frequency Penalty + + + {inputs.frequency_penalty} + +
+
+ + 频率惩罚,减少重复词汇的出现 + + handleInputChange('frequency_penalty', value)} + className="mt-2" + disabled={!parameterEnabled.frequency_penalty} + /> +
+ + {/* Presence Penalty */} +
+
+
+ + + Presence Penalty + + + {inputs.presence_penalty} + +
+
+ + 存在惩罚,鼓励讨论新话题 + + handleInputChange('presence_penalty', value)} + className="mt-2" + disabled={!parameterEnabled.presence_penalty} + /> +
+ + {/* MaxTokens */} +
+
+
+ + + Max Tokens + +
+
+ handleInputChange('max_tokens', value)} + className="!rounded-lg" + disabled={!parameterEnabled.max_tokens} + /> +
+ + {/* Seed */} +
+
+
+ + + Seed + + + (可选,用于复现结果) + +
+
+ handleInputChange('seed', value === '' ? null : value)} + className="!rounded-lg" + disabled={!parameterEnabled.seed} + /> +
+ + {/* Stream Toggle */} +
+
+
+ + + 流式输出 + +
+ +
+
+ + {/* System Prompt */} +
+
+ + + System Prompt + +
+