完成请求推送,但组装mes还有问题

This commit is contained in:
2026-05-04 00:33:29 +08:00
parent 7fc9e10c99
commit 2050a30a52
93 changed files with 9499 additions and 2719 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
import {
require_react_dom
} from "./chunk-TYILIMWK.js";
import "./chunk-CANBAPAS.js";
import "./chunk-5WRI5ZAA.js";
export default require_react_dom();
//# sourceMappingURL=react-dom.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["../../react-dom/client.js"],
"sourcesContent": ["'use strict';\n\nvar m = require('react-dom');\nif (process.env.NODE_ENV === 'production') {\n exports.createRoot = m.createRoot;\n exports.hydrateRoot = m.hydrateRoot;\n} else {\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n exports.createRoot = function(c, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.createRoot(c, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n exports.hydrateRoot = function(c, h, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.hydrateRoot(c, h, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n}\n"],
"mappings": ";;;;;;;;;AAAA;AAAA;AAEA,QAAI,IAAI;AACR,QAAI,OAAuC;AACzC,cAAQ,aAAa,EAAE;AACvB,cAAQ,cAAc,EAAE;AAAA,IAC1B,OAAO;AACD,UAAI,EAAE;AACV,cAAQ,aAAa,SAAS,GAAG,GAAG;AAClC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,WAAW,GAAG,CAAC;AAAA,QAC1B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AACA,cAAQ,cAAc,SAAS,GAAG,GAAG,GAAG;AACtC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,YAAY,GAAG,GAAG,CAAC;AAAA,QAC9B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAjBM;AAAA;AAAA;",
"names": []
}

View File

@@ -1,6 +0,0 @@
import {
require_react
} from "./chunk-CANBAPAS.js";
import "./chunk-5WRI5ZAA.js";
export default require_react();
//# sourceMappingURL=react.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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 读取状态 */}

View File

@@ -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;
}
}
)
)

View File

@@ -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);
}
}
}
)
);

View File

@@ -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
}
});
},

View File

@@ -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;

View File

@@ -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 })

View 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;

View File

@@ -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();
}
}
}
)
);

View 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;

View 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;

View File

@@ -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;

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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) => (

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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 => (

View File

@@ -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;
}

View File

@@ -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

View 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;
}

View 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>默认值&lt;thinking&gt;</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>默认值&lt;/thinking&gt;</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;

View File

@@ -0,0 +1 @@
export { default } from './Settings';

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);
}}
/>
区分大小写

View File

@@ -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);
}
/* 未选择分页提示 */

View File

@@ -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>
</>

View File

@@ -1,12 +0,0 @@
import React from 'react';
const Debug = () => {
return (
<div className="debug-panel">
<h2>上下文调试</h2>
<p>这是上下文调试面板的占位页面</p>
</div>
);
};
export default Debug;

View File

@@ -1 +0,0 @@
export { default } from './Debug';

View 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);
}

View 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;

View File

@@ -0,0 +1 @@
export { default } from './Tasks';

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default } from './WorldBookActive';

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View 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> 标签及其内容'
};
}

View File

@@ -6,7 +6,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:23337',
target: 'http://backend:8000',
changeOrigin: true,
}
}