完成请求推送,但组装mes还有问题
This commit is contained in:
@@ -5,6 +5,10 @@ import { ChatBox } from './components/Mid';
|
||||
import SideBarLeft from './components/SideBarLeft';
|
||||
import SideBarRight from './components/SideBarRight';
|
||||
import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增
|
||||
import useApiConfigStore from './Store/SideBarLeft/ApiConfigSlice'; // ✅ 引入 API 配置 Store
|
||||
import usePresetStore from './Store/SideBarLeft/PresetSlice'; // ✅ 引入预设 Store
|
||||
import useCharacterStore from './Store/SideBarLeft/CharacterSlice'; // ✅ 引入角色卡 Store
|
||||
import useWorldBookStore from './Store/SideBarLeft/WorldBookSlice'; // ✅ 引入世界书 Store
|
||||
import './index.css';
|
||||
|
||||
function App() {
|
||||
@@ -64,6 +68,125 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ✅ 初始化时应用主题到 DOM(确保页面加载时就显示正确的主题)
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', colorTheme);
|
||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||
}, [colorTheme]);
|
||||
|
||||
// ✅ 应用启动时自动加载必要的配置数据
|
||||
useEffect(() => {
|
||||
console.log('[App] 🚀 应用启动,开始加载默认配置...');
|
||||
const startTime = Date.now();
|
||||
|
||||
// 获取各个 Store 的方法
|
||||
const apiConfigStore = useApiConfigStore.getState();
|
||||
const presetStore = usePresetStore.getState();
|
||||
const characterStore = useCharacterStore.getState();
|
||||
const worldBookStore = useWorldBookStore.getState();
|
||||
|
||||
// 并行加载所有必要的数据
|
||||
Promise.allSettled([
|
||||
// 1. 加载 API 配置文件列表
|
||||
(async () => {
|
||||
try {
|
||||
await apiConfigStore.fetchProfiles();
|
||||
console.log('[App] ✅ API 配置文件列表加载完成');
|
||||
|
||||
// ✅ 如果有持久化的配置 ID,优先使用;否则加载第一个
|
||||
const persistedProfileId = apiConfigStore.currentProfileId;
|
||||
const currentProfile = apiConfigStore.currentProfile;
|
||||
|
||||
if (persistedProfileId && !currentProfile) {
|
||||
// 有持久化 ID 但没有详情,加载详情
|
||||
console.log(`[App] 🔄 恢复上次选中的配置: ${persistedProfileId}`);
|
||||
await apiConfigStore.fetchProfile(persistedProfileId);
|
||||
console.log('[App] ✅ API 配置详情加载完成');
|
||||
} else if (!currentProfile) {
|
||||
// 没有持久化配置,加载第一个
|
||||
const profiles = useApiConfigStore.getState().profiles;
|
||||
if (profiles.length > 0) {
|
||||
const firstProfile = profiles[0];
|
||||
console.log(`[App] 📝 自动加载第一个配置文件: ${firstProfile.name}`);
|
||||
await apiConfigStore.fetchProfile(firstProfile.id);
|
||||
console.log('[App] ✅ API 配置详情加载完成');
|
||||
} else {
|
||||
console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建');
|
||||
}
|
||||
} else {
|
||||
console.log('[App] ✅ API 配置已从缓存恢复');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] ❌ API 配置加载失败:', err);
|
||||
}
|
||||
})(),
|
||||
|
||||
// 2. 加载预设列表
|
||||
(async () => {
|
||||
try {
|
||||
await presetStore.fetchPresets();
|
||||
console.log('[App] ✅ 预设列表加载完成');
|
||||
|
||||
// ✅ 如果有持久化的预设,优先使用
|
||||
const persistedPreset = presetStore.selectedPreset;
|
||||
|
||||
if (persistedPreset) {
|
||||
console.log(`[App] 🔄 恢复上次选中的预设: ${persistedPreset}`);
|
||||
// 重新加载预设详情以获取最新配置
|
||||
await presetStore.setSelectedPreset(persistedPreset);
|
||||
console.log('[App] ✅ 预设详情加载完成');
|
||||
} else {
|
||||
// 没有持久化预设,选择第一个
|
||||
const presets = usePresetStore.getState().presets;
|
||||
if (presets.length > 0) {
|
||||
const firstPreset = presets[0];
|
||||
console.log(`[App] 📝 自动选择第一个预设: ${firstPreset.name}`);
|
||||
presetStore.selectPreset(firstPreset.name);
|
||||
} else {
|
||||
console.warn('[App] ⚠️ 没有可用的预设');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] ❌ 预设列表加载失败:', err);
|
||||
}
|
||||
})(),
|
||||
|
||||
// 3. 加载角色卡列表
|
||||
(async () => {
|
||||
try {
|
||||
await characterStore.fetchCharacters();
|
||||
console.log('[App] ✅ 角色卡列表加载完成');
|
||||
} catch (err) {
|
||||
console.error('[App] ❌ 角色卡列表加载失败:', err);
|
||||
}
|
||||
})(),
|
||||
|
||||
// 4. 加载世界书列表
|
||||
(async () => {
|
||||
try {
|
||||
await worldBookStore.fetchWorldBooks();
|
||||
console.log('[App] ✅ 世界书列表加载完成');
|
||||
} catch (err) {
|
||||
console.error('[App] ❌ 世界书列表加载失败:', err);
|
||||
}
|
||||
})()
|
||||
]).then((results) => {
|
||||
const endTime = Date.now();
|
||||
const duration = ((endTime - startTime) / 1000).toFixed(2);
|
||||
|
||||
// 统计加载结果
|
||||
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}`);
|
||||
|
||||
if (failCount > 0) {
|
||||
console.warn('[App] ⚠️ 部分配置加载失败,但应用仍可正常使用');
|
||||
}
|
||||
});
|
||||
}, []); // 仅在应用启动时执行一次
|
||||
|
||||
return (
|
||||
<div className={`app ${layoutMode}-mode`}>
|
||||
{/* ✅ TopBar 不再需要 props,直接从 Store 读取状态 */}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { create } from 'zustand';
|
||||
import { subscribeWithSelector, persist } from 'zustand/middleware';
|
||||
import useApiConfigStore from '../SideBarLeft/ApiConfigSlice';
|
||||
import usePresetStore from '../SideBarLeft/PresetSlice';
|
||||
import useCharacterStore from '../SideBarLeft/CharacterSlice'; // 引入角色卡 Store
|
||||
import useWorldBookStore from '../SideBarLeft/WorldBookSlice'; // 引入世界书 Store
|
||||
|
||||
const useChatBoxStore = create(
|
||||
subscribeWithSelector(
|
||||
@@ -39,8 +41,8 @@ const useChatBoxStore = create(
|
||||
dynamicTable: false, // 动态表格
|
||||
streamOutput: false, // 流式输出
|
||||
imageWorkflow: false, // 生图工作流
|
||||
htmlRender: false, // HTML渲染
|
||||
markdownRender: true, // Markdown渲染(默认开启)
|
||||
renderMode: 'markdown', // 渲染模式: 'none' | 'html' | 'markdown'
|
||||
autoDiceRoll: false, // 自动掷骰子替换(默认关闭)
|
||||
},
|
||||
|
||||
// 设置消息列表
|
||||
@@ -80,11 +82,32 @@ const useChatBoxStore = create(
|
||||
dynamicTable: false,
|
||||
streamOutput: false,
|
||||
imageWorkflow: false,
|
||||
htmlRender: false,
|
||||
markdownRender: true
|
||||
renderMode: 'markdown',
|
||||
autoDiceRoll: false
|
||||
}
|
||||
}),
|
||||
|
||||
// 切换渲染模式 (none -> html -> markdown -> none)
|
||||
cycleRenderMode: () => set((state) => {
|
||||
const modes = ['none', 'html', 'markdown'];
|
||||
const currentIndex = modes.indexOf(state.options.renderMode);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
return {
|
||||
options: {
|
||||
...state.options,
|
||||
renderMode: modes[nextIndex]
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
// 设置渲染模式
|
||||
setRenderMode: (mode) => set((state) => ({
|
||||
options: {
|
||||
...state.options,
|
||||
renderMode: mode
|
||||
}
|
||||
})),
|
||||
|
||||
// 同时设置角色和聊天
|
||||
setChatBoxRoleAndChat: (role, chat) => {
|
||||
console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
|
||||
@@ -108,6 +131,17 @@ const useChatBoxStore = create(
|
||||
sendMessage: async (content) => {
|
||||
const { messages, userName, characterName, currentRole, currentChat, options, wsConnection } = get();
|
||||
|
||||
// ✅ 如果启用了自动掷骰子,处理内容
|
||||
let processedContent = content;
|
||||
if (options.autoDiceRoll) {
|
||||
const result = get().processDiceRoll(content);
|
||||
processedContent = result.content;
|
||||
|
||||
if (result.hasDiceCommand) {
|
||||
console.log('[DiceRoll] 自动替换掷骰指令:', { original: content, processed: processedContent });
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 如果没有 currentChat,先创建聊天文件
|
||||
let actualChat = currentChat;
|
||||
if (!currentChat && currentRole) {
|
||||
@@ -147,6 +181,14 @@ const useChatBoxStore = create(
|
||||
// 获取预设配置
|
||||
const presetStore = usePresetStore.getState();
|
||||
|
||||
// ✅ 获取角色卡数据
|
||||
const characterStore = useCharacterStore.getState();
|
||||
const selectedCharacter = characterStore.selectedCharacter;
|
||||
|
||||
// ✅ 获取世界书数据
|
||||
const worldBookStore = useWorldBookStore.getState();
|
||||
const globalWorldBooks = worldBookStore.globalWorldBooks;
|
||||
|
||||
// 关闭之前的WebSocket连接
|
||||
if (wsConnection) {
|
||||
wsConnection.close();
|
||||
@@ -161,7 +203,7 @@ const useChatBoxStore = create(
|
||||
messages: [...messages, {
|
||||
id: Date.now(),
|
||||
floor: nextFloor,
|
||||
mes: content,
|
||||
mes: processedContent, // 使用处理后的内容
|
||||
is_user: true,
|
||||
name: userName || 'User',
|
||||
sendDate: new Date().toISOString()
|
||||
@@ -170,8 +212,10 @@ const useChatBoxStore = create(
|
||||
|
||||
try {
|
||||
// 统一使用WebSocket处理流式和非流式输出
|
||||
const backendUrl = import.meta.env.VITE_API_URL || 'http://localhost:23337';
|
||||
const wsUrl = `${backendUrl.replace(/^http/, 'ws')}/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(actualChat)}/ws`;
|
||||
// WebSocket 直接连接到后端,使用浏览器的 host
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHost = window.location.host.replace('23338', '23337'); // 前端 23338 -> 后端 23337
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(actualChat)}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
console.log('[WebSocket] 正在建立连接...', { url: wsUrl });
|
||||
|
||||
@@ -210,23 +254,34 @@ const useChatBoxStore = create(
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(connectionTimeout); // 清除超时定时器
|
||||
console.log('[WebSocket] 连接已建立', { readyState: ws.readyState });
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('[WebSocket] 📡 连接已建立');
|
||||
console.log(' - URL:', wsUrl);
|
||||
console.log(' - Ready State:', ws.readyState);
|
||||
console.log('='.repeat(80) + '\n');
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('[WebSocket] 连接已关闭', {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean
|
||||
});
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('[WebSocket] 🔌 连接已关闭');
|
||||
console.log(' - Code:', event.code);
|
||||
console.log(' - Reason:', event.reason);
|
||||
console.log(' - Was Clean:', event.wasClean);
|
||||
console.log('='.repeat(80) + '\n');
|
||||
};
|
||||
|
||||
// 处理WebSocket消息
|
||||
let chunkCount = 0;
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[WebSocket] 收到消息', { type: data.type, content: data.content });
|
||||
|
||||
|
||||
if (data.type === 'chunk') {
|
||||
chunkCount++;
|
||||
// 每10个chunk记录一次
|
||||
if (chunkCount % 10 === 0) {
|
||||
console.log(`[WebSocket] 📊 已接收 ${chunkCount} 个 chunks`);
|
||||
}
|
||||
|
||||
// 处理流式数据块
|
||||
assistantMessage += data.content;
|
||||
set((state) => ({
|
||||
@@ -234,12 +289,68 @@ const useChatBoxStore = create(
|
||||
msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg
|
||||
)
|
||||
}));
|
||||
} else if (data.type === 'worldbook_active') {
|
||||
console.log('[WebSocket] 📚 收到世界书激活信息:', data.entries.length, '个条目');
|
||||
// ✅ 更新世界书激活显示
|
||||
import('../../Store/SideBarRight/WorldBookActiveSlice').then(module => {
|
||||
module.default.getState().setActiveEntries(data.entries);
|
||||
});
|
||||
} else if (data.type === 'tasks_created') {
|
||||
console.log('[WebSocket] 📋 收到任务ID信息:', data.tasks);
|
||||
// ✅ 创建了新任务
|
||||
import('../../Store/SideBarRight/TasksSlice').then(module => {
|
||||
const tasksStore = module.default;
|
||||
const newTasks = [];
|
||||
|
||||
if (data.tasks.imageWorkflow) {
|
||||
newTasks.push({
|
||||
taskId: data.tasks.imageWorkflow,
|
||||
taskType: 'image_workflow',
|
||||
chatId: `${currentRole}/${actualChat}`
|
||||
});
|
||||
}
|
||||
|
||||
if (data.tasks.dynamicTable) {
|
||||
newTasks.push({
|
||||
taskId: data.tasks.dynamicTable,
|
||||
taskType: 'dynamic_table',
|
||||
chatId: `${currentRole}/${actualChat}`
|
||||
});
|
||||
}
|
||||
|
||||
if (newTasks.length > 0) {
|
||||
tasksStore.getState().addTasks(newTasks);
|
||||
}
|
||||
});
|
||||
} else if (data.type === 'task_status_update') {
|
||||
console.log('[WebSocket] 🔄 收到任务状态更新:', data.tasks);
|
||||
// ✅ 任务状态更新
|
||||
import('../../Store/SideBarRight/TasksSlice').then(module => {
|
||||
module.default.getState().setTasks(data.tasks);
|
||||
});
|
||||
} else if (data.type === 'task_cancelled') {
|
||||
console.log('[WebSocket] ❌ 任务取消确认:', data.taskId);
|
||||
// ✅ 任务取消确认
|
||||
import('../../Store/SideBarRight/TasksSlice').then(module => {
|
||||
module.default.getState().updateTaskStatus(data.taskId, 'cancelled');
|
||||
});
|
||||
} else if (data.type === 'interrupted') {
|
||||
console.log('\n[WebSocket] 🛑 收到中断信号');
|
||||
console.log(' - 已生成内容长度:', data.content?.length || 0);
|
||||
// ✅ 处理中断:保存已生成的部分内容
|
||||
isStreamComplete = true;
|
||||
ws.close();
|
||||
set({ wsConnection: null, isGenerating: false });
|
||||
} else if (data.type === 'complete') {
|
||||
console.log('\n[WebSocket] ✅ 收到完成信号');
|
||||
console.log(' - 总 Chunks:', chunkCount);
|
||||
console.log(' - 消息长度:', assistantMessage.length);
|
||||
// 完成响应
|
||||
isStreamComplete = true;
|
||||
ws.close();
|
||||
set({ wsConnection: null, isGenerating: false });
|
||||
} else if (data.type === 'error') {
|
||||
console.error('[WebSocket] ❌ 收到错误:', data.message);
|
||||
// 错误处理
|
||||
set({
|
||||
error: data.message,
|
||||
@@ -272,23 +383,74 @@ const useChatBoxStore = create(
|
||||
// 发送请求到WebSocket(确保连接已建立)
|
||||
const sendAfterConnect = () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
console.log('[WebSocket] 发送消息', { readyState: ws.readyState });
|
||||
// ✅ 获取 API 配置
|
||||
const apiConfigData = {
|
||||
api_url: apiConfigStore.currentProfile?.apis?.mainLLM?.apiUrl || '',
|
||||
api_key: apiConfigStore.currentProfile?.apis?.mainLLM?.apiKey ? '***' : '',
|
||||
model: apiConfigStore.currentProfile?.apis?.mainLLM?.model || ''
|
||||
};
|
||||
|
||||
console.log('\n' + '-'.repeat(80));
|
||||
console.log('[WebSocket] 📤 发送消息:');
|
||||
console.log(' - Floor:', nextFloor);
|
||||
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');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
floor: nextFloor,
|
||||
mes: content,
|
||||
mes: processedContent, // 使用处理后的内容
|
||||
is_user: true,
|
||||
currentRole: currentRole,
|
||||
currentChat: actualChat, // 使用 actualChat
|
||||
options: options,
|
||||
apiConfig: {
|
||||
api_url: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_url || '',
|
||||
api_key: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_key || ''
|
||||
// 从 currentProfile 中获取 mainLLM 配置(不包含 apiKey)
|
||||
api_url: apiConfigStore.currentProfile?.apis?.mainLLM?.apiUrl || '',
|
||||
model: apiConfigStore.currentProfile?.apis?.mainLLM?.model || ''
|
||||
},
|
||||
// ✅ 传递 profileId,让后端从配置文件读取 API Key
|
||||
currentProfile: {
|
||||
id: apiConfigStore.currentProfile?.id || null
|
||||
},
|
||||
presetConfig: {
|
||||
selectedPreset: presetStore.selectedPreset,
|
||||
parameters: presetStore.parameters,
|
||||
promptComponents: presetStore.promptComponents
|
||||
},
|
||||
// ✅ 角色卡数据
|
||||
characterData: selectedCharacter ? {
|
||||
id: selectedCharacter.id,
|
||||
name: selectedCharacter.name,
|
||||
description: selectedCharacter.description,
|
||||
personality: selectedCharacter.personality,
|
||||
scenario: selectedCharacter.scenario,
|
||||
first_mes: selectedCharacter.first_mes,
|
||||
mes_example: selectedCharacter.mes_example,
|
||||
worldInfoId: selectedCharacter.worldInfoId || null, // 绑定的世界书ID
|
||||
tags: selectedCharacter.tags || [],
|
||||
categories: selectedCharacter.categories || []
|
||||
} : null,
|
||||
// ✅ 世界书数据
|
||||
worldBookData: {
|
||||
globalBooks: globalWorldBooks.map(wb => ({
|
||||
id: wb.id,
|
||||
name: wb.name,
|
||||
description: wb.description
|
||||
})),
|
||||
characterBookId: selectedCharacter?.worldInfoId || null // 角色绑定的世界书ID
|
||||
},
|
||||
// ✅ 动态表格数据(如果启用)
|
||||
dynamicTableData: options.dynamicTable ? {
|
||||
headers: selectedCharacter?.tableHeaders || [],
|
||||
currentValues: selectedCharacter?.tableDefaults || {}
|
||||
} : null,
|
||||
// ✅ 时间戳(用于冲突解决)
|
||||
timestamp: Date.now(),
|
||||
stream: options.streamOutput
|
||||
}));
|
||||
} else if (ws.readyState === WebSocket.CONNECTING) {
|
||||
@@ -317,9 +479,24 @@ const useChatBoxStore = create(
|
||||
|
||||
// 终止生成
|
||||
stopGeneration: () => set((state) => {
|
||||
if (state.wsConnection) {
|
||||
state.wsConnection.close();
|
||||
if (state.wsConnection && state.wsConnection.readyState === WebSocket.OPEN) {
|
||||
console.log('[ChatBoxStore] 🛑 发送终止信号...');
|
||||
|
||||
// ✅ 先发送取消任务信号给后端
|
||||
state.wsConnection.send(JSON.stringify({
|
||||
type: 'cancel_task',
|
||||
taskId: 'current_llm_generation' // 标记为当前LLM生成任务
|
||||
}));
|
||||
|
||||
// 等待一小段时间让后端处理
|
||||
setTimeout(() => {
|
||||
if (state.wsConnection) {
|
||||
state.wsConnection.close();
|
||||
console.log('[ChatBoxStore] 🔌 WebSocket 已关闭');
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return {
|
||||
isGenerating: false,
|
||||
wsConnection: null
|
||||
@@ -517,7 +694,37 @@ const useChatBoxStore = create(
|
||||
partialize: (state) => ({
|
||||
// 只持久化选项状态,不持久化聊天历史等
|
||||
options: state.options
|
||||
})
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
// 合并持久化状态和当前状态
|
||||
const merged = {
|
||||
...currentState,
|
||||
...persistedState,
|
||||
};
|
||||
|
||||
// 确保 options 中的所有字段都存在,使用默认值填充缺失的字段
|
||||
if (persistedState?.options) {
|
||||
merged.options = {
|
||||
dynamicTable: persistedState.options.dynamicTable ?? false,
|
||||
streamOutput: persistedState.options.streamOutput ?? false,
|
||||
imageWorkflow: persistedState.options.imageWorkflow ?? false,
|
||||
// 兼容旧版本:如果存在 htmlRender/markdownRender,转换为 renderMode
|
||||
renderMode: (() => {
|
||||
// 优先使用新的 renderMode
|
||||
if (persistedState.options.renderMode) {
|
||||
return persistedState.options.renderMode;
|
||||
}
|
||||
// 兼容旧版本的 htmlRender/markdownRender
|
||||
if (persistedState.options.markdownRender) return 'markdown';
|
||||
if (persistedState.options.htmlRender) return 'html';
|
||||
return 'none';
|
||||
})(),
|
||||
autoDiceRoll: persistedState.options.autoDiceRoll ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ const useApiConfigStore = create(
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/api/api-config/profiles');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch profiles');
|
||||
}
|
||||
@@ -47,7 +48,11 @@ const useApiConfigStore = create(
|
||||
throw new Error('Failed to fetch profile');
|
||||
}
|
||||
const data = await response.json();
|
||||
set({ currentProfile: data, loading: false });
|
||||
set({
|
||||
currentProfile: data,
|
||||
currentProfileId: profileId, // ✅ 保存当前配置 ID
|
||||
loading: false
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
set({ error: err.message, loading: false });
|
||||
@@ -193,8 +198,27 @@ const useApiConfigStore = create(
|
||||
{
|
||||
name: 'ApiConfigStore',
|
||||
partialize: (state) => ({
|
||||
activeMap: state.activeMap
|
||||
})
|
||||
activeMap: state.activeMap,
|
||||
// ✅ 持久化当前选中的配置文件 ID
|
||||
currentProfileId: state.currentProfile?.id || null
|
||||
}),
|
||||
// ✅ 恢复时自动加载对应的配置详情
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error) {
|
||||
console.error('[ApiConfigStore] 恢复状态失败:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state?.currentProfileId) {
|
||||
console.log(`[ApiConfigStore] 🔄 恢复上次选中的配置: ${state.currentProfileId}`);
|
||||
// 异步加载配置详情
|
||||
setTimeout(() => {
|
||||
useApiConfigStore.getState().fetchProfile(state.currentProfileId).catch(err => {
|
||||
console.error('[ApiConfigStore] 加载配置详情失败:', err);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -7,8 +7,9 @@ import { create } from 'zustand';
|
||||
const useCharacterCardUIStore = create((set) => ({
|
||||
// ==================== 状态 ====================
|
||||
|
||||
// 筛选标签
|
||||
filterTag: '',
|
||||
// 筛选标签数组 - 支持多标签交集筛选
|
||||
// 格式: ['include:tag1', 'exclude:tag2', 'include:tag3']
|
||||
filterTags: [],
|
||||
|
||||
// 是否处于编辑模式
|
||||
isEditing: false,
|
||||
@@ -25,11 +26,27 @@ const useCharacterCardUIStore = create((set) => ({
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* 设置筛选标签
|
||||
* 设置筛选标签(三次切换:无筛选 -> 包含 -> 排除 -> 无筛选)
|
||||
* @param {string} tag - 标签名
|
||||
*/
|
||||
setFilterTag: (tag) => {
|
||||
set({ filterTag: tag, currentPage: 1 }); // 重置页码
|
||||
set((state) => {
|
||||
const currentFilter = state.filterTag;
|
||||
|
||||
// 如果点击的是同一个标签,循环切换状态
|
||||
if (currentFilter && (currentFilter === tag || currentFilter === `include:${tag}` || currentFilter === `exclude:${tag}`)) {
|
||||
if (currentFilter === tag || currentFilter === `include:${tag}`) {
|
||||
// 从包含切换到排除
|
||||
return { filterTag: `exclude:${tag}`, currentPage: 1 };
|
||||
} else {
|
||||
// 从排除切换到无筛选
|
||||
return { filterTag: '', currentPage: 1 };
|
||||
}
|
||||
} else {
|
||||
// 点击新标签,设置为包含模式
|
||||
return { filterTag: `include:${tag}`, currentPage: 1 };
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -54,7 +71,8 @@ const useCharacterCardUIStore = create((set) => ({
|
||||
first_mes: character.first_mes || '',
|
||||
mes_example: character.mes_example || '',
|
||||
categories: character.categories || [],
|
||||
tags: character.tags || []
|
||||
tags: character.tags || [],
|
||||
worldInfoId: character.worldInfoId || null
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware'; // ✅ 添加持久化支持
|
||||
|
||||
const usePresetStore = create((set, get) => ({
|
||||
const usePresetStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// 预设选择
|
||||
selectedPreset: '',
|
||||
|
||||
@@ -405,6 +408,47 @@ const usePresetStore = create((set, get) => ({
|
||||
const { presets, pageSize } = get();
|
||||
return Math.ceil(presets.length / pageSize);
|
||||
}
|
||||
}));
|
||||
}), // ✅ 闭合 (set, get) => ({...})
|
||||
{
|
||||
name: 'PresetStore', // localStorage 中的键名
|
||||
partialize: (state) => ({
|
||||
// ✅ 持久化选中的预设名称
|
||||
selectedPreset: state.selectedPreset,
|
||||
// ✅ 持久化参数设置
|
||||
parameters: state.parameters,
|
||||
// ✅ 持久化提示词组件配置
|
||||
promptComponents: state.promptComponents,
|
||||
// ✅ 持久化折叠状态
|
||||
isParametersExpanded: state.isParametersExpanded
|
||||
}),
|
||||
// ✅ 恢复时的回调
|
||||
onRehydrateStorage: () => {
|
||||
return (state, error) => {
|
||||
if (error) {
|
||||
console.error('[PresetStore] 恢复状态失败:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state?.selectedPreset) {
|
||||
console.log(`[PresetStore] 🔄 恢复上次选中的预设: ${state.selectedPreset}`);
|
||||
// 异步加载预设详情
|
||||
setTimeout(() => {
|
||||
usePresetStore.getState().fetchPresets().then(() => {
|
||||
// 加载完列表后,重新选择之前选中的预设以加载其详细配置
|
||||
if (state.selectedPreset) {
|
||||
usePresetStore.getState().setSelectedPreset(state.selectedPreset).catch(err => {
|
||||
console.error('[PresetStore] 加载预设详情失败:', err);
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[PresetStore] 加载预设列表失败:', err);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
) // ✅ 闭合 persist(
|
||||
);
|
||||
|
||||
export default usePresetStore;
|
||||
|
||||
@@ -11,7 +11,8 @@ const useSideBarLeftStore = create(
|
||||
{ id: 'character', label: '角色', title: '管理AI角色卡' },
|
||||
{ id: 'api', label: 'API', title: '配置LLM和生图、向量化API连接' },
|
||||
{ id: 'presets', label: '预设', title: '管理对话预设和系统提示词' },
|
||||
{ id: 'worldbook', label: '世界', title: '管理世界观设定和背景知识' }
|
||||
{ id: 'worldbook', label: '世界', title: '管理世界观设定和背景知识' },
|
||||
{ id: 'tokenUsage', label: 'Token', title: '查看 Token 使用统计' }
|
||||
],
|
||||
|
||||
setActiveTab: (tab) => set({ activeTab: tab })
|
||||
|
||||
122
frontend/src/Store/SideBarLeft/TokenUsageSlice.jsx
Normal file
122
frontend/src/Store/SideBarLeft/TokenUsageSlice.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Token 使用统计 Store
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
|
||||
const useTokenUsageStore = create((set, get) => ({
|
||||
// 状态
|
||||
months: [],
|
||||
currentMonth: null,
|
||||
stats: null,
|
||||
roles: [],
|
||||
chats: [],
|
||||
selectedRole: null,
|
||||
selectedChat: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// Actions
|
||||
fetchMonths: async () => {
|
||||
try {
|
||||
set({ loading: true, error: null });
|
||||
const response = await fetch('/api/token-usage/months');
|
||||
if (!response.ok) throw new Error('Failed to fetch months');
|
||||
|
||||
const months = await response.json();
|
||||
set({ months, loading: false });
|
||||
|
||||
// 默认选择最新的月份
|
||||
if (months.length > 0) {
|
||||
const latest = months[months.length - 1];
|
||||
await get().fetchStats(latest.year, latest.month);
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: error.message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchStats: async (year, month, role = null, chat = null) => {
|
||||
try {
|
||||
set({ loading: true, error: null, currentMonth: { year, month } });
|
||||
|
||||
let url = `/api/token-usage/stats/${year}/${month}`;
|
||||
const params = new URLSearchParams();
|
||||
if (role) params.append('role_name', role);
|
||||
if (chat) params.append('chat_name', chat);
|
||||
|
||||
if (params.toString()) {
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch stats');
|
||||
|
||||
const stats = await response.json();
|
||||
set({ stats, loading: false });
|
||||
} catch (error) {
|
||||
set({ error: error.message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchRoles: async (year, month) => {
|
||||
try {
|
||||
const response = await fetch(`/api/token-usage/roles/${year}/${month}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch roles');
|
||||
|
||||
const data = await response.json();
|
||||
set({ roles: data.roles });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch roles:', error);
|
||||
}
|
||||
},
|
||||
|
||||
fetchChats: async (year, month, role = null) => {
|
||||
try {
|
||||
let url = `/api/token-usage/chats/${year}/${month}`;
|
||||
if (role) {
|
||||
url += `?role_name=${encodeURIComponent(role)}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch chats');
|
||||
|
||||
const data = await response.json();
|
||||
set({ chats: data.chats });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch chats:', error);
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedRole: (role) => {
|
||||
set({ selectedRole: role });
|
||||
const { currentMonth } = get();
|
||||
if (currentMonth) {
|
||||
get().fetchStats(currentMonth.year, currentMonth.month, role, get().selectedChat);
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedChat: (chat) => {
|
||||
set({ selectedChat: chat });
|
||||
const { currentMonth } = get();
|
||||
if (currentMonth) {
|
||||
get().fetchStats(currentMonth.year, currentMonth.month, get().selectedRole, chat);
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentMonth: (year, month) => {
|
||||
set({ currentMonth: { year, month } });
|
||||
get().fetchStats(year, month, get().selectedRole, get().selectedChat);
|
||||
get().fetchRoles(year, month);
|
||||
get().fetchChats(year, month, get().selectedRole);
|
||||
},
|
||||
|
||||
resetFilters: () => {
|
||||
set({ selectedRole: null, selectedChat: null });
|
||||
const { currentMonth } = get();
|
||||
if (currentMonth) {
|
||||
get().fetchStats(currentMonth.year, currentMonth.month);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export default useTokenUsageStore;
|
||||
@@ -3,15 +3,16 @@ import { persist } from 'zustand/middleware';
|
||||
|
||||
const useSideBarRightStore = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
selectedTabs: ['dice', 'rag'],
|
||||
|
||||
allTabs: [
|
||||
{ id: 'dice', label: '骰子', title: '掷骰子和随机数生成工具', component: null },
|
||||
{ id: 'debug', label: '调试', title: '查看具体发送了哪些上下文', component: null },
|
||||
{ id: 'macros', label: '宏', title: '主要为快捷输入', component: null },
|
||||
{ id: 'table', label: '表格', title: '动态数据表格展示与编辑,可直接影响后端', component: null },
|
||||
{ id: 'rag', label: 'RAG', title: '查看具体召回了哪些条目', component: null }
|
||||
{ id: 'rag', label: 'RAG', title: '查看具体召回了哪些条目', component: null },
|
||||
{ id: 'worldbook_active', label: '世界书', title: '查看当前对话中激活的世界书条目', component: null }, // ✅ 添加世界书激活标签
|
||||
{ id: 'tasks', label: '任务', title: '查看和管理并行任务(生图、表格维护等)', component: null } // ✅ 添加任务队列标签
|
||||
],
|
||||
|
||||
handleTabClick: (tabId) => set((state) => {
|
||||
@@ -32,11 +33,46 @@ const useSideBarRightStore = create(
|
||||
allTabs: state.allTabs.map(tab =>
|
||||
tab.id === tabId ? { ...tab, component } : tab
|
||||
)
|
||||
}))
|
||||
})),
|
||||
|
||||
// ✅ 验证并清理无效的选中标签
|
||||
validateSelectedTabs: () => {
|
||||
const state = get();
|
||||
const validIds = state.allTabs.map(tab => tab.id);
|
||||
const validSelectedTabs = state.selectedTabs.filter(id => validIds.includes(id));
|
||||
|
||||
if (validSelectedTabs.length !== state.selectedTabs.length) {
|
||||
set({ selectedTabs: validSelectedTabs });
|
||||
}
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'sidebar-right-storage', // localStorage key
|
||||
partialize: (state) => ({ selectedTabs: state.selectedTabs }) // 只保存 selectedTabs
|
||||
partialize: (state) => ({ selectedTabs: state.selectedTabs }), // 只保存 selectedTabs
|
||||
// ✅ 迁移逻辑:清除已删除的标签
|
||||
migrate: (persistedState, version) => {
|
||||
if (persistedState.selectedTabs) {
|
||||
// 移除已删除的 'debug' 标签
|
||||
const cleanedTabs = persistedState.selectedTabs.filter(id => id !== 'debug');
|
||||
|
||||
// 如果清理后为空或只有一个,恢复到默认值
|
||||
if (cleanedTabs.length === 0) {
|
||||
persistedState.selectedTabs = ['dice', 'rag'];
|
||||
} else if (cleanedTabs.length === 1 && cleanedTabs[0] === 'dice') {
|
||||
// 如果只剩dice,添加rag作为第二个
|
||||
persistedState.selectedTabs = ['dice', 'rag'];
|
||||
} else {
|
||||
persistedState.selectedTabs = cleanedTabs;
|
||||
}
|
||||
}
|
||||
return persistedState;
|
||||
},
|
||||
// ✅ 加载后验证
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) {
|
||||
state.validateSelectedTabs();
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
88
frontend/src/Store/SideBarRight/TasksSlice.jsx
Normal file
88
frontend/src/Store/SideBarRight/TasksSlice.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { create } from 'zustand';
|
||||
import useChatBoxStore from '../Mid/ChatBoxSlice';
|
||||
|
||||
/**
|
||||
* 任务队列 Store
|
||||
* 管理并行任务(生图、动态表格维护等)的状态
|
||||
*/
|
||||
const useTasksStore = create((set, get) => ({
|
||||
// ==================== 状态 ====================
|
||||
|
||||
// 任务列表
|
||||
tasks: [],
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* 添加任务
|
||||
* @param {Object} task - 任务对象 {taskId, taskType, chatId}
|
||||
*/
|
||||
addTask: (task) => set((state) => ({
|
||||
tasks: [...state.tasks, { ...task, status: 'pending' }]
|
||||
})),
|
||||
|
||||
/**
|
||||
* 批量添加任务
|
||||
* @param {Array} newTasks - 新任务数组
|
||||
*/
|
||||
addTasks: (newTasks) => set((state) => ({
|
||||
tasks: [...state.tasks, ...newTasks.map(t => ({ ...t, status: 'pending' }))]
|
||||
})),
|
||||
|
||||
/**
|
||||
* 更新任务状态
|
||||
* @param {string} taskId - 任务ID
|
||||
* @param {string} status - 新状态
|
||||
* @param {Object} metadata - 额外元数据
|
||||
*/
|
||||
updateTaskStatus: (taskId, status, metadata = null) => set((state) => ({
|
||||
tasks: state.tasks.map(task =>
|
||||
task.taskId === taskId
|
||||
? { ...task, status, ...(metadata && { metadata }) }
|
||||
: task
|
||||
)
|
||||
})),
|
||||
|
||||
/**
|
||||
* 取消任务
|
||||
* @param {string} taskId - 任务ID
|
||||
*/
|
||||
cancelTask: async (taskId) => {
|
||||
const { wsConnection } = useChatBoxStore.getState();
|
||||
if (wsConnection) {
|
||||
wsConnection.send(JSON.stringify({
|
||||
type: 'cancel_task',
|
||||
taskId: taskId
|
||||
}));
|
||||
|
||||
// 乐观更新UI
|
||||
set((state) => ({
|
||||
tasks: state.tasks.map(task =>
|
||||
task.taskId === taskId ? { ...task, status: 'cancelled' } : task
|
||||
)
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清理已完成的任务
|
||||
*/
|
||||
clearCompleted: () => set((state) => ({
|
||||
tasks: state.tasks.filter(task =>
|
||||
!['completed', 'failed', 'cancelled'].includes(task.status)
|
||||
)
|
||||
})),
|
||||
|
||||
/**
|
||||
* 设置任务列表(用于全量更新)
|
||||
* @param {Array} tasks - 任务列表
|
||||
*/
|
||||
setTasks: (tasks) => set({ tasks }),
|
||||
|
||||
/**
|
||||
* 清空所有任务
|
||||
*/
|
||||
clearAll: () => set({ tasks: [] })
|
||||
}));
|
||||
|
||||
export default useTasksStore;
|
||||
37
frontend/src/Store/SideBarRight/WorldBookActiveSlice.jsx
Normal file
37
frontend/src/Store/SideBarRight/WorldBookActiveSlice.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* 世界书激活显示 Store
|
||||
* 管理当前对话中激活的世界书条目
|
||||
*/
|
||||
const useWorldBookActiveStore = create((set, get) => ({
|
||||
// ==================== 状态 ====================
|
||||
|
||||
// 激活的条目列表
|
||||
activeEntries: [],
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* 设置激活的条目(覆盖)
|
||||
* @param {Array} entries - 激活的条目列表
|
||||
*/
|
||||
setActiveEntries: (entries) => set({
|
||||
activeEntries: entries || []
|
||||
}),
|
||||
|
||||
/**
|
||||
* 添加单个激活条目
|
||||
* @param {Object} entry - 条目对象
|
||||
*/
|
||||
addActiveEntry: (entry) => set((state) => ({
|
||||
activeEntries: [...state.activeEntries, entry]
|
||||
})),
|
||||
|
||||
/**
|
||||
* 清空所有激活条目
|
||||
*/
|
||||
clearEntries: () => set({ activeEntries: [] })
|
||||
}));
|
||||
|
||||
export default useWorldBookActiveStore;
|
||||
@@ -12,7 +12,6 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
padding-bottom: 180px; /* 为固定的输入框留出空间 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
@@ -330,27 +329,70 @@
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--spacing-sm));
|
||||
left: 0;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(102, 126, 234, 0.2);
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
padding: var(--spacing-md);
|
||||
min-width: 200px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
padding: var(--spacing-sm);
|
||||
min-width: 220px;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* 选项组标题 */
|
||||
.option-group-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* 渲染模式按钮 */
|
||||
.render-mode-btn {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.render-mode-btn:hover {
|
||||
background-color: rgba(102, 126, 234, 0.08);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.render-mode-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 复选框选项 - 简化样式 */
|
||||
.option-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background-color 0.15s ease;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.15s ease;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.option-checkbox:hover {
|
||||
background-color: rgba(102, 126, 234, 0.05);
|
||||
background-color: rgba(102, 126, 234, 0.06);
|
||||
}
|
||||
|
||||
.option-checkbox input[type="checkbox"] {
|
||||
@@ -358,15 +400,19 @@
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(102, 126, 234, 0.3);
|
||||
border-radius: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-checkbox:hover .checkmark {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.option-checkbox input:checked + .checkmark {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
@@ -375,8 +421,8 @@
|
||||
.option-checkbox input:checked + .checkmark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 2px;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
@@ -388,17 +434,18 @@
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-primary);
|
||||
user-select: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
.option-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(to right,
|
||||
transparent,
|
||||
rgba(102, 126, 234, 0.15),
|
||||
transparent);
|
||||
background-color: var(--color-border);
|
||||
margin: var(--spacing-sm) 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 切换聊天按钮 */
|
||||
.switch-chat-btn {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
@@ -409,7 +456,7 @@
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
@@ -419,6 +466,10 @@
|
||||
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.switch-chat-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -584,6 +635,40 @@
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 新建聊天按钮区域 */
|
||||
.chat-selector-actions {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.new-chat-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.new-chat-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -46,7 +46,8 @@ const ChatBox = () => {
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
options,
|
||||
toggleOption
|
||||
toggleOption,
|
||||
cycleRenderMode // ✅ 新增:切换渲染模式
|
||||
} = useChatBoxStore();
|
||||
|
||||
// 点击外部关闭选项面板
|
||||
@@ -202,6 +203,35 @@ const ChatBox = () => {
|
||||
alert('切换聊天失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 新建聊天
|
||||
const handleCreateNewChat = async () => {
|
||||
try {
|
||||
const { currentRole, createChat, setChatBoxRoleAndChat } = useChatBoxStore.getState();
|
||||
|
||||
if (!currentRole) {
|
||||
alert('请先选择一个角色');
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用当前时间戳作为聊天名称
|
||||
const chatName = `chat_${Date.now()}`;
|
||||
|
||||
// 创建新聊天
|
||||
await createChat(currentRole, chatName);
|
||||
|
||||
// 切换到新创建的聊天
|
||||
setChatBoxRoleAndChat(currentRole, chatName);
|
||||
|
||||
// 关闭选择器
|
||||
setShowChatSelector(false);
|
||||
|
||||
console.log(`已创建并切换到新聊天: ${chatName}`);
|
||||
} catch (error) {
|
||||
console.error('创建聊天失败:', error);
|
||||
alert('创建聊天失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单条消息
|
||||
const renderMessage = (message) => {
|
||||
@@ -283,14 +313,30 @@ const ChatBox = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="bubble">
|
||||
{options.htmlRender && !isUser ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: currentMes }} />
|
||||
) : options.markdownRender ? (
|
||||
{options.renderMode === 'html' && !isUser ? (
|
||||
// ✅ 智能 HTML 渲染:检测是否为纯文本,自动转换换行符
|
||||
<div dangerouslySetInnerHTML={{
|
||||
__html: (() => {
|
||||
// 检测是否包含 HTML 标签
|
||||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(currentMes);
|
||||
if (hasHtmlTags) {
|
||||
// 如果已有 HTML 标签,直接返回
|
||||
return currentMes;
|
||||
}
|
||||
// 如果是纯文本,将换行符转换为 <br>
|
||||
return currentMes
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
})()
|
||||
}} />
|
||||
) : options.renderMode === 'markdown' ? (
|
||||
<MarkdownRenderer content={currentMes} />
|
||||
) : (
|
||||
<div className="plain-text">{currentMes}</div>
|
||||
)}
|
||||
{hasSwipes && isLatestMessage && !isUser && (
|
||||
{hasSwipes && !isUser && isLatestMessage && (
|
||||
<div className="swipe-controls">
|
||||
<button
|
||||
className="swipe-button"
|
||||
@@ -345,24 +391,19 @@ const ChatBox = () => {
|
||||
</button>
|
||||
{showOptions && (
|
||||
<div className="chat-options">
|
||||
<label className="option-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.markdownRender}
|
||||
onChange={() => toggleOption('markdownRender')}
|
||||
/>
|
||||
<span className="checkmark"></span>
|
||||
<span className="option-label">Markdown渲染</span>
|
||||
</label>
|
||||
<label className="option-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.htmlRender}
|
||||
onChange={() => toggleOption('htmlRender')}
|
||||
/>
|
||||
<span className="checkmark"></span>
|
||||
<span className="option-label">HTML渲染</span>
|
||||
</label>
|
||||
{/* 渲染模式切换按钮 */}
|
||||
<div className="option-group-title">显示</div>
|
||||
<button
|
||||
className="render-mode-btn"
|
||||
onClick={() => cycleRenderMode()}
|
||||
title={`当前: ${options.renderMode === 'none' ? '纯文本' : options.renderMode === 'html' ? 'HTML' : 'Markdown'},点击切换`}
|
||||
>
|
||||
{options.renderMode === 'none' ? '📄 纯文本' :
|
||||
options.renderMode === 'html' ? '🌐 HTML' :
|
||||
'📝 Markdown'}
|
||||
</button>
|
||||
|
||||
<div className="option-group-title">功能</div>
|
||||
<label className="option-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -382,6 +423,17 @@ const ChatBox = () => {
|
||||
<span className="option-label">动态表格</span>
|
||||
</label>
|
||||
|
||||
{/* 自动掷骰子替换 */}
|
||||
<label className="option-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.autoDiceRoll}
|
||||
onChange={() => toggleOption('autoDiceRoll')}
|
||||
/>
|
||||
<span className="checkmark"></span>
|
||||
<span className="option-label">🎲 自动掷骰子</span>
|
||||
</label>
|
||||
|
||||
{/* 生图工作流选项 */}
|
||||
<label className="option-checkbox">
|
||||
<input
|
||||
@@ -453,6 +505,16 @@ const ChatBox = () => {
|
||||
</div>
|
||||
|
||||
<div className="chat-selector-body">
|
||||
{/* 新建聊天按钮 */}
|
||||
<div className="chat-selector-actions">
|
||||
<button
|
||||
className="new-chat-btn"
|
||||
onClick={handleCreateNewChat}
|
||||
>
|
||||
+ 新建聊天
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{characterChats.length > 0 ? (
|
||||
<div className="chat-list">
|
||||
{characterChats.map((chat, index) => (
|
||||
|
||||
@@ -47,10 +47,29 @@
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
padding: 0; /* ✅ 移除padding,滚动条作为间隔 */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ✅ 美化滚动条 - 符合主题 */
|
||||
.sidebar-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scrollbar-hover);
|
||||
}
|
||||
|
||||
/* Tab placeholder for empty states */
|
||||
.tab-placeholder {
|
||||
padding: var(--spacing-lg);
|
||||
|
||||
@@ -3,15 +3,18 @@ import React from 'react';
|
||||
import './SideBarLeft.css';
|
||||
import { useSideBarLeftStore } from '../../Store/indexStore';
|
||||
import usePresetStore from '../../Store/SideBarLeft/PresetSlice';
|
||||
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
|
||||
import Gallery from './tabs/Gallery';
|
||||
import CharacterCard from './tabs/CharacterCard';
|
||||
import ApiConfig from './tabs/ApiConfig';
|
||||
import Presets from './tabs/Presets';
|
||||
import WorldBook from './tabs/WorldBook';
|
||||
import TokenUsage from './tabs/TokenUsage/TokenUsage';
|
||||
|
||||
const SideBarLeft = () => {
|
||||
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
|
||||
const { fetchPresets } = usePresetStore();
|
||||
const { fetchProfiles } = useApiConfigStore();
|
||||
|
||||
// 处理标签切换
|
||||
const handleTabClick = (tabId) => {
|
||||
@@ -21,6 +24,11 @@ const SideBarLeft = () => {
|
||||
if (tabId === 'presets') {
|
||||
fetchPresets();
|
||||
}
|
||||
|
||||
// 如果切换到API配置标签,刷新配置文件列表
|
||||
if (tabId === 'api') {
|
||||
fetchProfiles();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -44,6 +52,7 @@ const SideBarLeft = () => {
|
||||
{activeTab === 'api' && <ApiConfig />}
|
||||
{activeTab === 'presets' && <Presets />}
|
||||
{activeTab === 'worldbook' && <WorldBook />}
|
||||
{activeTab === 'tokenUsage' && <TokenUsage />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import ComfyUIWorkflowManager from './ComfyUIWorkflowManager';
|
||||
import './ApiConfig.css';
|
||||
|
||||
const ApiConfig = () => {
|
||||
// 从store中获取状态和方法
|
||||
// 从 store中获取状态和方法
|
||||
const {
|
||||
profiles,
|
||||
currentProfile,
|
||||
@@ -77,21 +77,11 @@ const ApiConfig = () => {
|
||||
// 跟踪哪些 API 有修改
|
||||
const [modifiedApis, setModifiedApis] = useState({});
|
||||
|
||||
// 跟踪用户是否正在编辑 API Key(用于控制脱敏显示)
|
||||
const [isEditingApiKey, setIsEditingApiKey] = useState(false);
|
||||
|
||||
// 获取当前激活的分页
|
||||
const { activeTab } = useSideBarLeftStore();
|
||||
|
||||
// 记录上一次的分页状态
|
||||
const prevActiveTabRef = React.useRef(activeTab);
|
||||
|
||||
// 组件加载时获取配置文件列表 - 只在切换到API分页时刷新
|
||||
useEffect(() => {
|
||||
// 检测是否从其他分页切换到API分页
|
||||
if (activeTab === 'api' && prevActiveTabRef.current !== 'api') {
|
||||
fetchProfiles();
|
||||
}
|
||||
// 更新上一次的分页状态
|
||||
prevActiveTabRef.current = activeTab;
|
||||
}, [activeTab, fetchProfiles]);
|
||||
|
||||
// 当选中配置文件时,加载该配置
|
||||
useEffect(() => {
|
||||
@@ -100,6 +90,15 @@ const ApiConfig = () => {
|
||||
}
|
||||
}, [selectedProfileId]);
|
||||
|
||||
// 当 profiles 加载完成后,如果没有选中的配置文件,则自动加载第一个
|
||||
useEffect(() => {
|
||||
if (profiles.length > 0 && !selectedProfileId && activeTab === 'api') {
|
||||
// 尝试加载 activeMap 中记录的配置文件,如果没有则加载第一个
|
||||
const defaultProfileId = activeMap[currentCategory] || profiles[0].id;
|
||||
setSelectedProfileId(defaultProfileId);
|
||||
}
|
||||
}, [profiles, selectedProfileId, activeTab, currentCategory, activeMap]);
|
||||
|
||||
// 加载配置文件
|
||||
const loadProfile = async (profileId) => {
|
||||
try {
|
||||
@@ -123,6 +122,11 @@ const ApiConfig = () => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
const newValue = type === 'checkbox' ? checked : value;
|
||||
|
||||
// 如果是 API Key 字段,标记为正在编辑
|
||||
if (name === 'apiKey') {
|
||||
setIsEditingApiKey(true);
|
||||
}
|
||||
|
||||
if (path) {
|
||||
// 嵌套路径更新,例如: ['imageModel', 'local', 'apiUrl']
|
||||
setFormData(prev => {
|
||||
@@ -267,8 +271,10 @@ const ApiConfig = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentApi.apiKey) {
|
||||
alert('请先填写 API 密钥');
|
||||
// 检查 API Key:可以为空(如果已保存过,后端会使用保存的 key)
|
||||
// 但如果既没有输入 key,也没有保存过,则会失败
|
||||
if (!currentApi.apiKey || currentApi.apiKey.trim() === '') {
|
||||
alert('请先填写 API 密钥,或先保存配置文件');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,6 +283,7 @@ const ApiConfig = () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: selectedProfileId || null, // 使用 id 字段传递 profileId
|
||||
apiUrl: currentApi.apiUrl,
|
||||
apiKey: currentApi.apiKey,
|
||||
category: currentCategory,
|
||||
@@ -388,19 +395,9 @@ const ApiConfig = () => {
|
||||
if (selectedProfileId && confirm('确定要删除此配置文件吗?')) {
|
||||
await deleteProfile(selectedProfileId);
|
||||
setSelectedProfileId('');
|
||||
handleCreateNew();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理设为默认配置
|
||||
const handleSetActive = async () => {
|
||||
if (!selectedProfileId) {
|
||||
alert('请先保存配置文件');
|
||||
return;
|
||||
}
|
||||
await setActiveConfig(currentCategory, selectedProfileId);
|
||||
};
|
||||
|
||||
// 配置区域标签(简短名称 + Tooltip)
|
||||
const configTabs = [
|
||||
{ id: 'mainLLM', label: '核心', tooltip: '主 LLM 模型 - 用于主要对话和推理' },
|
||||
@@ -409,8 +406,13 @@ const ApiConfig = () => {
|
||||
{ id: 'ragEmbedding', label: '向量', tooltip: 'RAG 嵌入模型 - 用于文本向量化和检索' }
|
||||
];
|
||||
|
||||
// 判断当前 API 密钥是否是脱敏的
|
||||
const isApiKeyMasked = formData[currentCategory].apiKey && formData[currentCategory].apiKey.endsWith('****');
|
||||
// 脱敏 API Key 显示(只显示最后6位)
|
||||
const maskApiKey = (apiKey) => {
|
||||
if (!apiKey || apiKey.length <= 6) {
|
||||
return apiKey || '';
|
||||
}
|
||||
return '•'.repeat(apiKey.length - 6) + apiKey.slice(-6);
|
||||
};
|
||||
|
||||
// 计算有修改的 API 数量
|
||||
const modifiedCount = Object.keys(modifiedApis).filter(k => modifiedApis[k]).length;
|
||||
@@ -438,41 +440,38 @@ const ApiConfig = () => {
|
||||
onChange={handleSelectProfile}
|
||||
className="form-control profile-select-input"
|
||||
>
|
||||
<option value="">新建配置文件...</option>
|
||||
{profiles.map(profile => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="profile-buttons">
|
||||
{!selectedProfileId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleOpenSaveModal}
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
{profiles.length === 0 ? (
|
||||
<option value="">暂无配置文件</option>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleSetActive}
|
||||
disabled={activeMap[currentCategory] === selectedProfileId}
|
||||
>
|
||||
设为默认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
<option value="">-- 选择配置文件 --</option>
|
||||
{profiles.map(profile => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
<div className="profile-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleOpenSaveModal}
|
||||
title="创建新的配置文件"
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
{selectedProfileId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleDelete}
|
||||
title="删除当前配置文件"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -606,13 +605,31 @@ const ApiConfig = () => {
|
||||
<div className="form-group">
|
||||
<label htmlFor="cloud-apiKey">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
type={isEditingApiKey ? 'text' : 'password'}
|
||||
id="cloud-apiKey"
|
||||
value={formData.imageModel.cloud.apiKey}
|
||||
value={isEditingApiKey ? formData.imageModel.cloud.apiKey : maskApiKey(formData.imageModel.cloud.apiKey)}
|
||||
onChange={(e) => handleChange(e, ['imageModel', 'cloud', 'apiKey'])}
|
||||
placeholder="sk-..."
|
||||
onBlur={() => setIsEditingApiKey(false)}
|
||||
onFocus={() => {
|
||||
if (formData.imageModel.cloud.apiKey && !isEditingApiKey) {
|
||||
// 聚焦时清空,让用户重新输入
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
imageModel: {
|
||||
...prev.imageModel,
|
||||
cloud: {
|
||||
...prev.imageModel.cloud,
|
||||
apiKey: ''
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
setIsEditingApiKey(true);
|
||||
}}
|
||||
placeholder="sk-...(输入新密钥将覆盖旧密钥)"
|
||||
className="form-control"
|
||||
/>
|
||||
<span className="form-hint">首次输入可见,之后仅显示最后6位</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
@@ -668,17 +685,29 @@ const ApiConfig = () => {
|
||||
<div className="form-group">
|
||||
<label htmlFor="apiKey">密钥</label>
|
||||
<input
|
||||
type="password"
|
||||
type={isEditingApiKey ? 'text' : 'password'}
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
value={isApiKeyMasked ? '' : formData[currentCategory].apiKey}
|
||||
value={isEditingApiKey ? formData[currentCategory].apiKey : maskApiKey(formData[currentCategory].apiKey)}
|
||||
onChange={handleChange}
|
||||
placeholder={isApiKeyMasked ? '已保存(留空保持不变)' : 'sk-...'}
|
||||
onBlur={() => setIsEditingApiKey(false)}
|
||||
onFocus={() => {
|
||||
if (formData[currentCategory].apiKey && !isEditingApiKey) {
|
||||
// 聚焦时清空,让用户重新输入
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: {
|
||||
...prev[currentCategory],
|
||||
apiKey: ''
|
||||
}
|
||||
}));
|
||||
}
|
||||
setIsEditingApiKey(true);
|
||||
}}
|
||||
placeholder="sk-...(输入新密钥将覆盖旧密钥)"
|
||||
className="form-control"
|
||||
/>
|
||||
{isApiKeyMasked && (
|
||||
<span className="form-hint">当前密钥已加密存储</span>
|
||||
)}
|
||||
<span className="form-hint">首次输入可见,之后仅显示最后6位</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
transition: all 0.15s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tag-btn:hover {
|
||||
@@ -76,12 +79,36 @@
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tag-btn.active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
/* 包含模式(第一次点击)- 绿色 */
|
||||
.tag-btn.tag-include {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tag-btn.tag-include:hover {
|
||||
background: #059669;
|
||||
border-color: #059669;
|
||||
}
|
||||
|
||||
/* 排除模式(第二次点击)- 红色 */
|
||||
.tag-btn.tag-exclude {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tag-btn.tag-exclude:hover {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
/* 排除前缀符号 */
|
||||
.tag-prefix {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 错误和加载提示 */
|
||||
.error-message,
|
||||
.loading-message,
|
||||
@@ -270,47 +297,116 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
position: relative; /* 为悬浮工具栏提供定位上下文 */
|
||||
}
|
||||
|
||||
.edit-header {
|
||||
/* 悬浮工具栏 */
|
||||
.floating-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 工具栏内容(默认隐藏) */
|
||||
.toolbar-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
flex-wrap: wrap; /* 允许换行 */
|
||||
gap: 6px; /* 添加间距 */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
/* 默认状态:收缩 */
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transform: scale(0.8);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 悬停时展开 */
|
||||
.floating-toolbar:hover .toolbar-content {
|
||||
max-height: 60px;
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0; /* 允许收缩 */
|
||||
}
|
||||
|
||||
.edit-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
flex: 1; /* 占据剩余空间 */
|
||||
min-width: 120px; /* 最小宽度,防止过度压缩 */
|
||||
white-space: nowrap; /* 不换行 */
|
||||
overflow: hidden; /* 超出隐藏 */
|
||||
text-overflow: ellipsis; /* 显示省略号 */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
/* 小球指示器 */
|
||||
.toolbar-indicator {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap; /* 按钮也可以换行 */
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: translateY(-50%) scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-toolbar:hover .toolbar-indicator {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scale(0);
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
font-size: 18px;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.export-format-selector {
|
||||
padding: 4px 8px;
|
||||
padding: 3px 6px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.export-format-selector:hover {
|
||||
@@ -322,55 +418,66 @@
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
padding: 4px 10px;
|
||||
.toolbar-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.edit-btn.save {
|
||||
.toolbar-btn:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.toolbar-btn.save {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-btn.save:hover {
|
||||
.toolbar-btn.save:hover {
|
||||
background: var(--color-accent-dark);
|
||||
border-color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.edit-btn.export {
|
||||
.toolbar-btn.export {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-btn.export:hover {
|
||||
.toolbar-btn.export:hover {
|
||||
background: #059669;
|
||||
border-color: #059669;
|
||||
}
|
||||
|
||||
.edit-btn.delete {
|
||||
.toolbar-btn.delete {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-btn.delete:hover {
|
||||
.toolbar-btn.delete:hover {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.edit-btn.cancel {
|
||||
.toolbar-btn.cancel {
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.edit-btn.cancel:hover {
|
||||
.toolbar-btn.cancel:hover {
|
||||
background: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
color: white;
|
||||
@@ -415,6 +522,9 @@
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
max-height: 300px; /* 设置最大高度 */
|
||||
overflow-y: auto;
|
||||
transition: height 0.2s ease; /* 平滑过渡 */
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; // ✅ 保留 useState 用于 exportFormat
|
||||
import { useCharacterStore, useCharacterCardUIStore } from '../../../../Store/SideBarLeft'; // ✅ 新增
|
||||
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
|
||||
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; // 引入世界书 Store
|
||||
import './CharacterCard.css';
|
||||
|
||||
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
|
||||
@@ -54,8 +55,8 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
||||
)}
|
||||
{character.tags && character.tags.length > 0 && (
|
||||
<div className="character-tags">
|
||||
{character.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="tag">{tag}</span>
|
||||
{character.tags.slice(0, 3).map((tag, index) => (
|
||||
<span key={`tag-${character.id}-${tag}-${index}`} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -86,13 +87,14 @@ const CharacterCard = () => {
|
||||
|
||||
// ✅ 从 CharacterCardUIStore 获取 UI 状态
|
||||
const {
|
||||
filterTag,
|
||||
filterTags,
|
||||
isEditing,
|
||||
editForm,
|
||||
currentPage,
|
||||
pageSize,
|
||||
setFilterTag,
|
||||
clearFilter,
|
||||
toggleFilterTag,
|
||||
removeFilterTag,
|
||||
clearAllFilters,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
updateEditForm,
|
||||
@@ -102,13 +104,17 @@ const CharacterCard = () => {
|
||||
prevPage
|
||||
} = useCharacterCardUIStore();
|
||||
|
||||
// 引入世界书 Store
|
||||
const { worldBooks, fetchWorldBooks } = useWorldBookStore();
|
||||
|
||||
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
|
||||
const fileInputRef = useRef(null);
|
||||
const clickTimeoutRef = useRef(null);
|
||||
|
||||
// 加载角色列表
|
||||
// 加载角色列表和世界书列表
|
||||
useEffect(() => {
|
||||
fetchCharacters();
|
||||
fetchWorldBooks(); // 加载世界书列表
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
@@ -121,10 +127,31 @@ const CharacterCard = () => {
|
||||
// 获取所有唯一的 tags
|
||||
const allTags = [...new Set(characters.flatMap(char => char.tags || []))];
|
||||
|
||||
// 过滤角色
|
||||
const filteredCharacters = filterTag
|
||||
? characters.filter(char => (char.tags || []).includes(filterTag))
|
||||
: characters;
|
||||
// 过滤角色(支持多标签交集筛选)
|
||||
const filteredCharacters = React.useMemo(() => {
|
||||
if (filterTags.length === 0) return characters;
|
||||
|
||||
// 分离包含和排除标签
|
||||
const includeTags = filterTags
|
||||
.filter(f => f.startsWith('include:'))
|
||||
.map(f => f.substring(8));
|
||||
|
||||
const excludeTags = filterTags
|
||||
.filter(f => f.startsWith('exclude:'))
|
||||
.map(f => f.substring(8));
|
||||
|
||||
return characters.filter(char => {
|
||||
const charTags = char.tags || [];
|
||||
|
||||
// 检查是否包含所有必须的标签(交集)
|
||||
const hasAllInclude = includeTags.every(tag => charTags.includes(tag));
|
||||
|
||||
// 检查是否不包含所有排除的标签
|
||||
const hasNoExclude = excludeTags.every(tag => !charTags.includes(tag));
|
||||
|
||||
return hasAllInclude && hasNoExclude;
|
||||
});
|
||||
}, [characters, filterTags]);
|
||||
|
||||
// ✅ 计算当前页和总页数
|
||||
const totalPages = Math.ceil(filteredCharacters.length / pageSize) || 1;
|
||||
@@ -314,6 +341,13 @@ const CharacterCard = () => {
|
||||
updateEditForm(field, value); // ✅ 使用 store 方法
|
||||
};
|
||||
|
||||
// 自动调整 textarea 高度
|
||||
const autoResizeTextarea = (e) => {
|
||||
const textarea = e.target;
|
||||
textarea.style.height = 'auto'; // 重置高度
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; // 设置新高度,最大300px
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="character-card-content">
|
||||
{/* 标题栏 */}
|
||||
@@ -338,20 +372,93 @@ const CharacterCard = () => {
|
||||
{allTags.length > 0 && (
|
||||
<div className="tag-filter">
|
||||
<button
|
||||
className={`tag-btn ${!filterTag ? 'active' : ''}`}
|
||||
onClick={() => setFilterTag('')}
|
||||
className={`tag-btn ${filterTags.length === 0 ? 'active' : ''}`}
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{allTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
className={`tag-btn ${filterTag === tag ? 'active' : ''}`}
|
||||
onClick={() => setFilterTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{allTags.map(tag => {
|
||||
// 判断当前标签的状态
|
||||
let status = 'none'; // none, include, exclude
|
||||
const includeFilter = `include:${tag}`;
|
||||
const excludeFilter = `exclude:${tag}`;
|
||||
|
||||
if (filterTags.includes(includeFilter)) {
|
||||
status = 'include';
|
||||
} else if (filterTags.includes(excludeFilter)) {
|
||||
status = 'exclude';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
className={`tag-btn tag-${status}`}
|
||||
onClick={() => toggleFilterTag(tag)}
|
||||
title={
|
||||
status === 'include' ? '包含该标签(再次点击排除)' :
|
||||
status === 'exclude' ? '排除该标签(再次点击取消)' :
|
||||
'筛选包含该标签的角色'
|
||||
}
|
||||
>
|
||||
{status === 'exclude' && <span className="tag-prefix">¬</span>}
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 当前筛选条件显示 */}
|
||||
{filterTags.length > 0 && (
|
||||
<div className="active-filters" style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
padding: '4px 6px',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary)', marginRight: '4px' }}>
|
||||
筛选中:
|
||||
</span>
|
||||
{filterTags.map((filter) => {
|
||||
const mode = filter.startsWith('include:') ? 'include' : 'exclude';
|
||||
const tag = filter.substring(filter.indexOf(':') + 1);
|
||||
return (
|
||||
<span
|
||||
key={`filter-${mode}-${tag}`}
|
||||
className={`active-filter-tag active-filter-${mode}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
background: mode === 'include' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)',
|
||||
border: `1px solid ${mode === 'include' ? '#10b981' : '#ef4444'}`,
|
||||
color: mode === 'include' ? '#10b981' : '#ef4444'
|
||||
}}
|
||||
>
|
||||
{mode === 'exclude' && '¬'}
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => removeFilterTag(tag)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
padding: '0 2px',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1
|
||||
}}
|
||||
title="移除筛选"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -372,21 +479,39 @@ const CharacterCard = () => {
|
||||
{/* 编辑模式 */}
|
||||
{isEditing && selectedCharacter && editForm && (
|
||||
<div className="character-edit-panel">
|
||||
<div className="edit-header">
|
||||
<span className="edit-title">编辑角色: {selectedCharacter.name}</span>
|
||||
<div className="edit-actions">
|
||||
<select
|
||||
className="export-format-selector"
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
>
|
||||
<option value="png">🖼️ 图片</option>
|
||||
<option value="json">📄 JSON</option>
|
||||
</select>
|
||||
<button className="edit-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)}>📤 导出</button>
|
||||
<button className="edit-btn delete" onClick={() => handleDelete(selectedCharacter.name)}>🗑️ 删除</button>
|
||||
<button className="edit-btn save" onClick={handleSaveEdit}>💾 保存</button>
|
||||
<button className="edit-btn cancel" onClick={handleCancelEdit}>❌ 取消</button>
|
||||
{/* 悬浮工具栏 */}
|
||||
<div className="floating-toolbar">
|
||||
<div className="toolbar-content">
|
||||
<div className="toolbar-left">
|
||||
<span className="edit-title">编辑: {selectedCharacter.name}</span>
|
||||
</div>
|
||||
<div className="toolbar-actions">
|
||||
<select
|
||||
className="export-format-selector"
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
title="选择导出格式"
|
||||
>
|
||||
<option value="png">🖼️ PNG</option>
|
||||
<option value="json">📄 JSON</option>
|
||||
</select>
|
||||
<button className="toolbar-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)} title="导出角色卡">
|
||||
📤
|
||||
</button>
|
||||
<button className="toolbar-btn delete" onClick={() => handleDelete(selectedCharacter.name)} title="删除角色">
|
||||
🗑️
|
||||
</button>
|
||||
<button className="toolbar-btn save" onClick={handleSaveEdit} title="保存更改">
|
||||
💾
|
||||
</button>
|
||||
<button className="toolbar-btn cancel" onClick={handleCancelEdit} title="返回列表">
|
||||
←
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 小球指示器 */}
|
||||
<div className="toolbar-indicator">
|
||||
<span className="indicator-icon">⚙️</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -399,13 +524,18 @@ const CharacterCard = () => {
|
||||
onChange={(e) => handleFormChange('name', e.target.value)}
|
||||
placeholder="角色名称"
|
||||
/>
|
||||
<small className="form-hint">💡 提示:修改角色名将同步重命名文件夹和所有相关引用</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) => handleFormChange('description', e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleFormChange('description', e.target.value);
|
||||
autoResizeTextarea(e);
|
||||
}}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder="角色描述"
|
||||
rows={6}
|
||||
/>
|
||||
@@ -415,7 +545,11 @@ const CharacterCard = () => {
|
||||
<label>性格</label>
|
||||
<textarea
|
||||
value={editForm.personality}
|
||||
onChange={(e) => handleFormChange('personality', e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleFormChange('personality', e.target.value);
|
||||
autoResizeTextarea(e);
|
||||
}}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder="角色性格"
|
||||
rows={3}
|
||||
/>
|
||||
@@ -425,7 +559,11 @@ const CharacterCard = () => {
|
||||
<label>场景</label>
|
||||
<textarea
|
||||
value={editForm.scenario}
|
||||
onChange={(e) => handleFormChange('scenario', e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleFormChange('scenario', e.target.value);
|
||||
autoResizeTextarea(e);
|
||||
}}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder="场景设定"
|
||||
rows={3}
|
||||
/>
|
||||
@@ -435,7 +573,11 @@ const CharacterCard = () => {
|
||||
<label>开场白</label>
|
||||
<textarea
|
||||
value={editForm.first_mes}
|
||||
onChange={(e) => handleFormChange('first_mes', e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleFormChange('first_mes', e.target.value);
|
||||
autoResizeTextarea(e);
|
||||
}}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder="第一条消息"
|
||||
rows={4}
|
||||
/>
|
||||
@@ -445,7 +587,11 @@ const CharacterCard = () => {
|
||||
<label>对话示例</label>
|
||||
<textarea
|
||||
value={editForm.mes_example}
|
||||
onChange={(e) => handleFormChange('mes_example', e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleFormChange('mes_example', e.target.value);
|
||||
autoResizeTextarea(e);
|
||||
}}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder="对话示例"
|
||||
rows={4}
|
||||
/>
|
||||
@@ -462,21 +608,57 @@ const CharacterCard = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>动态表格标签 (SillyTavern 关键字机制)</label>
|
||||
<label>绑定世界书</label>
|
||||
<select
|
||||
value={editForm.worldInfoId || ''}
|
||||
onChange={(e) => handleFormChange('worldInfoId', e.target.value || null)}
|
||||
>
|
||||
<option value="">不绑定</option>
|
||||
{worldBooks.map(book => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small className="form-hint">💡 选择后,该角色将自动加载绑定的世界书内容</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>动态表格数据 (SillyTavern 关键字机制)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.tags?.join(', ') || ''}
|
||||
value={Object.entries(editForm.tableDefaults || {}).map(([k, v]) => `${k}:${v}`).join(';')}
|
||||
onChange={(e) => {
|
||||
// 支持多种分隔符:逗号、分号、空格
|
||||
const tagsList = e.target.value
|
||||
.split(/[,;\s]+/)
|
||||
.map(tag => tag.trim())
|
||||
.filter(tag => tag !== '');
|
||||
handleFormChange('tags', tagsList);
|
||||
// 解析格式:key1:value1;key2:value2,key3:value3
|
||||
// 支持分隔符:分号、逗号
|
||||
const entries = e.target.value
|
||||
.split(/[;,]/)
|
||||
.map(pair => pair.trim())
|
||||
.filter(pair => pair !== '')
|
||||
.map(pair => {
|
||||
const [key, ...valueParts] = pair.split(':');
|
||||
const value = valueParts.join(':').trim(); // 支持值中包含冒号
|
||||
return { key: key.trim(), value };
|
||||
})
|
||||
.filter(({ key, value }) => key && value !== undefined);
|
||||
|
||||
// 构建 tableDefaults 对象
|
||||
const tableDefaults = {};
|
||||
const tableHeaders = [];
|
||||
|
||||
entries.forEach(({ key, value }) => {
|
||||
tableHeaders.push(key);
|
||||
// 尝试转换为数字
|
||||
const numValue = Number(value);
|
||||
tableDefaults[key] = isNaN(numValue) ? value : numValue;
|
||||
});
|
||||
|
||||
handleFormChange('tableHeaders', tableHeaders);
|
||||
handleFormChange('tableDefaults', tableDefaults);
|
||||
}}
|
||||
placeholder="力量:80, 敏捷:65, 智力:90, HP:100"
|
||||
placeholder="力量:80;敏捷:65;智力:90;HP:100"
|
||||
/>
|
||||
<small className="form-hint">每个标签是一个键值对,用逗号/分号/空格分隔。双击右侧表格中的标签可编辑。</small>
|
||||
<small className="form-hint">格式:key:value,用分号或逗号分隔。例如:力量:80;敏捷:65,HP:100</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -488,7 +670,7 @@ const CharacterCard = () => {
|
||||
<div className="character-list">
|
||||
{currentPageCharacters.length === 0 ? (
|
||||
<div className="empty-message">
|
||||
{filterTag ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
|
||||
{filterTags.length > 0 ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
|
||||
</div>
|
||||
) : (
|
||||
currentPageCharacters.map(char => (
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
z-index: 1100; /* ✅ 高于 ChatBox (z-index: 1000) */
|
||||
min-width: 140px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ const PresetPanel = () => {
|
||||
top_k: "随机采样范围,从概率最高的K个词中选择",
|
||||
max_ctx: "上下文窗口大小(Token上限)",
|
||||
max_tokens: "单次回复的最大长度",
|
||||
request_timeout: "LLM请求超时时间(秒)",
|
||||
seed: "随机种子(-1为随机)",
|
||||
n: "生成回复的数量"
|
||||
};
|
||||
@@ -90,6 +91,7 @@ const PresetPanel = () => {
|
||||
top_k: "Top-K",
|
||||
max_ctx: "上下文",
|
||||
max_tokens: "MaxTok",
|
||||
request_timeout: "超时",
|
||||
seed: "种子",
|
||||
n: "数量"
|
||||
};
|
||||
@@ -114,7 +116,7 @@ const PresetPanel = () => {
|
||||
let convertedValue = value;
|
||||
if (name === 'temperature' || name === 'freq_penalty' || name === 'pres_penalty' || name === 'top_p') {
|
||||
convertedValue = parseFloat(value);
|
||||
} else if (name === 'top_k' || name === 'max_ctx' || name === 'max_tokens' || name === 'seed' || name === 'n') {
|
||||
} else if (name === 'top_k' || name === 'max_ctx' || name === 'max_tokens' || name === 'request_timeout' || name === 'seed' || name === 'n') {
|
||||
convertedValue = parseInt(value, 10);
|
||||
}
|
||||
|
||||
@@ -186,7 +188,8 @@ const PresetPanel = () => {
|
||||
top_k: importedPreset.topK,
|
||||
frequency_penalty: importedPreset.frequencyPenalty,
|
||||
presence_penalty: importedPreset.presencePenalty,
|
||||
max_tokens: importedPreset.maxLength
|
||||
max_tokens: importedPreset.maxLength,
|
||||
request_timeout: importedPreset.requestTimeout || 60
|
||||
};
|
||||
|
||||
// 转换 entries 为 promptComponents 格式
|
||||
@@ -213,7 +216,8 @@ const PresetPanel = () => {
|
||||
presence_penalty: importedPreset.presence_penalty,
|
||||
top_p: importedPreset.top_p,
|
||||
top_k: importedPreset.top_k,
|
||||
max_tokens: importedPreset.max_tokens || importedPreset.openai_max_tokens
|
||||
max_tokens: importedPreset.max_tokens || importedPreset.openai_max_tokens,
|
||||
request_timeout: importedPreset.request_timeout || 60
|
||||
};
|
||||
importedComponents = importedPreset.promptComponents || [];
|
||||
}
|
||||
@@ -261,6 +265,7 @@ const PresetPanel = () => {
|
||||
frequencyPenalty: parameters.frequency_penalty,
|
||||
presencePenalty: parameters.presence_penalty,
|
||||
maxLength: parameters.max_tokens,
|
||||
requestTimeout: parameters.request_timeout || 60,
|
||||
isDefault: false,
|
||||
|
||||
// PromptPresetView 部分
|
||||
@@ -744,6 +749,25 @@ const PresetPanel = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 请求超时输入框 */}
|
||||
<div className="parameter-row-compact">
|
||||
<label
|
||||
className="parameter-label-compact"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.request_timeout)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
{parameterLabels.request_timeout}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="600"
|
||||
value={parameters.request_timeout || 60}
|
||||
onChange={(e) => handleParameterChange('request_timeout', e.target.value)}
|
||||
className="parameter-input-compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 随机种子输入框 */}
|
||||
<div className="parameter-row-compact">
|
||||
<label
|
||||
|
||||
230
frontend/src/components/SideBarLeft/tabs/Settings/Settings.css
Normal file
230
frontend/src/components/SideBarLeft/tabs/Settings/Settings.css
Normal file
@@ -0,0 +1,230 @@
|
||||
.settings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 消息提示 */
|
||||
.message {
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
border: 1px solid #4caf50;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border: 1px solid #f44336;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
/* 分页标签 */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--accent-color);
|
||||
border-bottom-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 设置区块 */
|
||||
.settings-section {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 设置项 */
|
||||
.setting-item {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.setting-item label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.setting-item input[type="text"],
|
||||
.setting-item input[type="file"] {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.setting-item input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.setting-item small {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.setting-item code {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 文件结构列表 */
|
||||
.file-structure {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.file-structure li {
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 预览框 */
|
||||
.preview-box {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.preview-box .original,
|
||||
.preview-box .processed {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-box .original:last-child,
|
||||
.preview-box .processed:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preview-box strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.preview-box p {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: var(--accent-color);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
266
frontend/src/components/SideBarLeft/tabs/Settings/Settings.jsx
Normal file
266
frontend/src/components/SideBarLeft/tabs/Settings/Settings.jsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './Settings.css';
|
||||
|
||||
/**
|
||||
* 系统设置组件
|
||||
*
|
||||
* 按功能分页展示:
|
||||
* - 正则规则设置
|
||||
* - 思考标签配置
|
||||
* - 其他系统设置
|
||||
*/
|
||||
const Settings = () => {
|
||||
const [activeTab, setActiveTab] = useState('regex');
|
||||
const [settings, setSettings] = useState({
|
||||
thinkingTagPrefix: '<thinking>',
|
||||
thinkingTagSuffix: '</thinking>',
|
||||
currentPresetName: null
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
// 加载系统设置
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/regex/settings');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setSettings(data.settings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载系统设置失败:', error);
|
||||
showMessage('error', '加载设置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/regex/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage('success', '设置已保存');
|
||||
} else {
|
||||
showMessage('error', '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存设置失败:', error);
|
||||
showMessage('error', '保存失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showMessage = (type, text) => {
|
||||
setMessage({ type, text });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
};
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-container">
|
||||
{/* 标题 */}
|
||||
<div className="settings-header">
|
||||
<h2>系统设置</h2>
|
||||
</div>
|
||||
|
||||
{/* 消息提示 */}
|
||||
{message.text && (
|
||||
<div className={`message ${message.type}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页标签 */}
|
||||
<div className="settings-tabs">
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'regex' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('regex')}
|
||||
>
|
||||
正则规则
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'thinking' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('thinking')}
|
||||
>
|
||||
思考标签
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'general' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('general')}
|
||||
>
|
||||
通用设置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="settings-content">
|
||||
{loading && <div className="loading">加载中...</div>}
|
||||
|
||||
{/* 正则规则设置 */}
|
||||
{activeTab === 'regex' && (
|
||||
<div className="settings-section">
|
||||
<h3>正则规则管理</h3>
|
||||
<p className="section-description">
|
||||
管理文本处理规则,支持 SillyTavern 格式导入
|
||||
</p>
|
||||
|
||||
<div className="setting-item">
|
||||
<label>规则文件位置</label>
|
||||
<code>data/regex/</code>
|
||||
<ul className="file-structure">
|
||||
<li>📁 global/ - 全局规则</li>
|
||||
<li>📁 characters/ - 角色卡规则</li>
|
||||
<li>📁 presets/ - 预设规则</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<label>导入规则</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) {
|
||||
showMessage('success', data.message);
|
||||
} else {
|
||||
showMessage('error', '导入失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '导入失败');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>支持 SillyTavern 格式的规则文件</small>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<button className="btn-primary" onClick={() => window.open('/api/regex/export/global', '_blank')}>
|
||||
导出全局规则
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 思考标签配置 */}
|
||||
{activeTab === 'thinking' && (
|
||||
<div className="settings-section">
|
||||
<h3>思考标签配置</h3>
|
||||
<p className="section-description">
|
||||
配置 AI 推理/思考内容的标记标签
|
||||
</p>
|
||||
|
||||
<div className="setting-item">
|
||||
<label htmlFor="thinking-prefix">前缀</label>
|
||||
<input
|
||||
id="thinking-prefix"
|
||||
type="text"
|
||||
value={settings.thinkingTagPrefix}
|
||||
onChange={(e) => handleInputChange('thinkingTagPrefix', e.target.value)}
|
||||
placeholder="<thinking>"
|
||||
/>
|
||||
<small>默认值:<thinking></small>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<label htmlFor="thinking-suffix">后缀</label>
|
||||
<input
|
||||
id="thinking-suffix"
|
||||
type="text"
|
||||
value={settings.thinkingTagSuffix}
|
||||
onChange={(e) => handleInputChange('thinkingTagSuffix', e.target.value)}
|
||||
placeholder="</thinking>"
|
||||
/>
|
||||
<small>默认值:</thinking></small>
|
||||
</div>
|
||||
|
||||
<div className="setting-item preview">
|
||||
<label>预览效果</label>
|
||||
<div className="preview-box">
|
||||
<div className="original">
|
||||
<strong>原始内容:</strong>
|
||||
<p>这是回复 {settings.thinkingTagPrefix}思考过程{settings.thinkingTagSuffix} 继续</p>
|
||||
</div>
|
||||
<div className="processed">
|
||||
<strong>处理后:</strong>
|
||||
<p>这是回复 继续</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
|
||||
{loading ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通用设置 */}
|
||||
{activeTab === 'general' && (
|
||||
<div className="settings-section">
|
||||
<h3>通用设置</h3>
|
||||
<p className="section-description">
|
||||
其他系统级配置
|
||||
</p>
|
||||
|
||||
<div className="setting-item">
|
||||
<label htmlFor="current-preset">当前预设</label>
|
||||
<input
|
||||
id="current-preset"
|
||||
type="text"
|
||||
value={settings.currentPresetName || ''}
|
||||
onChange={(e) => handleInputChange('currentPresetName', e.target.value || null)}
|
||||
placeholder="未选择"
|
||||
/>
|
||||
<small>影响全局正则规则的启用</small>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
|
||||
{loading ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Settings';
|
||||
@@ -0,0 +1,207 @@
|
||||
/* Token Usage Styles */
|
||||
.token-usage-container {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.token-usage-loading,
|
||||
.token-usage-error,
|
||||
.token-usage-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.token-usage-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-usage-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.month-selector {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.token-usage-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-reset:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.total {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-card.prompt {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-card.completion {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Request Stats */
|
||||
.request-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat-item.success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-item.interrupted {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.stat-item.failed {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.daily-stats-section,
|
||||
.role-stats-section,
|
||||
.chat-stats-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.daily-stats-section h4,
|
||||
.role-stats-section h4,
|
||||
.chat-stats-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.daily-stats-list,
|
||||
.role-stats-list,
|
||||
.chat-stats-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.daily-stat-item,
|
||||
.role-stat-item,
|
||||
.chat-stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.day-label,
|
||||
.role-name,
|
||||
.chat-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.day-tokens,
|
||||
.role-tokens,
|
||||
.chat-tokens {
|
||||
margin: 0 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.day-count,
|
||||
.role-count,
|
||||
.chat-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// frontend/src/components/SideBarLeft/tabs/TokenUsage/TokenUsage.jsx
|
||||
import React, { useEffect } from 'react';
|
||||
import useTokenUsageStore from '../../../../Store/SideBarLeft/TokenUsageSlice';
|
||||
import './TokenUsage.css';
|
||||
|
||||
const TokenUsage = () => {
|
||||
const {
|
||||
months,
|
||||
currentMonth,
|
||||
stats,
|
||||
roles,
|
||||
chats,
|
||||
selectedRole,
|
||||
selectedChat,
|
||||
loading,
|
||||
error,
|
||||
fetchMonths,
|
||||
setCurrentMonth,
|
||||
setSelectedRole,
|
||||
setSelectedChat,
|
||||
resetFilters
|
||||
} = useTokenUsageStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMonths();
|
||||
}, []);
|
||||
|
||||
if (loading && !stats) {
|
||||
return <div className="token-usage-loading">加载中...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="token-usage-error">错误: {error}</div>;
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return <div className="token-usage-empty">暂无数据</div>;
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(2) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(2) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="token-usage-container">
|
||||
{/* 月份选择器 */}
|
||||
<div className="token-usage-header">
|
||||
<h3>📊 Token 使用统计</h3>
|
||||
<select
|
||||
className="month-selector"
|
||||
value={currentMonth ? `${currentMonth.year}-${currentMonth.month}` : ''}
|
||||
onChange={(e) => {
|
||||
const [year, month] = e.target.value.split('-').map(Number);
|
||||
setCurrentMonth(year, month);
|
||||
}}
|
||||
>
|
||||
{months.map((m) => (
|
||||
<option key={`${m.year}-${m.month}`} value={`${m.year}-${m.month}`}>
|
||||
{m.year}年{m.month}月
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 筛选器 */}
|
||||
<div className="token-usage-filters">
|
||||
<select
|
||||
className="filter-select"
|
||||
value={selectedRole || ''}
|
||||
onChange={(e) => setSelectedRole(e.target.value || null)}
|
||||
>
|
||||
<option value="">所有角色</option>
|
||||
{roles.map((role) => (
|
||||
<option key={role} value={role}>{role}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="filter-select"
|
||||
value={selectedChat || ''}
|
||||
onChange={(e) => setSelectedChat(e.target.value || null)}
|
||||
>
|
||||
<option value="">所有聊天</option>
|
||||
{chats.map((chat) => (
|
||||
<option key={chat} value={chat}>{chat}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button className="btn-reset" onClick={resetFilters}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="stats-cards">
|
||||
<div className="stat-card total">
|
||||
<div className="stat-label">总 Token</div>
|
||||
<div className="stat-value">{formatNumber(stats.totalTokens)}</div>
|
||||
</div>
|
||||
<div className="stat-card prompt">
|
||||
<div className="stat-label">输入 Token</div>
|
||||
<div className="stat-value">{formatNumber(stats.totalPromptTokens)}</div>
|
||||
</div>
|
||||
<div className="stat-card completion">
|
||||
<div className="stat-label">输出 Token</div>
|
||||
<div className="stat-value">{formatNumber(stats.totalCompletionTokens)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 请求统计 */}
|
||||
<div className="request-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-name">总请求数:</span>
|
||||
<span className="stat-number">{stats.totalRecords}</span>
|
||||
</div>
|
||||
<div className="stat-item success">
|
||||
<span className="stat-name">✅ 成功:</span>
|
||||
<span className="stat-number">{stats.completedCount}</span>
|
||||
</div>
|
||||
<div className="stat-item interrupted">
|
||||
<span className="stat-name">⏸️ 中断:</span>
|
||||
<span className="stat-number">{stats.interruptedCount}</span>
|
||||
</div>
|
||||
<div className="stat-item failed">
|
||||
<span className="stat-name">❌ 失败:</span>
|
||||
<span className="stat-number">{stats.failedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日统计 */}
|
||||
{stats.dailyStats && Object.keys(stats.dailyStats).length > 0 && (
|
||||
<div className="daily-stats-section">
|
||||
<h4>📅 每日统计</h4>
|
||||
<div className="daily-stats-list">
|
||||
{Object.entries(stats.dailyStats)
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.slice(0, 10)
|
||||
.map(([day, data]) => (
|
||||
<div key={day} className="daily-stat-item">
|
||||
<span className="day-label">{day}</span>
|
||||
<span className="day-tokens">{formatNumber(data.totalTokens)} tokens</span>
|
||||
<span className="day-count">{data.count} 次</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 角色统计 */}
|
||||
{stats.roleStats && Object.keys(stats.roleStats).length > 0 && (
|
||||
<div className="role-stats-section">
|
||||
<h4>👤 角色统计</h4>
|
||||
<div className="role-stats-list">
|
||||
{Object.entries(stats.roleStats)
|
||||
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
|
||||
.map(([role, data]) => (
|
||||
<div key={role} className="role-stat-item">
|
||||
<span className="role-name">{role}</span>
|
||||
<span className="role-tokens">{formatNumber(data.totalTokens)} tokens</span>
|
||||
<span className="role-count">{data.count} 次</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 聊天统计 */}
|
||||
{stats.chatStats && Object.keys(stats.chatStats).length > 0 && (
|
||||
<div className="chat-stats-section">
|
||||
<h4>💬 聊天统计</h4>
|
||||
<div className="chat-stats-list">
|
||||
{Object.entries(stats.chatStats)
|
||||
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
|
||||
.slice(0, 10)
|
||||
.map(([chat, data]) => (
|
||||
<div key={chat} className="chat-stat-item">
|
||||
<span className="chat-name">{chat}</span>
|
||||
<span className="chat-tokens">{formatNumber(data.totalTokens)} tokens</span>
|
||||
<span className="chat-count">{data.count} 次</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenUsage;
|
||||
@@ -135,6 +135,10 @@
|
||||
margin-top: 8px; /* 与全局世界书保持间距 */
|
||||
position: relative; /* 确保正常文档流 */
|
||||
z-index: 1; /* 降低层级,不遮挡其他元素 */
|
||||
/* 最小高度 = header(33px) + actions(37px) + selector(37px) + entries最小内容(约200px) */
|
||||
min-height: 307px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.worldbook-header {
|
||||
@@ -183,6 +187,35 @@
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ✅ 排序按钮 - 仿照预设的操作按钮 */
|
||||
.sort-toggle-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sort-toggle-btn:hover {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sort-toggle-btn:active {
|
||||
transform: scale(0.95) translateY(0);
|
||||
}
|
||||
|
||||
/* 世界书选择区域 */
|
||||
.worldbook-selector {
|
||||
display: flex;
|
||||
@@ -320,7 +353,7 @@
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
min-height: 65px;
|
||||
min-height: 40px; /* ✅ 减小高度,单行显示 */
|
||||
}
|
||||
|
||||
.entry-item-compact:hover {
|
||||
@@ -334,17 +367,21 @@
|
||||
border-left: 2px solid var(--color-accent);
|
||||
}
|
||||
|
||||
/* 第一行:名称 + Token + 开关 */
|
||||
.entry-row-top {
|
||||
.entry-item-compact.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 单行布局容器 */
|
||||
.entry-row-single {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entry-name-compact {
|
||||
flex: 1;
|
||||
flex: 1; /* ✅ 占据剩余空间 */
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-primary);
|
||||
@@ -355,28 +392,24 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-tokens {
|
||||
/* Token数量(仅数字)- 固定宽度容纳6位数字 */
|
||||
.entry-tokens-inline {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
font-weight: 600;
|
||||
padding: 2px 5px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
width: 45px; /* ✅ 固定宽度,可容纳6位数字 */
|
||||
text-align: right; /* ✅ 右对齐数字 */
|
||||
}
|
||||
|
||||
/* 第二行:插入位置下拉框 */
|
||||
.entry-row-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.position-select {
|
||||
width: 100%;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
/* 内联位置下拉框 - 固定在右侧 */
|
||||
.position-select-inline {
|
||||
padding: 2px 4px;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -384,62 +417,66 @@
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.15s ease;
|
||||
width: 70px; /* ✅ 去掉箭头后更窄 */
|
||||
flex-shrink: 0;
|
||||
appearance: none; /* ✅ 隐藏默认箭头 */
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.position-select:hover {
|
||||
.position-select-inline:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.position-select:focus {
|
||||
.position-select-inline:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
/* 开关组件 */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
/* 状态指示器 - 小圆点 - 固定在右侧(点击切换启用/禁用) */
|
||||
.status-dot {
|
||||
width: 12px; /* ✅ 稍微大一点,更容易点击 */
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3); /* ✅ 更明显的边框 */
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: var(--color-accent);
|
||||
.status-dot.enabled {
|
||||
background: #10b981; /* 绿色 - 启用 */
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5); /* ✅ 更强的发光 */
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(16px);
|
||||
.status-dot.enabled:hover {
|
||||
background: #059669;
|
||||
transform: scale(1.3); /* ✅ 更大的放大效果 */
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
|
||||
.status-dot.enabled:active {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.status-dot.disabled {
|
||||
background: #6b7280; /* 灰色 - 禁用 */
|
||||
opacity: 0.4;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.status-dot.disabled:hover {
|
||||
background: #4b5563;
|
||||
opacity: 0.7;
|
||||
transform: scale(1.3);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.status-dot.disabled:active {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 分页导航 */
|
||||
@@ -626,8 +663,8 @@
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -642,48 +679,85 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-panel-header h2 {
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.saving-indicator {
|
||||
font-size: 12px;
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--color-danger-light);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.edit-panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 美化滚动条 */
|
||||
.edit-panel-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.edit-panel-content::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.edit-panel-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-panel-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scrollbar-hover);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@@ -710,27 +784,33 @@
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 8px 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
transition: all 0.15s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:hover {
|
||||
border-color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-light);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
min-height: 250px;
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
font-family: inherit;
|
||||
line-height: 1.8;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.trigger-config-row {
|
||||
@@ -739,6 +819,34 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ✅ 编辑面板底部操作栏 */
|
||||
.edit-panel-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-panel-footer .btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-panel-footer .btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.edit-panel-footer .btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.trigger-selector {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
@@ -816,3 +924,90 @@
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
/* ✅ 排序下拉菜单 - 仿照预设的操作下拉菜单 */
|
||||
.sort-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1100;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.sort-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sort-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 4px 8px 2px;
|
||||
}
|
||||
|
||||
.sort-option {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sort-option:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sort-option.active {
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sort-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border-light);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sort-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sort-checkbox-label:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sort-checkbox-label input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -68,10 +68,34 @@ const WorldBook = () => {
|
||||
const [showWorldBookDropdown, setShowWorldBookDropdown] = useState(false);
|
||||
const [activeTriggerStrategy, setActiveTriggerStrategy] = useState('constant');
|
||||
|
||||
// ✅ 性能优化:使用本地状态暂存编辑内容,避免频繁API调用
|
||||
const [localEntry, setLocalEntry] = useState(null);
|
||||
const saveTimeoutRef = React.useRef(null);
|
||||
const [isSaving, setIsSaving] = useState(false); // ✅ 保存状态指示
|
||||
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20); // 默认每页20条
|
||||
|
||||
// ✅ 排序状态
|
||||
const [sortBy, setSortBy] = useState('order'); // 'name' | 'name_desc' | 'order'
|
||||
const [activeFirst, setActiveFirst] = useState(false); // 激活条目优先
|
||||
const [showSortMenu, setShowSortMenu] = useState(false); // 显示排序菜单
|
||||
|
||||
// ✅ 点击外部关闭排序下拉菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (showSortMenu && !event.target.closest('.sort-settings-container')) {
|
||||
setShowSortMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showSortMenu]);
|
||||
|
||||
// 获取当前激活的分页
|
||||
const { activeTab } = useSideBarLeftStore();
|
||||
|
||||
@@ -246,6 +270,23 @@ const WorldBook = () => {
|
||||
setShowEditPanel(true);
|
||||
};
|
||||
|
||||
// ✅ 处理条目快速更新(位置和启用状态)
|
||||
const handleEntryUpdate = async (field, value) => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
const updatedEntry = { ...currentEntry, [field]: value };
|
||||
|
||||
try {
|
||||
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
|
||||
setCurrentEntry(updatedEntry);
|
||||
// 更新成功后刷新当前页
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
console.log(`[WorldBook] ✅ 快速更新 ${field} 成功`);
|
||||
} catch (err) {
|
||||
console.error(`[WorldBook] ❌ 快速更新 ${field} 失败:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = async (newPage) => {
|
||||
if (!currentWorldBook) return;
|
||||
@@ -269,22 +310,41 @@ const WorldBook = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEntryUpdate = async (field, value) => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
const updatedEntry = {...currentEntry, [field]: value};
|
||||
try {
|
||||
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
|
||||
setCurrentEntry(updatedEntry);
|
||||
// 更新成功后刷新当前页
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
} catch (err) {
|
||||
console.error('更新条目失败:', err);
|
||||
// ✅ 性能优化:防抖保存,500ms后自动保存
|
||||
const debouncedSave = React.useCallback((updatedEntry) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
setIsSaving(true); // ✅ 显示保存中状态
|
||||
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
if (!currentWorldBook || !updatedEntry) return;
|
||||
|
||||
try {
|
||||
await updateWorldBookEntry(currentWorldBook.name, updatedEntry.uid, updatedEntry);
|
||||
setCurrentEntry(updatedEntry);
|
||||
// 更新成功后刷新当前页
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
console.log('[WorldBook] ✅ 自动保存成功');
|
||||
} catch (err) {
|
||||
console.error('[WorldBook] ❌ 自动保存失败:', err);
|
||||
} finally {
|
||||
setIsSaving(false); // ✅ 隐藏保存中状态
|
||||
}
|
||||
}, 500); // 500ms 防抖
|
||||
}, [currentWorldBook, currentPage, pageSize, updateWorldBookEntry, fetchWorldBookEntries]);
|
||||
|
||||
const handleLocalEntryChange = (field, value) => {
|
||||
if (!localEntry) return;
|
||||
|
||||
const updatedEntry = { ...localEntry, [field]: value };
|
||||
setLocalEntry(updatedEntry);
|
||||
debouncedSave(updatedEntry); // ✅ 防抖保存
|
||||
};
|
||||
|
||||
const handleTriggerStrategyChange = async (strategy) => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
if (!localEntry || !currentWorldBook) return;
|
||||
|
||||
setActiveTriggerStrategy(strategy);
|
||||
|
||||
@@ -317,34 +377,30 @@ const WorldBook = () => {
|
||||
}
|
||||
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: updatedTriggers
|
||||
};
|
||||
|
||||
try {
|
||||
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, {
|
||||
...currentEntry,
|
||||
trigger_config: updatedTriggerConfig
|
||||
});
|
||||
setCurrentEntry({
|
||||
...currentEntry,
|
||||
trigger_config: updatedTriggerConfig
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('更新触发策略失败:', err);
|
||||
}
|
||||
const updatedEntry = {
|
||||
...localEntry,
|
||||
trigger_config: updatedTriggerConfig
|
||||
};
|
||||
|
||||
setLocalEntry(updatedEntry);
|
||||
debouncedSave(updatedEntry); // ✅ 防抖保存
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async () => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
if (!localEntry || !currentWorldBook) return;
|
||||
|
||||
if (!window.confirm('确定要删除此条目吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
|
||||
await deleteWorldBookEntry(currentWorldBook.name, localEntry.uid);
|
||||
setShowEditPanel(false);
|
||||
setLocalEntry(null); // ✅ 清空本地状态
|
||||
setCurrentEntry(null);
|
||||
// 删除成功后刷新当前页
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
@@ -493,6 +549,51 @@ const WorldBook = () => {
|
||||
return positions[position] || positions[0];
|
||||
};
|
||||
|
||||
// ✅ 排序函数
|
||||
const getSortedEntries = () => {
|
||||
if (!currentEntries || currentEntries.length === 0) return [];
|
||||
|
||||
let sorted = [...currentEntries];
|
||||
|
||||
// 1. 先应用激活条目优先(独立于其他排序)
|
||||
if (activeFirst) {
|
||||
sorted.sort((a, b) => {
|
||||
const aActive = !a.disable;
|
||||
const bActive = !b.disable;
|
||||
if (aActive && !bActive) return -1;
|
||||
if (!aActive && bActive) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 再应用主排序规则
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
// 文件名顺序(按comment或key)
|
||||
sorted.sort((a, b) => {
|
||||
const nameA = (a.comment || a.key?.join(',') || '').toLowerCase();
|
||||
const nameB = (b.comment || b.key?.join(',') || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB, 'zh-CN');
|
||||
});
|
||||
break;
|
||||
case 'name_desc':
|
||||
// 文件名逆序
|
||||
sorted.sort((a, b) => {
|
||||
const nameA = (a.comment || a.key?.join(',') || '').toLowerCase();
|
||||
const nameB = (b.comment || b.key?.join(',') || '').toLowerCase();
|
||||
return nameB.localeCompare(nameA, 'zh-CN');
|
||||
});
|
||||
break;
|
||||
case 'order':
|
||||
default:
|
||||
// 插入顺序(按order字段)
|
||||
sorted.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="worldbook-content">
|
||||
{/* 全局世界书区域 */}
|
||||
@@ -543,6 +644,68 @@ const WorldBook = () => {
|
||||
📤 导出
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ✅ 排序设置按钮 - 仿照预设的操作下拉菜单 */}
|
||||
{currentWorldBook && (
|
||||
<div className="sort-settings-container" style={{position: 'relative', marginLeft: 'auto'}}>
|
||||
<button
|
||||
className="sort-toggle-btn"
|
||||
onClick={() => setShowSortMenu(!showSortMenu)}
|
||||
title="排序设置"
|
||||
>
|
||||
🔀
|
||||
</button>
|
||||
|
||||
{/* 排序下拉菜单 */}
|
||||
{showSortMenu && (
|
||||
<div className="sort-dropdown-menu">
|
||||
<div className="sort-section">
|
||||
<div className="sort-label">主排序</div>
|
||||
<button
|
||||
className={`sort-option ${sortBy === 'order' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSortBy('order');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
>
|
||||
📋 插入顺序
|
||||
</button>
|
||||
<button
|
||||
className={`sort-option ${sortBy === 'name' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSortBy('name');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
>
|
||||
🔤 名称升序
|
||||
</button>
|
||||
<button
|
||||
className={`sort-option ${sortBy === 'name_desc' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSortBy('name_desc');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
>
|
||||
🔤 名称降序
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sort-divider"></div>
|
||||
|
||||
<div className="sort-section">
|
||||
<label className="sort-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeFirst}
|
||||
onChange={(e) => setActiveFirst(e.target.checked)}
|
||||
/>
|
||||
<span>🟢 激活条目优先</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 世界书选择区域 */}
|
||||
@@ -598,8 +761,8 @@ const WorldBook = () => {
|
||||
<div className="error">{error}</div>
|
||||
) : currentWorldBook ? (
|
||||
<div className="entries-container">
|
||||
{currentEntries.length > 0 ? (
|
||||
currentEntries.map(entry => {
|
||||
{getSortedEntries().length > 0 ? (
|
||||
getSortedEntries().map(entry => {
|
||||
// 计算 token 数(估算)
|
||||
const tokenCount = Math.ceil((entry.content?.length || 0) / 4);
|
||||
|
||||
@@ -610,41 +773,25 @@ const WorldBook = () => {
|
||||
return (
|
||||
<div
|
||||
key={entry.uid}
|
||||
className={`entry-item-compact ${currentEntry?.uid === entry.uid ? 'active' : ''}`}
|
||||
className={`entry-item-compact ${currentEntry?.uid === entry.uid ? 'active' : ''} ${isDisabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
>
|
||||
{/* 第一行:条目名称 + Token数 + 开关 */}
|
||||
<div className="entry-row-top">
|
||||
{/* 单行布局:名称 + Token数 + 位置下拉框 + 状态指示器 */}
|
||||
<div className="entry-row-single">
|
||||
<span className="entry-name-compact">
|
||||
{entry.comment || entry.key?.join(', ') || `条目 #${entry.uid.substring(0, 8)}`}
|
||||
</span>
|
||||
|
||||
<span className="entry-tokens">
|
||||
{tokenCount} tokens
|
||||
{/* Token数量(仅数字) */}
|
||||
<span className="entry-tokens-inline">
|
||||
{tokenCount}
|
||||
</span>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<label
|
||||
className="toggle-switch"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntryUpdate('disable', !e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 第二行:插入位置下拉框 */}
|
||||
<div className="entry-row-bottom" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 插入位置下拉框 */}
|
||||
<select
|
||||
className="position-select"
|
||||
className="position-select-inline"
|
||||
value={entry.position || 0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntryUpdate('position', parseInt(e.target.value));
|
||||
@@ -659,6 +806,16 @@ const WorldBook = () => {
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
|
||||
{/* 启用状态指示器 - 小圆点 */}
|
||||
<div
|
||||
className={`status-dot ${isEnabled ? 'enabled' : 'disabled'}`}
|
||||
title={isEnabled ? '已启用' : '已禁用'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntryUpdate('disable', !isEnabled);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -717,11 +874,11 @@ const WorldBook = () => {
|
||||
onClick={() => setShowEditPanel(false)}/>
|
||||
|
||||
{/* 编辑面板 */}
|
||||
{showEditPanel && currentEntry && (
|
||||
{showEditPanel && localEntry && (
|
||||
<div className={`edit-panel ${showEditPanel ? 'open' : ''}`}>
|
||||
{/* 头部 */}
|
||||
<div className="edit-panel-header">
|
||||
<h2>编辑条目 - UID: {currentEntry.uid}</h2>
|
||||
<h2>编辑条目 - UID: {localEntry.uid} {isSaving && <span className="saving-indicator">💾 保存中...</span>}</h2>
|
||||
<button className="close-btn" onClick={() => setShowEditPanel(false)}>
|
||||
✕
|
||||
</button>
|
||||
@@ -736,8 +893,8 @@ const WorldBook = () => {
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={currentEntry.comment || ''}
|
||||
onChange={(e) => handleEntryUpdate('comment', e.target.value)}
|
||||
value={localEntry.comment || ''}
|
||||
onChange={(e) => handleLocalEntryChange('comment', e.target.value)}
|
||||
placeholder="可选的备注名称"
|
||||
/>
|
||||
</div>
|
||||
@@ -746,8 +903,8 @@ const WorldBook = () => {
|
||||
<label className="form-label">插入位置</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={currentEntry.position || 0}
|
||||
onChange={(e) => handleEntryUpdate('position', parseInt(e.target.value))}
|
||||
value={localEntry.position || 0}
|
||||
onChange={(e) => handleLocalEntryChange('position', parseInt(e.target.value))}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5].map(pos => {
|
||||
const info = getPositionInfo(pos);
|
||||
@@ -765,8 +922,8 @@ const WorldBook = () => {
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={currentEntry.order || 100}
|
||||
onChange={(e) => handleEntryUpdate('order', parseInt(e.target.value))}
|
||||
value={localEntry.order || 100}
|
||||
onChange={(e) => handleLocalEntryChange('order', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -775,8 +932,8 @@ const WorldBook = () => {
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={currentEntry.depth || 4}
|
||||
onChange={(e) => handleEntryUpdate('depth', parseInt(e.target.value))}
|
||||
value={localEntry.depth || 4}
|
||||
onChange={(e) => handleLocalEntryChange('depth', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -786,8 +943,8 @@ const WorldBook = () => {
|
||||
<label className="form-label">条目内容</label>
|
||||
<textarea
|
||||
className="form-input content-textarea"
|
||||
value={currentEntry.content || ''}
|
||||
onChange={(e) => handleEntryUpdate('content', e.target.value)}
|
||||
value={localEntry.content || ''}
|
||||
onChange={(e) => handleLocalEntryChange('content', e.target.value)}
|
||||
placeholder="在此输入世界书条目的内容..."
|
||||
/>
|
||||
</div>
|
||||
@@ -818,22 +975,22 @@ const WorldBook = () => {
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.key?.join(', ') || ''}
|
||||
value={localEntry.trigger_config?.triggers?.keyword?.[1]?.key?.join(', ') || ''}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
...localEntry.trigger_config?.triggers,
|
||||
keyword: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
...localEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
key: e.target.value.split(',').map(k => k.trim()).filter(k => k)
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
placeholder="例如:魔法, 火焰, 冰霜"
|
||||
/>
|
||||
@@ -844,22 +1001,22 @@ const WorldBook = () => {
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.keysecondary?.join(', ') || ''}
|
||||
value={localEntry.trigger_config?.triggers?.keyword?.[1]?.keysecondary?.join(', ') || ''}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
...localEntry.trigger_config?.triggers,
|
||||
keyword: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
...localEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
keysecondary: e.target.value.split(',').map(k => k.trim()).filter(k => k)
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
placeholder="可选的过滤关键词"
|
||||
/>
|
||||
@@ -869,22 +1026,22 @@ const WorldBook = () => {
|
||||
<label className="form-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.selective || false}
|
||||
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.selective || false}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
...localEntry.trigger_config?.triggers,
|
||||
keyword: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
...localEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
selective: e.target.checked
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
选择性匹配
|
||||
@@ -893,22 +1050,22 @@ const WorldBook = () => {
|
||||
<label className="form-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.matchWholeWords || false}
|
||||
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.matchWholeWords || false}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
...localEntry.trigger_config?.triggers,
|
||||
keyword: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
...localEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
matchWholeWords: e.target.checked
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
全词匹配
|
||||
@@ -917,22 +1074,22 @@ const WorldBook = () => {
|
||||
<label className="form-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.caseSensitive || false}
|
||||
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.caseSensitive || false}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
...localEntry.trigger_config?.triggers,
|
||||
keyword: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
...localEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
caseSensitive: e.target.checked
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
区分大小写
|
||||
|
||||
@@ -53,6 +53,26 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
padding: 0; /* ✅ 移除padding,滚动条作为间隔 */
|
||||
}
|
||||
|
||||
/* ✅ 美化滚动条 - 符合主题 */
|
||||
.sidebar-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scrollbar-hover);
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
@@ -73,7 +93,6 @@
|
||||
|
||||
.tab-content {
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 分割线样式 */
|
||||
@@ -121,7 +140,6 @@
|
||||
.split-top,
|
||||
.split-bottom {
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 未选择分页提示 */
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import './SideBarRight.css';
|
||||
import Dice from './tabs/Dice';
|
||||
import Debug from './tabs/Debug';
|
||||
import Macros from './tabs/Macros';
|
||||
import Table from './tabs/Table';
|
||||
import RagRecall from './tabs/RagRecall';
|
||||
import WorldBookActive from './tabs/WorldBookActive'; // ✅ 世界书激活显示
|
||||
import Tasks from './tabs/Tasks'; // ✅ 任务队列分页
|
||||
import useSideBarRightStore from '../../Store/SideBarRight/SideBarRightSlice';
|
||||
|
||||
const SideBarRight = () => {
|
||||
@@ -22,10 +23,11 @@ const SideBarRight = () => {
|
||||
// 设置标签组件
|
||||
useEffect(() => {
|
||||
setTabComponent('dice', Dice);
|
||||
setTabComponent('debug', Debug);
|
||||
setTabComponent('macros', Macros);
|
||||
setTabComponent('table', Table);
|
||||
setTabComponent('rag', RagRecall);
|
||||
setTabComponent('worldbook_active', WorldBookActive); // ✅ 注册世界书激活组件
|
||||
setTabComponent('tasks', Tasks); // ✅ 注册任务队列组件
|
||||
}, [setTabComponent]);
|
||||
|
||||
// 保存分割线位置到 localStorage
|
||||
@@ -100,7 +102,7 @@ const SideBarRight = () => {
|
||||
<div className="tab-content full-height">
|
||||
{(() => {
|
||||
const tab = allTabs.find(t => t.id === selectedTabs[0]);
|
||||
return tab.component ? <tab.component /> : <div>{tab.label}内容</div>;
|
||||
return tab && tab.component ? <tab.component /> : <div>{tab?.label || '未知'}内容</div>;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
@@ -112,7 +114,7 @@ const SideBarRight = () => {
|
||||
>
|
||||
{(() => {
|
||||
const tab = allTabs.find(t => t.id === selectedTabs[0]);
|
||||
return tab.component ? <tab.component /> : <div>{tab.label}内容</div>;
|
||||
return tab && tab.component ? <tab.component /> : <div>{tab?.label || '未知'}内容</div>;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
@@ -130,7 +132,7 @@ const SideBarRight = () => {
|
||||
>
|
||||
{(() => {
|
||||
const tab = allTabs.find(t => t.id === selectedTabs[1]);
|
||||
return tab.component ? <tab.component /> : <div>{tab.label}内容</div>;
|
||||
return tab && tab.component ? <tab.component /> : <div>{tab?.label || '未知'}内容</div>;
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const Debug = () => {
|
||||
return (
|
||||
<div className="debug-panel">
|
||||
<h2>上下文调试</h2>
|
||||
<p>这是上下文调试面板的占位页面</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Debug;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Debug';
|
||||
152
frontend/src/components/SideBarRight/tabs/Tasks/Tasks.css
Normal file
152
frontend/src/components/SideBarRight/tabs/Tasks/Tasks.css
Normal file
@@ -0,0 +1,152 @@
|
||||
.tasks-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.task-count {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-clear:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tasks-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.task-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-status-badge {
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.status-running {
|
||||
color: var(--color-primary);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-cancel-task {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-cancel-task:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
73
frontend/src/components/SideBarRight/tabs/Tasks/Tasks.jsx
Normal file
73
frontend/src/components/SideBarRight/tabs/Tasks/Tasks.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// frontend/src/components/SideBarRight/tabs/Tasks/Tasks.jsx
|
||||
import React from 'react';
|
||||
import './Tasks.css';
|
||||
import useTasksStore from '../../../../Store/SideBarRight/TasksSlice';
|
||||
|
||||
const Tasks = () => {
|
||||
const { tasks, clearCompleted, cancelTask } = useTasksStore();
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="tasks-content">
|
||||
<div className="tab-header">
|
||||
<span className="title-text">任务队列</span>
|
||||
<span className="task-count">0</span>
|
||||
</div>
|
||||
<div className="empty-state">
|
||||
<p>暂无进行中的任务</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tasks-content">
|
||||
<div className="tab-header">
|
||||
<span className="title-text">任务队列</span>
|
||||
<span className="task-count">{tasks.length}</span>
|
||||
<button
|
||||
className="btn-clear"
|
||||
onClick={clearCompleted}
|
||||
title="清理已完成任务"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tasks-list">
|
||||
{tasks.map(task => (
|
||||
<div key={task.taskId} className={`task-item ${task.status}`}>
|
||||
<div className="task-header">
|
||||
<span className="task-icon">
|
||||
{task.taskType === 'image_workflow' ? '🖼️' : '📊'}
|
||||
</span>
|
||||
<span className="task-name">
|
||||
{task.taskType === 'image_workflow' ? '生图任务' : '表格维护'}
|
||||
</span>
|
||||
<span className="task-status-badge">
|
||||
{task.status === 'pending' && <span className="status-pending">⏸️ 等待</span>}
|
||||
{task.status === 'running' && <span className="status-running">⏳ 进行中</span>}
|
||||
{task.status === 'completed' && <span className="status-completed">✓ 完成</span>}
|
||||
{task.status === 'failed' && <span className="status-failed">✗ 失败</span>}
|
||||
{task.status === 'cancelled' && <span className="status-cancelled">⊘ 取消</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{task.status === 'running' && (
|
||||
<div className="task-actions">
|
||||
<button
|
||||
className="btn-cancel-task"
|
||||
onClick={() => cancelTask(task.taskId)}
|
||||
>
|
||||
终止
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tasks;
|
||||
1
frontend/src/components/SideBarRight/tabs/Tasks/index.js
Normal file
1
frontend/src/components/SideBarRight/tabs/Tasks/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Tasks';
|
||||
@@ -0,0 +1,152 @@
|
||||
.worldbook-active-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.entry-count {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-clear:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.active-entries-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position-group {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.position-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.position-number {
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.position-name {
|
||||
flex: 1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.position-count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.active-entry-item {
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.active-entry-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.entry-uid {
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.entry-depth {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.entry-keywords {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.keyword-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(var(--color-primary-rgb), 0.1);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 3px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.trigger-type {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.entry-preview {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// frontend-react/src/components/SideBarRight/tabs/WorldBookActive/WorldBookActive.jsx
|
||||
import React from 'react';
|
||||
import './WorldBookActive.css';
|
||||
import useWorldBookActiveStore from '../../../../Store/SideBarRight/WorldBookActiveSlice';
|
||||
|
||||
const WorldBookActive = () => {
|
||||
const { activeEntries, clearEntries } = useWorldBookActiveStore();
|
||||
|
||||
if (activeEntries.length === 0) {
|
||||
return (
|
||||
<div className="worldbook-active-content">
|
||||
<div className="tab-header">
|
||||
<span className="title-text">世界书激活</span>
|
||||
<span className="entry-count">0</span>
|
||||
</div>
|
||||
<div className="empty-state">
|
||||
<p>暂无激活的世界书条目</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 按位置分组显示
|
||||
const groupedEntries = activeEntries.reduce((acc, entry) => {
|
||||
const pos = entry.position || 0;
|
||||
if (!acc[pos]) {
|
||||
acc[pos] = [];
|
||||
}
|
||||
acc[pos].push(entry);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 位置标签映射
|
||||
const positionLabels = {
|
||||
0: '世界书之前',
|
||||
1: '世界书之后',
|
||||
2: '示例对话之前',
|
||||
3: '示例对话之后',
|
||||
4: '作者注释(顶部)',
|
||||
5: '作者注释(底部)',
|
||||
6: '深度插入',
|
||||
7: '宏替换'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="worldbook-active-content">
|
||||
{/* 标题栏 */}
|
||||
<div className="tab-header">
|
||||
<span className="title-text">世界书激活</span>
|
||||
<span className="entry-count">{activeEntries.length}</span>
|
||||
<button
|
||||
className="btn-clear"
|
||||
onClick={clearEntries}
|
||||
title="清空显示"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 激活条目列表 */}
|
||||
<div className="active-entries-list">
|
||||
{Object.entries(groupedEntries).map(([position, entries]) => (
|
||||
<div key={position} className="position-group">
|
||||
<div className="position-label">
|
||||
<span className="position-number">Pos {position}</span>
|
||||
<span className="position-name">{positionLabels[position] || `位置 ${position}`}</span>
|
||||
<span className="position-count">{entries.length} 条</span>
|
||||
</div>
|
||||
|
||||
{entries.map((entry, index) => (
|
||||
<div key={`${entry.uid}-${index}`} className="active-entry-item">
|
||||
<div className="entry-header">
|
||||
<span className="entry-uid">UID: {entry.uid}</span>
|
||||
<span className="entry-depth">Depth: {entry.depth || 0}</span>
|
||||
</div>
|
||||
<div className="entry-keywords">
|
||||
{entry.triggerKeywords && entry.triggerKeywords.length > 0 ? (
|
||||
<span className="keyword-tag">
|
||||
🔑 {entry.triggerKeywords.join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="trigger-type">
|
||||
{entry.triggerType === 'constant' ? '📌 常驻' :
|
||||
entry.triggerType === 'condition' ? '⚙️ 条件' : '❓ 未知'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="entry-preview">
|
||||
{entry.contentPreview || entry.content?.substring(0, 100) || '(无内容)'}
|
||||
{entry.content?.length > 100 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorldBookActive;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './WorldBookActive';
|
||||
@@ -330,3 +330,160 @@
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==================== 设置面板专用样式 ==================== */
|
||||
|
||||
.settings-panel .panel-body {
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.settings-tabs .tab-button {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.settings-tabs .tab-button:hover {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.settings-tabs .tab-button.active {
|
||||
color: white;
|
||||
border-bottom-color: var(--color-accent);
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
border: 1px solid #4caf50;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border: 1px solid #f44336;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover, #0056b3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 设置面板输入框 */
|
||||
.settings-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.settings-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.settings-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 设置面板代码块 */
|
||||
.settings-code {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--color-accent);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 设置面板文件列表 */
|
||||
.settings-file-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.settings-file-list li {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 设置面板提示文字 */
|
||||
.settings-hint {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 设置面板预览框 */
|
||||
.preview-box {
|
||||
padding: 12px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,16 @@ const Toolbar = () => {
|
||||
const { activeMap, fetchProfile } = useApiConfigStore();
|
||||
const [coreModel, setCoreModel] = useState('未设置');
|
||||
const [assistModel, setAssistModel] = useState('未设置');
|
||||
|
||||
// 系统设置状态
|
||||
const [settings, setSettings] = useState({
|
||||
thinkingTagPrefix: '<thinking>',
|
||||
thinkingTagSuffix: '</thinking>',
|
||||
currentPresetName: null
|
||||
});
|
||||
const [settingsLoading, setSettingsLoading] = useState(false);
|
||||
const [settingsMessage, setSettingsMessage] = useState({ type: '', text: '' });
|
||||
const [settingsActiveTab, setSettingsActiveTab] = useState('display'); // 默认显示分页
|
||||
|
||||
// 点击外部关闭面板 - 使用 useCallback 优化
|
||||
const handleClickOutside = React.useCallback((event) => {
|
||||
@@ -43,6 +53,68 @@ const Toolbar = () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [handleClickOutside]);
|
||||
|
||||
// 加载系统设置
|
||||
useEffect(() => {
|
||||
if (activePanel === 'settings') {
|
||||
loadSettings();
|
||||
}
|
||||
}, [activePanel]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setSettingsLoading(true);
|
||||
const response = await fetch('/api/regex/settings');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setSettings(data.settings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载系统设置失败:', error);
|
||||
showSettingsMessage('error', '加载设置失败');
|
||||
} finally {
|
||||
setSettingsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
setSettingsLoading(true);
|
||||
const response = await fetch('/api/regex/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSettingsMessage('success', '设置已保存');
|
||||
} else {
|
||||
showSettingsMessage('error', '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存设置失败:', error);
|
||||
showSettingsMessage('error', '保存失败');
|
||||
} finally {
|
||||
setSettingsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showSettingsMessage = (type, text) => {
|
||||
setSettingsMessage({ type, text });
|
||||
setTimeout(() => setSettingsMessage({ type: '', text: '' }), 3000);
|
||||
};
|
||||
|
||||
const handleSettingsInputChange = (field, value) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// ✅ 简化:直接调用 store 方法
|
||||
const handleSidebarModeChange = (mode) => {
|
||||
@@ -170,55 +242,231 @@ const Toolbar = () => {
|
||||
{/* 设置面板 - 使用条件渲染优化 */}
|
||||
{activePanel === 'settings' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-content settings-panel">
|
||||
<div className="panel-header">
|
||||
<h3>系统设置</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{/* 左侧栏模式 */}
|
||||
<div className="setting-section">
|
||||
<h4>左侧栏模式</h4>
|
||||
<div className="setting-options">
|
||||
<label className={`setting-option ${sidebarMode === 'fixed' ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="sidebarMode"
|
||||
value="fixed"
|
||||
checked={sidebarMode === 'fixed'}
|
||||
onChange={(e) => handleSidebarModeChange(e.target.value)} // ✅ 使用 store 方法
|
||||
/>
|
||||
<span>固定</span>
|
||||
</label>
|
||||
<label className={`setting-option ${sidebarMode === 'smart' ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="sidebarMode"
|
||||
value="smart"
|
||||
checked={sidebarMode === 'smart'}
|
||||
onChange={(e) => handleSidebarModeChange(e.target.value)} // ✅ 使用 store 方法
|
||||
/>
|
||||
<span>智能</span>
|
||||
</label>
|
||||
<label className={`setting-option ${sidebarMode === 'expanded' ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="sidebarMode"
|
||||
value="expanded"
|
||||
checked={sidebarMode === 'expanded'}
|
||||
onChange={(e) => handleSidebarModeChange(e.target.value)} // ✅ 使用 store 方法
|
||||
/>
|
||||
<span>扩展</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
{sidebarMode === 'fixed' && '左侧栏保持默认宽度,不响应交互'}
|
||||
{sidebarMode === 'smart' && '鼠标悬停时自动展开,移开后收起'}
|
||||
{sidebarMode === 'expanded' && '左侧栏保持最大宽度'}
|
||||
</p>
|
||||
|
||||
{/* 消息提示 */}
|
||||
{settingsMessage.text && (
|
||||
<div className={`message ${settingsMessage.type}`} style={{ margin: '10px', padding: '8px', borderRadius: '4px' }}>
|
||||
{settingsMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页标签 */}
|
||||
<div className="settings-tabs">
|
||||
<button
|
||||
className={`tab-button ${settingsActiveTab === 'display' ? 'active' : ''}`}
|
||||
onClick={() => setSettingsActiveTab('display')}
|
||||
>
|
||||
显示
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${settingsActiveTab === 'formatting' ? 'active' : ''}`}
|
||||
onClick={() => setSettingsActiveTab('formatting')}
|
||||
>
|
||||
格式化
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${settingsActiveTab === 'user' ? 'active' : ''}`}
|
||||
onClick={() => setSettingsActiveTab('user')}
|
||||
>
|
||||
用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="panel-body" style={{ padding: '16px' }}>
|
||||
{settingsLoading && <div className="loading">加载中...</div>}
|
||||
|
||||
{/* 显示设置 */}
|
||||
{settingsActiveTab === 'display' && (
|
||||
<div className="settings-section">
|
||||
<div className="setting-section">
|
||||
<h4>左侧栏模式</h4>
|
||||
<div className="setting-options">
|
||||
<label className={`setting-option ${sidebarMode === 'fixed' ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="sidebarMode"
|
||||
value="fixed"
|
||||
checked={sidebarMode === 'fixed'}
|
||||
onChange={(e) => handleSidebarModeChange(e.target.value)}
|
||||
/>
|
||||
<span>固定</span>
|
||||
</label>
|
||||
<label className={`setting-option ${sidebarMode === 'smart' ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="sidebarMode"
|
||||
value="smart"
|
||||
checked={sidebarMode === 'smart'}
|
||||
onChange={(e) => handleSidebarModeChange(e.target.value)}
|
||||
/>
|
||||
<span>智能</span>
|
||||
</label>
|
||||
<label className={`setting-option ${sidebarMode === 'expanded' ? 'active' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="sidebarMode"
|
||||
value="expanded"
|
||||
checked={sidebarMode === 'expanded'}
|
||||
onChange={(e) => handleSidebarModeChange(e.target.value)}
|
||||
/>
|
||||
<span>扩展</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
{sidebarMode === 'fixed' && '左侧栏保持默认宽度,不响应交互'}
|
||||
{sidebarMode === 'smart' && '鼠标悬停时自动展开,移开后收起'}
|
||||
{sidebarMode === 'expanded' && '左侧栏保持最大宽度'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 格式化设置 */}
|
||||
{settingsActiveTab === 'formatting' && (
|
||||
<div className="settings-section">
|
||||
<div className="setting-section">
|
||||
<h4>思考标签配置</h4>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500', color: 'var(--color-text-primary)' }}>前缀</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.thinkingTagPrefix}
|
||||
onChange={(e) => handleSettingsInputChange('thinkingTagPrefix', e.target.value)}
|
||||
placeholder="<thinking>"
|
||||
className="settings-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500', color: 'var(--color-text-primary)' }}>后缀</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.thinkingTagSuffix}
|
||||
onChange={(e) => handleSettingsInputChange('thinkingTagSuffix', e.target.value)}
|
||||
placeholder="</thinking>"
|
||||
className="settings-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="preview-box">
|
||||
<div className="original" style={{ marginBottom: '12px' }}>
|
||||
<strong style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: 'var(--color-text-secondary)' }}>原始内容:</strong>
|
||||
<p style={{ margin: 0, padding: '8px', background: 'rgba(0, 0, 0, 0.2)', borderRadius: '3px', fontFamily: 'Courier New, monospace', fontSize: '12px', color: 'var(--color-text-primary)' }}>
|
||||
这是回复 {settings.thinkingTagPrefix}思考过程{settings.thinkingTagSuffix} 继续
|
||||
</p>
|
||||
</div>
|
||||
<div className="processed">
|
||||
<strong style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: 'var(--color-text-secondary)' }}>处理后:</strong>
|
||||
<p style={{ margin: 0, padding: '8px', background: 'rgba(0, 0, 0, 0.2)', borderRadius: '3px', fontFamily: 'Courier New, monospace', fontSize: '12px', color: 'var(--color-text-primary)' }}>
|
||||
这是回复 继续
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={saveSettings}
|
||||
disabled={settingsLoading}
|
||||
>
|
||||
{settingsLoading ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户设置 */}
|
||||
{settingsActiveTab === 'user' && (
|
||||
<div className="settings-section">
|
||||
<div className="setting-section">
|
||||
<h4>当前预设</h4>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.currentPresetName || ''}
|
||||
onChange={(e) => handleSettingsInputChange('currentPresetName', e.target.value || null)}
|
||||
placeholder="未选择"
|
||||
className="settings-input"
|
||||
/>
|
||||
<small className="settings-hint">
|
||||
影响全局正则规则的启用
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={saveSettings}
|
||||
disabled={settingsLoading}
|
||||
>
|
||||
{settingsLoading ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import useAppLayoutStore from '../../../../Store/AppLayoutSlice';
|
||||
import './ThemeToggle.css';
|
||||
|
||||
const themes = [
|
||||
@@ -50,19 +51,16 @@ const themes = [
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState(() => {
|
||||
return localStorage.getItem('colorTheme') || 'dark';
|
||||
});
|
||||
const panelRef = useRef(null);
|
||||
|
||||
// ✅ 从 Zustand store 获取主题状态和方法
|
||||
const { colorTheme, setColorTheme } = useAppLayoutStore();
|
||||
|
||||
// ✅ 当 store 中的主题变化时,同步到 DOM
|
||||
useEffect(() => {
|
||||
// 应用主题到 document
|
||||
document.documentElement.setAttribute('data-theme', currentTheme);
|
||||
document.documentElement.setAttribute('data-color-theme', currentTheme);
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('theme', currentTheme);
|
||||
localStorage.setItem('colorTheme', currentTheme);
|
||||
}, [currentTheme]);
|
||||
document.documentElement.setAttribute('data-theme', colorTheme);
|
||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||
}, [colorTheme]);
|
||||
|
||||
// 点击外部关闭面板
|
||||
useEffect(() => {
|
||||
@@ -82,11 +80,11 @@ const ThemeToggle = () => {
|
||||
}, [isOpen]);
|
||||
|
||||
const handleThemeSelect = (themeId) => {
|
||||
setCurrentTheme(themeId);
|
||||
setColorTheme(themeId); // ✅ 使用 store 的方法
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const currentThemeData = themes.find(t => t.id === currentTheme) || themes[2];
|
||||
const currentThemeData = themes.find(t => t.id === colorTheme) || themes[2];
|
||||
|
||||
return (
|
||||
<div className="theme-toggle-wrapper" ref={panelRef}>
|
||||
@@ -108,7 +106,7 @@ const ThemeToggle = () => {
|
||||
{themes.map(theme => (
|
||||
<button
|
||||
key={theme.id}
|
||||
className={`theme-item ${currentTheme === theme.id ? 'active' : ''}`}
|
||||
className={`theme-item ${colorTheme === theme.id ? 'active' : ''}`}
|
||||
onClick={() => handleThemeSelect(theme.id)}
|
||||
>
|
||||
<div className="theme-item-icon">{theme.icon}</div>
|
||||
|
||||
161
frontend/src/utils/regexProcessor.js
Normal file
161
frontend/src/utils/regexProcessor.js
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 正则替换工具函数
|
||||
*
|
||||
* 在前端应用正则规则到消息显示,不影响原始数据存储。
|
||||
* 参考 SillyTavern 的正则处理逻辑。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 应用正则规则到文本
|
||||
*
|
||||
* @param {string} text - 原始文本
|
||||
* @param {Array} rules - 正则规则数组
|
||||
* @param {number} messageDepth - 消息深度(从最新消息开始计数,0表示最新)
|
||||
* @returns {string} - 处理后的文本(仅用于显示)
|
||||
*/
|
||||
export function applyRegexRules(text, rules, messageDepth = 0) {
|
||||
if (!text || !rules || rules.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = text;
|
||||
|
||||
// 按 order 排序规则
|
||||
const sortedRules = [...rules].sort((a, b) => a.order - b.order);
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
// 跳过禁用的规则
|
||||
if (!rule.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查消息深度限制
|
||||
if (messageDepth < rule.minDepth) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxDepth !== null && rule.maxDepth !== undefined && messageDepth > rule.maxDepth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 应用单条规则
|
||||
result = applySingleRule(result, rule);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用单条正则规则
|
||||
*
|
||||
* @param {string} text - 输入文本
|
||||
* @param {Object} rule - 正则规则对象
|
||||
* @returns {string} - 处理后的文本
|
||||
*/
|
||||
function applySingleRule(text, rule) {
|
||||
try {
|
||||
const { findRegex, replaceString, substituteRegex, trimStrings } = rule;
|
||||
|
||||
if (!findRegex) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// 解析正则表达式和标志
|
||||
let pattern = findRegex;
|
||||
let flags = 'g'; // 默认全局匹配
|
||||
|
||||
// 提取标志(如果格式为 /pattern/flags)
|
||||
if (findRegex.startsWith('/') && findRegex.lastIndexOf('/') > 0) {
|
||||
const lastSlashIndex = findRegex.lastIndexOf('/');
|
||||
pattern = findRegex.slice(1, lastSlashIndex);
|
||||
const extractedFlags = findRegex.slice(lastSlashIndex + 1);
|
||||
if (extractedFlags) {
|
||||
flags = extractedFlags;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建正则表达式对象
|
||||
const regex = new RegExp(pattern, flags);
|
||||
|
||||
// 根据替换模式执行替换
|
||||
let replaced;
|
||||
if (substituteRegex === 1) {
|
||||
// REPLACE_FIRST: 仅替换首次匹配
|
||||
replaced = text.replace(regex, replaceString);
|
||||
} else {
|
||||
// REPLACE_ALL (0) 或 REPLACE_CAPTURED (2): 替换所有匹配
|
||||
replaced = text.replace(regex, replaceString);
|
||||
}
|
||||
|
||||
// 应用 trimStrings(删除指定的字符串)
|
||||
if (trimStrings && Array.isArray(trimStrings)) {
|
||||
for (const trimStr of trimStrings) {
|
||||
replaced = replaced.split(trimStr).join('');
|
||||
}
|
||||
}
|
||||
|
||||
return replaced;
|
||||
} catch (error) {
|
||||
console.error(`正则规则执行错误 [${rule.name}]:`, error);
|
||||
return text; // 出错时返回原文本
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据作用域过滤适用的规则
|
||||
*
|
||||
* @param {Array} allRules - 所有规则
|
||||
* @param {string|null} characterId - 当前角色卡 ID
|
||||
* @param {string|null} presetId - 当前预设 ID
|
||||
* @returns {Array} - 适用的规则数组
|
||||
*/
|
||||
export function filterRulesByScope(allRules, characterId = null, presetId = null) {
|
||||
if (!allRules || !Array.isArray(allRules)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return allRules.filter(rule => {
|
||||
// 跳过禁用的规则
|
||||
if (!rule.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 根据作用域过滤
|
||||
switch (rule.scope) {
|
||||
case 'global':
|
||||
return true;
|
||||
case 'character':
|
||||
return rule.characterId === characterId;
|
||||
case 'preset':
|
||||
return rule.presetId === presetId;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏思考标签的示例规则
|
||||
*
|
||||
* @returns {Object} - 规则对象
|
||||
*/
|
||||
export function createHideThinkingRule() {
|
||||
return {
|
||||
id: 'rule-hide-thinking',
|
||||
name: '隐藏思考标签',
|
||||
findRegex: '<thinking>[\\s\\S]*?<\\/thinking>',
|
||||
replaceString: '',
|
||||
trimStrings: [],
|
||||
substituteRegex: 0, // REPLACE_ALL
|
||||
markdownOnly: false,
|
||||
promptOnly: false,
|
||||
runOnEdit: true,
|
||||
minDepth: 0,
|
||||
maxDepth: null,
|
||||
scope: 'global',
|
||||
characterId: null,
|
||||
presetId: null,
|
||||
enabled: true,
|
||||
order: 1,
|
||||
description: '隐藏 AI 回复中的 <thinking> 标签及其内容'
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user