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

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

View File

@@ -47,10 +47,29 @@
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
padding: 0; /* ✅ 移除padding滚动条作为间隔 */
min-height: 0;
}
/* ✅ 美化滚动条 - 符合主题 */
.sidebar-content::-webkit-scrollbar {
width: 8px;
}
.sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-content::-webkit-scrollbar-thumb {
background: var(--color-scrollbar);
border-radius: 4px;
transition: all 0.2s ease;
}
.sidebar-content::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-hover);
}
/* Tab placeholder for empty states */
.tab-placeholder {
padding: var(--spacing-lg);

View File

@@ -3,15 +3,18 @@ import React from 'react';
import './SideBarLeft.css';
import { useSideBarLeftStore } from '../../Store/indexStore';
import usePresetStore from '../../Store/SideBarLeft/PresetSlice';
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
import Gallery from './tabs/Gallery';
import CharacterCard from './tabs/CharacterCard';
import ApiConfig from './tabs/ApiConfig';
import Presets from './tabs/Presets';
import WorldBook from './tabs/WorldBook';
import TokenUsage from './tabs/TokenUsage/TokenUsage';
const SideBarLeft = () => {
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
const { fetchPresets } = usePresetStore();
const { fetchProfiles } = useApiConfigStore();
// 处理标签切换
const handleTabClick = (tabId) => {
@@ -21,6 +24,11 @@ const SideBarLeft = () => {
if (tabId === 'presets') {
fetchPresets();
}
// 如果切换到API配置标签刷新配置文件列表
if (tabId === 'api') {
fetchProfiles();
}
};
return (
@@ -44,6 +52,7 @@ const SideBarLeft = () => {
{activeTab === 'api' && <ApiConfig />}
{activeTab === 'presets' && <Presets />}
{activeTab === 'worldbook' && <WorldBook />}
{activeTab === 'tokenUsage' && <TokenUsage />}
</div>
</div>
);

View File

@@ -6,7 +6,7 @@ import ComfyUIWorkflowManager from './ComfyUIWorkflowManager';
import './ApiConfig.css';
const ApiConfig = () => {
// 从store中获取状态和方法
// 从 store中获取状态和方法
const {
profiles,
currentProfile,
@@ -77,21 +77,11 @@ const ApiConfig = () => {
// 跟踪哪些 API 有修改
const [modifiedApis, setModifiedApis] = useState({});
// 跟踪用户是否正在编辑 API Key用于控制脱敏显示
const [isEditingApiKey, setIsEditingApiKey] = useState(false);
// 获取当前激活的分页
const { activeTab } = useSideBarLeftStore();
// 记录上一次的分页状态
const prevActiveTabRef = React.useRef(activeTab);
// 组件加载时获取配置文件列表 - 只在切换到API分页时刷新
useEffect(() => {
// 检测是否从其他分页切换到API分页
if (activeTab === 'api' && prevActiveTabRef.current !== 'api') {
fetchProfiles();
}
// 更新上一次的分页状态
prevActiveTabRef.current = activeTab;
}, [activeTab, fetchProfiles]);
// 当选中配置文件时,加载该配置
useEffect(() => {
@@ -100,6 +90,15 @@ const ApiConfig = () => {
}
}, [selectedProfileId]);
// 当 profiles 加载完成后,如果没有选中的配置文件,则自动加载第一个
useEffect(() => {
if (profiles.length > 0 && !selectedProfileId && activeTab === 'api') {
// 尝试加载 activeMap 中记录的配置文件,如果没有则加载第一个
const defaultProfileId = activeMap[currentCategory] || profiles[0].id;
setSelectedProfileId(defaultProfileId);
}
}, [profiles, selectedProfileId, activeTab, currentCategory, activeMap]);
// 加载配置文件
const loadProfile = async (profileId) => {
try {
@@ -123,6 +122,11 @@ const ApiConfig = () => {
const { name, value, type, checked } = e.target;
const newValue = type === 'checkbox' ? checked : value;
// 如果是 API Key 字段,标记为正在编辑
if (name === 'apiKey') {
setIsEditingApiKey(true);
}
if (path) {
// 嵌套路径更新,例如: ['imageModel', 'local', 'apiUrl']
setFormData(prev => {
@@ -267,8 +271,10 @@ const ApiConfig = () => {
return;
}
if (!currentApi.apiKey) {
alert('请先填写 API 密钥');
// 检查 API Key可以为空如果已保存过后端会使用保存的 key
// 但如果既没有输入 key也没有保存过则会失败
if (!currentApi.apiKey || currentApi.apiKey.trim() === '') {
alert('请先填写 API 密钥,或先保存配置文件');
return;
}
@@ -277,6 +283,7 @@ const ApiConfig = () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: selectedProfileId || null, // 使用 id 字段传递 profileId
apiUrl: currentApi.apiUrl,
apiKey: currentApi.apiKey,
category: currentCategory,
@@ -388,19 +395,9 @@ const ApiConfig = () => {
if (selectedProfileId && confirm('确定要删除此配置文件吗?')) {
await deleteProfile(selectedProfileId);
setSelectedProfileId('');
handleCreateNew();
}
};
// 处理设为默认配置
const handleSetActive = async () => {
if (!selectedProfileId) {
alert('请先保存配置文件');
return;
}
await setActiveConfig(currentCategory, selectedProfileId);
};
// 配置区域标签(简短名称 + Tooltip
const configTabs = [
{ id: 'mainLLM', label: '核心', tooltip: '主 LLM 模型 - 用于主要对话和推理' },
@@ -409,8 +406,13 @@ const ApiConfig = () => {
{ id: 'ragEmbedding', label: '向量', tooltip: 'RAG 嵌入模型 - 用于文本向量化和检索' }
];
// 判断当前 API 密钥是否是脱敏的
const isApiKeyMasked = formData[currentCategory].apiKey && formData[currentCategory].apiKey.endsWith('****');
// 脱敏 API Key 显示只显示最后6位
const maskApiKey = (apiKey) => {
if (!apiKey || apiKey.length <= 6) {
return apiKey || '';
}
return '•'.repeat(apiKey.length - 6) + apiKey.slice(-6);
};
// 计算有修改的 API 数量
const modifiedCount = Object.keys(modifiedApis).filter(k => modifiedApis[k]).length;
@@ -438,41 +440,38 @@ const ApiConfig = () => {
onChange={handleSelectProfile}
className="form-control profile-select-input"
>
<option value="">新建配置文件...</option>
{profiles.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
<div className="profile-buttons">
{!selectedProfileId ? (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleOpenSaveModal}
>
+ 新建
</button>
{profiles.length === 0 ? (
<option value="">暂无配置文件</option>
) : (
<>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleSetActive}
disabled={activeMap[currentCategory] === selectedProfileId}
>
设为默认
</button>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={handleDelete}
>
删除
</button>
<option value="">-- 选择配置文件 --</option>
{profiles.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</>
)}
</select>
<div className="profile-buttons">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleOpenSaveModal}
title="创建新的配置文件"
>
+ 新建
</button>
{selectedProfileId && (
<button
type="button"
className="btn btn-danger btn-sm"
onClick={handleDelete}
title="删除当前配置文件"
>
删除
</button>
)}
</div>
</div>
</div>
@@ -606,13 +605,31 @@ const ApiConfig = () => {
<div className="form-group">
<label htmlFor="cloud-apiKey">API Key</label>
<input
type="password"
type={isEditingApiKey ? 'text' : 'password'}
id="cloud-apiKey"
value={formData.imageModel.cloud.apiKey}
value={isEditingApiKey ? formData.imageModel.cloud.apiKey : maskApiKey(formData.imageModel.cloud.apiKey)}
onChange={(e) => handleChange(e, ['imageModel', 'cloud', 'apiKey'])}
placeholder="sk-..."
onBlur={() => setIsEditingApiKey(false)}
onFocus={() => {
if (formData.imageModel.cloud.apiKey && !isEditingApiKey) {
// 聚焦时清空,让用户重新输入
setFormData(prev => ({
...prev,
imageModel: {
...prev.imageModel,
cloud: {
...prev.imageModel.cloud,
apiKey: ''
}
}
}));
}
setIsEditingApiKey(true);
}}
placeholder="sk-...(输入新密钥将覆盖旧密钥)"
className="form-control"
/>
<span className="form-hint">首次输入可见之后仅显示最后6位</span>
</div>
<div className="form-group">
@@ -668,17 +685,29 @@ const ApiConfig = () => {
<div className="form-group">
<label htmlFor="apiKey">密钥</label>
<input
type="password"
type={isEditingApiKey ? 'text' : 'password'}
id="apiKey"
name="apiKey"
value={isApiKeyMasked ? '' : formData[currentCategory].apiKey}
value={isEditingApiKey ? formData[currentCategory].apiKey : maskApiKey(formData[currentCategory].apiKey)}
onChange={handleChange}
placeholder={isApiKeyMasked ? '已保存(留空保持不变)' : 'sk-...'}
onBlur={() => setIsEditingApiKey(false)}
onFocus={() => {
if (formData[currentCategory].apiKey && !isEditingApiKey) {
// 聚焦时清空,让用户重新输入
setFormData(prev => ({
...prev,
[currentCategory]: {
...prev[currentCategory],
apiKey: ''
}
}));
}
setIsEditingApiKey(true);
}}
placeholder="sk-...(输入新密钥将覆盖旧密钥)"
className="form-control"
/>
{isApiKeyMasked && (
<span className="form-hint">当前密钥已加密存储</span>
)}
<span className="form-hint">首次输入可见之后仅显示最后6位</span>
</div>
<div className="form-row">

View File

@@ -69,6 +69,9 @@
cursor: pointer;
font-size: 10px;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 2px;
}
.tag-btn:hover {
@@ -76,12 +79,36 @@
color: var(--color-accent);
}
.tag-btn.active {
background: var(--color-accent);
border-color: var(--color-accent);
/* 包含模式(第一次点击)- 绿色 */
.tag-btn.tag-include {
background: #10b981;
border-color: #10b981;
color: white;
}
.tag-btn.tag-include:hover {
background: #059669;
border-color: #059669;
}
/* 排除模式(第二次点击)- 红色 */
.tag-btn.tag-exclude {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.tag-btn.tag-exclude:hover {
background: #dc2626;
border-color: #dc2626;
}
/* 排除前缀符号 */
.tag-prefix {
font-weight: bold;
font-size: 12px;
}
/* 错误和加载提示 */
.error-message,
.loading-message,
@@ -270,47 +297,116 @@
flex-direction: column;
gap: 8px;
padding: 8px;
position: relative; /* 为悬浮工具栏提供定位上下文 */
}
.edit-header {
/* 悬浮工具栏 */
.floating-toolbar {
position: sticky;
top: 0;
z-index: 100;
margin-bottom: 8px;
}
/* 工具栏内容(默认隐藏) */
.toolbar-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding: 6px 10px;
background: var(--color-bg-tertiary);
border-radius: 4px;
border-radius: 6px;
border: 1px solid var(--color-border-light);
flex-wrap: wrap; /* 允许换行 */
gap: 6px; /* 添加间距 */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
/* 默认状态:收缩 */
max-height: 0;
opacity: 0;
overflow: hidden;
transform: scale(0.8);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 悬停时展开 */
.floating-toolbar:hover .toolbar-content {
max-height: 60px;
opacity: 1;
transform: scale(1);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0; /* 允许收缩 */
}
.edit-title {
font-size: 12px;
font-weight: 600;
color: var(--color-text-primary);
flex: 1; /* 占据剩余空间 */
min-width: 120px; /* 最小宽度,防止过度压缩 */
white-space: nowrap; /* 不换行 */
overflow: hidden; /* 超出隐藏 */
text-overflow: ellipsis; /* 显示省略号 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.edit-actions {
/* 小球指示器 */
.toolbar-indicator {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
background: var(--color-accent);
border-radius: 50%;
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap; /* 按钮也可以换行 */
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
transform: translateY(-50%) scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
50% {
transform: translateY(-50%) scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
.floating-toolbar:hover .toolbar-indicator {
opacity: 0;
transform: translateY(-50%) scale(0);
}
.indicator-icon {
font-size: 18px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
.toolbar-actions {
display: flex;
gap: 4px;
align-items: center;
}
.export-format-selector {
padding: 4px 8px;
padding: 3px 6px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-primary);
font-size: 11px;
cursor: pointer;
transition: border-color 0.15s ease;
transition: all 0.15s ease;
}
.export-format-selector:hover {
@@ -322,55 +418,66 @@
border-color: var(--color-accent);
}
.edit-btn {
padding: 4px 10px;
.toolbar-btn {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-size: 14px;
font-weight: 500;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-primary);
}
.edit-btn.save {
.toolbar-btn:hover {
transform: scale(1.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.toolbar-btn.save {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
}
.edit-btn.save:hover {
.toolbar-btn.save:hover {
background: var(--color-accent-dark);
border-color: var(--color-accent-dark);
}
.edit-btn.export {
.toolbar-btn.export {
background: #10b981;
border-color: #10b981;
color: white;
}
.edit-btn.export:hover {
.toolbar-btn.export:hover {
background: #059669;
border-color: #059669;
}
.edit-btn.delete {
.toolbar-btn.delete {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.edit-btn.delete:hover {
.toolbar-btn.delete:hover {
background: #dc2626;
border-color: #dc2626;
}
.edit-btn.cancel {
.toolbar-btn.cancel {
background: var(--color-bg-primary);
color: var(--color-text-secondary);
}
.edit-btn.cancel:hover {
.toolbar-btn.cancel:hover {
background: var(--color-error);
border-color: var(--color-error);
color: white;
@@ -415,6 +522,9 @@
.form-group textarea {
resize: vertical;
min-height: 60px;
max-height: 300px; /* 设置最大高度 */
overflow-y: auto;
transition: height 0.2s ease; /* 平滑过渡 */
}
.form-hint {

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; // ✅ 保留 useState 用于 exportFormat
import { useCharacterStore, useCharacterCardUIStore } from '../../../../Store/SideBarLeft'; // ✅ 新增
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; // 引入世界书 Store
import './CharacterCard.css';
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
@@ -54,8 +55,8 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
)}
{character.tags && character.tags.length > 0 && (
<div className="character-tags">
{character.tags.slice(0, 3).map(tag => (
<span key={tag} className="tag">{tag}</span>
{character.tags.slice(0, 3).map((tag, index) => (
<span key={`tag-${character.id}-${tag}-${index}`} className="tag">{tag}</span>
))}
</div>
)}
@@ -86,13 +87,14 @@ const CharacterCard = () => {
// ✅ 从 CharacterCardUIStore 获取 UI 状态
const {
filterTag,
filterTags,
isEditing,
editForm,
currentPage,
pageSize,
setFilterTag,
clearFilter,
toggleFilterTag,
removeFilterTag,
clearAllFilters,
startEditing,
cancelEditing,
updateEditForm,
@@ -102,13 +104,17 @@ const CharacterCard = () => {
prevPage
} = useCharacterCardUIStore();
// 引入世界书 Store
const { worldBooks, fetchWorldBooks } = useWorldBookStore();
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
const fileInputRef = useRef(null);
const clickTimeoutRef = useRef(null);
// 加载角色列表
// 加载角色列表和世界书列表
useEffect(() => {
fetchCharacters();
fetchWorldBooks(); // 加载世界书列表
// 清理函数
return () => {
@@ -121,10 +127,31 @@ const CharacterCard = () => {
// 获取所有唯一的 tags
const allTags = [...new Set(characters.flatMap(char => char.tags || []))];
// 过滤角色
const filteredCharacters = filterTag
? characters.filter(char => (char.tags || []).includes(filterTag))
: characters;
// 过滤角色(支持多标签交集筛选)
const filteredCharacters = React.useMemo(() => {
if (filterTags.length === 0) return characters;
// 分离包含和排除标签
const includeTags = filterTags
.filter(f => f.startsWith('include:'))
.map(f => f.substring(8));
const excludeTags = filterTags
.filter(f => f.startsWith('exclude:'))
.map(f => f.substring(8));
return characters.filter(char => {
const charTags = char.tags || [];
// 检查是否包含所有必须的标签(交集)
const hasAllInclude = includeTags.every(tag => charTags.includes(tag));
// 检查是否不包含所有排除的标签
const hasNoExclude = excludeTags.every(tag => !charTags.includes(tag));
return hasAllInclude && hasNoExclude;
});
}, [characters, filterTags]);
// ✅ 计算当前页和总页数
const totalPages = Math.ceil(filteredCharacters.length / pageSize) || 1;
@@ -314,6 +341,13 @@ const CharacterCard = () => {
updateEditForm(field, value); // ✅ 使用 store 方法
};
// 自动调整 textarea 高度
const autoResizeTextarea = (e) => {
const textarea = e.target;
textarea.style.height = 'auto'; // 重置高度
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; // 设置新高度最大300px
};
return (
<div className="character-card-content">
{/* 标题栏 */}
@@ -338,20 +372,93 @@ const CharacterCard = () => {
{allTags.length > 0 && (
<div className="tag-filter">
<button
className={`tag-btn ${!filterTag ? 'active' : ''}`}
onClick={() => setFilterTag('')}
className={`tag-btn ${filterTags.length === 0 ? 'active' : ''}`}
onClick={clearAllFilters}
>
全部
</button>
{allTags.map(tag => (
<button
key={tag}
className={`tag-btn ${filterTag === tag ? 'active' : ''}`}
onClick={() => setFilterTag(tag)}
>
{tag}
</button>
))}
{allTags.map(tag => {
// 判断当前标签的状态
let status = 'none'; // none, include, exclude
const includeFilter = `include:${tag}`;
const excludeFilter = `exclude:${tag}`;
if (filterTags.includes(includeFilter)) {
status = 'include';
} else if (filterTags.includes(excludeFilter)) {
status = 'exclude';
}
return (
<button
key={tag}
className={`tag-btn tag-${status}`}
onClick={() => toggleFilterTag(tag)}
title={
status === 'include' ? '包含该标签(再次点击排除)' :
status === 'exclude' ? '排除该标签(再次点击取消)' :
'筛选包含该标签的角色'
}
>
{status === 'exclude' && <span className="tag-prefix">¬</span>}
{tag}
</button>
);
})}
</div>
)}
{/* 当前筛选条件显示 */}
{filterTags.length > 0 && (
<div className="active-filters" style={{
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
padding: '4px 6px',
alignItems: 'center'
}}>
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary)', marginRight: '4px' }}>
筛选中:
</span>
{filterTags.map((filter) => {
const mode = filter.startsWith('include:') ? 'include' : 'exclude';
const tag = filter.substring(filter.indexOf(':') + 1);
return (
<span
key={`filter-${mode}-${tag}`}
className={`active-filter-tag active-filter-${mode}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '10px',
background: mode === 'include' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)',
border: `1px solid ${mode === 'include' ? '#10b981' : '#ef4444'}`,
color: mode === 'include' ? '#10b981' : '#ef4444'
}}
>
{mode === 'exclude' && '¬'}
{tag}
<button
onClick={() => removeFilterTag(tag)}
style={{
background: 'none',
border: 'none',
color: 'inherit',
cursor: 'pointer',
padding: '0 2px',
fontSize: '12px',
lineHeight: 1
}}
title="移除筛选"
>
×
</button>
</span>
);
})}
</div>
)}
@@ -372,21 +479,39 @@ const CharacterCard = () => {
{/* 编辑模式 */}
{isEditing && selectedCharacter && editForm && (
<div className="character-edit-panel">
<div className="edit-header">
<span className="edit-title">编辑角色: {selectedCharacter.name}</span>
<div className="edit-actions">
<select
className="export-format-selector"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
>
<option value="png">🖼 图片</option>
<option value="json">📄 JSON</option>
</select>
<button className="edit-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)}>📤 导出</button>
<button className="edit-btn delete" onClick={() => handleDelete(selectedCharacter.name)}>🗑 删除</button>
<button className="edit-btn save" onClick={handleSaveEdit}>💾 保存</button>
<button className="edit-btn cancel" onClick={handleCancelEdit}> 取消</button>
{/* 悬浮工具栏 */}
<div className="floating-toolbar">
<div className="toolbar-content">
<div className="toolbar-left">
<span className="edit-title">编辑: {selectedCharacter.name}</span>
</div>
<div className="toolbar-actions">
<select
className="export-format-selector"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
title="选择导出格式"
>
<option value="png">🖼 PNG</option>
<option value="json">📄 JSON</option>
</select>
<button className="toolbar-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)} title="导出角色卡">
📤
</button>
<button className="toolbar-btn delete" onClick={() => handleDelete(selectedCharacter.name)} title="删除角色">
🗑
</button>
<button className="toolbar-btn save" onClick={handleSaveEdit} title="保存更改">
💾
</button>
<button className="toolbar-btn cancel" onClick={handleCancelEdit} title="返回列表">
</button>
</div>
</div>
{/* 小球指示器 */}
<div className="toolbar-indicator">
<span className="indicator-icon"></span>
</div>
</div>
@@ -399,13 +524,18 @@ const CharacterCard = () => {
onChange={(e) => handleFormChange('name', e.target.value)}
placeholder="角色名称"
/>
<small className="form-hint">💡 提示修改角色名将同步重命名文件夹和所有相关引用</small>
</div>
<div className="form-group">
<label>描述</label>
<textarea
value={editForm.description}
onChange={(e) => handleFormChange('description', e.target.value)}
onChange={(e) => {
handleFormChange('description', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="角色描述"
rows={6}
/>
@@ -415,7 +545,11 @@ const CharacterCard = () => {
<label>性格</label>
<textarea
value={editForm.personality}
onChange={(e) => handleFormChange('personality', e.target.value)}
onChange={(e) => {
handleFormChange('personality', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="角色性格"
rows={3}
/>
@@ -425,7 +559,11 @@ const CharacterCard = () => {
<label>场景</label>
<textarea
value={editForm.scenario}
onChange={(e) => handleFormChange('scenario', e.target.value)}
onChange={(e) => {
handleFormChange('scenario', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="场景设定"
rows={3}
/>
@@ -435,7 +573,11 @@ const CharacterCard = () => {
<label>开场白</label>
<textarea
value={editForm.first_mes}
onChange={(e) => handleFormChange('first_mes', e.target.value)}
onChange={(e) => {
handleFormChange('first_mes', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="第一条消息"
rows={4}
/>
@@ -445,7 +587,11 @@ const CharacterCard = () => {
<label>对话示例</label>
<textarea
value={editForm.mes_example}
onChange={(e) => handleFormChange('mes_example', e.target.value)}
onChange={(e) => {
handleFormChange('mes_example', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="对话示例"
rows={4}
/>
@@ -462,21 +608,57 @@ const CharacterCard = () => {
</div>
<div className="form-group">
<label>动态表格标签 (SillyTavern 关键字机制)</label>
<label>绑定世界书</label>
<select
value={editForm.worldInfoId || ''}
onChange={(e) => handleFormChange('worldInfoId', e.target.value || null)}
>
<option value="">不绑定</option>
{worldBooks.map(book => (
<option key={book.id} value={book.id}>
{book.name}
</option>
))}
</select>
<small className="form-hint">💡 选择后该角色将自动加载绑定的世界书内容</small>
</div>
<div className="form-group">
<label>动态表格数据 (SillyTavern 关键字机制)</label>
<input
type="text"
value={editForm.tags?.join(', ') || ''}
value={Object.entries(editForm.tableDefaults || {}).map(([k, v]) => `${k}:${v}`).join(';')}
onChange={(e) => {
// 支持多种分隔符:逗号、分号、空格
const tagsList = e.target.value
.split(/[,;\s]+/)
.map(tag => tag.trim())
.filter(tag => tag !== '');
handleFormChange('tags', tagsList);
// 解析格式key1:value1;key2:value2,key3:value3
// 支持分隔符:分号、逗号
const entries = e.target.value
.split(/[;,]/)
.map(pair => pair.trim())
.filter(pair => pair !== '')
.map(pair => {
const [key, ...valueParts] = pair.split(':');
const value = valueParts.join(':').trim(); // 支持值中包含冒号
return { key: key.trim(), value };
})
.filter(({ key, value }) => key && value !== undefined);
// 构建 tableDefaults 对象
const tableDefaults = {};
const tableHeaders = [];
entries.forEach(({ key, value }) => {
tableHeaders.push(key);
// 尝试转换为数字
const numValue = Number(value);
tableDefaults[key] = isNaN(numValue) ? value : numValue;
});
handleFormChange('tableHeaders', tableHeaders);
handleFormChange('tableDefaults', tableDefaults);
}}
placeholder="力量:80, 敏捷:65, 智力:90, HP:100"
placeholder="力量:80;敏捷:65;智力:90;HP:100"
/>
<small className="form-hint">每个标签是一个键值对用逗号/分号/空格分隔双击右侧表格中的标签可编辑</small>
<small className="form-hint">格式key:value用分号或逗号分隔例如力量:80;敏捷:65,HP:100</small>
</div>
</div>
</div>
@@ -488,7 +670,7 @@ const CharacterCard = () => {
<div className="character-list">
{currentPageCharacters.length === 0 ? (
<div className="empty-message">
{filterTag ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
{filterTags.length > 0 ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
</div>
) : (
currentPageCharacters.map(char => (

View File

@@ -108,7 +108,7 @@
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: var(--shadow-lg);
z-index: 100;
z-index: 1100; /* ✅ 高于 ChatBox (z-index: 1000) */
min-width: 140px;
padding: 4px;
}

View File

@@ -77,6 +77,7 @@ const PresetPanel = () => {
top_k: "随机采样范围从概率最高的K个词中选择",
max_ctx: "上下文窗口大小(Token上限)",
max_tokens: "单次回复的最大长度",
request_timeout: "LLM请求超时时间",
seed: "随机种子(-1为随机)",
n: "生成回复的数量"
};
@@ -90,6 +91,7 @@ const PresetPanel = () => {
top_k: "Top-K",
max_ctx: "上下文",
max_tokens: "MaxTok",
request_timeout: "超时",
seed: "种子",
n: "数量"
};
@@ -114,7 +116,7 @@ const PresetPanel = () => {
let convertedValue = value;
if (name === 'temperature' || name === 'freq_penalty' || name === 'pres_penalty' || name === 'top_p') {
convertedValue = parseFloat(value);
} else if (name === 'top_k' || name === 'max_ctx' || name === 'max_tokens' || name === 'seed' || name === 'n') {
} else if (name === 'top_k' || name === 'max_ctx' || name === 'max_tokens' || name === 'request_timeout' || name === 'seed' || name === 'n') {
convertedValue = parseInt(value, 10);
}
@@ -186,7 +188,8 @@ const PresetPanel = () => {
top_k: importedPreset.topK,
frequency_penalty: importedPreset.frequencyPenalty,
presence_penalty: importedPreset.presencePenalty,
max_tokens: importedPreset.maxLength
max_tokens: importedPreset.maxLength,
request_timeout: importedPreset.requestTimeout || 60
};
// 转换 entries 为 promptComponents 格式
@@ -213,7 +216,8 @@ const PresetPanel = () => {
presence_penalty: importedPreset.presence_penalty,
top_p: importedPreset.top_p,
top_k: importedPreset.top_k,
max_tokens: importedPreset.max_tokens || importedPreset.openai_max_tokens
max_tokens: importedPreset.max_tokens || importedPreset.openai_max_tokens,
request_timeout: importedPreset.request_timeout || 60
};
importedComponents = importedPreset.promptComponents || [];
}
@@ -261,6 +265,7 @@ const PresetPanel = () => {
frequencyPenalty: parameters.frequency_penalty,
presencePenalty: parameters.presence_penalty,
maxLength: parameters.max_tokens,
requestTimeout: parameters.request_timeout || 60,
isDefault: false,
// PromptPresetView 部分
@@ -744,6 +749,25 @@ const PresetPanel = () => {
/>
</div>
{/* 请求超时输入框 */}
<div className="parameter-row-compact">
<label
className="parameter-label-compact"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.request_timeout)}
onMouseLeave={hideTooltip}
>
{parameterLabels.request_timeout}
</label>
<input
type="number"
min="5"
max="600"
value={parameters.request_timeout || 60}
onChange={(e) => handleParameterChange('request_timeout', e.target.value)}
className="parameter-input-compact"
/>
</div>
{/* 随机种子输入框 */}
<div className="parameter-row-compact">
<label

View File

@@ -0,0 +1,230 @@
.settings-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
background: var(--bg-primary);
color: var(--text-primary);
}
.settings-header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid var(--border-color);
}
.settings-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
/* 消息提示 */
.message {
padding: 12px;
margin-bottom: 16px;
border-radius: 4px;
font-size: 14px;
}
.message.success {
background: rgba(76, 175, 80, 0.1);
border: 1px solid #4caf50;
color: #4caf50;
}
.message.error {
background: rgba(244, 67, 54, 0.1);
border: 1px solid #f44336;
color: #f44336;
}
/* 分页标签 */
.settings-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.tab-button {
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.tab-button:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
.tab-button.active {
color: var(--accent-color);
border-bottom-color: var(--accent-color);
}
/* 内容区域 */
.settings-content {
flex: 1;
overflow-y: auto;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
/* 设置区块 */
.settings-section {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.settings-section h3 {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 600;
}
.section-description {
margin: 0 0 16px 0;
font-size: 13px;
color: var(--text-secondary);
}
/* 设置项 */
.setting-item {
margin-bottom: 20px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.setting-item label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.setting-item input[type="text"],
.setting-item input[type="file"] {
width: 100%;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 14px;
box-sizing: border-box;
}
.setting-item input[type="text"]:focus {
outline: none;
border-color: var(--accent-color);
}
.setting-item small {
display: block;
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.setting-item code {
display: block;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: var(--accent-color);
}
/* 文件结构列表 */
.file-structure {
margin: 8px 0 0 0;
padding-left: 20px;
list-style: none;
}
.file-structure li {
padding: 4px 0;
font-size: 13px;
color: var(--text-secondary);
}
/* 预览框 */
.preview-box {
margin-top: 8px;
padding: 12px;
background: var(--bg-secondary);
border-radius: 4px;
font-size: 13px;
}
.preview-box .original,
.preview-box .processed {
margin-bottom: 12px;
}
.preview-box .original:last-child,
.preview-box .processed:last-child {
margin-bottom: 0;
}
.preview-box strong {
display: block;
margin-bottom: 4px;
color: var(--text-primary);
}
.preview-box p {
margin: 0;
padding: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 12px;
}
/* 按钮 */
.btn-primary {
padding: 10px 20px;
background: var(--accent-color);
border: none;
border-radius: 4px;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View File

@@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import './Settings.css';
/**
* 系统设置组件
*
* 按功能分页展示:
* - 正则规则设置
* - 思考标签配置
* - 其他系统设置
*/
const Settings = () => {
const [activeTab, setActiveTab] = useState('regex');
const [settings, setSettings] = useState({
thinkingTagPrefix: '<thinking>',
thinkingTagSuffix: '</thinking>',
currentPresetName: null
});
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState({ type: '', text: '' });
// 加载系统设置
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setLoading(true);
const response = await fetch('/api/regex/settings');
const data = await response.json();
if (data.success) {
setSettings(data.settings);
}
} catch (error) {
console.error('加载系统设置失败:', error);
showMessage('error', '加载设置失败');
} finally {
setLoading(false);
}
};
const saveSettings = async () => {
try {
setLoading(true);
const response = await fetch('/api/regex/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
});
const data = await response.json();
if (data.success) {
showMessage('success', '设置已保存');
} else {
showMessage('error', '保存失败');
}
} catch (error) {
console.error('保存设置失败:', error);
showMessage('error', '保存失败');
} finally {
setLoading(false);
}
};
const showMessage = (type, text) => {
setMessage({ type, text });
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
};
const handleInputChange = (field, value) => {
setSettings(prev => ({
...prev,
[field]: value
}));
};
return (
<div className="settings-container">
{/* 标题 */}
<div className="settings-header">
<h2>系统设置</h2>
</div>
{/* 消息提示 */}
{message.text && (
<div className={`message ${message.type}`}>
{message.text}
</div>
)}
{/* 分页标签 */}
<div className="settings-tabs">
<button
className={`tab-button ${activeTab === 'regex' ? 'active' : ''}`}
onClick={() => setActiveTab('regex')}
>
正则规则
</button>
<button
className={`tab-button ${activeTab === 'thinking' ? 'active' : ''}`}
onClick={() => setActiveTab('thinking')}
>
思考标签
</button>
<button
className={`tab-button ${activeTab === 'general' ? 'active' : ''}`}
onClick={() => setActiveTab('general')}
>
通用设置
</button>
</div>
{/* 内容区域 */}
<div className="settings-content">
{loading && <div className="loading">加载中...</div>}
{/* 正则规则设置 */}
{activeTab === 'regex' && (
<div className="settings-section">
<h3>正则规则管理</h3>
<p className="section-description">
管理文本处理规则支持 SillyTavern 格式导入
</p>
<div className="setting-item">
<label>规则文件位置</label>
<code>data/regex/</code>
<ul className="file-structure">
<li>📁 global/ - 全局规则</li>
<li>📁 characters/ - 角色卡规则</li>
<li>📁 presets/ - 预设规则</li>
</ul>
</div>
<div className="setting-item">
<label>导入规则</label>
<input
type="file"
accept=".json"
onChange={async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/regex/import', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showMessage('success', data.message);
} else {
showMessage('error', '导入失败');
}
} catch (error) {
showMessage('error', '导入失败');
}
}}
/>
<small>支持 SillyTavern 格式的规则文件</small>
</div>
<div className="setting-item">
<button className="btn-primary" onClick={() => window.open('/api/regex/export/global', '_blank')}>
导出全局规则
</button>
</div>
</div>
)}
{/* 思考标签配置 */}
{activeTab === 'thinking' && (
<div className="settings-section">
<h3>思考标签配置</h3>
<p className="section-description">
配置 AI 推理/思考内容的标记标签
</p>
<div className="setting-item">
<label htmlFor="thinking-prefix">前缀</label>
<input
id="thinking-prefix"
type="text"
value={settings.thinkingTagPrefix}
onChange={(e) => handleInputChange('thinkingTagPrefix', e.target.value)}
placeholder="<thinking>"
/>
<small>默认值&lt;thinking&gt;</small>
</div>
<div className="setting-item">
<label htmlFor="thinking-suffix">后缀</label>
<input
id="thinking-suffix"
type="text"
value={settings.thinkingTagSuffix}
onChange={(e) => handleInputChange('thinkingTagSuffix', e.target.value)}
placeholder="</thinking>"
/>
<small>默认值&lt;/thinking&gt;</small>
</div>
<div className="setting-item preview">
<label>预览效果</label>
<div className="preview-box">
<div className="original">
<strong>原始内容</strong>
<p>这是回复 {settings.thinkingTagPrefix}思考过程{settings.thinkingTagSuffix} 继续</p>
</div>
<div className="processed">
<strong>处理后</strong>
<p>这是回复 继续</p>
</div>
</div>
</div>
<div className="setting-item">
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
{loading ? '保存中...' : '保存设置'}
</button>
</div>
</div>
)}
{/* 通用设置 */}
{activeTab === 'general' && (
<div className="settings-section">
<h3>通用设置</h3>
<p className="section-description">
其他系统级配置
</p>
<div className="setting-item">
<label htmlFor="current-preset">当前预设</label>
<input
id="current-preset"
type="text"
value={settings.currentPresetName || ''}
onChange={(e) => handleInputChange('currentPresetName', e.target.value || null)}
placeholder="未选择"
/>
<small>影响全局正则规则的启用</small>
</div>
<div className="setting-item">
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
{loading ? '保存中...' : '保存设置'}
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default Settings;

View File

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

View File

@@ -0,0 +1,207 @@
/* Token Usage Styles */
.token-usage-container {
padding: 16px;
height: 100%;
overflow-y: auto;
}
.token-usage-loading,
.token-usage-error,
.token-usage-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
}
.token-usage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.token-usage-header h3 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.month-selector {
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
}
.token-usage-filters {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filter-select {
flex: 1;
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
}
.btn-reset {
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.btn-reset:hover {
background: var(--bg-hover);
}
/* Stats Cards */
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
padding: 12px;
border-radius: 8px;
text-align: center;
}
.stat-card.total {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stat-card.prompt {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.stat-card.completion {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.stat-label {
font-size: 12px;
opacity: 0.9;
margin-bottom: 4px;
}
.stat-value {
font-size: 20px;
font-weight: bold;
}
/* Request Stats */
.request-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background: var(--bg-secondary);
border-radius: 8px;
}
.stat-item {
display: flex;
justify-content: space-between;
font-size: 13px;
}
.stat-item.success {
color: #10b981;
}
.stat-item.interrupted {
color: #f59e0b;
}
.stat-item.failed {
color: #ef4444;
}
.stat-name {
font-weight: 500;
}
.stat-number {
font-weight: bold;
}
/* Sections */
.daily-stats-section,
.role-stats-section,
.chat-stats-section {
margin-bottom: 16px;
}
.daily-stats-section h4,
.role-stats-section h4,
.chat-stats-section h4 {
margin: 0 0 8px 0;
font-size: 15px;
color: var(--text-primary);
}
/* Lists */
.daily-stats-list,
.role-stats-list,
.chat-stats-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.daily-stat-item,
.role-stat-item,
.chat-stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 6px;
font-size: 13px;
}
.day-label,
.role-name,
.chat-name {
flex: 1;
font-weight: 500;
color: var(--text-primary);
}
.day-tokens,
.role-tokens,
.chat-tokens {
margin: 0 12px;
color: var(--text-secondary);
font-size: 12px;
}
.day-count,
.role-count,
.chat-count {
color: var(--text-secondary);
font-size: 12px;
}

View File

@@ -0,0 +1,195 @@
// frontend/src/components/SideBarLeft/tabs/TokenUsage/TokenUsage.jsx
import React, { useEffect } from 'react';
import useTokenUsageStore from '../../../../Store/SideBarLeft/TokenUsageSlice';
import './TokenUsage.css';
const TokenUsage = () => {
const {
months,
currentMonth,
stats,
roles,
chats,
selectedRole,
selectedChat,
loading,
error,
fetchMonths,
setCurrentMonth,
setSelectedRole,
setSelectedChat,
resetFilters
} = useTokenUsageStore();
useEffect(() => {
fetchMonths();
}, []);
if (loading && !stats) {
return <div className="token-usage-loading">加载中...</div>;
}
if (error) {
return <div className="token-usage-error">错误: {error}</div>;
}
if (!stats) {
return <div className="token-usage-empty">暂无数据</div>;
}
// 格式化数字
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(2) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(2) + 'K';
}
return num.toString();
};
return (
<div className="token-usage-container">
{/* 月份选择器 */}
<div className="token-usage-header">
<h3>📊 Token 使用统计</h3>
<select
className="month-selector"
value={currentMonth ? `${currentMonth.year}-${currentMonth.month}` : ''}
onChange={(e) => {
const [year, month] = e.target.value.split('-').map(Number);
setCurrentMonth(year, month);
}}
>
{months.map((m) => (
<option key={`${m.year}-${m.month}`} value={`${m.year}-${m.month}`}>
{m.year}{m.month}
</option>
))}
</select>
</div>
{/* 筛选器 */}
<div className="token-usage-filters">
<select
className="filter-select"
value={selectedRole || ''}
onChange={(e) => setSelectedRole(e.target.value || null)}
>
<option value="">所有角色</option>
{roles.map((role) => (
<option key={role} value={role}>{role}</option>
))}
</select>
<select
className="filter-select"
value={selectedChat || ''}
onChange={(e) => setSelectedChat(e.target.value || null)}
>
<option value="">所有聊天</option>
{chats.map((chat) => (
<option key={chat} value={chat}>{chat}</option>
))}
</select>
<button className="btn-reset" onClick={resetFilters}>
重置
</button>
</div>
{/* 统计卡片 */}
<div className="stats-cards">
<div className="stat-card total">
<div className="stat-label"> Token</div>
<div className="stat-value">{formatNumber(stats.totalTokens)}</div>
</div>
<div className="stat-card prompt">
<div className="stat-label">输入 Token</div>
<div className="stat-value">{formatNumber(stats.totalPromptTokens)}</div>
</div>
<div className="stat-card completion">
<div className="stat-label">输出 Token</div>
<div className="stat-value">{formatNumber(stats.totalCompletionTokens)}</div>
</div>
</div>
{/* 请求统计 */}
<div className="request-stats">
<div className="stat-item">
<span className="stat-name">总请求数:</span>
<span className="stat-number">{stats.totalRecords}</span>
</div>
<div className="stat-item success">
<span className="stat-name"> 成功:</span>
<span className="stat-number">{stats.completedCount}</span>
</div>
<div className="stat-item interrupted">
<span className="stat-name"> 中断:</span>
<span className="stat-number">{stats.interruptedCount}</span>
</div>
<div className="stat-item failed">
<span className="stat-name"> 失败:</span>
<span className="stat-number">{stats.failedCount}</span>
</div>
</div>
{/* 日统计 */}
{stats.dailyStats && Object.keys(stats.dailyStats).length > 0 && (
<div className="daily-stats-section">
<h4>📅 每日统计</h4>
<div className="daily-stats-list">
{Object.entries(stats.dailyStats)
.sort(([a], [b]) => b.localeCompare(a))
.slice(0, 10)
.map(([day, data]) => (
<div key={day} className="daily-stat-item">
<span className="day-label">{day}</span>
<span className="day-tokens">{formatNumber(data.totalTokens)} tokens</span>
<span className="day-count">{data.count} </span>
</div>
))}
</div>
</div>
)}
{/* 角色统计 */}
{stats.roleStats && Object.keys(stats.roleStats).length > 0 && (
<div className="role-stats-section">
<h4>👤 角色统计</h4>
<div className="role-stats-list">
{Object.entries(stats.roleStats)
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
.map(([role, data]) => (
<div key={role} className="role-stat-item">
<span className="role-name">{role}</span>
<span className="role-tokens">{formatNumber(data.totalTokens)} tokens</span>
<span className="role-count">{data.count} </span>
</div>
))}
</div>
</div>
)}
{/* 聊天统计 */}
{stats.chatStats && Object.keys(stats.chatStats).length > 0 && (
<div className="chat-stats-section">
<h4>💬 聊天统计</h4>
<div className="chat-stats-list">
{Object.entries(stats.chatStats)
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
.slice(0, 10)
.map(([chat, data]) => (
<div key={chat} className="chat-stat-item">
<span className="chat-name">{chat}</span>
<span className="chat-tokens">{formatNumber(data.totalTokens)} tokens</span>
<span className="chat-count">{data.count} </span>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default TokenUsage;

View File

@@ -135,6 +135,10 @@
margin-top: 8px; /* 与全局世界书保持间距 */
position: relative; /* 确保正常文档流 */
z-index: 1; /* 降低层级,不遮挡其他元素 */
/* 最小高度 = header(33px) + actions(37px) + selector(37px) + entries最小内容(约200px) */
min-height: 307px;
display: flex;
flex-direction: column;
}
.worldbook-header {
@@ -183,6 +187,35 @@
transform: scale(0.98);
}
/* ✅ 排序按钮 - 仿照预设的操作按钮 */
.sort-toggle-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 14px;
padding: 0;
transition: all 0.15s ease;
flex-shrink: 0;
}
.sort-toggle-btn:hover {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
transform: translateY(-1px);
}
.sort-toggle-btn:active {
transform: scale(0.95) translateY(0);
}
/* 世界书选择区域 */
.worldbook-selector {
display: flex;
@@ -320,7 +353,7 @@
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
min-height: 65px;
min-height: 40px; /* ✅ 减小高度,单行显示 */
}
.entry-item-compact:hover {
@@ -334,17 +367,21 @@
border-left: 2px solid var(--color-accent);
}
/* 第一行:名称 + Token + 开关 */
.entry-row-top {
.entry-item-compact.disabled {
opacity: 0.6;
}
/* 单行布局容器 */
.entry-row-single {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
width: 100%;
overflow: hidden;
}
.entry-name-compact {
flex: 1;
flex: 1; /* ✅ 占据剩余空间 */
font-weight: 500;
font-size: 12px;
color: var(--color-text-primary);
@@ -355,28 +392,24 @@
min-width: 0;
}
.entry-tokens {
/* Token数量仅数字- 固定宽度容纳6位数字 */
.entry-tokens-inline {
font-size: 10px;
color: var(--color-text-muted);
font-weight: 500;
padding: 2px 6px;
font-weight: 600;
padding: 2px 5px;
background: var(--color-bg-tertiary);
border-radius: 8px;
border-radius: 3px;
white-space: nowrap;
flex-shrink: 0;
width: 45px; /* ✅ 固定宽度可容纳6位数字 */
text-align: right; /* ✅ 右对齐数字 */
}
/* 第二行:插入位置下拉框 */
.entry-row-bottom {
display: flex;
align-items: center;
width: 100%;
}
.position-select {
width: 100%;
padding: 3px 6px;
font-size: 11px;
/* 内联位置下拉框 - 固定在右侧 */
.position-select-inline {
padding: 2px 4px;
font-size: 10px;
color: var(--color-text-secondary);
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
@@ -384,62 +417,66 @@
cursor: pointer;
outline: none;
transition: all 0.15s ease;
width: 70px; /* ✅ 去掉箭头后更窄 */
flex-shrink: 0;
appearance: none; /* ✅ 隐藏默认箭头 */
-webkit-appearance: none;
-moz-appearance: none;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
}
.position-select:hover {
.position-select-inline:hover {
border-color: var(--color-accent);
}
.position-select:focus {
.position-select-inline:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
/* 开关组件 */
.toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
cursor: pointer;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-border);
border-radius: 20px;
transition: all 0.2s ease;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
/* 状态指示器 - 小圆点 - 固定在右侧(点击切换启用/禁用) */
.status-dot {
width: 12px; /* ✅ 稍微大一点,更容易点击 */
height: 12px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
border: 2px solid rgba(255, 255, 255, 0.3); /* ✅ 更明显的边框 */
}
.toggle-switch input:checked + .toggle-slider {
background: var(--color-accent);
.status-dot.enabled {
background: #10b981; /* 绿色 - 启用 */
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5); /* ✅ 更强的发光 */
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(16px);
.status-dot.enabled:hover {
background: #059669;
transform: scale(1.3); /* ✅ 更大的放大效果 */
box-shadow: 0 0 12px rgba(16, 185, 129, 0.7);
}
.status-dot.enabled:active {
transform: scale(1.1);
}
.status-dot.disabled {
background: #6b7280; /* 灰色 - 禁用 */
opacity: 0.4;
border-color: rgba(255, 255, 255, 0.2);
}
.status-dot.disabled:hover {
background: #4b5563;
opacity: 0.7;
transform: scale(1.3);
border-color: rgba(255, 255, 255, 0.4);
}
.status-dot.disabled:active {
transform: scale(1.1);
}
/* 分页导航 */
@@ -626,8 +663,8 @@
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
border-radius: 8px;
box-shadow: var(--shadow-xl);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
}
@@ -642,48 +679,85 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
padding: 20px 24px;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%);
flex-shrink: 0;
}
.edit-panel-header h2 {
font-size: 16px;
font-size: 18px;
color: var(--color-text-primary);
margin: 0;
font-weight: 600;
letter-spacing: 0.3px;
display: flex;
align-items: center;
gap: 12px;
}
.saving-indicator {
font-size: 12px;
color: var(--color-accent);
font-weight: 500;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: 6px;
color: var(--color-text-muted);
cursor: pointer;
font-size: 14px;
transition: all 0.15s ease;
font-size: 16px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--color-danger-light);
border-color: var(--color-danger);
color: var(--color-danger);
transform: rotate(90deg);
}
.edit-panel-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 20px;
}
/* 美化滚动条 */
.edit-panel-content::-webkit-scrollbar {
width: 8px;
}
.edit-panel-content::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
border-radius: 4px;
}
.edit-panel-content::-webkit-scrollbar-thumb {
background: var(--color-scrollbar);
border-radius: 4px;
transition: all 0.2s ease;
}
.edit-panel-content::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-hover);
}
.form-row {
@@ -710,27 +784,33 @@
}
.form-input {
padding: 8px 10px;
padding: 10px 12px;
font-size: 13px;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: 6px;
background: var(--color-bg-primary);
color: var(--color-text-primary);
transition: all 0.15s ease;
transition: all 0.2s ease;
}
.form-input:hover {
border-color: var(--color-accent-light);
}
.form-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
box-shadow: 0 0 0 3px var(--color-accent-light);
transform: translateY(-1px);
}
.content-textarea {
flex: 1;
min-height: 200px;
min-height: 250px;
resize: vertical;
line-height: 1.6;
font-family: inherit;
line-height: 1.8;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
.trigger-config-row {
@@ -739,6 +819,34 @@
flex-wrap: wrap;
}
/* ✅ 编辑面板底部操作栏 */
.edit-panel-footer {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--color-border);
background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%);
flex-shrink: 0;
}
.edit-panel-footer .btn {
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
transition: all 0.2s ease;
}
.edit-panel-footer .btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.edit-panel-footer .btn:active {
transform: translateY(0);
}
.trigger-selector {
flex: 1;
min-width: 200px;
@@ -816,3 +924,90 @@
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
/* ✅ 排序下拉菜单 - 仿照预设的操作下拉菜单 */
.sort-dropdown-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: var(--shadow-lg);
z-index: 1100;
min-width: 160px;
padding: 4px;
}
.sort-section {
display: flex;
flex-direction: column;
gap: 2px;
}
.sort-label {
font-size: 10px;
color: var(--color-text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 8px 2px;
}
.sort-option {
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: 4px;
font-size: 12px;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
display: flex;
align-items: center;
gap: 8px;
}
.sort-option:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.sort-option.active {
background: var(--color-accent-light);
color: var(--color-accent);
font-weight: 600;
}
.sort-divider {
height: 1px;
background: var(--color-border-light);
margin: 4px 0;
}
.sort-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 12px;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
border-radius: 4px;
}
.sort-checkbox-label:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.sort-checkbox-label input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--color-accent);
}

View File

@@ -68,10 +68,34 @@ const WorldBook = () => {
const [showWorldBookDropdown, setShowWorldBookDropdown] = useState(false);
const [activeTriggerStrategy, setActiveTriggerStrategy] = useState('constant');
// ✅ 性能优化使用本地状态暂存编辑内容避免频繁API调用
const [localEntry, setLocalEntry] = useState(null);
const saveTimeoutRef = React.useRef(null);
const [isSaving, setIsSaving] = useState(false); // ✅ 保存状态指示
// 分页状态
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20); // 默认每页20条
// ✅ 排序状态
const [sortBy, setSortBy] = useState('order'); // 'name' | 'name_desc' | 'order'
const [activeFirst, setActiveFirst] = useState(false); // 激活条目优先
const [showSortMenu, setShowSortMenu] = useState(false); // 显示排序菜单
// ✅ 点击外部关闭排序下拉菜单
useEffect(() => {
const handleClickOutside = (event) => {
if (showSortMenu && !event.target.closest('.sort-settings-container')) {
setShowSortMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showSortMenu]);
// 获取当前激活的分页
const { activeTab } = useSideBarLeftStore();
@@ -246,6 +270,23 @@ const WorldBook = () => {
setShowEditPanel(true);
};
// ✅ 处理条目快速更新(位置和启用状态)
const handleEntryUpdate = async (field, value) => {
if (!currentEntry || !currentWorldBook) return;
const updatedEntry = { ...currentEntry, [field]: value };
try {
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
setCurrentEntry(updatedEntry);
// 更新成功后刷新当前页
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
console.log(`[WorldBook] ✅ 快速更新 ${field} 成功`);
} catch (err) {
console.error(`[WorldBook] ❌ 快速更新 ${field} 失败:`, err);
}
};
// 处理页码变化
const handlePageChange = async (newPage) => {
if (!currentWorldBook) return;
@@ -269,22 +310,41 @@ const WorldBook = () => {
}
};
const handleEntryUpdate = async (field, value) => {
if (!currentEntry || !currentWorldBook) return;
const updatedEntry = {...currentEntry, [field]: value};
try {
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
setCurrentEntry(updatedEntry);
// 更新成功后刷新当前页
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
} catch (err) {
console.error('更新条目失败:', err);
// ✅ 性能优化防抖保存500ms后自动保存
const debouncedSave = React.useCallback((updatedEntry) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
setIsSaving(true); // ✅ 显示保存中状态
saveTimeoutRef.current = setTimeout(async () => {
if (!currentWorldBook || !updatedEntry) return;
try {
await updateWorldBookEntry(currentWorldBook.name, updatedEntry.uid, updatedEntry);
setCurrentEntry(updatedEntry);
// 更新成功后刷新当前页
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
console.log('[WorldBook] ✅ 自动保存成功');
} catch (err) {
console.error('[WorldBook] ❌ 自动保存失败:', err);
} finally {
setIsSaving(false); // ✅ 隐藏保存中状态
}
}, 500); // 500ms 防抖
}, [currentWorldBook, currentPage, pageSize, updateWorldBookEntry, fetchWorldBookEntries]);
const handleLocalEntryChange = (field, value) => {
if (!localEntry) return;
const updatedEntry = { ...localEntry, [field]: value };
setLocalEntry(updatedEntry);
debouncedSave(updatedEntry); // ✅ 防抖保存
};
const handleTriggerStrategyChange = async (strategy) => {
if (!currentEntry || !currentWorldBook) return;
if (!localEntry || !currentWorldBook) return;
setActiveTriggerStrategy(strategy);
@@ -317,34 +377,30 @@ const WorldBook = () => {
}
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: updatedTriggers
};
try {
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, {
...currentEntry,
trigger_config: updatedTriggerConfig
});
setCurrentEntry({
...currentEntry,
trigger_config: updatedTriggerConfig
});
} catch (err) {
console.error('更新触发策略失败:', err);
}
const updatedEntry = {
...localEntry,
trigger_config: updatedTriggerConfig
};
setLocalEntry(updatedEntry);
debouncedSave(updatedEntry); // ✅ 防抖保存
};
const handleDeleteEntry = async () => {
if (!currentEntry || !currentWorldBook) return;
if (!localEntry || !currentWorldBook) return;
if (!window.confirm('确定要删除此条目吗?')) {
return;
}
try {
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
await deleteWorldBookEntry(currentWorldBook.name, localEntry.uid);
setShowEditPanel(false);
setLocalEntry(null); // ✅ 清空本地状态
setCurrentEntry(null);
// 删除成功后刷新当前页
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
@@ -493,6 +549,51 @@ const WorldBook = () => {
return positions[position] || positions[0];
};
// ✅ 排序函数
const getSortedEntries = () => {
if (!currentEntries || currentEntries.length === 0) return [];
let sorted = [...currentEntries];
// 1. 先应用激活条目优先(独立于其他排序)
if (activeFirst) {
sorted.sort((a, b) => {
const aActive = !a.disable;
const bActive = !b.disable;
if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1;
return 0;
});
}
// 2. 再应用主排序规则
switch (sortBy) {
case 'name':
// 文件名顺序按comment或key
sorted.sort((a, b) => {
const nameA = (a.comment || a.key?.join(',') || '').toLowerCase();
const nameB = (b.comment || b.key?.join(',') || '').toLowerCase();
return nameA.localeCompare(nameB, 'zh-CN');
});
break;
case 'name_desc':
// 文件名逆序
sorted.sort((a, b) => {
const nameA = (a.comment || a.key?.join(',') || '').toLowerCase();
const nameB = (b.comment || b.key?.join(',') || '').toLowerCase();
return nameB.localeCompare(nameA, 'zh-CN');
});
break;
case 'order':
default:
// 插入顺序按order字段
sorted.sort((a, b) => (a.order || 0) - (b.order || 0));
break;
}
return sorted;
};
return (
<div className="worldbook-content">
{/* 全局世界书区域 */}
@@ -543,6 +644,68 @@ const WorldBook = () => {
📤 导出
</button>
)}
{/* ✅ 排序设置按钮 - 仿照预设的操作下拉菜单 */}
{currentWorldBook && (
<div className="sort-settings-container" style={{position: 'relative', marginLeft: 'auto'}}>
<button
className="sort-toggle-btn"
onClick={() => setShowSortMenu(!showSortMenu)}
title="排序设置"
>
🔀
</button>
{/* 排序下拉菜单 */}
{showSortMenu && (
<div className="sort-dropdown-menu">
<div className="sort-section">
<div className="sort-label">主排序</div>
<button
className={`sort-option ${sortBy === 'order' ? 'active' : ''}`}
onClick={() => {
setSortBy('order');
setShowSortMenu(false);
}}
>
📋 插入顺序
</button>
<button
className={`sort-option ${sortBy === 'name' ? 'active' : ''}`}
onClick={() => {
setSortBy('name');
setShowSortMenu(false);
}}
>
🔤 名称升序
</button>
<button
className={`sort-option ${sortBy === 'name_desc' ? 'active' : ''}`}
onClick={() => {
setSortBy('name_desc');
setShowSortMenu(false);
}}
>
🔤 名称降序
</button>
</div>
<div className="sort-divider"></div>
<div className="sort-section">
<label className="sort-checkbox-label">
<input
type="checkbox"
checked={activeFirst}
onChange={(e) => setActiveFirst(e.target.checked)}
/>
<span>🟢 激活条目优先</span>
</label>
</div>
</div>
)}
</div>
)}
</div>
{/* 世界书选择区域 */}
@@ -598,8 +761,8 @@ const WorldBook = () => {
<div className="error">{error}</div>
) : currentWorldBook ? (
<div className="entries-container">
{currentEntries.length > 0 ? (
currentEntries.map(entry => {
{getSortedEntries().length > 0 ? (
getSortedEntries().map(entry => {
// 计算 token 数(估算)
const tokenCount = Math.ceil((entry.content?.length || 0) / 4);
@@ -610,41 +773,25 @@ const WorldBook = () => {
return (
<div
key={entry.uid}
className={`entry-item-compact ${currentEntry?.uid === entry.uid ? 'active' : ''}`}
className={`entry-item-compact ${currentEntry?.uid === entry.uid ? 'active' : ''} ${isDisabled ? 'disabled' : ''}`}
onClick={() => handleEntryClick(entry)}
>
{/* 第一行:条目名称 + Token数 + 开关 */}
<div className="entry-row-top">
{/* 单行布局:名称 + Token数 + 位置下拉框 + 状态指示器 */}
<div className="entry-row-single">
<span className="entry-name-compact">
{entry.comment || entry.key?.join(', ') || `条目 #${entry.uid.substring(0, 8)}`}
</span>
<span className="entry-tokens">
{tokenCount} tokens
{/* Token数量仅数字 */}
<span className="entry-tokens-inline">
{tokenCount}
</span>
{/* 启用开关 */}
<label
className="toggle-switch"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => {
e.stopPropagation();
handleEntryUpdate('disable', !e.target.checked);
}}
/>
<span className="toggle-slider"></span>
</label>
</div>
{/* 第二行:插入位置下拉框 */}
<div className="entry-row-bottom" onClick={(e) => e.stopPropagation()}>
{/* 插入位置下拉框 */}
<select
className="position-select"
className="position-select-inline"
value={entry.position || 0}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
e.stopPropagation();
handleEntryUpdate('position', parseInt(e.target.value));
@@ -659,6 +806,16 @@ const WorldBook = () => {
);
})}
</select>
{/* 启用状态指示器 - 小圆点 */}
<div
className={`status-dot ${isEnabled ? 'enabled' : 'disabled'}`}
title={isEnabled ? '已启用' : '已禁用'}
onClick={(e) => {
e.stopPropagation();
handleEntryUpdate('disable', !isEnabled);
}}
/>
</div>
</div>
);
@@ -717,11 +874,11 @@ const WorldBook = () => {
onClick={() => setShowEditPanel(false)}/>
{/* 编辑面板 */}
{showEditPanel && currentEntry && (
{showEditPanel && localEntry && (
<div className={`edit-panel ${showEditPanel ? 'open' : ''}`}>
{/* 头部 */}
<div className="edit-panel-header">
<h2>编辑条目 - UID: {currentEntry.uid}</h2>
<h2>编辑条目 - UID: {localEntry.uid} {isSaving && <span className="saving-indicator">💾 保存中...</span>}</h2>
<button className="close-btn" onClick={() => setShowEditPanel(false)}>
</button>
@@ -736,8 +893,8 @@ const WorldBook = () => {
<input
type="text"
className="form-input"
value={currentEntry.comment || ''}
onChange={(e) => handleEntryUpdate('comment', e.target.value)}
value={localEntry.comment || ''}
onChange={(e) => handleLocalEntryChange('comment', e.target.value)}
placeholder="可选的备注名称"
/>
</div>
@@ -746,8 +903,8 @@ const WorldBook = () => {
<label className="form-label">插入位置</label>
<select
className="form-input"
value={currentEntry.position || 0}
onChange={(e) => handleEntryUpdate('position', parseInt(e.target.value))}
value={localEntry.position || 0}
onChange={(e) => handleLocalEntryChange('position', parseInt(e.target.value))}
>
{[0, 1, 2, 3, 4, 5].map(pos => {
const info = getPositionInfo(pos);
@@ -765,8 +922,8 @@ const WorldBook = () => {
<input
type="number"
className="form-input"
value={currentEntry.order || 100}
onChange={(e) => handleEntryUpdate('order', parseInt(e.target.value))}
value={localEntry.order || 100}
onChange={(e) => handleLocalEntryChange('order', parseInt(e.target.value))}
/>
</div>
@@ -775,8 +932,8 @@ const WorldBook = () => {
<input
type="number"
className="form-input"
value={currentEntry.depth || 4}
onChange={(e) => handleEntryUpdate('depth', parseInt(e.target.value))}
value={localEntry.depth || 4}
onChange={(e) => handleLocalEntryChange('depth', parseInt(e.target.value))}
/>
</div>
</div>
@@ -786,8 +943,8 @@ const WorldBook = () => {
<label className="form-label">条目内容</label>
<textarea
className="form-input content-textarea"
value={currentEntry.content || ''}
onChange={(e) => handleEntryUpdate('content', e.target.value)}
value={localEntry.content || ''}
onChange={(e) => handleLocalEntryChange('content', e.target.value)}
placeholder="在此输入世界书条目的内容..."
/>
</div>
@@ -818,22 +975,22 @@ const WorldBook = () => {
<input
type="text"
className="form-input"
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.key?.join(', ') || ''}
value={localEntry.trigger_config?.triggers?.keyword?.[1]?.key?.join(', ') || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
...localEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
...localEntry.trigger_config?.triggers?.keyword?.[1],
key: e.target.value.split(',').map(k => k.trim()).filter(k => k)
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
placeholder="例如:魔法, 火焰, 冰霜"
/>
@@ -844,22 +1001,22 @@ const WorldBook = () => {
<input
type="text"
className="form-input"
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.keysecondary?.join(', ') || ''}
value={localEntry.trigger_config?.triggers?.keyword?.[1]?.keysecondary?.join(', ') || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
...localEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
...localEntry.trigger_config?.triggers?.keyword?.[1],
keysecondary: e.target.value.split(',').map(k => k.trim()).filter(k => k)
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
placeholder="可选的过滤关键词"
/>
@@ -869,22 +1026,22 @@ const WorldBook = () => {
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.selective || false}
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.selective || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
...localEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
...localEntry.trigger_config?.triggers?.keyword?.[1],
selective: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
/>
选择性匹配
@@ -893,22 +1050,22 @@ const WorldBook = () => {
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.matchWholeWords || false}
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.matchWholeWords || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
...localEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
...localEntry.trigger_config?.triggers?.keyword?.[1],
matchWholeWords: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
/>
全词匹配
@@ -917,22 +1074,22 @@ const WorldBook = () => {
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.caseSensitive || false}
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.caseSensitive || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
...localEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
...localEntry.trigger_config?.triggers?.keyword?.[1],
caseSensitive: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
/>
区分大小写