正则相关
This commit is contained in:
@@ -76,7 +76,7 @@ function App() {
|
||||
|
||||
// ✅ 应用启动时自动加载必要的配置数据
|
||||
useEffect(() => {
|
||||
console.log('[App] 🚀 应用启动,开始加载默认配置...');
|
||||
// console.log('[App] 🚀 应用启动,开始加载默认配置...');
|
||||
const startTime = Date.now();
|
||||
|
||||
// 获取各个 Store 的方法
|
||||
@@ -91,7 +91,7 @@ function App() {
|
||||
(async () => {
|
||||
try {
|
||||
await apiConfigStore.fetchProfiles();
|
||||
console.log('[App] ✅ API 配置文件列表加载完成');
|
||||
// console.log('[App] ✅ API 配置文件列表加载完成');
|
||||
|
||||
// ✅ 如果有持久化的配置 ID,优先使用;否则加载第一个
|
||||
const persistedProfileId = apiConfigStore.currentProfileId;
|
||||
@@ -99,7 +99,7 @@ function App() {
|
||||
|
||||
if (persistedProfileId && !currentProfile) {
|
||||
// 有持久化 ID 但没有详情,加载详情
|
||||
console.log(`[App] 🔄 恢复上次选中的配置: ${persistedProfileId}`);
|
||||
// console.log(`[App] 🔄 恢复上次选中的配置: ${persistedProfileId}`);
|
||||
await apiConfigStore.fetchProfile(persistedProfileId);
|
||||
console.log('[App] ✅ API 配置详情加载完成');
|
||||
} else if (!currentProfile) {
|
||||
@@ -107,17 +107,17 @@ function App() {
|
||||
const profiles = useApiConfigStore.getState().profiles;
|
||||
if (profiles.length > 0) {
|
||||
const firstProfile = profiles[0];
|
||||
console.log(`[App] 📝 自动加载第一个配置文件: ${firstProfile.name}`);
|
||||
// console.log(`[App] 📝 自动加载第一个配置文件: ${firstProfile.name}`);
|
||||
await apiConfigStore.fetchProfile(firstProfile.id);
|
||||
console.log('[App] ✅ API 配置详情加载完成');
|
||||
// console.log('[App] ✅ API 配置详情加载完成');
|
||||
} else {
|
||||
console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建');
|
||||
// console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建');
|
||||
}
|
||||
} else {
|
||||
// 从缓存恢复
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] ❌ API 配置加载失败:', err);
|
||||
// console.error('[App] ❌ API 配置加载失败:', err);
|
||||
}
|
||||
})(),
|
||||
|
||||
@@ -143,11 +143,11 @@ function App() {
|
||||
console.log(`[App] 📝 自动选择第一个预设: ${firstPreset.name}`);
|
||||
presetStore.selectPreset(firstPreset.name);
|
||||
} else {
|
||||
console.warn('[App] ⚠️ 没有可用的预设');
|
||||
// console.warn('[App] ⚠️ 没有可用的预设');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] ❌ 预设列表加载失败:', err);
|
||||
// console.error('[App] ❌ 预设列表加载失败:', err);
|
||||
}
|
||||
})(),
|
||||
|
||||
@@ -155,9 +155,9 @@ function App() {
|
||||
(async () => {
|
||||
try {
|
||||
await characterStore.fetchCharacters();
|
||||
console.log('[App] ✅ 角色卡列表加载完成');
|
||||
// console.log('[App] ✅ 角色卡列表加载完成');
|
||||
} catch (err) {
|
||||
console.error('[App] ❌ 角色卡列表加载失败:', err);
|
||||
// console.error('[App] ❌ 角色卡列表加载失败:', err);
|
||||
}
|
||||
})(),
|
||||
|
||||
@@ -165,9 +165,9 @@ function App() {
|
||||
(async () => {
|
||||
try {
|
||||
await worldBookStore.fetchWorldBooks();
|
||||
console.log('[App] ✅ 世界书列表加载完成');
|
||||
// console.log('[App] ✅ 世界书列表加载完成');
|
||||
} catch (err) {
|
||||
console.error('[App] ❌ 世界书列表加载失败:', err);
|
||||
// console.error('[App] ❌ 世界书列表加载失败:', err);
|
||||
}
|
||||
})()
|
||||
]).then((results) => {
|
||||
@@ -178,12 +178,12 @@ function App() {
|
||||
const successCount = results.filter(r => r.status === 'fulfilled').length;
|
||||
const failCount = results.filter(r => r.status === 'rejected').length;
|
||||
|
||||
console.log(`[App] 🎉 配置加载完成 (${duration}s)`);
|
||||
console.log(`[App] 📊 成功: ${successCount}, 失败: ${failCount}`);
|
||||
// console.log(`[App] 🎉 配置加载完成 (${duration}s)`);
|
||||
// console.log(`[App] 📊 成功: ${successCount}, 失败: ${failCount}`);
|
||||
|
||||
if (failCount > 0) {
|
||||
console.warn('[App] ⚠️ 部分配置加载失败,但应用仍可正常使用');
|
||||
}
|
||||
// if (failCount > 0) {
|
||||
// console.warn('[App] ⚠️ 部分配置加载失败,但应用仍可正常使用');
|
||||
// }
|
||||
});
|
||||
}, []); // 仅在应用启动时执行一次
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ const useChatBoxStore = create(
|
||||
|
||||
// 同时设置角色和聊天
|
||||
setChatBoxRoleAndChat: (role, chat) => {
|
||||
console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
|
||||
// console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
|
||||
set({
|
||||
currentRole: role,
|
||||
currentChat: typeof chat === 'object' && chat !== null ? chat.chat_name : chat
|
||||
@@ -145,7 +145,7 @@ const useChatBoxStore = create(
|
||||
// ✅ 如果没有 currentChat,先创建聊天文件
|
||||
let actualChat = currentChat;
|
||||
if (!currentChat && currentRole) {
|
||||
console.log(`[ChatBoxStore] 检测到未选择聊天,自动创建...`);
|
||||
// console.log(`[ChatBoxStore] 检测到未选择聊天,自动创建...`);
|
||||
try {
|
||||
const chatName = '默认聊天';
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}`, {
|
||||
@@ -164,12 +164,12 @@ const useChatBoxStore = create(
|
||||
actualChat = chatName;
|
||||
// 更新 currentChat
|
||||
set({ currentChat: chatName });
|
||||
console.log(`[ChatBoxStore] ✅ 已创建/使用聊天: ${chatName}`);
|
||||
// console.log(`[ChatBoxStore] ✅ 已创建/使用聊天: ${chatName}`);
|
||||
} else {
|
||||
throw new Error(`创建聊天失败: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatBoxStore] ❌ 创建聊天失败:', error);
|
||||
// console.error('[ChatBoxStore] ❌ 创建聊天失败:', error);
|
||||
set({ error: '创建聊天失败: ' + error.message });
|
||||
return;
|
||||
}
|
||||
@@ -194,28 +194,50 @@ const useChatBoxStore = create(
|
||||
wsConnection.close();
|
||||
}
|
||||
|
||||
// ✅ 判断是重roll还是新消息(现在重roll也创建新消息)
|
||||
// ✅ 判断是重roll还是新消息
|
||||
const isReroll = targetFloor !== null;
|
||||
|
||||
let userFloor, assistantFloor, nextFloor;
|
||||
|
||||
// ✅ 重roll模式也创建新消息,而不是更新现有消息
|
||||
nextFloor = get().getNextFloor(messages);
|
||||
userFloor = nextFloor;
|
||||
assistantFloor = nextFloor + 1;
|
||||
|
||||
set({
|
||||
isGenerating: true,
|
||||
wsConnection: null, // 重置连接
|
||||
messages: [...messages, {
|
||||
id: `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // ✅ 使用唯一ID
|
||||
floor: userFloor,
|
||||
mes: processedContent, // 使用处理后的内容
|
||||
is_user: true,
|
||||
name: userName || 'User',
|
||||
sendDate: new Date().toISOString()
|
||||
}]
|
||||
});
|
||||
if (isReroll) {
|
||||
// 重roll模式:不创建新用户消息楼层,直接使用目标楼层的上一条用户消息
|
||||
// console.log('[ChatBoxStore] 🔄 重roll模式,目标楼层:', targetFloor);
|
||||
|
||||
// 找到目标 AI 消息
|
||||
const targetMessage = messages.find(m => m.floor === targetFloor);
|
||||
if (!targetMessage || targetMessage.is_user) {
|
||||
// console.error('[ChatBoxStore] ❌ 无效的目标楼层');
|
||||
return;
|
||||
}
|
||||
|
||||
// 助手楼层就是目标楼层
|
||||
assistantFloor = targetFloor;
|
||||
nextFloor = targetFloor; // ✅ 设置 nextFloor 用于后续发送
|
||||
|
||||
// ✅ 不添加新的用户消息,只准备更新 AI 消息
|
||||
set({
|
||||
isGenerating: true,
|
||||
wsConnection: null, // 重置连接
|
||||
});
|
||||
} else {
|
||||
// 正常模式:创建新的用户消息和 AI 消息
|
||||
nextFloor = get().getNextFloor(messages);
|
||||
userFloor = nextFloor;
|
||||
assistantFloor = nextFloor + 1;
|
||||
|
||||
set({
|
||||
isGenerating: true,
|
||||
wsConnection: null, // 重置连接
|
||||
messages: [...messages, {
|
||||
id: `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // ✅ 使用唯一ID
|
||||
floor: userFloor,
|
||||
mes: processedContent, // 使用处理后的内容
|
||||
is_user: true,
|
||||
name: userName || 'User',
|
||||
sendDate: new Date().toISOString()
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 统一使用WebSocket处理流式和非流式输出
|
||||
@@ -245,19 +267,34 @@ const useChatBoxStore = create(
|
||||
// ✅ 根据模式处理 AI 消息
|
||||
let newMessageId;
|
||||
|
||||
// ✅ 正常模式:添加一个空的助手消息,稍后会更新
|
||||
newMessageId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // ✅ 使用唯一ID
|
||||
set((state) => ({
|
||||
messages: [...state.messages, {
|
||||
id: newMessageId,
|
||||
floor: assistantFloor,
|
||||
mes: '',
|
||||
is_user: false,
|
||||
name: characterName || 'Assistant',
|
||||
sendDate: new Date().toISOString()
|
||||
}],
|
||||
isGenerating: true
|
||||
}));
|
||||
if (isReroll) {
|
||||
// 重roll模式:找到目标消息并准备添加新的 swipe
|
||||
const targetMessage = messages.find(m => m.floor === assistantFloor);
|
||||
if (!targetMessage) {
|
||||
// console.error('[ChatBoxStore] ❌ 找不到目标消息');
|
||||
return;
|
||||
}
|
||||
|
||||
newMessageId = targetMessage.id; // 使用现有的 ID
|
||||
// console.log('[ChatBoxStore] 🔄 重roll模式,更新消息:', newMessageId);
|
||||
|
||||
// ✅ 不添加新消息,只是标记为正在生成
|
||||
set({ isGenerating: true });
|
||||
} else {
|
||||
// 正常模式:添加一个空的助手消息,稍后会更新
|
||||
newMessageId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // ✅ 使用唯一ID
|
||||
set((state) => ({
|
||||
messages: [...state.messages, {
|
||||
id: newMessageId,
|
||||
floor: assistantFloor,
|
||||
mes: '',
|
||||
is_user: false,
|
||||
name: characterName || 'Assistant',
|
||||
sendDate: new Date().toISOString()
|
||||
}],
|
||||
isGenerating: true
|
||||
}));
|
||||
}
|
||||
|
||||
let assistantMessage = '';
|
||||
let isStreamComplete = false;
|
||||
@@ -295,12 +332,17 @@ const useChatBoxStore = create(
|
||||
// 处理流式数据块
|
||||
assistantMessage += data.content;
|
||||
|
||||
// ✅ 更新新创建的消息
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg
|
||||
)
|
||||
}));
|
||||
if (isReroll) {
|
||||
// ✅ 重roll模式:不更新 mes,只在内部累积 assistantMessage
|
||||
// mes 保持不变,直到 complete 事件才更新
|
||||
} else {
|
||||
// 正常模式:更新新创建的消息
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg
|
||||
)
|
||||
}));
|
||||
}
|
||||
} else if (data.type === 'worldbook_active') {
|
||||
console.log('[WebSocket] 📚 收到世界书激活信息:', data.entries.length, '个条目');
|
||||
// ✅ 更新世界书激活显示
|
||||
@@ -358,6 +400,48 @@ const useChatBoxStore = create(
|
||||
console.log(' - 总 Chunks:', chunkCount);
|
||||
console.log(' - 消息长度:', assistantMessage.length);
|
||||
|
||||
// ✅ 处理重roll模式:将新生成的内容添加到 swipes 数组
|
||||
if (isReroll) {
|
||||
// console.log('[ChatBoxStore] 🔄 重roll完成,添加新的 swipe 版本');
|
||||
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) => {
|
||||
if (msg.id === newMessageId) {
|
||||
// 获取现有的 swipes 数组
|
||||
const existingSwipes = msg.swipes || [];
|
||||
const currentMes = msg.mes; // 当前显示的内容(旧版本)
|
||||
|
||||
// 构建新的 swipes 数组:包含所有旧版本 + 新版本
|
||||
let updatedSwipes = [...existingSwipes];
|
||||
|
||||
// 如果当前 mes 不在 swipes 中,先添加它
|
||||
if (!updatedSwipes.includes(currentMes)) {
|
||||
updatedSwipes.push(currentMes);
|
||||
}
|
||||
|
||||
// 添加新生成的内容
|
||||
updatedSwipes.push(assistantMessage);
|
||||
|
||||
// console.log('[ChatBoxStore] 📊 Swipes 更新:', {
|
||||
// oldCount: existingSwipes.length,
|
||||
// newCount: updatedSwipes.length,
|
||||
// newSwipeIndex: updatedSwipes.length - 1,
|
||||
// currentMesLength: currentMes.length,
|
||||
// assistantMessageLength: assistantMessage.length
|
||||
// });
|
||||
|
||||
return {
|
||||
...msg,
|
||||
mes: assistantMessage, // 显示新生成的内容
|
||||
swipes: updatedSwipes, // 更新 swipes 数组
|
||||
swipe_id: updatedSwipes.length - 1 // 自动切换到新版本
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
// 完成响应
|
||||
isStreamComplete = true;
|
||||
ws.close();
|
||||
@@ -406,21 +490,21 @@ const useChatBoxStore = create(
|
||||
model: apiConfigStore.currentProfile?.apis?.mainLLM?.model || ''
|
||||
};
|
||||
|
||||
console.log('\n' + '-'.repeat(80));
|
||||
console.log('[WebSocket] 📤 发送消息:');
|
||||
console.log(' - Floor:', nextFloor);
|
||||
console.log(' - Mode:', isReroll ? '🔄 Reroll (新消息)' : '➕ New Message');
|
||||
console.log(' - Role:', currentRole);
|
||||
console.log(' - Chat:', actualChat);
|
||||
console.log(' - Stream:', options.streamOutput);
|
||||
console.log(' - Message Length:', processedContent.length);
|
||||
console.log(' - API Config:', apiConfigData);
|
||||
console.log(' - Current Profile:', apiConfigStore.currentProfile);
|
||||
console.log('-'.repeat(80) + '\n');
|
||||
// console.log('\n' + '-'.repeat(80));
|
||||
// console.log('[WebSocket] 📤 发送消息:');
|
||||
// console.log(' - Floor:', isReroll ? assistantFloor : nextFloor);
|
||||
// console.log(' - Mode:', isReroll ? '🔄 Reroll (添加swipe)' : '➕ New Message');
|
||||
// console.log(' - Role:', currentRole);
|
||||
// console.log(' - Chat:', actualChat);
|
||||
// console.log(' - Stream:', options.streamOutput);
|
||||
// console.log(' - Message Length:', processedContent.length);
|
||||
// console.log(' - API Config:', apiConfigData);
|
||||
// console.log(' - Current Profile:', apiConfigStore.currentProfile);
|
||||
// console.log('-'.repeat(80) + '\n');
|
||||
|
||||
// ✅ 实际发送消息
|
||||
const messageData = JSON.stringify({
|
||||
floor: nextFloor, // ✅ 总是使用 nextFloor,因为重roll也创建新消息
|
||||
floor: isReroll ? assistantFloor : nextFloor, // ✅ 重roll模式使用目标楼层
|
||||
mes: processedContent, // 使用处理后的内容
|
||||
is_user: true,
|
||||
currentRole: currentRole,
|
||||
@@ -469,19 +553,21 @@ const useChatBoxStore = create(
|
||||
} : null,
|
||||
// ✅ 时间戳(用于冲突解决)
|
||||
timestamp: Date.now(),
|
||||
stream: options.streamOutput
|
||||
stream: options.streamOutput,
|
||||
// ✅ 调试标志:请求后端返回完整的prompt拼接内容
|
||||
debugPrompt: true
|
||||
});
|
||||
|
||||
console.log('[WebSocket] 📤 正在发送消息,数据长度:', messageData.length);
|
||||
// console.log('[WebSocket] 📤 正在发送消息,数据长度:', messageData.length);
|
||||
ws.send(messageData);
|
||||
console.log('[WebSocket] ✅ 消息已发送');
|
||||
// console.log('[WebSocket] ✅ 消息已发送');
|
||||
} else if (ws.readyState === WebSocket.CONNECTING) {
|
||||
// 如果正在连接,继续等待
|
||||
console.log('[WebSocket] 等待连接...', { readyState: ws.readyState });
|
||||
// console.log('[WebSocket] 等待连接...', { readyState: ws.readyState });
|
||||
setTimeout(sendAfterConnect, 100);
|
||||
} else {
|
||||
// 连接失败或已关闭
|
||||
console.error('[WebSocket] 连接失败', { readyState: ws.readyState });
|
||||
// console.error('[WebSocket] 连接失败', { readyState: ws.readyState });
|
||||
set({
|
||||
error: 'WebSocket connection failed',
|
||||
isGenerating: false,
|
||||
@@ -502,7 +588,7 @@ const useChatBoxStore = create(
|
||||
// 终止生成
|
||||
stopGeneration: () => set((state) => {
|
||||
if (state.wsConnection && state.wsConnection.readyState === WebSocket.OPEN) {
|
||||
console.log('[ChatBoxStore] 🛑 发送终止信号...');
|
||||
// console.log('[ChatBoxStore] 🛑 发送终止信号...');
|
||||
|
||||
// ✅ 先发送取消任务信号给后端
|
||||
state.wsConnection.send(JSON.stringify({
|
||||
@@ -514,7 +600,7 @@ const useChatBoxStore = create(
|
||||
setTimeout(() => {
|
||||
if (state.wsConnection) {
|
||||
state.wsConnection.close();
|
||||
console.log('[ChatBoxStore] 🔌 WebSocket 已关闭');
|
||||
// console.log('[ChatBoxStore] 🔌 WebSocket 已关闭');
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
@@ -531,7 +617,7 @@ const useChatBoxStore = create(
|
||||
|
||||
// 如果已经在加载中,跳过
|
||||
if (currentState.isLoading) {
|
||||
console.log(`[ChatBoxStore] 跳过重复加载: ${roleName}/${chatName}`);
|
||||
// console.log(`[ChatBoxStore] 跳过重复加载: ${roleName}/${chatName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -563,11 +649,11 @@ const useChatBoxStore = create(
|
||||
name: characterData.name || roleName,
|
||||
sendDate: new Date().toISOString()
|
||||
}];
|
||||
console.log(`[ChatBoxStore] 显示角色 ${roleName} 的开场白(临时消息)`);
|
||||
// console.log(`[ChatBoxStore] 显示角色 ${roleName} 的开场白(临时消息)`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ChatBoxStore] 获取角色信息失败:', error);
|
||||
// console.warn('[ChatBoxStore] 获取角色信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,13 +665,13 @@ const useChatBoxStore = create(
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
console.log(`[ChatBoxStore] 已加载聊天: ${roleName}/${actualChatName}, 消息数: ${messages.length}`);
|
||||
// console.log(`[ChatBoxStore] 已加载聊天: ${roleName}/${actualChatName}, 消息数: ${messages.length}`);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error.message,
|
||||
isLoading: false
|
||||
});
|
||||
console.error('[ChatBoxStore] 加载聊天失败:', error);
|
||||
// console.error('[ChatBoxStore] 加载聊天失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ const usePresetStore = create(
|
||||
|
||||
set({ presets: presetList, isLoadingPresets: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch presets:', error);
|
||||
// console.error('Failed to fetch presets:', error);
|
||||
set({ isLoadingPresets: false });
|
||||
}
|
||||
},
|
||||
@@ -217,7 +217,7 @@ const usePresetStore = create(
|
||||
isParametersExpanded: true // 确保参数容器展开
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load preset:', error);
|
||||
// console.error('Failed to load preset:', error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -298,7 +298,7 @@ const usePresetStore = create(
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to save preset:', error);
|
||||
// console.error('Failed to save preset:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -329,7 +329,7 @@ const usePresetStore = create(
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to update preset name:', error);
|
||||
// console.error('Failed to update preset name:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -383,7 +383,7 @@ const usePresetStore = create(
|
||||
saveComponentOrder: async () => {
|
||||
const state = get();
|
||||
if (!state.selectedPreset) {
|
||||
console.warn('No preset selected, cannot save order');
|
||||
// console.warn('No preset selected, cannot save order');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -406,10 +406,10 @@ const usePresetStore = create(
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Component order saved successfully:', result);
|
||||
// console.log('Component order saved successfully:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to save component order:', error);
|
||||
// console.error('Failed to save component order:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -522,13 +522,20 @@
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.95rem;
|
||||
resize: none;
|
||||
overflow-y: hidden;
|
||||
overflow-y: auto;
|
||||
min-height: 36px;
|
||||
max-height: 300px;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
|
||||
/* ✅ 业界标准:使用 field-sizing 实现自适应高度 */
|
||||
/* 支持此属性的浏览器会自动根据内容调整高度 */
|
||||
field-sizing: content;
|
||||
|
||||
/* ✅ 兼容性方案:为不支持 field-sizing 的浏览器提供回退 */
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.message-input:focus {
|
||||
@@ -717,6 +724,10 @@
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative; /* ✅ 为删除按钮定位 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.chat-option:hover {
|
||||
@@ -726,6 +737,31 @@
|
||||
|
||||
.chat-option-content {
|
||||
cursor: pointer;
|
||||
flex: 1; /* ✅ 占据剩余空间 */
|
||||
}
|
||||
|
||||
/* ✅ 删除按钮样式 */
|
||||
.chat-delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 0; /* 默认隐藏 */
|
||||
transition: all var(--transition-fast);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chat-option:hover .chat-delete-btn {
|
||||
opacity: 0.6; /* hover 时显示 */
|
||||
}
|
||||
|
||||
.chat-delete-btn:hover {
|
||||
opacity: 1 !important;
|
||||
background: rgba(255, 77, 77, 0.15);
|
||||
color: #ff4d4d;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.chat-option-name {
|
||||
|
||||
@@ -120,10 +120,11 @@ const ChatBox = () => {
|
||||
const handleInputHeight = (e) => {
|
||||
const textarea = e.target;
|
||||
|
||||
// 标准方案:先重置为 auto,再设置为 scrollHeight
|
||||
// 这是业界公认的最佳实践,确保高度计算准确
|
||||
// ✅ 业界标准方案:先重置为 auto,再设置为 scrollHeight
|
||||
// 这是 SillyTavern、Discord、Slack 等成熟产品使用的方案
|
||||
// 确保删除内容时高度也能正确收缩
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
|
||||
};
|
||||
|
||||
// 处理发送或终止
|
||||
@@ -133,7 +134,11 @@ const ChatBox = () => {
|
||||
} else {
|
||||
sendMessage(inputValue);
|
||||
clearInput(); // ✅ 使用 store 方法
|
||||
setInputHeight(42); // ✅ 重置为一行高度
|
||||
// ✅ 重置输入框高度:直接操作 DOM 元素重置为 auto
|
||||
const textarea = document.querySelector('.message-input');
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -228,13 +233,13 @@ const ChatBox = () => {
|
||||
if (currentIndex < lastAiMessage.swipes.length - 1) {
|
||||
handleSwipeChange(lastAiMessage.floor, 1);
|
||||
} else {
|
||||
// 已在最后一个版本,触发重roll
|
||||
console.log('[ChatBox] 已在最后一个版本,触发重roll(发送新消息)');
|
||||
// 已在最后一个版本,触发重roll(在目标消息的swipe数组中添加新版本)
|
||||
console.log('[ChatBox] 已在最后一个版本,触发重roll(添加swipe)');
|
||||
handleRerollMessage(lastAiMessage);
|
||||
}
|
||||
} else {
|
||||
// 没有 swipe,直接触发重roll
|
||||
console.log('[ChatBox] 没有 swipe,触发重roll(发送新消息)');
|
||||
// 没有 swipe,直接触发重roll(在目标消息的swipe数组中添加新版本)
|
||||
console.log('[ChatBox] 没有 swipe,触发重roll(添加swipe)');
|
||||
handleRerollMessage(lastAiMessage);
|
||||
}
|
||||
}
|
||||
@@ -271,7 +276,8 @@ const ChatBox = () => {
|
||||
const newIndex = currentIndex + direction;
|
||||
|
||||
if (newIndex >= 0 && newIndex < message.swipes.length) {
|
||||
setCurrentSwipeId({ [messageId]: newIndex }); // ✅ 使用 store 方法
|
||||
// ✅ 合并更新,保留其他楼层的 swipe 状态
|
||||
setCurrentSwipeId({ ...currentSwipeId, [messageId]: newIndex });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -428,6 +434,47 @@ const ChatBox = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 新增:删除聊天
|
||||
const handleDeleteChat = async (chatName, e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发选择聊天
|
||||
|
||||
if (!confirm(`确定要删除聊天 "${chatName}" 吗?此操作不可恢复!`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { currentRole } = useChatBoxStore.getState();
|
||||
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(chatName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('删除聊天失败');
|
||||
}
|
||||
|
||||
console.log(`[ChatBox] 已删除聊天: ${chatName}`);
|
||||
|
||||
// 重新获取聊天列表
|
||||
const chatsResponse = await fetch(`/api/chat/${encodeURIComponent(currentRole)}`);
|
||||
if (chatsResponse.ok) {
|
||||
const chats = await chatsResponse.json();
|
||||
setCharacterChats(chats);
|
||||
}
|
||||
|
||||
// 如果删除的是当前聊天,清空当前聊天
|
||||
const { currentChat, setChatBoxRoleAndChat } = useChatBoxStore.getState();
|
||||
if (currentChat === chatName) {
|
||||
setChatBoxRoleAndChat(currentRole, null);
|
||||
}
|
||||
|
||||
alert('聊天已删除');
|
||||
} catch (error) {
|
||||
console.error('[ChatBox] 删除聊天失败:', error);
|
||||
alert('删除聊天失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 新建聊天
|
||||
const handleCreateNewChat = async () => {
|
||||
try {
|
||||
@@ -686,10 +733,6 @@ const ChatBox = () => {
|
||||
handleInputHeight(e);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
height: `${inputHeight}px`,
|
||||
overflowY: inputHeight >= 300 ? 'auto' : 'hidden'
|
||||
}}
|
||||
placeholder="Type your message..."
|
||||
/>
|
||||
</div>
|
||||
@@ -751,6 +794,14 @@ const ChatBox = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* ✅ 删除按钮 */}
|
||||
<button
|
||||
className="chat-delete-btn"
|
||||
onClick={(e) => handleDeleteChat(chat.chat_name, e)}
|
||||
title="删除此聊天"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -298,7 +298,9 @@ const PresetPanel = () => {
|
||||
|
||||
// 导入预设
|
||||
const handleImportPreset = async (file) => {
|
||||
console.log('[Preset Import] 开始导入文件:', file.name);
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('[预设导入] 📥 开始导入文件:', file.name);
|
||||
console.log('='.repeat(80));
|
||||
try {
|
||||
// 读取文件内容
|
||||
const importData = await new Promise((resolve, reject) => {
|
||||
@@ -308,9 +310,7 @@ const PresetPanel = () => {
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
console.log('[Preset Import] 文件内容读取成功');
|
||||
const importedPreset = JSON.parse(importData);
|
||||
console.log('[Preset Import] JSON 解析成功');
|
||||
|
||||
// 支持内部结构和SillyTavern结构
|
||||
let presetName = '';
|
||||
@@ -321,7 +321,8 @@ const PresetPanel = () => {
|
||||
if (importedPreset.entries && Array.isArray(importedPreset.entries)) {
|
||||
// 内部结构
|
||||
presetName = importedPreset.name || file.name.replace('.json', '');
|
||||
console.log('[Preset Import] 检测到内部格式, preset name:', presetName);
|
||||
console.log('[预设导入] ✅ 检测到内部格式');
|
||||
console.log(' - 预设名称:', presetName);
|
||||
importedParameters = {
|
||||
temperature: importedPreset.temperature,
|
||||
top_p: importedPreset.topP,
|
||||
@@ -351,7 +352,8 @@ const PresetPanel = () => {
|
||||
else if (importedPreset.name || importedPreset.temperature || importedPreset.parameters || importedPreset.prompts) {
|
||||
// SillyTavern 格式通常没有 name 字段,使用文件名
|
||||
presetName = importedPreset.name || file.name.replace('.json', '');
|
||||
console.log('[Preset Import] 检测到 SillyTavern 格式, preset name:', presetName);
|
||||
console.log('[预设导入] ✅ 检测到 SillyTavern 格式');
|
||||
console.log(' - 预设名称:', presetName);
|
||||
|
||||
// 提取参数
|
||||
importedParameters = importedPreset.parameters || {
|
||||
@@ -366,7 +368,7 @@ const PresetPanel = () => {
|
||||
|
||||
// 转换 SillyTavern 的 prompts 为内部格式
|
||||
if (importedPreset.prompts && Array.isArray(importedPreset.prompts)) {
|
||||
console.log('[Preset Import] 找到', importedPreset.prompts.length, '个 prompts');
|
||||
console.log(' - Prompts 数量:', importedPreset.prompts.length);
|
||||
|
||||
// 获取 prompt_order(支持多个 prompt_order,优先使用最后一个或组件数最多的)
|
||||
let promptOrder = [];
|
||||
@@ -376,19 +378,21 @@ const PresetPanel = () => {
|
||||
if (importedPreset.prompt_order.length === 1) {
|
||||
if (importedPreset.prompt_order[0].order) {
|
||||
promptOrder = importedPreset.prompt_order[0].order;
|
||||
console.log('[Preset Import] 使用唯一的 prompt_order');
|
||||
console.log(' - 使用唯一的 prompt_order (组件数:', promptOrder.length, ')');
|
||||
}
|
||||
} else if (importedPreset.prompt_order.length > 1) {
|
||||
// 有多个时,使用最后一个(通常是最完整的)
|
||||
const lastOrder = importedPreset.prompt_order[importedPreset.prompt_order.length - 1];
|
||||
if (lastOrder && lastOrder.order) {
|
||||
promptOrder = lastOrder.order;
|
||||
console.log('[Preset Import] 使用最后一个 prompt_order (character_id:', lastOrder.character_id, ', 组件数:', promptOrder.length, ')');
|
||||
console.log(' - 使用最后一个 prompt_order');
|
||||
console.log(' * character_id:', lastOrder.character_id);
|
||||
console.log(' * 组件数:', promptOrder.length);
|
||||
}
|
||||
|
||||
console.log('[Preset Import] 检测到', importedPreset.prompt_order.length, '个 prompt_order');
|
||||
console.log(' - 检测到', importedPreset.prompt_order.length, '个 prompt_order:');
|
||||
importedPreset.prompt_order.forEach((po, idx) => {
|
||||
console.log(` [${idx}] character_id: ${po.character_id}, order_count: ${po.order?.length || 0}`);
|
||||
console.log(` [${idx}] character_id: ${po.character_id}, order_count: ${po.order?.length || 0}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -444,18 +448,62 @@ const PresetPanel = () => {
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Preset Import] 转换后组件数量:', importedComponents.length);
|
||||
console.log(' - 转换后组件数量:', importedComponents.length);
|
||||
} else {
|
||||
console.log('[Preset Import] 未找到 prompts 字段');
|
||||
console.log(' - ⚠️ 未找到 prompts 字段');
|
||||
importedComponents = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (presetName) {
|
||||
console.log('[Preset Import] 准备应用导入的配置');
|
||||
console.log('\n[预设导入] 🔄 应用导入的配置...');
|
||||
|
||||
// ✅ 调试:检查导入的预设结构
|
||||
console.log('[预设导入] 🔍 检查预设文件结构...');
|
||||
console.log('[预设导入] 📋 预设文件名:', presetName);
|
||||
console.log('[预设导入] 🔑 是否有 extensions:', !!importedPreset.extensions);
|
||||
console.log('[预设导入] 📜 是否有 regex_scripts:', !!importedPreset.extensions?.regex_scripts);
|
||||
console.log('[预设导入] 📊 regex_scripts 数量:', importedPreset.extensions?.regex_scripts?.length || 0);
|
||||
|
||||
// ✅ 提取并保存正则规则(如果存在)
|
||||
if (importedPreset.extensions?.regex_scripts && importedPreset.extensions.regex_scripts.length > 0) {
|
||||
const regexScripts = importedPreset.extensions.regex_scripts;
|
||||
console.log(`\n[预设导入] ✅ 检测到 ${regexScripts.length} 条正则规则`);
|
||||
console.log('[预设导入] 📜 前3条规则:', regexScripts.slice(0, 3).map(r => r.scriptName));
|
||||
|
||||
try {
|
||||
// 将正则规则发送到后端保存
|
||||
console.log('[预设导入] 开始保存正则规则到后端...');
|
||||
const response = await fetch('/api/regex/import-from-preset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rules: regexScripts,
|
||||
scope: 'preset',
|
||||
presetName: presetName
|
||||
})
|
||||
});
|
||||
|
||||
console.log('[预设导入] 📥 后端响应状态:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[预设导入] ✅ 正则规则已保存到:', `data/regex/presets/${presetName}.json`);
|
||||
console.log('[预设导入] 📊 导入结果:', result.message);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error('[预设导入] ❌ 保存正则规则失败:', response.status, errorText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[预设导入] ❌ 保存正则规则失败:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('[预设导入] ️ 预设中未找到 extensions.regex_scripts');
|
||||
console.log('[预设导入] 💡 预设文件中的 extensions 字段:', Object.keys(importedPreset.extensions || {}));
|
||||
}
|
||||
|
||||
// 先更新参数
|
||||
console.log('[Preset Import] 更新参数:', importedParameters);
|
||||
console.log(' - 参数配置:', Object.keys(importedParameters).filter(k => importedParameters[k] !== undefined).join(', '));
|
||||
Object.keys(importedParameters).forEach(key => {
|
||||
if (importedParameters[key] !== undefined) {
|
||||
updateParameter({ name: key, value: importedParameters[key] });
|
||||
@@ -464,30 +512,33 @@ const PresetPanel = () => {
|
||||
|
||||
// 再更新组件列表
|
||||
if (importedComponents.length > 0) {
|
||||
console.log('[Preset Import] 更新组件列表:', importedComponents.length, '个组件');
|
||||
console.log(' - 组件列表:', importedComponents.length, '个组件');
|
||||
setPromptComponents(importedComponents);
|
||||
}
|
||||
|
||||
// 使用 requestAnimationFrame 确保状态更新完成后再保存
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(async () => {
|
||||
console.log('[Preset Import] 准备保存预设:', presetName);
|
||||
console.log('\n[预设导入] 💾 保存预设:', presetName);
|
||||
try {
|
||||
await saveCurrentAsPreset({ name: presetName });
|
||||
console.log('[Preset Import] 预设保存成功');
|
||||
console.log('[预设导入] ✅ 预设保存成功');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
// 重新加载预设列表并重置到第一页
|
||||
setCurrentPage(1);
|
||||
fetchPresets();
|
||||
} catch (error) {
|
||||
console.error('[Preset Import] 保存预设失败:', error);
|
||||
console.error('[预设导入] ❌ 保存预设失败:', error);
|
||||
console.log('='.repeat(80) + '\n');
|
||||
alert('保存预设失败: ' + error.message);
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导入预设失败:', error);
|
||||
console.error('\n[预设导入] ❌ 导入失败:', error);
|
||||
console.log('='.repeat(80) + '\n');
|
||||
alert('导入预设失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
536
frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.css
Normal file
536
frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.css
Normal file
@@ -0,0 +1,536 @@
|
||||
/* ==================== 正则面板 - 完整重写 ==================== */
|
||||
|
||||
/* 主容器 */
|
||||
.regex-panel {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.regex-panel-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.regex-panel-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ==================== 左右布局 ==================== */
|
||||
|
||||
.regex-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 20px;
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
|
||||
.regex-left {
|
||||
overflow-y: auto;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.regex-right {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ==================== 可折叠区域 ==================== */
|
||||
|
||||
.regex-section {
|
||||
margin-bottom: 16px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.regex-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-bg-tertiary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.regex-section-header:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section-title h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.rule-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-add-rule-small {
|
||||
padding: 4px 12px;
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-add-rule-small:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.regex-section-content {
|
||||
padding: 12px;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.empty-rules {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ==================== 规则列表 ==================== */
|
||||
|
||||
.rules-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.rule-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.rule-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rule-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rule-badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rule-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rule-badge.disabled {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.rule-badge.prompt {
|
||||
background: var(--color-info);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.rule-badge.markdown {
|
||||
/* 注意:Markdown 徽章使用固定紫色,作为功能标识色 */
|
||||
background: #8844ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.rule-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
padding: 4px 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 开关按钮 */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-border);
|
||||
transition: 0.3s;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* ==================== 右侧信息卡片 ==================== */
|
||||
|
||||
.regex-info-card {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.regex-info-card h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.regex-info-card ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.regex-info-card li {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.regex-info-card li strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-quick {
|
||||
padding: 10px 16px;
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-quick:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* ==================== 编辑器弹窗 ==================== */
|
||||
|
||||
.regex-editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.regex-editor-modal {
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.regex-editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.regex-editor-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.regex-editor-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group.small {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.regex-editor-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-save {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-save:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
background: var(--color-border);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ==================== 响应式 ==================== */
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.regex-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.regex-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
575
frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.jsx
Normal file
575
frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.jsx
Normal file
@@ -0,0 +1,575 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import usePresetStore from '../../../../Store/SideBarLeft/PresetSlice';
|
||||
import './RegexPanel.css';
|
||||
|
||||
const RegexPanel = () => {
|
||||
// 状态管理
|
||||
const [globalRulesets, setGlobalRulesets] = useState([]);
|
||||
const [presetRulesets, setPresetRulesets] = useState([]);
|
||||
const [editingRule, setEditingRule] = useState(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 折叠状态
|
||||
const [collapsedSections, setCollapsedSections] = useState({
|
||||
global: false,
|
||||
preset: false
|
||||
});
|
||||
|
||||
// 获取当前预设
|
||||
const { selectedPreset } = usePresetStore();
|
||||
|
||||
// 编辑器表单状态
|
||||
const [formData, setFormData] = useState({
|
||||
id: '',
|
||||
scriptName: '',
|
||||
findRegex: '',
|
||||
replaceString: '',
|
||||
trimStrings: [],
|
||||
placement: [1], // 默认 AI输出 (注意:0=System, 1=AI Output, 2=User Input)
|
||||
disabled: false,
|
||||
markdownOnly: false,
|
||||
promptOnly: false,
|
||||
runOnEdit: true,
|
||||
substituteRegex: 0,
|
||||
minDepth: null,
|
||||
maxDepth: null
|
||||
});
|
||||
|
||||
// 作用范围选项(修正顺序)
|
||||
const placementOptions = [
|
||||
{ value: 0, label: '系统提示词' },
|
||||
{ value: 1, label: 'AI输出' },
|
||||
{ value: 2, label: '用户输入' },
|
||||
{ value: 3, label: '快捷命令' },
|
||||
{ value: 4, label: '世界信息' },
|
||||
{ value: 5, label: '推理' }
|
||||
];
|
||||
|
||||
// 替换模式选项
|
||||
const substituteOptions = [
|
||||
{ value: 0, label: '不替换' },
|
||||
{ value: 1, label: '替换首次匹配' },
|
||||
{ value: 2, label: '替换所有匹配' }
|
||||
];
|
||||
|
||||
// 加载全局规则集
|
||||
useEffect(() => {
|
||||
fetchGlobalRulesets();
|
||||
}, []);
|
||||
|
||||
// 当预设变化时加载预设规则集
|
||||
useEffect(() => {
|
||||
if (selectedPreset) {
|
||||
fetchPresetRulesets(selectedPreset);
|
||||
} else {
|
||||
setPresetRulesets([]);
|
||||
}
|
||||
}, [selectedPreset]);
|
||||
|
||||
const fetchGlobalRulesets = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch('/api/regex/rulesets/global');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setGlobalRulesets(data.rulesets || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载全局规则集失败:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPresetRulesets = async (presetName) => {
|
||||
try {
|
||||
const response = await fetch(`/api/regex/rulesets/preset/${encodeURIComponent(presetName)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.ruleset) {
|
||||
setPresetRulesets([data.ruleset]);
|
||||
} else {
|
||||
setPresetRulesets([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载预设规则集失败:', error);
|
||||
setPresetRulesets([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换折叠状态
|
||||
const toggleCollapse = (section) => {
|
||||
setCollapsedSections(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
// 打开编辑器 - 新建规则
|
||||
const handleNewRule = (scope = 'global') => {
|
||||
setFormData({
|
||||
id: crypto.randomUUID(),
|
||||
scriptName: '',
|
||||
findRegex: '',
|
||||
replaceString: '',
|
||||
trimStrings: [],
|
||||
placement: [1],
|
||||
disabled: false,
|
||||
markdownOnly: false,
|
||||
promptOnly: false,
|
||||
runOnEdit: true,
|
||||
substituteRegex: 0,
|
||||
minDepth: null,
|
||||
maxDepth: null
|
||||
});
|
||||
setEditingRule({ scope });
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
// 打开编辑器 - 编辑规则
|
||||
const handleEditRule = (rule) => {
|
||||
setFormData({
|
||||
...rule,
|
||||
placement: rule.placement || [2],
|
||||
trimStrings: rule.trimStrings || []
|
||||
});
|
||||
setEditingRule(rule);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
// 保存规则
|
||||
const handleSaveRule = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const scope = editingRule?.scope || 'global';
|
||||
const presetName = scope === 'preset' ? selectedPreset : null;
|
||||
|
||||
const response = await fetch('/api/regex/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rule: formData,
|
||||
scope: scope,
|
||||
name: presetName
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('规则保存成功!');
|
||||
setShowEditor(false);
|
||||
if (scope === 'preset') {
|
||||
fetchPresetRulesets(selectedPreset);
|
||||
} else {
|
||||
fetchGlobalRulesets();
|
||||
}
|
||||
} else {
|
||||
alert('保存失败: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存规则失败:', error);
|
||||
alert('保存失败: ' + error.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除规则
|
||||
const handleDeleteRule = async (ruleId, ruleName, sectionKey = 'global') => {
|
||||
if (!confirm(`确定要删除规则 "${ruleName}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const scope = sectionKey === 'preset' ? 'preset' : 'global';
|
||||
const presetName = scope === 'preset' ? selectedPreset : null;
|
||||
|
||||
const url = new URL(`/api/regex/rules/${ruleId}`, window.location.origin);
|
||||
url.searchParams.set('scope', scope);
|
||||
if (presetName) {
|
||||
url.searchParams.set('name', presetName);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('规则已删除');
|
||||
if (sectionKey === 'preset') {
|
||||
fetchPresetRulesets(selectedPreset);
|
||||
} else {
|
||||
fetchGlobalRulesets();
|
||||
}
|
||||
} else {
|
||||
alert('删除失败: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除规则失败:', error);
|
||||
alert('删除失败: ' + error.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换规则启用状态
|
||||
const handleToggleRule = async (rule, sectionKey = 'global') => {
|
||||
try {
|
||||
const updatedRule = { ...rule, disabled: !rule.disabled };
|
||||
const scope = sectionKey === 'preset' ? 'preset' : 'global';
|
||||
const presetName = scope === 'preset' ? selectedPreset : null;
|
||||
|
||||
const response = await fetch('/api/regex/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rule: updatedRule,
|
||||
scope: scope,
|
||||
name: presetName
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (sectionKey === 'preset') {
|
||||
fetchPresetRulesets(selectedPreset);
|
||||
} else {
|
||||
fetchGlobalRulesets();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新规则状态失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新表单字段
|
||||
const updateFormField = (field, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 切换作用范围
|
||||
const togglePlacement = (value) => {
|
||||
setFormData(prev => {
|
||||
const current = prev.placement || [];
|
||||
const updated = current.includes(value)
|
||||
? current.filter(v => v !== value)
|
||||
: [...current, value];
|
||||
return { ...prev, placement: updated };
|
||||
});
|
||||
};
|
||||
|
||||
// 更新修剪字符串
|
||||
const updateTrimStrings = (value) => {
|
||||
const trimStrings = value.split('\n').filter(s => s.trim());
|
||||
setFormData(prev => ({ ...prev, trimStrings }));
|
||||
};
|
||||
|
||||
// 渲染编辑器
|
||||
const renderEditor = () => {
|
||||
if (!showEditor) return null;
|
||||
|
||||
return (
|
||||
<div className="regex-editor-overlay">
|
||||
<div className="regex-editor-modal">
|
||||
<div className="regex-editor-header">
|
||||
<h3>正则表达式编辑器</h3>
|
||||
<button className="close-btn" onClick={() => setShowEditor(false)}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="regex-editor-body">
|
||||
{/* 脚本名称 */}
|
||||
<div className="form-group">
|
||||
<label>脚本名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.scriptName}
|
||||
onChange={(e) => updateFormField('scriptName', e.target.value)}
|
||||
placeholder="例如:隐藏thinking"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 查找正则表达式 */}
|
||||
<div className="form-group">
|
||||
<label>查找正则表达式</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.findRegex}
|
||||
onChange={(e) => updateFormField('findRegex', e.target.value)}
|
||||
placeholder="/pattern/flags"
|
||||
/>
|
||||
<small>SillyTavern 格式,例如:/<thinking>[\\s\\S]*?<\\/thinking>/gs</small>
|
||||
</div>
|
||||
|
||||
{/* 替换为 */}
|
||||
<div className="form-group">
|
||||
<label>替换为</label>
|
||||
<textarea
|
||||
value={formData.replaceString}
|
||||
onChange={(e) => updateFormField('replaceString', e.target.value)}
|
||||
placeholder="使用 {{match}} 或 $1, $2 等捕获组"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 修剪掉 */}
|
||||
<div className="form-group">
|
||||
<label>修剪掉</label>
|
||||
<textarea
|
||||
value={formData.trimStrings.join('\n')}
|
||||
onChange={(e) => updateTrimStrings(e.target.value)}
|
||||
placeholder="每行一个字符串"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 作用范围和其他选项 */}
|
||||
<div className="form-row">
|
||||
<div className="form-section">
|
||||
<h4>作用范围</h4>
|
||||
<div className="checkbox-group">
|
||||
{placementOptions.map(opt => (
|
||||
<label key={opt.value} className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.placement.includes(opt.value)}
|
||||
onChange={() => togglePlacement(opt.value)}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h4>其他选项</h4>
|
||||
<div className="checkbox-group">
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.disabled}
|
||||
onChange={(e) => updateFormField('disabled', e.target.checked)}
|
||||
/>
|
||||
<span>已禁用</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.runOnEdit}
|
||||
onChange={(e) => updateFormField('runOnEdit', e.target.checked)}
|
||||
/>
|
||||
<span>在编辑时运行</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.markdownOnly}
|
||||
onChange={(e) => updateFormField('markdownOnly', e.target.checked)}
|
||||
/>
|
||||
<span>仅格式显示</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.promptOnly}
|
||||
onChange={(e) => updateFormField('promptOnly', e.target.checked)}
|
||||
/>
|
||||
<span>仅格式提示词</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 深度设置 */}
|
||||
<div className="form-row">
|
||||
<div className="form-group small">
|
||||
<label>最小深度</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.minDepth || ''}
|
||||
onChange={(e) => updateFormField('minDepth', e.target.value ? parseInt(e.target.value) : null)}
|
||||
placeholder="无限"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group small">
|
||||
<label>最大深度</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.maxDepth || ''}
|
||||
onChange={(e) => updateFormField('maxDepth', e.target.value ? parseInt(e.target.value) : null)}
|
||||
placeholder="无限"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 替换模式 */}
|
||||
<div className="form-group">
|
||||
<label>替换模式</label>
|
||||
<select
|
||||
value={formData.substituteRegex}
|
||||
onChange={(e) => updateFormField('substituteRegex', parseInt(e.target.value))}
|
||||
>
|
||||
{substituteOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="regex-editor-footer">
|
||||
<button className="btn-cancel" onClick={() => setShowEditor(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-save" onClick={handleSaveRule} disabled={isLoading}>
|
||||
{isLoading ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染可折叠的规则列表
|
||||
const renderCollapsibleRules = (title, rulesets, sectionKey, icon = '📜') => {
|
||||
const allRules = rulesets.flatMap(ruleset => ruleset.rules || []);
|
||||
const isCollapsed = collapsedSections[sectionKey];
|
||||
|
||||
return (
|
||||
<div className="regex-section">
|
||||
<div
|
||||
className="regex-section-header"
|
||||
onClick={() => toggleCollapse(sectionKey)}
|
||||
>
|
||||
<div className="section-title">
|
||||
<span className="collapse-icon">{isCollapsed ? '▶' : '▼'}</span>
|
||||
<span className="section-icon">{icon}</span>
|
||||
<h4>{title}</h4>
|
||||
<span className="rule-count">({allRules.length})</span>
|
||||
</div>
|
||||
{!isCollapsed && allRules.length > 0 && (
|
||||
<button className="btn-add-rule-small" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNewRule(sectionKey);
|
||||
}}>
|
||||
+ 添加
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="regex-section-content">
|
||||
{allRules.length === 0 ? (
|
||||
<div className="empty-rules">
|
||||
<p>暂无规则</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rules-list">
|
||||
{allRules.map((rule) => (
|
||||
<div key={rule.id} className="rule-item">
|
||||
<div className="rule-info">
|
||||
<span className="rule-name">{rule.scriptName}</span>
|
||||
<div className="rule-badges">
|
||||
{rule.disabled && <span className="rule-badge disabled">已禁用</span>}
|
||||
{rule.promptOnly && <span className="rule-badge prompt">仅提示词</span>}
|
||||
{rule.markdownOnly && <span className="rule-badge markdown">仅格式</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rule-actions">
|
||||
<label className="toggle-switch" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!rule.disabled}
|
||||
onChange={() => handleToggleRule(rule, sectionKey)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<button className="icon-btn" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditRule(rule);
|
||||
}} title="编辑">
|
||||
✏️
|
||||
</button>
|
||||
<button className="icon-btn" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteRule(rule.id, rule.scriptName, sectionKey);
|
||||
}} title="删除">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="regex-panel">
|
||||
<div className="regex-panel-header">
|
||||
<h2>正则管理</h2>
|
||||
<p className="subtitle">管理全局和预设的正则规则。</p>
|
||||
</div>
|
||||
|
||||
{/* 左右布局 */}
|
||||
<div className="regex-layout">
|
||||
{/* 左侧:规则列表 */}
|
||||
<div className="regex-left">
|
||||
{/* 全局规则 */}
|
||||
{renderCollapsibleRules('全局正则脚本', globalRulesets, 'global', '🌍')}
|
||||
|
||||
{/* 预设规则 */}
|
||||
{renderCollapsibleRules(
|
||||
selectedPreset ? `预设正则脚本 (${selectedPreset})` : '预设正则脚本',
|
||||
presetRulesets,
|
||||
'preset',
|
||||
'⚙️'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:说明和快速操作 */}
|
||||
<div className="regex-right">
|
||||
<div className="regex-info-card">
|
||||
<h4>📖 正则规则说明</h4>
|
||||
<ul>
|
||||
<li><strong>全局规则:</strong>影响所有角色和聊天</li>
|
||||
<li><strong>预设规则:</strong>仅影响当前预设</li>
|
||||
<li><strong>仅提示词:</strong>只应用于发送LLM的内容</li>
|
||||
<li><strong>仅格式:</strong>只应用于Markdown渲染</li>
|
||||
</ul>
|
||||
|
||||
<h4 style={{ marginTop: '20px' }}>⚡ 快捷操作</h4>
|
||||
<div className="quick-actions">
|
||||
<button className="btn-quick" onClick={() => handleNewRule('global')}>
|
||||
+ 新建全局规则
|
||||
</button>
|
||||
{selectedPreset && (
|
||||
<button className="btn-quick" onClick={() => handleNewRule('preset')}>
|
||||
+ 新建预设规则
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑器弹窗 */}
|
||||
{renderEditor()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegexPanel;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './Settings.css';
|
||||
import RegexPanel from '../Regex/RegexPanel';
|
||||
|
||||
/**
|
||||
* 系统设置组件
|
||||
@@ -122,77 +123,7 @@ const Settings = () => {
|
||||
{/* 正则规则设置 */}
|
||||
{activeTab === 'regex' && (
|
||||
<div className="settings-panel">
|
||||
<div className="panel-header">
|
||||
<h3>📝 正则规则管理</h3>
|
||||
<p className="panel-description">
|
||||
管理文本处理规则,支持 SillyTavern 格式导入
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<div className="group-title">规则文件结构</div>
|
||||
<div className="setting-row">
|
||||
<div className="row-label">存储位置</div>
|
||||
<div className="row-content">
|
||||
<code className="path-code">data/regex/</code>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="file-structure-list">
|
||||
<li><span className="folder-icon">📁</span> global/ - 全局规则</li>
|
||||
<li><span className="folder-icon">📁</span> characters/ - 角色卡规则</li>
|
||||
<li><span className="folder-icon">📁</span> presets/ - 预设规则</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<div className="group-title">导入导出</div>
|
||||
<div className="setting-row">
|
||||
<div className="row-label">导入规则文件</div>
|
||||
<div className="row-content">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="file-input"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/regex/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showMessage('success', data.message);
|
||||
} else {
|
||||
showMessage('error', '导入失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '导入失败');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small className="hint-text">支持 SillyTavern 格式的规则文件</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-row">
|
||||
<div className="row-label">导出全局规则</div>
|
||||
<div className="row-content">
|
||||
<button
|
||||
className="btn-action"
|
||||
onClick={() => window.open('/api/regex/export/global', '_blank')}
|
||||
>
|
||||
📥 导出 JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RegexPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -487,3 +487,181 @@
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ==================== 正则规则列表样式 ==================== */
|
||||
|
||||
.regex-rules-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.regex-rule-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.regex-rule-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.regex-rule-drag {
|
||||
cursor: grab;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.regex-rule-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.regex-rule-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.regex-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.regex-badge.disabled {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.regex-badge.prompt {
|
||||
background: var(--color-info);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.regex-badge.markdown {
|
||||
/* 注意:Markdown 徽章使用固定紫色,作为功能标识色 */
|
||||
background: #8844ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.regex-rule-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 开关按钮 */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-border);
|
||||
transition: 0.3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.icon-btn-sm {
|
||||
padding: 4px 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn-sm:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-add-rule {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-add-rule:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import useUserStore from '../../Store/UserSlice'; // ✅ 新增 - 用户角色
|
||||
import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice';
|
||||
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
|
||||
import ThemeToggle from './items/ThemeToggle';
|
||||
import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel'; // ✅ 新增:导入正则管理组件
|
||||
import './TopBar.css';
|
||||
|
||||
const Toolbar = () => {
|
||||
@@ -372,59 +373,13 @@ const Toolbar = () => {
|
||||
</div>
|
||||
|
||||
<div className="setting-section" style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid var(--border-color)' }}>
|
||||
<h4>正则规则管理</h4>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<code className="settings-code">
|
||||
data/regex/
|
||||
</code>
|
||||
<ul className="settings-file-list">
|
||||
<li>📁 global/ - 全局规则</li>
|
||||
<li>📁 characters/ - 角色卡规则</li>
|
||||
<li>📁 presets/ - 预设规则</li>
|
||||
</ul>
|
||||
</div>
|
||||
<h4>📝 正则管理</h4>
|
||||
<p className="settings-hint" style={{ marginBottom: '16px' }}>
|
||||
管理全局和预设的正则规则。
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500', color: 'var(--color-text-primary)' }}>导入规则文件</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/regex/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showSettingsMessage('success', data.message);
|
||||
} else {
|
||||
showSettingsMessage('error', '导入失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showSettingsMessage('error', '导入失败');
|
||||
}
|
||||
}}
|
||||
className="settings-input"
|
||||
/>
|
||||
<small className="settings-hint">
|
||||
支持 SillyTavern 格式的规则文件
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => window.open('/api/regex/export/global', '_blank')}
|
||||
>
|
||||
导出全局规则
|
||||
</button>
|
||||
{/* ✅ 使用 RegexPanel 组件 */}
|
||||
<RegexPanel />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
|
||||
Reference in New Issue
Block a user