添加测试、添加总结、全量、rag(todo)3种历史记录保存方式,流式实现

This commit is contained in:
2026-05-05 03:01:20 +08:00
parent 2050a30a52
commit adb59da06d
46 changed files with 4484 additions and 882 deletions

View File

@@ -114,7 +114,7 @@ function App() {
console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建');
}
} else {
console.log('[App] ✅ API 配置已从缓存恢复');
// 从缓存恢复
}
} catch (err) {
console.error('[App] ❌ API 配置加载失败:', err);

View File

@@ -534,6 +534,7 @@ const useChatBoxStore = create(
if (characterData.first_mes && characterData.first_mes.trim()) {
// 创建临时的开场白消息(不保存到后端)
messages = [{
id: Date.now(), // ✅ 添加唯一 ID
floor: 1,
mes: characterData.first_mes,
is_user: false,

View File

@@ -72,7 +72,10 @@ const useCharacterCardUIStore = create((set) => ({
mes_example: character.mes_example || '',
categories: character.categories || [],
tags: character.tags || [],
worldInfoId: character.worldInfoId || null
worldInfoId: character.worldInfoId || null,
// ✅ 动态表格数据
tableHeaders: character.tableHeaders || [],
tableDefaults: character.tableDefaults || {}
}
});
},

View File

@@ -133,9 +133,6 @@ const usePresetStore = create(
const response = await fetch(`/api/presets/${presetId}`);
const presetData = await response.json();
// 记录原始数据用于调试
console.log('从后端获取的预设数据:', presetData);
// 提取参数并更新状态支持内部结构和SillyTavern结构
const parameters = {
temperature: presetData.temperature !== undefined ? presetData.temperature : 1.0,
@@ -158,9 +155,6 @@ const usePresetStore = create(
n: presetData.n !== undefined ? presetData.n : 1
};
// 记录映射后的参数用于调试
console.log('映射后的参数:', parameters);
// 处理预设组件 - 支持内部结构和SillyTavern结构
let components = [];
@@ -379,6 +373,41 @@ const usePresetStore = create(
newComponents.splice(toIndex, 0, movedComponent);
return { promptComponents: newComponents };
}),
// 保存组件排序到后端
saveComponentOrder: async () => {
const state = get();
if (!state.selectedPreset) {
console.warn('No preset selected, cannot save order');
return;
}
try {
// 提取组件 identifier 列表,按当前顺序
const componentOrder = state.promptComponents.map(component => component.identifier);
const response = await fetch(`/api/presets/${state.selectedPreset}/reorder`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
component_order: componentOrder
})
});
if (!response.ok) {
throw new Error('Failed to save component order');
}
const result = await response.json();
console.log('Component order saved successfully:', result);
return result;
} catch (error) {
console.error('Failed to save component order:', error);
throw error;
}
},
// 获取当前预设的prompt_order
getPromptOrder: () => {
@@ -428,27 +457,23 @@ const usePresetStore = create(
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);
}
// 异步加载预设详情
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(
) // ✅ 闭合 persist(
);
export default usePresetStore;

View File

@@ -1,17 +1,19 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import useChatBoxStore from '../Mid/ChatBoxSlice'; // ✅ 导入 ChatBoxStore (default export)
/**
* 动态表格 Store
* 管理 SillyTavern 风格的标签数据
* 管理角色卡的动态表格数据(键值对结构)
*/
const useTableStore = create(
persist(
(set, get) => ({
// ==================== 状态 ====================
// 标签数组
tags: [],
// ✅ 动态表格数据(键值对结构)
tableHeaders: [], // 表头数组
tableDefaults: {}, // 默认值对象
// 当前角色和聊天
currentRole: null,
@@ -26,207 +28,92 @@ const useTableStore = create(
// ==================== Actions ====================
/**
* 加载标签数据
* 加载动态表格数据
* @param {string} role - 角色名
* @param {string} chat - 聊天名
*/
loadTags: async (role, chat) => {
if (!role) {
set({ tags: [], currentRole: null, currentChat: null });
set({
tableHeaders: [],
tableDefaults: {},
currentRole: null,
currentChat: null
});
return;
}
set({ isLoading: true, currentRole: role, currentChat: chat });
try {
let fetchedTags = [];
let tableHeaders = [];
let tableDefaults = {};
// 优先级1从聊天文件读取
// 优先级1从聊天文件读取(如果聊天中有动态表格数据)
if (chat) {
console.log('[TableStore] 从聊天文件读取标签数据');
console.log('[TableStore] 尝试从聊天文件读取动态表格数据');
const response = await fetch(`/api/chat/${encodeURIComponent(role)}/${encodeURIComponent(chat)}`);
if (!response.ok) throw new Error('获取聊天数据失败');
const chatData = await response.json();
const header = chatData.header || {};
fetchedTags = header.tags || [];
if (fetchedTags.length > 0) {
set({
tags: fetchedTags,
lastUpdated: Date.now(),
isLoading: false
});
console.log(`[TableStore] ✅ 已加载 ${fetchedTags.length} 个标签`);
return;
if (response.ok) {
const chatData = await response.json();
const header = chatData.header || {};
// 读取聊天中的动态表格数据
tableHeaders = header.tableHeaders || [];
tableDefaults = header.tableDefaults || {};
if (tableHeaders.length > 0) {
set({
tableHeaders,
tableDefaults,
lastUpdated: Date.now(),
isLoading: false
});
console.log(`[TableStore] ✅ 已从聊天加载动态表格: ${tableHeaders.length} 个字段`);
return;
}
}
}
// 优先级2从角色卡读取降级
console.log('[TableStore] 聊天中无标签,从角色卡读取');
// 优先级2从角色卡读取降级
console.log('[TableStore] 聊天中无动态表格,从角色卡读取');
const charResponse = await fetch(`/api/characters/${encodeURIComponent(role)}`);
if (!charResponse.ok) throw new Error('获取角色卡失败');
const charData = await charResponse.json();
fetchedTags = charData.tags || [];
tableHeaders = charData.tableHeaders || [];
tableDefaults = charData.tableDefaults || {};
set({
tags: fetchedTags,
tableHeaders,
tableDefaults,
lastUpdated: Date.now(),
isLoading: false
});
console.log(`[TableStore] ✅ 已从角色卡加载 ${fetchedTags.length}标签`);
console.log(`[TableStore] ✅ 已从角色卡加载动态表格: ${tableHeaders.length}字段`);
} catch (error) {
console.error('[TableStore] 获取标签数据失败:', error);
set({ tags: [], isLoading: false });
console.error('[TableStore] 获取动态表格数据失败:', error);
set({
tableHeaders: [],
tableDefaults: {},
isLoading: false
});
}
},
/**
* 保存标签到后端(带防抖)
* @param {Array} newTags - 新的标签数组
*/
saveTags: (newTags) => {
const { currentRole, currentChat } = get();
if (!currentRole || !currentChat) {
console.warn('[TableStore] 无法保存:缺少角色或聊天信息');
return;
}
// 清除之前的定时器(如果存在)
if (get().saveTimeoutId) {
clearTimeout(get().saveTimeoutId);
}
// 延迟 1.5 秒后保存
const timeoutId = setTimeout(async () => {
try {
const response = await fetch(
`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/table`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags: newTags })
}
);
if (!response.ok) throw new Error('保存失败');
set({ lastUpdated: Date.now() });
console.log('[TableStore] ✅ 标签已保存到后端');
} catch (error) {
console.error('[TableStore] 保存标签失败:', error);
alert('保存失败: ' + error.message);
}
}, 1500);
// 保存定时器 ID
set({ saveTimeoutId: timeoutId });
},
/**
* 更新标签(乐观更新 + 延迟保存)
* @param {Array} newTags - 新的标签数组
*/
updateTags: (newTags) => {
set({ tags: newTags });
get().saveTags(newTags);
},
/**
* 添加标签
* @param {string|string[]} tagOrTags - 单个标签或标签数组
*/
addTag: (tagOrTags) => {
const { tags } = get();
// 支持批量添加
const newTagsList = Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags];
const newTags = [...tags, ...newTagsList];
get().updateTags(newTags);
},
/**
* 删除标签
* @param {number} index - 标签索引
*/
deleteTag: (index) => {
const { tags } = get();
const newTags = tags.filter((_, i) => i !== index);
get().updateTags(newTags);
},
/**
* 移动标签
* @param {number} fromIndex - 起始索引
* @param {number} toIndex - 目标索引
*/
moveTag: (fromIndex, toIndex) => {
const { tags } = get();
if (fromIndex < 0 || toIndex < 0 || fromIndex >= tags.length || toIndex >= tags.length) {
return;
}
const newTags = [...tags];
[newTags[fromIndex], newTags[toIndex]] = [newTags[toIndex], newTags[fromIndex]];
get().updateTags(newTags);
},
/**
* 左移标签
* @param {number} index - 标签索引
*/
moveLeft: (index) => {
if (index === 0) return;
get().moveTag(index, index - 1);
},
/**
* 右移标签
* @param {number} index - 标签索引
*/
moveRight: (index) => {
const { tags } = get();
if (index === tags.length - 1) return;
get().moveTag(index, index + 1);
},
/**
* 编辑标签
* @param {number} index - 标签索引
* @param {string} newValue - 新值
*/
editTag: (index, newValue) => {
const { tags } = get();
const newTags = [...tags];
newTags[index] = newValue.trim();
get().updateTags(newTags);
},
/**
* 清空状态
*/
clear: () => {
// 清除定时器
if (get().saveTimeoutId) {
clearTimeout(get().saveTimeoutId);
}
set({
tags: [],
tableHeaders: [],
tableDefaults: {},
currentRole: null,
currentChat: null,
isLoading: false,
lastUpdated: null,
saveTimeoutId: null
lastUpdated: null
});
},
@@ -238,12 +125,54 @@ const useTableStore = create(
if (!currentRole) return;
await get().loadTags(currentRole, currentChat);
},
/**
* 更新动态表格数据并保存到后端
* @param {Object} updates - 要更新的字段 { tableHeaders?, tableDefaults? }
*/
updateTableData: async (updates) => {
const { currentRole } = get();
if (!currentRole) {
console.warn('[TableStore] 无法保存:缺少角色信息');
return;
}
// 乐观更新:先更新本地状态
set((state) => ({
tableHeaders: updates.tableHeaders ?? state.tableHeaders,
tableDefaults: updates.tableDefaults ?? state.tableDefaults,
lastUpdated: Date.now()
}));
// 异步保存到后端
try {
const response = await fetch(`/api/characters/${encodeURIComponent(currentRole)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error('保存失败');
}
console.log('[TableStore] ✅ 动态表格数据已保存到后端');
} catch (error) {
console.error('[TableStore] 保存动态表格数据失败:', error);
alert('保存失败: ' + error.message);
// 如果保存失败,重新加载数据以恢复状态
await get().refresh();
}
}
}),
{
name: 'table-storage', // localStorage key
partialize: (state) => ({
tags: state.tags,
tableHeaders: state.tableHeaders,
tableDefaults: state.tableDefaults,
currentRole: state.currentRole,
currentChat: state.currentChat,
lastUpdated: state.lastUpdated
@@ -252,4 +181,35 @@ const useTableStore = create(
)
);
// ✅ 监听 ChatBoxStore 的变化,自动更新 TableStore 的 currentRole 和 currentChat
useChatBoxStore.subscribe(
(state) => ({ role: state.currentRole, chat: state.currentChat }),
({ role, chat }, prevState) => {
const tableStore = useTableStore.getState();
// 只有当角色或聊天真正变化时才更新
if (tableStore.currentRole !== role || tableStore.currentChat !== chat) {
const changeType = tableStore.currentRole !== role ? '角色切换' : '聊天切换';
console.log(`[TableStore] 🔄 检测到${changeType}:`, {
from: { role: tableStore.currentRole, chat: tableStore.currentChat },
to: { role, chat }
});
useTableStore.setState({
currentRole: role,
currentChat: chat
});
// 自动加载动态表格数据
if (role) {
console.log('[TableStore] 📊 开始加载动态表格数据...');
useTableStore.getState().loadTags(role, chat);
} else {
console.log('[TableStore] ⚠️ 角色为空,清空动态表格');
useTableStore.getState().clear();
}
}
}
);
export default useTableStore;

View File

@@ -272,7 +272,7 @@
right: 0;
background-color: var(--color-bg-primary); /* 跟随主题 */
padding: var(--spacing-md) var(--spacing-lg);
z-index: 10;
z-index: var(--z-divider); /* ✅ 基础层 - 分割线层级 */
border-top: 1px solid var(--color-border-light);
}
@@ -335,7 +335,7 @@
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
padding: var(--spacing-sm);
min-width: 220px;
z-index: 100;
z-index: var(--z-dropdown-menu); /* ✅ 组件层 - 下拉菜单 */
backdrop-filter: blur(10px);
}
@@ -579,7 +579,7 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
}
.chat-selector-modal {

View File

@@ -234,21 +234,21 @@ const ChatBox = () => {
};
// 渲染单条消息
const renderMessage = (message) => {
const renderMessage = (message, index) => {
const isUser = message.is_user;
const isEditing = editingId === message.floor;
// 判断是否为最新消息
const isLatestMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
// 根据消息类型设置显示名称
const displayName = isUser ? userName : characterName;
// 确定当前显示的消息内容
let currentMes = message.mes;
let hasSwipes = message.swipes && message.swipes.length > 0;
let currentSwipeIndex = message.swipe_id;
if (hasSwipes) {
// 如果有swipes数组
if (currentSwipeId[message.floor] !== undefined) {
@@ -258,14 +258,17 @@ const ChatBox = () => {
// 否则使用默认的swipe_id
currentSwipeIndex = message.swipe_id;
}
if (currentSwipeIndex >= 0 && currentSwipeIndex < message.swipes.length) {
currentMes = message.swipes[currentSwipeIndex];
}
}
// ✅ 使用 id 或组合 key 确保唯一性
const uniqueKey = message.id || `${message.floor}-${index}`;
return (
<div key={message.floor} className={`message ${isUser ? 'user' : 'ai'}`}>
<div key={uniqueKey} className={`message ${isUser ? 'user' : 'ai'}`}>
<div className="message-container">
<div className="message-header">
<span className="message-name">{displayName}</span>
@@ -517,9 +520,9 @@ const ChatBox = () => {
{characterChats.length > 0 ? (
<div className="chat-list">
{characterChats.map((chat, index) => (
{characterChats.map((chat) => (
<div
key={index}
key={chat.chat_name}
className="chat-option"
>
<div

View File

@@ -3,7 +3,7 @@
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
overflow: visible; /* ✅ 允许 fixed 定位的子元素显示 */
}
.sidebar-tabs {

View File

@@ -281,7 +281,7 @@ textarea.form-control {
font-size: 0.9rem;
box-shadow: var(--shadow-xl);
animation: slideIn 0.3s ease-out;
z-index: 1000;
z-index: var(--z-toast-item); /* ✅ 通知层 - Toast 项 */
}
.notification-success {
@@ -326,7 +326,7 @@ textarea.form-control {
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
animation: fadeIn 0.2s ease-out;
}

View File

@@ -52,6 +52,13 @@
color: white;
}
/* ✅ 激活状态的按钮 */
.action-btn.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
}
/* Tag 筛选器 */
.tag-filter {
display: flex;
@@ -131,6 +138,7 @@
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); /* 减小最小宽度让窄屏也能显示2列 */
gap: 8px; /* 增加间距,让卡片更有呼吸感 */
padding: 2px;
align-content: start; /* ✅ 从上到下排列,不留空白 */
}
.character-item {
@@ -138,7 +146,7 @@
display: flex;
flex-direction: column;
width: 100%; /* 改为自适应宽度 */
height: 170px; /* 减小高度从200px降到170px */
height: 170px; /* ✅ 固定高度,不随空间浮动 */
background: var(--color-bg-secondary);
border: 2px solid var(--color-border);
border-radius: 6px;
@@ -153,6 +161,19 @@
-ms-user-select: none;
}
/* ✅ 无图模式 - 缩小卡片 */
.character-item.no-image {
height: 80px; /* 减小高度 */
}
.character-item.no-image .character-avatar-wrapper {
display: none; /* 隐藏图片区域 */
}
.character-item.no-image .character-info {
padding: 8px; /* 增加内边距 */
}
.character-item:hover {
border-color: var(--color-accent);
transform: translateY(-2px);
@@ -304,7 +325,7 @@
.floating-toolbar {
position: sticky;
top: 0;
z-index: 100;
z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar 同级 */
margin-bottom: 8px;
}
@@ -357,7 +378,7 @@
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
margin-top: -18px; /* 使用负 margin 代替 transform确保点击区域和视觉位置一致 */
width: 36px;
height: 36px;
background: var(--color-accent);
@@ -368,23 +389,24 @@
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;
z-index: 10;
}
@keyframes pulse {
0%, 100% {
transform: translateY(-50%) scale(1);
transform: scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
50% {
transform: translateY(-50%) scale(1.1);
transform: 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);
transform: scale(0);
pointer-events: none; /* ✅ 消失时不阻挡点击 */
}
.indicator-icon {
@@ -461,6 +483,18 @@
border-color: #059669;
}
/* ✅ 复制按钮样式 */
.toolbar-btn.duplicate {
background: #8b5cf6;
border-color: #8b5cf6;
color: white;
}
.toolbar-btn.duplicate:hover {
background: #7c3aed;
border-color: #7c3aed;
}
.toolbar-btn.delete {
background: #ef4444;
border-color: #ef4444;
@@ -533,6 +567,59 @@
margin-top: 2px;
}
/* ✅ 总结配置网格布局 */
.summary-config-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
padding: 8px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
}
.summary-config-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.summary-config-item.full-width {
grid-column: 1 / -1;
}
.summary-config-item label {
font-size: 10px;
color: var(--color-text-secondary);
font-weight: 500;
}
.summary-config-item input[type="number"] {
padding: 4px 6px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-primary);
font-size: 11px;
}
.summary-config-item input[type="checkbox"] {
margin-right: 6px;
cursor: pointer;
}
.summary-config-item textarea {
padding: 6px 8px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-primary);
font-size: 11px;
font-family: inherit;
resize: vertical;
min-height: 60px;
}
/* 聊天选择器弹窗 */
.chat-selector-overlay {
position: fixed;
@@ -544,7 +631,7 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
}
.chat-selector-modal {

View File

@@ -6,7 +6,7 @@ import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; //
import './CharacterCard.css';
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
const CharacterItem = memo(({ character, isSelected, onSelect }) => {
const CharacterItem = memo(({ character, isSelected, onSelect, showImages }) => {
const handleImageError = useCallback((e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
@@ -25,7 +25,7 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
return (
<div
className={`character-item ${isSelected ? 'selected' : ''}`}
className={`character-item ${isSelected ? 'selected' : ''} ${!showImages ? 'no-image' : ''}`}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="button"
@@ -66,7 +66,8 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
}, (prevProps, nextProps) => {
// 自定义比较函数,只在必要时重新渲染
return prevProps.character.id === nextProps.character.id &&
prevProps.isSelected === nextProps.isSelected;
prevProps.isSelected === nextProps.isSelected &&
prevProps.showImages === nextProps.showImages;
});
CharacterItem.displayName = 'CharacterItem';
@@ -108,6 +109,7 @@ const CharacterCard = () => {
const { worldBooks, fetchWorldBooks } = useWorldBookStore();
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
const [showImages, setShowImages] = useState(true); // ✅ 是否显示图片
const fileInputRef = useRef(null);
const clickTimeoutRef = useRef(null);
@@ -187,6 +189,27 @@ const CharacterCard = () => {
const name = prompt('请输入角色名称:');
if (!name) return;
// ✅ 询问历史记录模式
const historyMode = prompt(
'请选择历史记录模式:\n' +
'1. 全量模式(保留所有消息)\n' +
'2. 总结模式(定期总结历史)\n' +
'3. RAG模式需要API配置选择后不可取消\n\n' +
'请输入数字 (1/2/3):'
);
let mode = 'full';
if (historyMode === '2') mode = 'summary';
else if (historyMode === '3') mode = 'rag';
if (mode === 'rag') {
const confirm = window.confirm(
'⚠️ RAG模式需要配置API且选择后不可取消。\n' +
'是否继续?'
);
if (!confirm) return;
}
try {
const { createCharacter } = useCharacterStore.getState();
await createCharacter({
@@ -197,7 +220,8 @@ const CharacterCard = () => {
first_mes: '',
mes_example: '',
categories: [],
tags: [] // ✅ 使用标签数组
tags: [],
historyMode: mode // ✅ 添加历史记录模式
});
} catch (err) {
console.error('创建失败:', err);
@@ -267,6 +291,62 @@ const CharacterCard = () => {
}
};
// ✅ 复制角色
const handleDuplicate = async (character) => {
if (!character) return;
const newName = prompt(`复制角色 "${character.name}"\n请输入新角色名称:`, `${character.name} - 副本`);
if (!newName) return;
// ✅ 询问历史记录模式
const historyMode = prompt(
'请选择历史记录模式:\n' +
'1. 全量模式(保留所有消息)\n' +
'2. 总结模式(定期总结历史)\n' +
'3. RAG模式需要API配置选择后不可取消\n\n' +
'请输入数字 (1/2/3):'
);
let mode = 'full';
if (historyMode === '2') mode = 'summary';
else if (historyMode === '3') mode = 'rag';
if (mode === 'rag') {
const confirm = window.confirm(
'⚠️ RAG模式需要配置API且选择后不可取消。\n' +
'是否继续?'
);
if (!confirm) return;
}
try {
const { createCharacter } = useCharacterStore.getState();
// 复制角色数据(排除聊天记录)
await createCharacter({
name: newName,
description: character.description || '',
personality: character.personality || '',
scenario: character.scenario || '',
first_mes: character.first_mes || '',
mes_example: character.mes_example || '',
categories: character.categories || [],
tags: character.tags || [],
worldInfoId: character.worldInfoId || null,
tableHeaders: character.tableHeaders || [],
tableDefaults: character.tableDefaults || {},
tableMaintenancePrompt: character.tableMaintenancePrompt || null,
imageGenerationPrompt: character.imageGenerationPrompt || null,
historyMode: mode // ✅ 使用新选择的历史记录模式
});
console.log(`[CharacterCard] 角色已复制: ${character.name} -> ${newName}`);
} catch (err) {
console.error('复制失败:', err);
alert('复制失败: ' + err.message);
}
};
// 选择角色并切换到该角色的最后聊天记录
const handleSelectCharacter = useCallback(async (character) => {
console.log('[CharacterCard] 点击角色:', character.name);
@@ -359,6 +439,14 @@ const CharacterCard = () => {
<div className="tab-actions">
<button className="action-btn" onClick={handleCreate}>+ 新建</button>
<button className="action-btn" onClick={triggerImport}>📥 导入</button>
{/* ✅ 切换图片显示 */}
<button
className={`action-btn ${showImages ? '' : 'active'}`}
onClick={() => setShowImages(!showImages)}
title={showImages ? '隐藏图片' : '显示图片'}
>
{showImages ? '🖼️' : '🚫'}
</button>
<input
ref={fileInputRef}
type="file"
@@ -495,6 +583,14 @@ const CharacterCard = () => {
<option value="png">🖼 PNG</option>
<option value="json">📄 JSON</option>
</select>
{/* ✅ 复制角色按钮 */}
<button
className="toolbar-btn duplicate"
onClick={() => handleDuplicate(selectedCharacter)}
title="复制角色"
>
📋
</button>
<button className="toolbar-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)} title="导出角色卡">
📤
</button>
@@ -613,7 +709,7 @@ const CharacterCard = () => {
value={editForm.worldInfoId || ''}
onChange={(e) => handleFormChange('worldInfoId', e.target.value || null)}
>
<option value="">不绑定</option>
<option key="none" value="">不绑定</option>
{worldBooks.map(book => (
<option key={book.id} value={book.id}>
{book.name}
@@ -623,6 +719,97 @@ const CharacterCard = () => {
<small className="form-hint">💡 选择后该角色将自动加载绑定的世界书内容</small>
</div>
{/* ✅ 历史记录总结设置 */}
<div className="form-group">
<label>历史记录模式</label>
<select
value={editForm.historyMode || 'full'}
onChange={(e) => handleFormChange('historyMode', e.target.value)}
>
<option value="full">全量模式保留所有消息</option>
<option value="summary">总结模式定期总结历史</option>
<option value="rag" disabled={editForm.historyMode === 'rag'}>
RAG模式需要API配置选择后不可取消
</option>
</select>
<small className="form-hint">
全量模式保留所有消息 | 总结模式自动总结旧消息 | RAG模式向量检索需配置API
</small>
</div>
{/* ✅ 总结配置仅在summary模式下显示 */}
{(editForm.historyMode === 'summary' || editForm.summaryConfig) && (
<div className="form-group">
<label>总结配置</label>
<div className="summary-config-grid">
<div className="summary-config-item">
<label>总结间隔</label>
<input
type="number"
min="2"
value={editForm.summaryConfig?.interval || 10}
onChange={(e) => {
const config = editForm.summaryConfig || {};
handleFormChange('summaryConfig', {
...config,
interval: Number(e.target.value)
});
}}
/>
</div>
<div className="summary-config-item">
<label>保留最近楼层</label>
<input
type="number"
min="1"
value={editForm.summaryConfig?.recentFloorsToKeep || 5}
onChange={(e) => {
const config = editForm.summaryConfig || {};
handleFormChange('summaryConfig', {
...config,
recentFloorsToKeep: Number(e.target.value)
});
}}
/>
</div>
<div className="summary-config-item full-width">
<label>
<input
type="checkbox"
checked={editForm.summaryConfig?.includeUserInput !== false}
onChange={(e) => {
const config = editForm.summaryConfig || {};
handleFormChange('summaryConfig', {
...config,
includeUserInput: e.target.checked
});
}}
/>
总结时包含用户输入
</label>
</div>
<div className="summary-config-item full-width">
<label>总结提示词</label>
<textarea
value={editForm.summaryConfig?.summaryPrompt || '请总结以下对话内容,保留关键信息和上下文。'}
onChange={(e) => {
const config = editForm.summaryConfig || {};
handleFormChange('summaryConfig', {
...config,
summaryPrompt: e.target.value
});
}}
rows={3}
placeholder="请输入总结提示词"
/>
</div>
</div>
<small className="form-hint">
💡 总结间隔AI回复达到此数量时触发总结 | 保留楼层最近X条不总结
</small>
</div>
)}
<div className="form-group">
<label>动态表格数据 (SillyTavern 关键字机制)</label>
<input
@@ -679,6 +866,7 @@ const CharacterCard = () => {
character={char}
isSelected={selectedCharacter?.id === char.id}
onSelect={handleSelectCharacter}
showImages={showImages}
/>
))
)}
@@ -712,10 +900,10 @@ const CharacterCard = () => {
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))} // 使用 store 方法
>
<option value={8}>8/</option>
<option value={12}>12/</option>
<option value={20}>20/</option>
<option value={50}>50/</option>
<option key="8" value={8}>8/</option>
<option key="12" value={12}>12/</option>
<option key="20" value={20}>20/</option>
<option key="50" value={50}>50/</option>
</select>
</div>
)}

View File

@@ -18,7 +18,7 @@
border-radius: 4px;
font-size: 11px;
font-weight: 500;
z-index: 1000;
z-index: var(--z-tooltip); /* ✅ 组件层 - 悬浮提示 */
pointer-events: none;
box-shadow: var(--shadow-md);
transform: translate(-50%, -100%);
@@ -100,15 +100,13 @@
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
position: fixed;
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; /* ✅ 高于 ChatBox (z-index: 1000) */
z-index: var(--z-dropdown-menu); /* ✅ 组件层 - 下拉菜单 */
min-width: 140px;
padding: 4px;
}
@@ -139,6 +137,15 @@
cursor: not-allowed;
}
.dropdown-item.delete-item {
color: #dc3545;
}
.dropdown-item.delete-item:hover:not(:disabled) {
background: #fff5f5;
color: #c82333;
}
/* 对话框 */
.preset-save-dialog,
.preset-edit-dialog,
@@ -151,7 +158,7 @@
padding: 16px;
border-radius: 6px;
box-shadow: var(--shadow-lg);
z-index: 1000;
z-index: var(--z-modal-content); /* ✅ 弹窗层 - 对话框内容 */
min-width: 280px;
}
@@ -282,6 +289,49 @@
padding: 16px;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ✅ 角色选择器 */
.component-role-selector {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.role-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
white-space: nowrap;
}
.role-select {
flex: 1;
padding: 6px 10px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 12px;
color: var(--color-text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.role-select:hover {
border-color: var(--color-accent-light);
}
.role-select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
.component-textarea {
@@ -695,6 +745,41 @@
text-align: center;
}
/* ✅ 页码跳转容器 */
.page-jump-container {
display: flex;
gap: 4px;
align-items: center;
}
.page-jump-input {
width: 50px;
padding: 4px 6px;
font-size: 12px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-primary);
color: var(--color-text-primary);
text-align: center;
outline: none;
transition: all 0.15s ease;
}
.page-jump-input:hover {
border-color: var(--color-border-focus);
}
.page-jump-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
.btn-jump {
padding: 5px 8px;
font-size: 12px;
min-width: 40px;
}
.page-size-selector {
padding: 4px 8px;
background: var(--color-bg-primary);

View File

@@ -24,6 +24,7 @@ const PresetPanel = () => {
addComponent,
removeComponent,
moveComponent,
saveComponentOrder,
setCurrentPage,
setPageSize,
getCurrentPagePresets,
@@ -44,12 +45,19 @@ const PresetPanel = () => {
// 组件编辑状态
const [editingComponentIndex, setEditingComponentIndex] = useState(-1);
const [editComponentContent, setEditComponentContent] = useState('');
const [editComponentRole, setEditComponentRole] = useState(0); // 0=system, 1=user, 2=ai
const [isEditing, setIsEditing] = useState(false);
// 拖拽状态
const [draggedItem, setDraggedItem] = useState(null);
const [dragOverItem, setDragOverItem] = useState(null);
// 下拉菜单位置
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, right: 0 });
// ✅ 页码跳转输入
const [jumpPageInput, setJumpPageInput] = useState('');
// 点击外部关闭下拉菜单
useEffect(() => {
const handleClickOutside = (event) => {
@@ -63,6 +71,20 @@ const PresetPanel = () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showActionsMenu]);
// 计算下拉菜单位置
useEffect(() => {
if (showActionsMenu) {
const dropdownElement = document.querySelector('.actions-dropdown');
if (dropdownElement) {
const rect = dropdownElement.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + 4,
right: window.innerWidth - rect.right
});
}
}
}, [showActionsMenu]);
// 计算分页数据
const currentPagePresets = getCurrentPagePresets();
@@ -134,6 +156,116 @@ const PresetPanel = () => {
// 加载预设列表 - 由 SideBarLeft 标签切换时触发
// 注意fetchPresets 已在 SideBarLeft.jsx 的 handleTabClick 中调用
// ✅ 处理页码跳转
const handleJumpToPage = () => {
if (!jumpPageInput) return;
const pageNum = parseInt(jumpPageInput);
// 验证页码范围
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) {
alert(`请输入 1 到 ${totalPages} 之间的页码`);
setJumpPageInput('');
return;
}
setCurrentPage(pageNum);
setJumpPageInput('');
fetchPresets();
};
// 创建空预设
const handleCreateEmptyPreset = () => {
setNewPresetName('');
setShowSaveDialog(true);
};
// 保存当前预设(覆盖现有预设)
const handleSaveCurrentPreset = async () => {
if (!selectedPreset) {
alert('请先选择一个预设');
return;
}
try {
// 构建预设数据 - 使用内部专有结构
const presetData = {
// GenerationPreset 部分 - 采样参数
id: selectedPreset,
name: presets.find(p => p.id === selectedPreset)?.name || selectedPreset,
temperature: parameters.temperature,
topP: parameters.top_p,
topK: parameters.top_k,
frequencyPenalty: parameters.frequency_penalty,
presencePenalty: parameters.presence_penalty,
maxLength: parameters.max_tokens,
isDefault: false,
// PromptPresetView 部分 - prompt组件列表
characterId: 'global',
entries: promptComponents.map((component, index) => ({
identifier: component.identifier,
name: component.name,
enabled: component.enabled !== false,
content: component.content || '',
order: index,
role: component.role === 0 ? 'system' : component.role === 1 ? 'user' : 'ai',
tokenCount: component.content ? component.content.length : 0,
isSystemNode: component.marker || false
}))
};
// 发送到后端更新
const response = await fetch(`/api/presets/${selectedPreset}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(presetData)
});
if (!response.ok) {
throw new Error('Failed to update preset');
}
alert('预设已保存!');
} catch (error) {
console.error('保存预设失败:', error);
alert('保存预设失败: ' + error.message);
}
};
// 删除预设
const handleDeletePreset = async (presetId) => {
if (!confirm(`确定要删除预设 "${presets.find(p => p.id === presetId)?.name}" 吗?`)) {
return;
}
try {
const response = await fetch(`/api/presets/${presetId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete preset');
}
// 如果删除的是当前选中的预设,清空选择
if (selectedPreset === presetId) {
setSelectedPreset('');
}
// 重新加载预设列表
setCurrentPage(1);
fetchPresets();
alert('预设已删除!');
} catch (error) {
console.error('删除预设失败:', error);
alert('删除预设失败: ' + error.message);
}
};
// 保存当前设置为预设
const handleSavePreset = async () => {
if (newPresetName.trim()) {
@@ -398,11 +530,19 @@ const PresetPanel = () => {
};
// 放置
const handleDrop = (e, index) => {
const handleDrop = async (e, index) => {
e.preventDefault();
if (draggedItem === null || draggedItem === index) return;
moveComponent(draggedItem, index);
// ✅ 拖拽后保存排序到后端
try {
await saveComponentOrder();
} catch (error) {
console.error('Failed to save component order:', error);
alert('保存排序失败: ' + error.message);
}
setDraggedItem(null);
setDragOverItem(null);
@@ -448,34 +588,35 @@ const PresetPanel = () => {
</button>
{showActionsMenu && (
<div className="dropdown-menu">
<div
className="dropdown-menu"
style={{
top: `${dropdownPosition.top}px`,
right: `${dropdownPosition.right}px`
}}
>
<button
className="dropdown-item"
title="将当前的参数设置和组件配置保存为新的预设"
title="创建一个全新的空白预设"
onClick={() => {
setShowSaveDialog(true);
handleCreateEmptyPreset();
setShowActionsMenu(false);
}}
>
💾 保存为新预设
建空预设
</button>
<button
className="dropdown-item"
disabled={!selectedPreset}
title="修改当前选中预设的名称"
title="保存当前的参数和组件配置到当前预设"
onClick={() => {
if (selectedPreset) {
const preset = presets.find(p => p.id === selectedPreset);
if (preset) {
setEditPresetId(selectedPreset);
setEditPresetName(preset.name);
setShowEditDialog(true);
}
handleSaveCurrentPreset();
}
setShowActionsMenu(false);
}}
>
编辑名称
💾 保存当前预设
</button>
<button
className="dropdown-item"
@@ -485,7 +626,7 @@ const PresetPanel = () => {
setShowActionsMenu(false);
}}
>
📥 导入预设
📥 导入
</button>
<button
className="dropdown-item"
@@ -496,7 +637,20 @@ const PresetPanel = () => {
setShowActionsMenu(false);
}}
>
📤 导出预设
📤 导出
</button>
<button
className="dropdown-item delete-item"
disabled={!selectedPreset}
title="删除当前选中的预设"
onClick={() => {
if (selectedPreset) {
handleDeletePreset(selectedPreset);
}
setShowActionsMenu(false);
}}
>
🗑 删除
</button>
</div>
)}
@@ -561,6 +715,22 @@ const PresetPanel = () => {
<button className="close-btn" onClick={handleCloseComponentView}>×</button>
</div>
<div className="dialog-content">
{/* ✅ 角色选择器 */}
{isEditing && (
<div className="component-role-selector">
<label className="role-label">角色</label>
<select
className="role-select"
value={editComponentRole}
onChange={(e) => setEditComponentRole(Number(e.target.value))}
>
<option value={0}>System (系统)</option>
<option value={1}>User (用户)</option>
<option value={2}>AI (助手)</option>
</select>
</div>
)}
<textarea
value={editComponentContent}
onChange={(e) => setEditComponentContent(e.target.value)}
@@ -818,10 +988,8 @@ const PresetPanel = () => {
draggable={!component.marker}
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
>
<div className="component-header">
<div className="component-controls">
@@ -870,7 +1038,10 @@ const PresetPanel = () => {
{/* 最后一个拖拽指示器 - 在列表末尾 */}
<div
className={`drag-indicator ${dragOverItem === promptComponents.length ? 'visible' : ''}`}
onDragOver={(e) => handleDragOver(e, promptComponents.length)}
onDragOver={(e) => {
e.preventDefault();
handleDragOver(e, promptComponents.length);
}}
onDrop={(e) => handleDrop(e, promptComponents.length)}
/>
</div>
@@ -889,6 +1060,31 @@ const PresetPanel = () => {
上一页
</button>
{/* ✅ 页码跳转输入框 */}
<div className="page-jump-container">
<input
type="number"
className="page-jump-input"
value={jumpPageInput}
onChange={(e) => setJumpPageInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleJumpToPage();
}
}}
placeholder="页码"
min="1"
max={totalPages}
/>
<button
className="pagination-btn btn-jump"
onClick={handleJumpToPage}
disabled={!jumpPageInput}
>
跳转
</button>
</div>
<span className="pagination-info">
{currentPage} / {totalPages}
</span>

View File

@@ -1,94 +1,27 @@
/* ==================== SillyTavern 风格系统设置 ==================== */
.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;
background: var(--color-bg-primary);
color: var(--color-text-primary);
}
/* 消息提示 */
.message {
padding: 12px;
margin-bottom: 16px;
border-radius: 4px;
font-size: 14px;
margin: 8px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
animation: slideDown 0.2s ease;
}
.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 {
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(10px);
transform: translateY(-10px);
}
to {
opacity: 1;
@@ -96,135 +29,313 @@
}
}
.settings-section h3 {
margin: 0 0 8px 0;
font-size: 16px;
.message.success {
background: rgba(76, 175, 80, 0.15);
border: 1px solid #4caf50;
color: #66bb6a;
}
.message.error {
background: rgba(244, 67, 54, 0.15);
border: 1px solid #f44336;
color: #ef5350;
}
/* ==================== 左右分栏布局 ==================== */
.settings-layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* 左侧边栏 - 垂直标签 */
.settings-sidebar {
width: 180px;
background: var(--color-bg-secondary);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
padding: 8px;
gap: 4px;
}
.sidebar-tab {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 13px;
transition: all 0.15s ease;
text-align: left;
}
.sidebar-tab:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.sidebar-tab.active {
background: var(--color-accent-light);
color: var(--color-accent);
font-weight: 600;
}
.section-description {
margin: 0 0 16px 0;
font-size: 13px;
color: var(--text-secondary);
.tab-icon {
font-size: 16px;
line-height: 1;
}
/* 设置项 */
.setting-item {
margin-bottom: 20px;
.tab-label {
flex: 1;
}
/* 右侧主内容区 */
.settings-main {
flex: 1;
overflow-y: auto;
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;
.loading {
text-align: center;
padding: 60px 20px;
color: var(--color-text-muted);
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.setting-item input[type="text"],
.setting-item input[type="file"] {
/* ==================== 设置面板 ==================== */
.settings-panel {
max-width: 800px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.panel-header {
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid var(--color-border);
}
.panel-header h3 {
margin: 0 0 6px 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary);
}
.panel-description {
margin: 0;
font-size: 13px;
color: var(--color-text-secondary);
}
/* ==================== 设置分组 ==================== */
.settings-group {
margin-bottom: 20px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.group-title {
padding: 10px 14px;
background: var(--color-bg-tertiary);
border-bottom: 1px solid var(--color-border);
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* 设置行 */
.setting-row {
display: flex;
align-items: center;
padding: 12px 14px;
border-bottom: 1px solid var(--color-border-light);
gap: 12px;
}
.setting-row:last-child {
border-bottom: none;
}
.row-label {
min-width: 120px;
font-size: 13px;
font-weight: 500;
color: var(--color-text-primary);
}
.row-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
/* 输入框 */
.text-input,
.file-input {
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;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text-primary);
font-size: 13px;
transition: all 0.15s ease;
box-sizing: border-box;
}
.setting-item input[type="text"]:focus {
.text-input:focus,
.file-input:focus {
outline: none;
border-color: var(--accent-color);
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
.setting-item small {
display: block;
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
.hint-text {
font-size: 11px;
color: var(--color-text-muted);
}
.setting-item code {
display: block;
padding: 8px 12px;
background: var(--bg-secondary);
/* 路径代码 */
.path-code {
display: inline-block;
padding: 6px 10px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: var(--accent-color);
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
color: var(--color-accent);
}
/* 文件结构列表 */
.file-structure {
margin: 8px 0 0 0;
padding-left: 20px;
.file-structure-list {
list-style: none;
margin: 0;
padding: 12px 14px;
}
.file-structure li {
padding: 4px 0;
.file-structure-list li {
padding: 6px 0;
font-size: 13px;
color: var(--text-secondary);
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
/* 预览框 */
.preview-box {
margin-top: 8px;
padding: 12px;
background: var(--bg-secondary);
border-radius: 4px;
font-size: 13px;
.folder-icon {
font-size: 14px;
}
.preview-box .original,
.preview-box .processed {
/* 预览容器 */
.preview-container {
padding: 14px;
}
.preview-section {
margin-bottom: 12px;
}
.preview-box .original:last-child,
.preview-box .processed:last-child {
.preview-section: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;
.preview-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
/* 按钮 */
.btn-primary {
.preview-code {
padding: 10px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 6px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
color: var(--color-text-primary);
line-height: 1.6;
word-break: break-all;
}
.preview-code.processed {
background: rgba(76, 175, 80, 0.05);
border-color: rgba(76, 175, 80, 0.3);
color: #66bb6a;
}
/* 操作按钮组 */
.actions-group {
padding: 14px;
background: var(--color-bg-tertiary);
border-top: 1px solid var(--color-border);
}
.btn-save {
padding: 10px 20px;
background: var(--accent-color);
background: var(--color-accent);
border: none;
border-radius: 4px;
border-radius: 6px;
color: white;
font-size: 14px;
font-weight: 500;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
transition: all 0.15s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
.btn-save:hover:not(:disabled) {
background: var(--color-accent-hover, #5a67d8);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.btn-primary:disabled {
.btn-save:active:not(:disabled) {
transform: translateY(0);
}
.btn-save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-action {
padding: 8px 14px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text-primary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-action:hover {
background: var(--color-accent-light);
border-color: var(--color-accent);
color: var(--color-accent);
}

View File

@@ -81,11 +81,6 @@ const Settings = () => {
return (
<div className="settings-container">
{/* 标题 */}
<div className="settings-header">
<h2>系统设置</h2>
</div>
{/* 消息提示 */}
{message.text && (
<div className={`message ${message.type}`}>
@@ -93,171 +88,216 @@ const Settings = () => {
</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>
{/* SillyTavern 风格:左右分栏布局 */}
<div className="settings-layout">
{/* 左侧:垂直标签栏 */}
<div className="settings-sidebar">
<button
className={`sidebar-tab ${activeTab === 'regex' ? 'active' : ''}`}
onClick={() => setActiveTab('regex')}
>
<span className="tab-icon">📝</span>
<span className="tab-label">正则规则</span>
</button>
<button
className={`sidebar-tab ${activeTab === 'thinking' ? 'active' : ''}`}
onClick={() => setActiveTab('thinking')}
>
<span className="tab-icon">💭</span>
<span className="tab-label">思考标签</span>
</button>
<button
className={`sidebar-tab ${activeTab === 'general' ? 'active' : ''}`}
onClick={() => setActiveTab('general')}
>
<span className="tab-icon"></span>
<span className="tab-label">通用设置</span>
</button>
</div>
{/* 内容区域 */}
<div className="settings-content">
{loading && <div className="loading">加载中...</div>}
{/* 右侧:内容区域 */}
<div className="settings-main">
{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>
{/* 正则规则设置 */}
{activeTab === 'regex' && (
<div className="settings-panel">
<div className="panel-header">
<h3>📝 正则规则管理</h3>
<p className="panel-description">
管理文本处理规则支持 SillyTavern 格式导入
</p>
</div>
<div className="settings-group">
<div className="group-title">规则文件结构</div>
<div className="setting-row">
<div className="row-label">存储位置</div>
<div className="row-content">
<code className="path-code">data/regex/</code>
</div>
</div>
<div className="processed">
<strong>处理后</strong>
<p>这是回复 继续</p>
<ul className="file-structure-list">
<li><span className="folder-icon">📁</span> global/ - 全局规则</li>
<li><span className="folder-icon">📁</span> characters/ - 角色卡规则</li>
<li><span className="folder-icon">📁</span> presets/ - 预设规则</li>
</ul>
</div>
<div className="settings-group">
<div className="group-title">导入导出</div>
<div className="setting-row">
<div className="row-label">导入规则文件</div>
<div className="row-content">
<input
type="file"
accept=".json"
className="file-input"
onChange={async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/regex/import', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showMessage('success', data.message);
} else {
showMessage('error', '导入失败');
}
} catch (error) {
showMessage('error', '导入失败');
}
}}
/>
<small className="hint-text">支持 SillyTavern 格式的规则文件</small>
</div>
</div>
<div className="setting-row">
<div className="row-label">导出全局规则</div>
<div className="row-content">
<button
className="btn-action"
onClick={() => window.open('/api/regex/export/global', '_blank')}
>
📥 导出 JSON
</button>
</div>
</div>
</div>
</div>
)}
<div className="setting-item">
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
{loading ? '保存中...' : '保存设置'}
</button>
</div>
</div>
)}
{/* 思考标签配置 */}
{activeTab === 'thinking' && (
<div className="settings-panel">
<div className="panel-header">
<h3>💭 思考标签配置</h3>
<p className="panel-description">
配置 AI 推理/思考内容的标记标签
</p>
</div>
<div className="settings-group">
<div className="group-title">标签设置</div>
<div className="setting-row">
<div className="row-label">前缀</div>
<div className="row-content">
<input
type="text"
value={settings.thinkingTagPrefix}
onChange={(e) => handleInputChange('thinkingTagPrefix', e.target.value)}
placeholder="&lt;thinking&gt;"
className="text-input"
/>
<small className="hint-text">默认值&lt;thinking&gt;</small>
</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-row">
<div className="row-label">后缀</div>
<div className="row-content">
<input
type="text"
value={settings.thinkingTagSuffix}
onChange={(e) => handleInputChange('thinkingTagSuffix', e.target.value)}
placeholder="&lt;/thinking&gt;"
className="text-input"
/>
<small className="hint-text">默认值&lt;/thinking&gt;</small>
</div>
</div>
</div>
<div className="setting-item">
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
{loading ? '保存中...' : '保存设置'}
</button>
<div className="settings-group">
<div className="group-title">效果预览</div>
<div className="preview-container">
<div className="preview-section">
<div className="preview-label">原始内容</div>
<div className="preview-code">
这是回复 {settings.thinkingTagPrefix}思考过程{settings.thinkingTagSuffix} 继续
</div>
</div>
<div className="preview-section">
<div className="preview-label">处理后</div>
<div className="preview-code processed">
这是回复 继续
</div>
</div>
</div>
</div>
<div className="settings-group actions-group">
<button className="btn-save" onClick={saveSettings} disabled={loading}>
{loading ? '保存中...' : '💾 保存设置'}
</button>
</div>
</div>
</div>
)}
)}
{/* 通用设置 */}
{activeTab === 'general' && (
<div className="settings-panel">
<div className="panel-header">
<h3> 通用设置</h3>
<p className="panel-description">
其他系统级配置
</p>
</div>
<div className="settings-group">
<div className="group-title">预设配置</div>
<div className="setting-row">
<div className="row-label">当前预设</div>
<div className="row-content">
<input
type="text"
value={settings.currentPresetName || ''}
onChange={(e) => handleInputChange('currentPresetName', e.target.value || null)}
placeholder="未选择"
className="text-input"
/>
<small className="hint-text">影响全局正则规则的启用</small>
</div>
</div>
</div>
<div className="settings-group actions-group">
<button className="btn-save" onClick={saveSettings} disabled={loading}>
{loading ? '保存中...' : '💾 保存设置'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
);

View File

@@ -134,7 +134,7 @@
overflow: hidden;
margin-top: 8px; /* 与全局世界书保持间距 */
position: relative; /* 确保正常文档流 */
z-index: 1; /* 降低层级,不遮挡其他元素 */
z-index: var(--z-base-content); /* ✅ 基础内容层 */
/* 最小高度 = header(33px) + actions(37px) + selector(37px) + entries最小内容(约200px) */
min-height: 307px;
display: flex;
@@ -258,17 +258,14 @@
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
position: fixed;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
z-index: var(--z-dropdown-menu); /* ✅ 组件层 - 下拉菜单 */
box-shadow: var(--shadow-md);
}
@@ -295,6 +292,33 @@
font-weight: 600;
}
/* ✅ 世界书下拉菜单项内容布局 */
.dropdown-item-content {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.global-checkbox {
cursor: pointer;
flex-shrink: 0;
}
.book-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.current-indicator {
color: var(--color-accent);
font-weight: bold;
font-size: 14px;
flex-shrink: 0;
}
/* ==================== 条目列表区域 ==================== */
.entries-container {
flex: 1;
@@ -321,6 +345,41 @@
font-weight: 500;
}
/* ✅ 页码跳转容器 */
.page-jump-container {
display: flex;
gap: 4px;
align-items: center;
}
.page-jump-input {
width: 50px;
padding: 4px 6px;
font-size: 11px;
border: 1px solid var(--color-border);
border-radius: 3px;
background: var(--color-bg-primary);
color: var(--color-text-primary);
text-align: center;
outline: none;
transition: all 0.15s ease;
}
.page-jump-input:hover {
border-color: var(--color-accent);
}
.page-jump-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
.btn-jump {
padding: 4px 8px;
font-size: 11px;
min-width: 40px;
}
.page-size-select {
padding: 3px 6px;
font-size: 11px;
@@ -634,12 +693,13 @@
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 9998;
background-color: rgba(0, 0, 0, 0.3); /* ✅ 降低透明度,不使用模糊 */
/* backdrop-filter: blur(4px); */ /* ✅ 移除模糊效果,允许操作旁边区域 */
z-index: var(--z-edit-panel-overlay); /* ✅ 弹窗层 - 遮罩 */
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
pointer-events: none; /* ✅ 允许点击穿透到下层元素 */
}
.edit-panel-overlay.open {
@@ -657,7 +717,7 @@
height: 85vh;
max-height: 900px;
background: var(--color-bg-primary);
z-index: 9999;
z-index: var(--z-edit-panel-content); /* ✅ 弹窗层 - 编辑面板 */
padding: 0;
overflow: hidden;
opacity: 0;
@@ -764,6 +824,7 @@
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: stretch; /* ✅ 确保所有子元素高度一致 */
}
.form-group {
@@ -775,22 +836,30 @@
.form-group.compact {
flex: 1;
min-width: 150px;
display: flex;
flex-direction: column;
}
.form-label {
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary);
}
/* ✅ 统一输入框和选择框的高度 */
.form-input {
padding: 10px 12px;
padding: 9px 12px;
font-size: 13px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-primary);
color: var(--color-text-primary);
transition: all 0.2s ease;
height: 38px; /* ✅ 固定高度 */
line-height: 1.4;
box-sizing: border-box;
}
.form-label {
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary);
display: block;
margin-bottom: 2px;
}
.form-input:hover {
@@ -804,6 +873,16 @@
transform: translateY(-1px);
}
/* select 特殊优化 */
select.form-input {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 30px;
}
.content-textarea {
flex: 1;
min-height: 250px;
@@ -817,6 +896,39 @@
display: flex;
gap: 12px;
flex-wrap: wrap;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 12px;
}
.trigger-selector {
flex: 0 0 auto;
min-width: 160px;
max-width: 200px;
}
.trigger-selector .form-label {
font-size: 11px;
font-weight: 600;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
display: block;
}
.trigger-config-panel {
flex: 1;
min-width: 300px;
padding-left: 12px;
border-left: 2px solid var(--color-border-light);
}
.keyword-config {
display: flex;
flex-direction: column;
gap: 8px;
}
/* ✅ 编辑面板底部操作栏 */
@@ -847,20 +959,118 @@
transform: translateY(0);
}
.trigger-selector {
flex: 1;
min-width: 200px;
}
.trigger-config-panel {
flex: 2;
min-width: 300px;
}
.keyword-config {
/* ✅ 条件触发配置样式 */
.condition-config {
display: flex;
flex-direction: column;
gap: 12px;
}
/* 条件块样式 */
.condition-block {
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 12px;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.condition-block:hover {
border-color: var(--color-accent);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
transform: translateY(-1px);
}
.condition-header {
display: flex;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-border-light);
}
.condition-label {
font-size: 11px;
font-weight: 700;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: 0.8px;
display: flex;
align-items: center;
gap: 6px;
}
.condition-label::before {
content: '';
display: inline-block;
width: 3px;
height: 12px;
background: var(--color-accent);
border-radius: 2px;
}
.condition-fields {
display: flex;
gap: 8px;
align-items: center;
}
.condition-fields .form-input {
flex: 1;
min-width: 0;
padding: 8px 10px;
font-size: 12px;
height: 36px;
}
.condition-fields select.form-input {
flex: 0 0 auto;
min-width: 95px;
max-width: 110px;
}
/* 条件预览 */
.condition-preview {
background: linear-gradient(135deg, var(--color-accent-ultra-light) 0%, rgba(102, 126, 234, 0.05) 100%);
border: 1px solid var(--color-accent-light);
border-left: 3px solid var(--color-accent);
border-radius: 6px;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.03);
}
.preview-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.preview-label::before {
content: '📋';
font-size: 12px;
}
.preview-code {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 12px;
color: var(--color-accent);
font-weight: 600;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: rgba(255, 255, 255, 0.5);
padding: 4px 8px;
border-radius: 4px;
}
/* ==================== 分页控件样式 ==================== */
@@ -927,15 +1137,13 @@
/* ✅ 排序下拉菜单 - 仿照预设的操作下拉菜单 */
.sort-dropdown-menu {
position: absolute;
top: 100%;
right: 0;
position: fixed;
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;
z-index: var(--z-sort-panel); /* ✅ 组件层 - 排序面板 */
min-width: 160px;
padding: 4px;
}

View File

@@ -1,4 +1,5 @@
import React, {useEffect, useState} from 'react';
import ReactDOM from 'react-dom'; // ✅ 用于 Portal
import './WorldBook.css';
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice';
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice';
@@ -76,25 +77,34 @@ const WorldBook = () => {
// 分页状态
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20); // 默认每页20条
const [jumpPageInput, setJumpPageInput] = useState(''); // ✅ 页码跳转输入
// ✅ 排序状态
const [sortBy, setSortBy] = useState('order'); // 'name' | 'name_desc' | 'order'
const [activeFirst, setActiveFirst] = useState(false); // 激活条目优先
const [showSortMenu, setShowSortMenu] = useState(false); // 显示排序菜单
// 下拉菜单位置
const [sortDropdownPosition, setSortDropdownPosition] = useState({ top: 0, right: 0 });
const [worldbookDropdownPosition, setWorldbookDropdownPosition] = useState({ top: 0, left: 0 });
// ✅ 点击外部关闭排序下拉菜单
useEffect(() => {
const handleClickOutside = (event) => {
if (showSortMenu && !event.target.closest('.sort-settings-container')) {
setShowSortMenu(false);
}
// ✅ 点击外部关闭世界书下拉菜单
if (showWorldBookDropdown && !event.target.closest('.worldbook-dropdown-container')) {
setShowWorldBookDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showSortMenu]);
}, [showSortMenu, showWorldBookDropdown]);
// 获取当前激活的分页
const { activeTab } = useSideBarLeftStore();
@@ -267,6 +277,7 @@ const WorldBook = () => {
const handleEntryClick = (entry) => {
setCurrentEntry(entry);
setLocalEntry(entry); // ✅ 设置本地状态以在编辑面板中显示
setShowEditPanel(true);
};
@@ -287,6 +298,27 @@ const WorldBook = () => {
}
};
// ✅ 处理列表中条目的快速更新(不依赖 currentEntry
const handleEntryQuickUpdate = async (entry, field, value) => {
if (!currentWorldBook) return;
const updatedEntry = { ...entry, [field]: value };
try {
await updateWorldBookEntry(currentWorldBook.name, entry.uid, updatedEntry);
// 如果当前正在编辑这个条目,也更新 currentEntry 和 localEntry
if (currentEntry?.uid === entry.uid) {
setCurrentEntry(updatedEntry);
setLocalEntry(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;
@@ -310,6 +342,29 @@ const WorldBook = () => {
}
};
// ✅ 处理页码跳转
const handleJumpToPage = async () => {
if (!currentWorldBook || !jumpPageInput) return;
const pageNum = parseInt(jumpPageInput);
const totalPages = entriesPagination.total_pages;
// 验证页码范围
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) {
alert(`请输入 1 到 ${totalPages} 之间的页码`);
setJumpPageInput('');
return;
}
setCurrentPage(pageNum);
setJumpPageInput('');
try {
await fetchWorldBookEntries(currentWorldBook.name, pageNum, pageSize);
} catch (err) {
console.error('加载世界书条目失败:', err);
}
};
// ✅ 性能优化防抖保存500ms后自动保存
const debouncedSave = React.useCallback((updatedEntry) => {
if (saveTimeoutRef.current) {
@@ -710,14 +765,33 @@ const WorldBook = () => {
{/* 世界书选择区域 */}
<div className="worldbook-selector">
<div className="dropdown" style={{flex: 1}}>
<div className="dropdown worldbook-dropdown-container" style={{flex: 1, position: 'relative'}}>
<button className="dropdown-btn"
onClick={() => setShowWorldBookDropdown(!showWorldBookDropdown)}>
onClick={(e) => {
e.stopPropagation();
// ✅ 计算下拉菜单位置
const rect = e.currentTarget.getBoundingClientRect();
setWorldbookDropdownPosition({
top: rect.bottom + 4, // 按钮下方 4px
left: rect.left
});
setShowWorldBookDropdown(!showWorldBookDropdown);
}}>
{currentWorldBook ? currentWorldBook.name : '选择世界书'}
<span></span>
</button>
{showWorldBookDropdown && (
<div className="dropdown-menu">
<div
className="dropdown-menu"
style={{
position: 'fixed',
top: `${worldbookDropdownPosition.top}px`,
left: `${worldbookDropdownPosition.left}px`,
minWidth: '200px',
maxWidth: '300px',
zIndex: 1000
}}
>
{worldBooks.map(book => (
<div
key={book.name}
@@ -794,7 +868,7 @@ const WorldBook = () => {
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
e.stopPropagation();
handleEntryUpdate('position', parseInt(e.target.value));
handleEntryQuickUpdate(entry, 'position', parseInt(e.target.value));
}}
>
{[0, 1, 2, 3, 4, 5].map(pos => {
@@ -813,7 +887,8 @@ const WorldBook = () => {
title={isEnabled ? '已启用' : '已禁用'}
onClick={(e) => {
e.stopPropagation();
handleEntryUpdate('disable', !isEnabled);
// ✅ 直接更新条目,使用 entry 而不是 currentEntry
handleEntryQuickUpdate(entry, 'disable', !isEnabled);
}}
/>
</div>
@@ -871,7 +946,8 @@ const WorldBook = () => {
{/* 编辑面板遮罩层 */}
<div className={`edit-panel-overlay ${showEditPanel ? 'open' : ''}`}
onClick={() => setShowEditPanel(false)}/>
/* 移除 onClick允许点击穿透到下层元素动态表格等 */
/>
{/* 编辑面板 */}
{showEditPanel && localEntry && (
@@ -879,7 +955,11 @@ const WorldBook = () => {
{/* 头部 */}
<div className="edit-panel-header">
<h2>编辑条目 - UID: {localEntry.uid} {isSaving && <span className="saving-indicator">💾 保存中...</span>}</h2>
<button className="close-btn" onClick={() => setShowEditPanel(false)}>
<button className="close-btn" onClick={() => {
setShowEditPanel(false);
setLocalEntry(null); // ✅ 清空本地状态
setCurrentEntry(null); // ✅ 清空当前条目
}}>
</button>
</div>
@@ -1183,12 +1263,12 @@ const WorldBook = () => {
{activeTriggerStrategy === 'condition' && (
<div className="condition-config">
<div className="form-group compact">
<label className="form-label">变量A</label>
<input
type="text"
{/* 逻辑运算符选择 */}
<div className="form-group compact" style={{marginBottom: '8px'}}>
<label className="form-label">逻辑关系</label>
<select
className="form-input"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_a || ''}
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.logic || 'AND'}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
@@ -1198,74 +1278,214 @@ const WorldBook = () => {
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
variable_a: e.target.value
logic: e.target.value
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
/>
</div>
>
<option value="AND"> (AND)</option>
<option value="OR"> (OR)</option>
<option value="NOT_A"> A (NOT)</option>
<option value="NOT_B"> B (NOT)</option>
<option value="XOR">异或 (XOR)</option>
</select>
</div>
<div className="form-group compact">
<label className="form-label">运算符</label>
<select
className="form-input"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.operator || '='}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
operator: e.target.value
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
>
<option value="等于">等于</option>
<option value="大于">大于</option>
<option value="小于">小于</option>
<option value="不小于">不小于</option>
<option value="不大于">不大于</option>
<option value="不等于">不等于</option>
<option value="包括">包括</option>
</select>
</div>
<div className="form-group compact">
<label className="form-label">变量B</label>
<input
{/* 条件 A */}
<div className="condition-block">
<div className="condition-header">
<span className="condition-label">条件 A</span>
</div>
<div className="condition-fields">
<input
type="text"
className="form-input"
placeholder="变量/值 A"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_a || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
variable_a: e.target.value
}
]
}
};
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
/>
<select
className="form-input"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.operator_a || '='}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
operator_a: e.target.value
}
]
}
};
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
>
<option value="=">等于</option>
<option value=">">大于</option>
<option value="<">小于</option>
<option value=">=">不小于</option>
<option value="<=">不大于</option>
<option value="!=">不等于</option>
<option value="contains">包含</option>
</select>
<input
type="text"
className="form-input"
placeholder="比较值"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.value_a || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
value_a: e.target.value
}
]
}
};
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
/>
</div>
</div>
{/* 条件 B */}
<div className="condition-block">
<div className="condition-header">
<span className="condition-label">条件 B</span>
</div>
<div className="condition-fields">
<input
type="text"
className="form-input"
placeholder="变量/值 A"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_b || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
variable_b: e.target.value
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
</div>
)}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
variable_b: e.target.value
}
]
}
};
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
/>
<select
className="form-input"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.operator_b || '='}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
operator_b: e.target.value
}
]
}
};
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
>
<option value="=">等于</option>
<option value=">">大于</option>
<option value="<">小于</option>
<option value=">=">不小于</option>
<option value="<=">不大于</option>
<option value="!=">不等于</option>
<option value="contains">包含</option>
</select>
<input
type="text"
className="form-input"
placeholder="比较值"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.value_b || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
value_b: e.target.value
}
]
}
};
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
/>
</div>
</div>
{/* 条件预览 */}
<div className="condition-preview">
<span className="preview-label">预览</span>
<code className="preview-code">
{(() => {
const config = currentEntry.trigger_config?.triggers?.condition?.[1] || {};
const logic = config.logic || 'AND';
const condA = `${config.variable_a || '?'} ${config.operator_a || '='} ${config.value_a || '?'}`;
const condB = `${config.variable_b || '?'} ${config.operator_b || '='} ${config.value_b || '?'}`;
const logicSymbols = {
'AND': '∧',
'OR': '',
'NOT_A': '¬',
'NOT_B': '¬',
'XOR': '⊕'
};
if (logic === 'NOT_A') {
return `¬(${condA})`;
} else if (logic === 'NOT_B') {
return `¬(${condB})`;
} else {
return `(${condA}) ${logicSymbols[logic]} (${condB})`;
}
})()}
</code>
</div>
</div>
)}
</div>
</div>
</div>
@@ -1286,13 +1506,21 @@ const WorldBook = () => {
<div style={{flex: 1}}/>
<button
className="btn"
onClick={() => setShowEditPanel(false)}
onClick={() => {
setShowEditPanel(false);
setLocalEntry(null); // ✅ 清空本地状态
setCurrentEntry(null); // ✅ 清空当前条目
}}
>
取消
</button>
<button
className="btn btn-primary"
onClick={() => setShowEditPanel(false)}
onClick={() => {
setShowEditPanel(false);
setLocalEntry(null); // ✅ 清空本地状态
setCurrentEntry(null); // ✅ 清空当前条目
}}
>
保存并关闭
</button>

View File

@@ -0,0 +1,229 @@
import React, { useState, useEffect } from 'react';
import './Settings.css';
import useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
import { HistoryMode } from '../../../types/internal.types';
/**
* 设置Tab - 显示和编辑当前聊天的总结配置
*/
const Settings = () => {
const { currentRole, currentChat } = useChatBoxStore();
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
// 加载设置
useEffect(() => {
if (currentRole && currentChat) {
loadSettings();
}
}, [currentRole, currentChat]);
const loadSettings = async () => {
if (!currentRole || !currentChat) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`/api/chats/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/summary-status`
);
if (!response.ok) {
throw new Error('加载设置失败');
}
const data = await response.json();
setSettings(data);
} catch (err) {
console.error('[Settings] 加载设置失败:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!currentRole || !currentChat || !settings) return;
setSaving(true);
try {
const response = await fetch(
`/api/chats/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/settings`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
}
);
if (!response.ok) {
throw new Error('保存设置失败');
}
alert('设置已保存');
} catch (err) {
console.error('[Settings] 保存设置失败:', err);
alert('保存失败: ' + err.message);
} finally {
setSaving(false);
}
};
const updateSummaryConfig = (field, value) => {
setSettings(prev => ({
...prev,
summaryConfig: {
...prev.summaryConfig,
[field]: value
}
}));
};
if (!currentRole || !currentChat) {
return (
<div className="settings-tab">
<div className="settings-empty">
<p>请先选择一个角色和聊天</p>
</div>
</div>
);
}
if (loading) {
return (
<div className="settings-tab">
<div className="settings-loading">加载中...</div>
</div>
);
}
if (error) {
return (
<div className="settings-tab">
<div className="settings-error">
<p>错误: {error}</p>
<button onClick={loadSettings}>重试</button>
</div>
</div>
);
}
return (
<div className="settings-tab">
<div className="settings-header">
<h3>聊天设置</h3>
<button
className="save-btn"
onClick={handleSave}
disabled={saving}
>
{saving ? '保存中...' : '保存设置'}
</button>
</div>
<div className="settings-content">
{/* 历史记录模式 */}
<div className="setting-group">
<label className="setting-label">历史记录模式</label>
<select
className="setting-select"
value={settings?.historyMode || 'full'}
onChange={(e) => setSettings(prev => ({
...prev,
historyMode: e.target.value
}))}
disabled={settings?.historyMode === 'rag'}
>
<option value="full">全量模式保留所有消息</option>
<option value="summary">总结模式定期总结历史</option>
<option value="rag" disabled>
RAG模式需要API配置选择后不可取消
</option>
</select>
{settings?.historyMode === 'rag' && (
<small className="setting-hint warning">
RAG模式一旦选择不可取消
</small>
)}
</div>
{/* 总结配置仅在summary模式下显示 */}
{(settings?.historyMode === 'summary' || settings?.summaryConfig) && (
<div className="setting-group">
<label className="setting-label">总结配置</label>
<div className="setting-row">
<label>总结间隔</label>
<input
type="number"
min="2"
value={settings?.summaryConfig?.interval || 10}
onChange={(e) => updateSummaryConfig('interval', Number(e.target.value))}
/>
</div>
<div className="setting-row">
<label>保留最近楼层</label>
<input
type="number"
min="1"
value={settings?.summaryConfig?.recentFloorsToKeep || 5}
onChange={(e) => updateSummaryConfig('recentFloorsToKeep', Number(e.target.value))}
/>
</div>
<div className="setting-row checkbox-row">
<label>
<input
type="checkbox"
checked={settings?.summaryConfig?.includeUserInput !== false}
onChange={(e) => updateSummaryConfig('includeUserInput', e.target.checked)}
/>
总结时包含用户输入
</label>
</div>
<div className="setting-row">
<label>总结提示词</label>
<textarea
value={settings?.summaryConfig?.summaryPrompt || '请总结以下对话内容,保留关键信息和上下文。'}
onChange={(e) => updateSummaryConfig('summaryPrompt', e.target.value)}
rows={3}
/>
</div>
<small className="setting-hint">
💡 总结间隔AI回复达到此数量时触发总结<br />
保留楼层最近X条不总结
</small>
</div>
)}
{/* 计数器状态 */}
{settings?.historyMode === 'summary' && (
<div className="setting-group status-group">
<label className="setting-label">总结状态</label>
<div className="status-info">
<div className="status-item">
<span className="status-label">当前计数:</span>
<span className="status-value">{settings?.summaryCounter || 0}</span>
</div>
<div className="status-item">
<span className="status-label">上次总结楼层:</span>
<span className="status-value">L{settings?.lastSummaryFloor || 0}</span>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default Settings;

View File

@@ -273,3 +273,104 @@
cursor: not-allowed;
transform: none;
}
/* ==================== 动态表格样式(键值对)==================== */
.dynamic-table {
display: flex;
flex-direction: column;
gap: 6px;
}
.table-row {
display: flex;
align-items: center;
padding: 8px 12px;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 6px;
transition: all 0.15s ease;
}
.table-row:hover {
border-color: var(--color-accent);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.1);
}
.table-key {
min-width: 100px;
max-width: 150px;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-value {
flex: 1;
font-size: 0.9rem;
color: var(--color-text-primary);
padding-left: 12px;
border-left: 2px solid var(--color-border-light);
word-break: break-word;
}
/* ✅ 可编辑输入框样式 */
.table-input {
width: 100%;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid transparent;
border-radius: 4px;
color: var(--color-text-primary);
font-size: 0.85rem;
transition: all 0.2s ease;
}
.table-input:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.15);
}
.table-input:focus {
outline: none;
background: rgba(255, 255, 255, 0.15);
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.table-input::placeholder {
color: var(--color-text-muted);
opacity: 0.5;
font-style: italic;
}
/* SillyTavern 标签区域 */
.tags-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-border-light);
}
.tags-header {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
margin-bottom: 8px;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag-badge {
padding: 3px 8px;
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 12px;
font-size: 0.75rem;
color: var(--color-text-secondary);
}

View File

@@ -5,66 +5,68 @@ import './Table.css';
const Table = () => {
// ✅ 从 Zustand store 获取状态和方法
const {
tags,
tableHeaders,
tableDefaults,
isLoading,
currentRole,
currentChat,
loadTags,
updateTags,
addTag,
deleteTag,
moveLeft,
moveRight,
editTag,
refresh
refresh,
updateTableData // ✅ 新增:保存方法
} = useTableStore();
// 本地 UI 状态(不需要持久化)
const [editingIndex, setEditingIndex] = useState(null);
const [editValue, setEditValue] = useState('');
const [newTagInput, setNewTagInput] = useState('');
// 本地编辑状态
const [editingValues, setEditingValues] = useState({});
const [isSaving, setIsSaving] = useState(false);
// 监听角色/聊天变化,自动加载数据
useEffect(() => {
if (currentRole) {
loadTags(currentRole, currentChat);
setEditingValues({}); // 重置编辑状态
}
}, [currentRole, currentChat, loadTags]);
// 开始编辑标签
const handleStartEdit = (index, value) => {
setEditingIndex(index);
setEditValue(value);
// ✅ 处理值变化
const handleValueChange = (key, value) => {
setEditingValues(prev => ({
...prev,
[key]: value
}));
};
// 保存编辑
const handleSaveEdit = () => {
if (editingIndex === null) return;
// ✅ 保存单个字段(失焦时自动保存)
const handleBlur = async (key) => {
const newValue = editingValues[key];
if (newValue === undefined || newValue === tableDefaults[key]) {
return; // 没有变化,不保存
}
setIsSaving(true);
editTag(editingIndex, editValue);
setEditingIndex(null);
setEditValue('');
// 更新 tableDefaults
const newTableDefaults = {
...tableDefaults,
[key]: newValue
};
await updateTableData({ tableDefaults: newTableDefaults });
// 清除编辑状态
setEditingValues(prev => {
const newState = { ...prev };
delete newState[key];
return newState;
});
setIsSaving(false);
};
// 取消编辑
const handleCancelEdit = () => {
setEditingIndex(null);
setEditValue('');
};
// 添加新标签(支持批量输入)
const handleAddTag = () => {
const input = newTagInput.trim();
if (!input) return;
// 智能分割:支持空格、逗号、分号作为分隔符
const newTagsList = input
.split(/[\s,;]+/)
.filter(tag => tag.trim() !== '')
.map(tag => tag.trim());
addTag(newTagsList);
setNewTagInput('');
// ✅ 按 Enter 键保存
const handleKeyDown = (e, key) => {
if (e.key === 'Enter') {
e.target.blur(); // 触发 blur 事件
}
};
// 如果没有角色,显示提示
@@ -82,116 +84,69 @@ const Table = () => {
);
}
// ✅ 如果有动态表格数据,显示键值对表格
if (tableHeaders.length > 0) {
return (
<div className="table-panel">
<div className="tab-header">
<span className="title-text">动态表格</span>
<div className="header-actions">
<span className="table-info">
{tableHeaders.length} 个字段 {isSaving && '💾'}
</span>
<button
className="refresh-btn"
onClick={refresh}
disabled={isLoading || isSaving}
title="刷新数据"
>
{isLoading ? '⏳' : '🔄'}
</button>
</div>
</div>
<div className="table-container">
{/* ✅ 动态表格 - 可编辑的键值对展示 */}
<div className="dynamic-table">
{tableHeaders.map((header, index) => {
const currentValue = editingValues[header] !== undefined
? editingValues[header]
: (tableDefaults[header] !== undefined ? tableDefaults[header] : '');
return (
<div key={index} className="table-row">
<div className="table-key">
{header}
</div>
<div className="table-value">
<input
type="text"
className="table-input"
value={currentValue}
onChange={(e) => handleValueChange(header, e.target.value)}
onBlur={() => handleBlur(header)}
onKeyDown={(e) => handleKeyDown(e, header)}
placeholder="输入值..."
/>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
// ✅ 否则显示空状态
return (
<div className="table-panel">
<div className="tab-header">
<span className="title-text">动态表格</span>
<div className="header-actions">
<span className="table-info">
{tags.length} 个标签
</span>
<button
className="refresh-btn"
onClick={refresh}
disabled={isLoading}
title="刷新数据"
>
{isLoading ? '⏳' : '🔄'}
</button>
</div>
</div>
<div className="table-container">
{/* 标签列表 */}
<div className="tags-container">
{tags.length === 0 ? (
<div className="no-tags-hint">
暂无标签请在下方添加
</div>
) : (
tags.map((tag, index) => (
<div
key={index}
className={`tag-item ${editingIndex === index ? 'editing' : ''}`}
>
{editingIndex === index ? (
// 编辑模式
<div className="tag-edit-container">
<input
type="text"
className="tag-edit-input"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit();
if (e.key === 'Escape') handleCancelEdit();
}}
onBlur={handleSaveEdit}
autoFocus
/>
</div>
) : (
// 显示模式
<>
<span
className="tag-text"
onDoubleClick={() => handleStartEdit(index, tag)}
title="双击编辑"
>
{tag}
</span>
<div className="tag-actions">
<button
className="tag-action-btn move-left"
onClick={() => moveLeft(index)}
disabled={index === 0}
title="左移"
>
</button>
<button
className="tag-action-btn move-right"
onClick={() => moveRight(index)}
disabled={index === tags.length - 1}
title="右移"
>
</button>
<button
className="tag-action-btn delete"
onClick={() => deleteTag(index)}
title="删除"
>
</button>
</div>
</>
)}
</div>
))
)}
</div>
{/* 添加新标签输入框 */}
<div className="add-tag-container">
<input
type="text"
className="add-tag-input"
placeholder="输入标签(支持空格/逗号分隔多个)..."
value={newTagInput}
onChange={(e) => setNewTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddTag();
}}
/>
<button
className="add-tag-btn"
onClick={handleAddTag}
disabled={!newTagInput.trim()}
>
+ 添加
</button>
</div>
<div className="empty-table">
<p>📊</p>
<p>该角色没有动态表格数据</p>
</div>
</div>
);

View File

@@ -7,7 +7,7 @@
background-color: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border-light);
box-shadow: var(--shadow-sm);
z-index: 100; /* 降低层级,不遮挡悬浮提示 */
z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar */
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}

View File

@@ -244,7 +244,7 @@ const Toolbar = () => {
<div className="panel-overlay" ref={panelRef}>
<div className="panel-content settings-panel">
<div className="panel-header">
<h3>系统设置</h3>
<h2>系统设置</h2>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button>

View File

@@ -1,6 +1,7 @@
/* Import global styles */
@import './styles/variables.css';
@import './styles/reset.css';
@import './styles/z-index.css'; /* ✅ Z-Index 层级规范 */
/* ==================== Main Layout ==================== */
.app {
@@ -128,12 +129,12 @@
radial-gradient(circle at 80% 70%, rgba(109, 140, 255, 0.03) 0%, transparent 50%),
linear-gradient(180deg, var(--color-bg-primary) 0%, var(--color-bg-subtle) 100%);
pointer-events: none;
z-index: 0;
z-index: var(--z-background); /* ✅ 基础层 - 背景 */
}
.chat-area > * {
position: relative;
z-index: 1;
z-index: var(--z-base-content); /* ✅ 基础层 - 内容 */
}
.sidebar-right {

View File

@@ -0,0 +1,39 @@
/**
* Z-Index CSS 变量定义
* ====================
*
* 在 CSS 文件中使用方式:
* z-index: var(--z-dropdown-menu);
* z-index: var(--z-modal-overlay);
*/
:root {
/* ==================== 基础层 (0-99) ==================== */
--z-background: 0;
--z-base-content: 1;
--z-divider: 10;
/* ==================== 组件层 (100-999) ==================== */
--z-top-bar: 100;
--z-sidebar: 100;
--z-dropdown-menu: 1000;
--z-sort-panel: 1100;
--z-tooltip: 1200;
--z-chat-actions: 1000;
--z-character-preview: 1000;
/* ==================== 弹窗层 (10000-19999) ==================== */
--z-modal-overlay: 10000;
--z-modal-backdrop: 10000; /* TopBar 面板遮罩 */
--z-modal-content: 10100;
--z-edit-panel-overlay: 10200;
--z-edit-panel-content: 10300;
/* ==================== 通知层 (20000-29999) ==================== */
--z-toast-container: 20000;
--z-toast-item: 20100;
/* ==================== 系统层 (30000+) ==================== */
--z-loading-spinner: 30000;
--z-error-boundary: 30100;
}

View File

@@ -0,0 +1,109 @@
/**
* Z-Index 层级规范
* ==================
*
* 本文件定义了项目中所有 z-index 的使用规范,确保层级关系清晰、一致。
*
* 层级划分原则:
* - 每层之间预留足够的空间(至少 100方便后续插入新层级
* - 同层级的元素使用相近的 z-index 值
* - 避免使用过大的数值(如 99999保持可读性
*/
export const Z_INDEX = {
// ==================== 基础层 (0-99) ====================
// 用于页面背景、基础布局等
/** 最底层 - 背景装饰 */
BACKGROUND: 0,
/** 基础内容层 - 普通文本、图片等 */
BASE_CONTENT: 1,
/** 分割线、边框装饰 */
DIVIDER: 10,
// ==================== 组件层 (100-999) ====================
// 用于常规 UI 组件,如下拉菜单、悬浮提示等
/** TopBar 导航栏 */
TOP_BAR: 100,
/** 侧边栏容器 */
SIDEBAR: 100,
/** 下拉菜单 - 预设操作菜单、世界书选择菜单等 */
DROPDOWN_MENU: 1000,
/** 排序设置面板 */
SORT_PANEL: 1100,
/** 悬浮提示 Tooltip */
TOOLTIP: 1200,
/** 聊天消息操作按钮 */
CHAT_ACTIONS: 1000,
/** 角色卡预览弹窗 */
CHARACTER_PREVIEW: 1000,
// ==================== 弹窗层 (10000-19999) ====================
// 用于模态对话框、编辑面板等需要覆盖整个页面的元素
/** 对话框遮罩层背景 */
MODAL_OVERLAY: 10000,
/** TopBar 面板遮罩 */
MODAL_BACKDROP: 10000,
/** 对话框内容 - API 配置对话框、预设保存对话框等 */
MODAL_CONTENT: 10100,
/** 世界书编辑面板遮罩层 */
EDIT_PANEL_OVERLAY: 10200,
/** 世界书编辑面板内容 */
EDIT_PANEL_CONTENT: 10300,
// ==================== 通知层 (20000-29999) ====================
// 用于全局通知、Toast 提示等
/** Toast 通知容器 */
TOAST_CONTAINER: 20000,
/** Toast 通知项 */
TOAST_ITEM: 20100,
// ==================== 系统层 (30000+) ====================
// 用于系统级元素,如加载动画、错误边界等
/** 全局加载动画 */
LOADING_SPINNER: 30000,
/** 错误边界覆盖层 */
ERROR_BOUNDARY: 30100,
} as const;
/**
* 使用示例:
*
* import { Z_INDEX } from '../styles/z-index';
*
* .dropdown-menu {
* z-index: ${Z_INDEX.DROPDOWN_MENU};
* }
*
* .modal-overlay {
* z-index: ${Z_INDEX.MODAL_OVERLAY};
* }
*
* .edit-panel {
* z-index: ${Z_INDEX.EDIT_PANEL_CONTENT};
* }
*/
export default Z_INDEX;

View File

@@ -0,0 +1,159 @@
/**
* 聊天总结计数器管理器
*
* 负责在LocalStorage中维护每个聊天的总结计数器
* 避免频繁请求后端,提升性能
*/
const STORAGE_KEY = 'chat_summary_counters';
/**
* 计数器数据结构
* @typedef {Object} CounterData
* @property {number} counter - 当前计数器值
* @property {number} lastSummaryFloor - 最后一次总结的楼层
* @property {number} updatedAt - 最后更新时间戳
*/
/**
* 获取所有聊天的计数器数据
* @returns {Object.<string, CounterData>}
*/
export function getAllCounters() {
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : {};
} catch (error) {
console.error('[SummaryCounter] 读取计数器失败:', error);
return {};
}
}
/**
* 获取指定聊天的计数器
* @param {string} chatKey - 聊天键格式role_name/chat_name
* @returns {CounterData}
*/
export function getCounter(chatKey) {
const allCounters = getAllCounters();
return allCounters[chatKey] || {
counter: 0,
lastSummaryFloor: 0,
updatedAt: Date.now()
};
}
/**
* 更新指定聊天的计数器
* @param {string} chatKey - 聊天键
* @param {Partial<CounterData>} updates - 更新的数据
*/
export function updateCounter(chatKey, updates) {
try {
const allCounters = getAllCounters();
const current = allCounters[chatKey] || {
counter: 0,
lastSummaryFloor: 0,
updatedAt: Date.now()
};
allCounters[chatKey] = {
...current,
...updates,
updatedAt: Date.now()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(allCounters));
} catch (error) {
console.error('[SummaryCounter] 更新计数器失败:', error);
}
}
/**
* 增加计数器当AI回复被用户确认时调用
* @param {string} chatKey - 聊天键
* @param {number} increment - 增加的值默认1
* @returns {number} 新的计数器值
*/
export function incrementCounter(chatKey, increment = 1) {
const current = getCounter(chatKey);
const newCounter = current.counter + increment;
updateCounter(chatKey, { counter: newCounter });
console.log(`[SummaryCounter] ${chatKey} 计数器: ${current.counter} -> ${newCounter}`);
return newCounter;
}
/**
* 重置计数器(总结完成后调用)
* @param {string} chatKey - 聊天键
* @param {number} lastSummaryFloor - 最后一次总结的楼层
*/
export function resetCounter(chatKey, lastSummaryFloor) {
updateCounter(chatKey, {
counter: 0,
lastSummaryFloor
});
console.log(`[SummaryCounter] ${chatKey} 计数器已重置lastSummaryFloor=${lastSummaryFloor}`);
}
/**
* 检查是否应该触发总结
* @param {string} chatKey - 聊天键
* @param {number} interval - 总结间隔阈值
* @returns {boolean}
*/
export function shouldTriggerSummary(chatKey, interval) {
const current = getCounter(chatKey);
return current.counter >= interval;
}
/**
* 清除指定聊天的计数器
* @param {string} chatKey - 聊天键
*/
export function clearCounter(chatKey) {
try {
const allCounters = getAllCounters();
delete allCounters[chatKey];
localStorage.setItem(STORAGE_KEY, JSON.stringify(allCounters));
console.log(`[SummaryCounter] 已清除 ${chatKey} 的计数器`);
} catch (error) {
console.error('[SummaryCounter] 清除计数器失败:', error);
}
}
/**
* 清除所有计数器
*/
export function clearAllCounters() {
try {
localStorage.removeItem(STORAGE_KEY);
console.log('[SummaryCounter] 已清除所有计数器');
} catch (error) {
console.error('[SummaryCounter] 清除所有计数器失败:', error);
}
}
/**
* 获取存储占用大小(字节)
* @returns {number}
*/
export function getStorageSize() {
const data = localStorage.getItem(STORAGE_KEY);
return data ? new Blob([data]).size : 0;
}
/**
* 格式化存储大小
* @returns {string}
*/
export function getFormattedStorageSize() {
const bytes = getStorageSize();
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}