✨ feat: Add custom request body editor with persistent message storage
- Add CustomRequestEditor component with JSON validation and real-time formatting - Implement bidirectional sync between chat messages and custom request body - Add persistent local storage for chat messages (separate from config) - Remove redundant System Prompt field in custom mode - Refactor configuration storage to separate messages and settings New Features: • Custom request body mode with JSON editor and syntax highlighting • Real-time bidirectional synchronization between chat UI and custom request body • Persistent message storage that survives page refresh • Enhanced configuration export/import including message data • Improved parameter organization with collapsible sections Technical Changes: • Add loadMessages/saveMessages functions in configStorage • Update usePlaygroundState hook to handle message persistence • Refactor SettingsPanel to remove System Prompt in custom mode • Add STORAGE_KEYS constants for better storage key management • Implement debounced auto-save for both config and messages • Add hash-based change detection to prevent unnecessary updates UI/UX Improvements: • Disabled state styling for parameters in custom mode • Warning banners and visual feedback for mode switching • Mobile-responsive design for custom request editor • Consistent styling with existing design system
This commit is contained in:
70
web/src/hooks/useDataLoader.js
Normal file
70
web/src/hooks/useDataLoader.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError } from '../helpers/index.js';
|
||||
import { API_ENDPOINTS } from '../utils/constants';
|
||||
import { processModelsData, processGroupsData } from '../utils/apiUtils';
|
||||
|
||||
export const useDataLoader = (
|
||||
userState,
|
||||
inputs,
|
||||
handleInputChange,
|
||||
setModels,
|
||||
setGroups
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
const res = await API.get(API_ENDPOINTS.USER_MODELS);
|
||||
const { success, message, data } = res.data;
|
||||
|
||||
if (success) {
|
||||
const { modelOptions, selectedModel } = processModelsData(data, inputs.model);
|
||||
setModels(modelOptions);
|
||||
|
||||
if (selectedModel !== inputs.model) {
|
||||
handleInputChange('model', selectedModel);
|
||||
}
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载模型失败'));
|
||||
}
|
||||
}, [inputs.model, handleInputChange, setModels, t]);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
try {
|
||||
const res = await API.get(API_ENDPOINTS.USER_GROUPS);
|
||||
const { success, message, data } = res.data;
|
||||
|
||||
if (success) {
|
||||
const userGroup = userState?.user?.group || JSON.parse(localStorage.getItem('user'))?.group;
|
||||
const groupOptions = processGroupsData(data, userGroup);
|
||||
setGroups(groupOptions);
|
||||
|
||||
const hasCurrentGroup = groupOptions.some(option => option.value === inputs.group);
|
||||
if (!hasCurrentGroup) {
|
||||
handleInputChange('group', groupOptions[0]?.value || '');
|
||||
}
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载分组失败'));
|
||||
}
|
||||
}, [userState, inputs.group, handleInputChange, setGroups, t]);
|
||||
|
||||
// 自动加载数据
|
||||
useEffect(() => {
|
||||
if (userState?.user) {
|
||||
loadModels();
|
||||
loadGroups();
|
||||
}
|
||||
}, [userState?.user, loadModels, loadGroups]);
|
||||
|
||||
return {
|
||||
loadModels,
|
||||
loadGroups
|
||||
};
|
||||
};
|
||||
93
web/src/hooks/useMessageEdit.js
Normal file
93
web/src/hooks/useMessageEdit.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Toast, Modal } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../utils/messageUtils';
|
||||
import { MESSAGE_ROLES } from '../utils/constants';
|
||||
|
||||
export const useMessageEdit = (
|
||||
setMessage,
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
sendRequest
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [editingMessageId, setEditingMessageId] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
const handleMessageEdit = useCallback((targetMessage) => {
|
||||
const editableContent = getTextContent(targetMessage);
|
||||
setEditingMessageId(targetMessage.id);
|
||||
setEditValue(editableContent);
|
||||
}, []);
|
||||
|
||||
const handleEditSave = useCallback(() => {
|
||||
if (!editingMessageId || !editValue.trim()) return;
|
||||
|
||||
setMessage(prevMessages => {
|
||||
const messageIndex = prevMessages.findIndex(msg => msg.id === editingMessageId);
|
||||
if (messageIndex === -1) return prevMessages;
|
||||
|
||||
const targetMessage = prevMessages[messageIndex];
|
||||
let newContent;
|
||||
|
||||
if (Array.isArray(targetMessage.content)) {
|
||||
newContent = targetMessage.content.map(item =>
|
||||
item.type === 'text' ? { ...item, text: editValue.trim() } : item
|
||||
);
|
||||
} else {
|
||||
newContent = editValue.trim();
|
||||
}
|
||||
|
||||
const updatedMessages = prevMessages.map(msg =>
|
||||
msg.id === editingMessageId ? { ...msg, content: newContent } : msg
|
||||
);
|
||||
|
||||
// 处理用户消息编辑后的重新生成
|
||||
if (targetMessage.role === MESSAGE_ROLES.USER) {
|
||||
const hasSubsequentAssistantReply = messageIndex < prevMessages.length - 1 &&
|
||||
prevMessages[messageIndex + 1].role === MESSAGE_ROLES.ASSISTANT;
|
||||
|
||||
if (hasSubsequentAssistantReply) {
|
||||
Modal.confirm({
|
||||
title: t('消息已编辑'),
|
||||
content: t('检测到该消息后有AI回复,是否删除后续回复并重新生成?'),
|
||||
okText: t('重新生成'),
|
||||
cancelText: t('仅保存'),
|
||||
onOk: () => {
|
||||
const messagesUntilUser = updatedMessages.slice(0, messageIndex + 1);
|
||||
setMessage(messagesUntilUser);
|
||||
|
||||
setTimeout(() => {
|
||||
const payload = buildApiPayload(messagesUntilUser, null, inputs, parameterEnabled);
|
||||
setMessage(prevMsg => [...prevMsg, createLoadingAssistantMessage()]);
|
||||
sendRequest(payload, inputs.stream);
|
||||
}, 100);
|
||||
},
|
||||
onCancel: () => setMessage(updatedMessages)
|
||||
});
|
||||
return prevMessages;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedMessages;
|
||||
});
|
||||
|
||||
setEditingMessageId(null);
|
||||
setEditValue('');
|
||||
Toast.success({ content: t('消息已更新'), duration: 2 });
|
||||
}, [editingMessageId, editValue, t, inputs, parameterEnabled, sendRequest, setMessage]);
|
||||
|
||||
const handleEditCancel = useCallback(() => {
|
||||
setEditingMessageId(null);
|
||||
setEditValue('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
editingMessageId,
|
||||
editValue,
|
||||
setEditValue,
|
||||
handleMessageEdit,
|
||||
handleEditSave,
|
||||
handleEditCancel
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS } from '../utils/constants';
|
||||
import { loadConfig, saveConfig } from '../components/playground/configStorage';
|
||||
import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage';
|
||||
|
||||
export const usePlaygroundState = () => {
|
||||
// 使用 ref 缓存初始配置,只加载一次
|
||||
@@ -10,17 +10,23 @@ export const usePlaygroundState = () => {
|
||||
}
|
||||
const savedConfig = initialConfigRef.current;
|
||||
|
||||
// 加载保存的消息,如果没有则使用默认消息
|
||||
const initialMessages = loadMessages() || DEFAULT_MESSAGES;
|
||||
|
||||
// 基础配置状态
|
||||
const [inputs, setInputs] = useState(savedConfig.inputs || DEFAULT_CONFIG.inputs);
|
||||
const [parameterEnabled, setParameterEnabled] = useState(
|
||||
savedConfig.parameterEnabled || DEFAULT_CONFIG.parameterEnabled
|
||||
);
|
||||
const [systemPrompt, setSystemPrompt] = useState(
|
||||
savedConfig.systemPrompt || DEFAULT_CONFIG.systemPrompt
|
||||
);
|
||||
const [showDebugPanel, setShowDebugPanel] = useState(
|
||||
savedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel
|
||||
);
|
||||
const [customRequestMode, setCustomRequestMode] = useState(
|
||||
savedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode
|
||||
);
|
||||
const [customRequestBody, setCustomRequestBody] = useState(
|
||||
savedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody
|
||||
);
|
||||
|
||||
// UI状态
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
@@ -28,8 +34,8 @@ export const usePlaygroundState = () => {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [status, setStatus] = useState({});
|
||||
|
||||
// 消息相关状态
|
||||
const [message, setMessage] = useState(DEFAULT_MESSAGES);
|
||||
// 消息相关状态 - 使用加载的消息初始化
|
||||
const [message, setMessage] = useState(initialMessages);
|
||||
|
||||
// 调试状态
|
||||
const [debugData, setDebugData] = useState({
|
||||
@@ -50,6 +56,7 @@ export const usePlaygroundState = () => {
|
||||
const sseSourceRef = useRef(null);
|
||||
const chatRef = useRef(null);
|
||||
const saveConfigTimeoutRef = useRef(null);
|
||||
const saveMessagesTimeoutRef = useRef(null);
|
||||
|
||||
// 配置更新函数
|
||||
const handleInputChange = useCallback((name, value) => {
|
||||
@@ -63,6 +70,17 @@ export const usePlaygroundState = () => {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 消息保存函数
|
||||
const debouncedSaveMessages = useCallback(() => {
|
||||
if (saveMessagesTimeoutRef.current) {
|
||||
clearTimeout(saveMessagesTimeoutRef.current);
|
||||
}
|
||||
|
||||
saveMessagesTimeoutRef.current = setTimeout(() => {
|
||||
saveMessages(message);
|
||||
}, 1000);
|
||||
}, [message]);
|
||||
|
||||
// 配置保存
|
||||
const debouncedSaveConfig = useCallback(() => {
|
||||
if (saveConfigTimeoutRef.current) {
|
||||
@@ -73,12 +91,13 @@ export const usePlaygroundState = () => {
|
||||
const configToSave = {
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
systemPrompt,
|
||||
showDebugPanel,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
};
|
||||
saveConfig(configToSave);
|
||||
}, 1000);
|
||||
}, [inputs, parameterEnabled, systemPrompt, showDebugPanel]);
|
||||
}, [inputs, parameterEnabled, showDebugPanel, customRequestMode, customRequestBody]);
|
||||
|
||||
// 配置导入/重置
|
||||
const handleConfigImport = useCallback((importedConfig) => {
|
||||
@@ -88,27 +107,60 @@ export const usePlaygroundState = () => {
|
||||
if (importedConfig.parameterEnabled) {
|
||||
setParameterEnabled(prev => ({ ...prev, ...importedConfig.parameterEnabled }));
|
||||
}
|
||||
if (importedConfig.systemPrompt) {
|
||||
setSystemPrompt(importedConfig.systemPrompt);
|
||||
}
|
||||
if (typeof importedConfig.showDebugPanel === 'boolean') {
|
||||
setShowDebugPanel(importedConfig.showDebugPanel);
|
||||
}
|
||||
if (importedConfig.customRequestMode) {
|
||||
setCustomRequestMode(importedConfig.customRequestMode);
|
||||
}
|
||||
if (importedConfig.customRequestBody) {
|
||||
setCustomRequestBody(importedConfig.customRequestBody);
|
||||
}
|
||||
// 如果导入的配置包含消息,也恢复消息
|
||||
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
|
||||
setMessage(importedConfig.messages);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConfigReset = useCallback(() => {
|
||||
const handleConfigReset = useCallback((options = {}) => {
|
||||
const { resetMessages = false } = options;
|
||||
|
||||
setInputs(DEFAULT_CONFIG.inputs);
|
||||
setParameterEnabled(DEFAULT_CONFIG.parameterEnabled);
|
||||
setSystemPrompt(DEFAULT_CONFIG.systemPrompt);
|
||||
setShowDebugPanel(DEFAULT_CONFIG.showDebugPanel);
|
||||
setCustomRequestMode(DEFAULT_CONFIG.customRequestMode);
|
||||
setCustomRequestBody(DEFAULT_CONFIG.customRequestBody);
|
||||
|
||||
// 只有在明确指定时才重置消息
|
||||
if (resetMessages) {
|
||||
setMessage(DEFAULT_MESSAGES);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 监听消息变化并自动保存
|
||||
useEffect(() => {
|
||||
debouncedSaveMessages();
|
||||
}, [debouncedSaveMessages]);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveConfigTimeoutRef.current) {
|
||||
clearTimeout(saveConfigTimeoutRef.current);
|
||||
}
|
||||
if (saveMessagesTimeoutRef.current) {
|
||||
clearTimeout(saveMessagesTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 配置状态
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
systemPrompt,
|
||||
showDebugPanel,
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
|
||||
// UI状态
|
||||
showSettings,
|
||||
@@ -136,8 +188,9 @@ export const usePlaygroundState = () => {
|
||||
// 更新函数
|
||||
setInputs,
|
||||
setParameterEnabled,
|
||||
setSystemPrompt,
|
||||
setShowDebugPanel,
|
||||
setCustomRequestMode,
|
||||
setCustomRequestBody,
|
||||
setShowSettings,
|
||||
setModels,
|
||||
setGroups,
|
||||
@@ -153,6 +206,7 @@ export const usePlaygroundState = () => {
|
||||
handleInputChange,
|
||||
handleParameterToggle,
|
||||
debouncedSaveConfig,
|
||||
debouncedSaveMessages,
|
||||
handleConfigImport,
|
||||
handleConfigReset,
|
||||
};
|
||||
|
||||
111
web/src/hooks/useSyncMessageAndCustomBody.js
Normal file
111
web/src/hooks/useSyncMessageAndCustomBody.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { MESSAGE_ROLES } from '../utils/constants';
|
||||
|
||||
export const useSyncMessageAndCustomBody = (
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
message,
|
||||
inputs,
|
||||
setCustomRequestBody,
|
||||
setMessage,
|
||||
debouncedSaveConfig
|
||||
) => {
|
||||
const isUpdatingFromMessage = useRef(false);
|
||||
const isUpdatingFromCustomBody = useRef(false);
|
||||
const lastMessageHash = useRef('');
|
||||
const lastCustomBodyHash = useRef('');
|
||||
|
||||
const getMessageHash = useCallback((messages) => {
|
||||
return JSON.stringify(messages.map(msg => ({
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
})));
|
||||
}, []);
|
||||
|
||||
const getCustomBodyHash = useCallback((customBody) => {
|
||||
try {
|
||||
const parsed = JSON.parse(customBody);
|
||||
return JSON.stringify(parsed.messages || []);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const syncMessageToCustomBody = useCallback(() => {
|
||||
if (!customRequestMode || isUpdatingFromCustomBody.current) return;
|
||||
|
||||
const currentMessageHash = getMessageHash(message);
|
||||
if (currentMessageHash === lastMessageHash.current) return;
|
||||
|
||||
try {
|
||||
isUpdatingFromMessage.current = true;
|
||||
let customPayload;
|
||||
|
||||
try {
|
||||
customPayload = JSON.parse(customRequestBody || '{}');
|
||||
} catch {
|
||||
customPayload = {
|
||||
model: inputs.model || 'gpt-4o',
|
||||
messages: [],
|
||||
temperature: inputs.temperature || 0.7,
|
||||
stream: inputs.stream !== false
|
||||
};
|
||||
}
|
||||
|
||||
customPayload.messages = message.map(msg => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
}));
|
||||
|
||||
const newCustomBody = JSON.stringify(customPayload, null, 2);
|
||||
setCustomRequestBody(newCustomBody);
|
||||
lastMessageHash.current = currentMessageHash;
|
||||
lastCustomBodyHash.current = getCustomBodyHash(newCustomBody);
|
||||
|
||||
setTimeout(() => {
|
||||
debouncedSaveConfig();
|
||||
}, 0);
|
||||
} finally {
|
||||
isUpdatingFromMessage.current = false;
|
||||
}
|
||||
}, [customRequestMode, customRequestBody, message, inputs.model, inputs.temperature, inputs.stream, getMessageHash, getCustomBodyHash, setCustomRequestBody, debouncedSaveConfig]);
|
||||
|
||||
const syncCustomBodyToMessage = useCallback(() => {
|
||||
if (!customRequestMode || isUpdatingFromMessage.current) return;
|
||||
|
||||
const currentCustomBodyHash = getCustomBodyHash(customRequestBody);
|
||||
if (currentCustomBodyHash === lastCustomBodyHash.current) return;
|
||||
|
||||
try {
|
||||
isUpdatingFromCustomBody.current = true;
|
||||
const customPayload = JSON.parse(customRequestBody || '{}');
|
||||
|
||||
if (customPayload.messages && Array.isArray(customPayload.messages)) {
|
||||
const newMessages = customPayload.messages.map((msg, index) => ({
|
||||
id: msg.id || (index + 1).toString(),
|
||||
role: msg.role || MESSAGE_ROLES.USER,
|
||||
content: msg.content || '',
|
||||
createAt: Date.now(),
|
||||
...(msg.role === MESSAGE_ROLES.ASSISTANT && {
|
||||
reasoningContent: msg.reasoningContent || '',
|
||||
isReasoningExpanded: false
|
||||
})
|
||||
}));
|
||||
|
||||
setMessage(newMessages);
|
||||
lastCustomBodyHash.current = currentCustomBodyHash;
|
||||
lastMessageHash.current = getMessageHash(newMessages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('同步自定义请求体到消息失败:', error);
|
||||
} finally {
|
||||
isUpdatingFromCustomBody.current = false;
|
||||
}
|
||||
}, [customRequestMode, customRequestBody, getCustomBodyHash, getMessageHash, setMessage]);
|
||||
|
||||
return {
|
||||
syncMessageToCustomBody,
|
||||
syncCustomBodyToMessage
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user