添加测试、添加总结、全量、rag(todo)3种历史记录保存方式,流式实现
This commit is contained in:
@@ -114,7 +114,7 @@ function App() {
|
||||
console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建');
|
||||
}
|
||||
} else {
|
||||
console.log('[App] ✅ API 配置已从缓存恢复');
|
||||
// 从缓存恢复
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] ❌ API 配置加载失败:', err);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || {}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow: visible; /* ✅ 允许 fixed 定位的子元素显示 */
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>默认值:<thinking></small>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<label htmlFor="thinking-suffix">后缀</label>
|
||||
<input
|
||||
id="thinking-suffix"
|
||||
type="text"
|
||||
value={settings.thinkingTagSuffix}
|
||||
onChange={(e) => handleInputChange('thinkingTagSuffix', e.target.value)}
|
||||
placeholder="</thinking>"
|
||||
/>
|
||||
<small>默认值:</thinking></small>
|
||||
</div>
|
||||
|
||||
<div className="setting-item preview">
|
||||
<label>预览效果</label>
|
||||
<div className="preview-box">
|
||||
<div className="original">
|
||||
<strong>原始内容:</strong>
|
||||
<p>这是回复 {settings.thinkingTagPrefix}思考过程{settings.thinkingTagSuffix} 继续</p>
|
||||
{/* 正则规则设置 */}
|
||||
{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="<thinking>"
|
||||
className="text-input"
|
||||
/>
|
||||
<small className="hint-text">默认值:<thinking></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="</thinking>"
|
||||
className="text-input"
|
||||
/>
|
||||
<small className="hint-text">默认值:</thinking></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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
229
frontend/src/components/SideBarRight/tabs/Settings/Settings.jsx
Normal file
229
frontend/src/components/SideBarRight/tabs/Settings/Settings.jsx
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
39
frontend/src/styles/z-index.css
Normal file
39
frontend/src/styles/z-index.css
Normal 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;
|
||||
}
|
||||
109
frontend/src/styles/z-index.ts
Normal file
109
frontend/src/styles/z-index.ts
Normal 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;
|
||||
159
frontend/src/utils/summaryCounter.js
Normal file
159
frontend/src/utils/summaryCounter.js
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user