完成请求推送,但组装mes还有问题
This commit is contained in:
@@ -47,10 +47,29 @@
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
padding: 0; /* ✅ 移除padding,滚动条作为间隔 */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ✅ 美化滚动条 - 符合主题 */
|
||||
.sidebar-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scrollbar-hover);
|
||||
}
|
||||
|
||||
/* Tab placeholder for empty states */
|
||||
.tab-placeholder {
|
||||
padding: var(--spacing-lg);
|
||||
|
||||
@@ -3,15 +3,18 @@ import React from 'react';
|
||||
import './SideBarLeft.css';
|
||||
import { useSideBarLeftStore } from '../../Store/indexStore';
|
||||
import usePresetStore from '../../Store/SideBarLeft/PresetSlice';
|
||||
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
|
||||
import Gallery from './tabs/Gallery';
|
||||
import CharacterCard from './tabs/CharacterCard';
|
||||
import ApiConfig from './tabs/ApiConfig';
|
||||
import Presets from './tabs/Presets';
|
||||
import WorldBook from './tabs/WorldBook';
|
||||
import TokenUsage from './tabs/TokenUsage/TokenUsage';
|
||||
|
||||
const SideBarLeft = () => {
|
||||
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
|
||||
const { fetchPresets } = usePresetStore();
|
||||
const { fetchProfiles } = useApiConfigStore();
|
||||
|
||||
// 处理标签切换
|
||||
const handleTabClick = (tabId) => {
|
||||
@@ -21,6 +24,11 @@ const SideBarLeft = () => {
|
||||
if (tabId === 'presets') {
|
||||
fetchPresets();
|
||||
}
|
||||
|
||||
// 如果切换到API配置标签,刷新配置文件列表
|
||||
if (tabId === 'api') {
|
||||
fetchProfiles();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -44,6 +52,7 @@ const SideBarLeft = () => {
|
||||
{activeTab === 'api' && <ApiConfig />}
|
||||
{activeTab === 'presets' && <Presets />}
|
||||
{activeTab === 'worldbook' && <WorldBook />}
|
||||
{activeTab === 'tokenUsage' && <TokenUsage />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import ComfyUIWorkflowManager from './ComfyUIWorkflowManager';
|
||||
import './ApiConfig.css';
|
||||
|
||||
const ApiConfig = () => {
|
||||
// 从store中获取状态和方法
|
||||
// 从 store中获取状态和方法
|
||||
const {
|
||||
profiles,
|
||||
currentProfile,
|
||||
@@ -77,21 +77,11 @@ const ApiConfig = () => {
|
||||
// 跟踪哪些 API 有修改
|
||||
const [modifiedApis, setModifiedApis] = useState({});
|
||||
|
||||
// 跟踪用户是否正在编辑 API Key(用于控制脱敏显示)
|
||||
const [isEditingApiKey, setIsEditingApiKey] = useState(false);
|
||||
|
||||
// 获取当前激活的分页
|
||||
const { activeTab } = useSideBarLeftStore();
|
||||
|
||||
// 记录上一次的分页状态
|
||||
const prevActiveTabRef = React.useRef(activeTab);
|
||||
|
||||
// 组件加载时获取配置文件列表 - 只在切换到API分页时刷新
|
||||
useEffect(() => {
|
||||
// 检测是否从其他分页切换到API分页
|
||||
if (activeTab === 'api' && prevActiveTabRef.current !== 'api') {
|
||||
fetchProfiles();
|
||||
}
|
||||
// 更新上一次的分页状态
|
||||
prevActiveTabRef.current = activeTab;
|
||||
}, [activeTab, fetchProfiles]);
|
||||
|
||||
// 当选中配置文件时,加载该配置
|
||||
useEffect(() => {
|
||||
@@ -100,6 +90,15 @@ const ApiConfig = () => {
|
||||
}
|
||||
}, [selectedProfileId]);
|
||||
|
||||
// 当 profiles 加载完成后,如果没有选中的配置文件,则自动加载第一个
|
||||
useEffect(() => {
|
||||
if (profiles.length > 0 && !selectedProfileId && activeTab === 'api') {
|
||||
// 尝试加载 activeMap 中记录的配置文件,如果没有则加载第一个
|
||||
const defaultProfileId = activeMap[currentCategory] || profiles[0].id;
|
||||
setSelectedProfileId(defaultProfileId);
|
||||
}
|
||||
}, [profiles, selectedProfileId, activeTab, currentCategory, activeMap]);
|
||||
|
||||
// 加载配置文件
|
||||
const loadProfile = async (profileId) => {
|
||||
try {
|
||||
@@ -123,6 +122,11 @@ const ApiConfig = () => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
const newValue = type === 'checkbox' ? checked : value;
|
||||
|
||||
// 如果是 API Key 字段,标记为正在编辑
|
||||
if (name === 'apiKey') {
|
||||
setIsEditingApiKey(true);
|
||||
}
|
||||
|
||||
if (path) {
|
||||
// 嵌套路径更新,例如: ['imageModel', 'local', 'apiUrl']
|
||||
setFormData(prev => {
|
||||
@@ -267,8 +271,10 @@ const ApiConfig = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentApi.apiKey) {
|
||||
alert('请先填写 API 密钥');
|
||||
// 检查 API Key:可以为空(如果已保存过,后端会使用保存的 key)
|
||||
// 但如果既没有输入 key,也没有保存过,则会失败
|
||||
if (!currentApi.apiKey || currentApi.apiKey.trim() === '') {
|
||||
alert('请先填写 API 密钥,或先保存配置文件');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,6 +283,7 @@ const ApiConfig = () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: selectedProfileId || null, // 使用 id 字段传递 profileId
|
||||
apiUrl: currentApi.apiUrl,
|
||||
apiKey: currentApi.apiKey,
|
||||
category: currentCategory,
|
||||
@@ -388,19 +395,9 @@ const ApiConfig = () => {
|
||||
if (selectedProfileId && confirm('确定要删除此配置文件吗?')) {
|
||||
await deleteProfile(selectedProfileId);
|
||||
setSelectedProfileId('');
|
||||
handleCreateNew();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理设为默认配置
|
||||
const handleSetActive = async () => {
|
||||
if (!selectedProfileId) {
|
||||
alert('请先保存配置文件');
|
||||
return;
|
||||
}
|
||||
await setActiveConfig(currentCategory, selectedProfileId);
|
||||
};
|
||||
|
||||
// 配置区域标签(简短名称 + Tooltip)
|
||||
const configTabs = [
|
||||
{ id: 'mainLLM', label: '核心', tooltip: '主 LLM 模型 - 用于主要对话和推理' },
|
||||
@@ -409,8 +406,13 @@ const ApiConfig = () => {
|
||||
{ id: 'ragEmbedding', label: '向量', tooltip: 'RAG 嵌入模型 - 用于文本向量化和检索' }
|
||||
];
|
||||
|
||||
// 判断当前 API 密钥是否是脱敏的
|
||||
const isApiKeyMasked = formData[currentCategory].apiKey && formData[currentCategory].apiKey.endsWith('****');
|
||||
// 脱敏 API Key 显示(只显示最后6位)
|
||||
const maskApiKey = (apiKey) => {
|
||||
if (!apiKey || apiKey.length <= 6) {
|
||||
return apiKey || '';
|
||||
}
|
||||
return '•'.repeat(apiKey.length - 6) + apiKey.slice(-6);
|
||||
};
|
||||
|
||||
// 计算有修改的 API 数量
|
||||
const modifiedCount = Object.keys(modifiedApis).filter(k => modifiedApis[k]).length;
|
||||
@@ -438,41 +440,38 @@ const ApiConfig = () => {
|
||||
onChange={handleSelectProfile}
|
||||
className="form-control profile-select-input"
|
||||
>
|
||||
<option value="">新建配置文件...</option>
|
||||
{profiles.map(profile => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="profile-buttons">
|
||||
{!selectedProfileId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleOpenSaveModal}
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
{profiles.length === 0 ? (
|
||||
<option value="">暂无配置文件</option>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleSetActive}
|
||||
disabled={activeMap[currentCategory] === selectedProfileId}
|
||||
>
|
||||
设为默认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
<option value="">-- 选择配置文件 --</option>
|
||||
{profiles.map(profile => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
<div className="profile-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleOpenSaveModal}
|
||||
title="创建新的配置文件"
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
{selectedProfileId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleDelete}
|
||||
title="删除当前配置文件"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -606,13 +605,31 @@ const ApiConfig = () => {
|
||||
<div className="form-group">
|
||||
<label htmlFor="cloud-apiKey">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
type={isEditingApiKey ? 'text' : 'password'}
|
||||
id="cloud-apiKey"
|
||||
value={formData.imageModel.cloud.apiKey}
|
||||
value={isEditingApiKey ? formData.imageModel.cloud.apiKey : maskApiKey(formData.imageModel.cloud.apiKey)}
|
||||
onChange={(e) => handleChange(e, ['imageModel', 'cloud', 'apiKey'])}
|
||||
placeholder="sk-..."
|
||||
onBlur={() => setIsEditingApiKey(false)}
|
||||
onFocus={() => {
|
||||
if (formData.imageModel.cloud.apiKey && !isEditingApiKey) {
|
||||
// 聚焦时清空,让用户重新输入
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
imageModel: {
|
||||
...prev.imageModel,
|
||||
cloud: {
|
||||
...prev.imageModel.cloud,
|
||||
apiKey: ''
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
setIsEditingApiKey(true);
|
||||
}}
|
||||
placeholder="sk-...(输入新密钥将覆盖旧密钥)"
|
||||
className="form-control"
|
||||
/>
|
||||
<span className="form-hint">首次输入可见,之后仅显示最后6位</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
@@ -668,17 +685,29 @@ const ApiConfig = () => {
|
||||
<div className="form-group">
|
||||
<label htmlFor="apiKey">密钥</label>
|
||||
<input
|
||||
type="password"
|
||||
type={isEditingApiKey ? 'text' : 'password'}
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
value={isApiKeyMasked ? '' : formData[currentCategory].apiKey}
|
||||
value={isEditingApiKey ? formData[currentCategory].apiKey : maskApiKey(formData[currentCategory].apiKey)}
|
||||
onChange={handleChange}
|
||||
placeholder={isApiKeyMasked ? '已保存(留空保持不变)' : 'sk-...'}
|
||||
onBlur={() => setIsEditingApiKey(false)}
|
||||
onFocus={() => {
|
||||
if (formData[currentCategory].apiKey && !isEditingApiKey) {
|
||||
// 聚焦时清空,让用户重新输入
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[currentCategory]: {
|
||||
...prev[currentCategory],
|
||||
apiKey: ''
|
||||
}
|
||||
}));
|
||||
}
|
||||
setIsEditingApiKey(true);
|
||||
}}
|
||||
placeholder="sk-...(输入新密钥将覆盖旧密钥)"
|
||||
className="form-control"
|
||||
/>
|
||||
{isApiKeyMasked && (
|
||||
<span className="form-hint">当前密钥已加密存储</span>
|
||||
)}
|
||||
<span className="form-hint">首次输入可见,之后仅显示最后6位</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
transition: all 0.15s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tag-btn:hover {
|
||||
@@ -76,12 +79,36 @@
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tag-btn.active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
/* 包含模式(第一次点击)- 绿色 */
|
||||
.tag-btn.tag-include {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tag-btn.tag-include:hover {
|
||||
background: #059669;
|
||||
border-color: #059669;
|
||||
}
|
||||
|
||||
/* 排除模式(第二次点击)- 红色 */
|
||||
.tag-btn.tag-exclude {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tag-btn.tag-exclude:hover {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
/* 排除前缀符号 */
|
||||
.tag-prefix {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 错误和加载提示 */
|
||||
.error-message,
|
||||
.loading-message,
|
||||
@@ -270,47 +297,116 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
position: relative; /* 为悬浮工具栏提供定位上下文 */
|
||||
}
|
||||
|
||||
.edit-header {
|
||||
/* 悬浮工具栏 */
|
||||
.floating-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 工具栏内容(默认隐藏) */
|
||||
.toolbar-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
flex-wrap: wrap; /* 允许换行 */
|
||||
gap: 6px; /* 添加间距 */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
/* 默认状态:收缩 */
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transform: scale(0.8);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 悬停时展开 */
|
||||
.floating-toolbar:hover .toolbar-content {
|
||||
max-height: 60px;
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0; /* 允许收缩 */
|
||||
}
|
||||
|
||||
.edit-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
flex: 1; /* 占据剩余空间 */
|
||||
min-width: 120px; /* 最小宽度,防止过度压缩 */
|
||||
white-space: nowrap; /* 不换行 */
|
||||
overflow: hidden; /* 超出隐藏 */
|
||||
text-overflow: ellipsis; /* 显示省略号 */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
/* 小球指示器 */
|
||||
.toolbar-indicator {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap; /* 按钮也可以换行 */
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: translateY(-50%) scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-toolbar:hover .toolbar-indicator {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scale(0);
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
font-size: 18px;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.export-format-selector {
|
||||
padding: 4px 8px;
|
||||
padding: 3px 6px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.export-format-selector:hover {
|
||||
@@ -322,55 +418,66 @@
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
padding: 4px 10px;
|
||||
.toolbar-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.edit-btn.save {
|
||||
.toolbar-btn:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.toolbar-btn.save {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-btn.save:hover {
|
||||
.toolbar-btn.save:hover {
|
||||
background: var(--color-accent-dark);
|
||||
border-color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.edit-btn.export {
|
||||
.toolbar-btn.export {
|
||||
background: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-btn.export:hover {
|
||||
.toolbar-btn.export:hover {
|
||||
background: #059669;
|
||||
border-color: #059669;
|
||||
}
|
||||
|
||||
.edit-btn.delete {
|
||||
.toolbar-btn.delete {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-btn.delete:hover {
|
||||
.toolbar-btn.delete:hover {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.edit-btn.cancel {
|
||||
.toolbar-btn.cancel {
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.edit-btn.cancel:hover {
|
||||
.toolbar-btn.cancel:hover {
|
||||
background: var(--color-error);
|
||||
border-color: var(--color-error);
|
||||
color: white;
|
||||
@@ -415,6 +522,9 @@
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
max-height: 300px; /* 设置最大高度 */
|
||||
overflow-y: auto;
|
||||
transition: height 0.2s ease; /* 平滑过渡 */
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; // ✅ 保留 useState 用于 exportFormat
|
||||
import { useCharacterStore, useCharacterCardUIStore } from '../../../../Store/SideBarLeft'; // ✅ 新增
|
||||
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
|
||||
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; // 引入世界书 Store
|
||||
import './CharacterCard.css';
|
||||
|
||||
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
|
||||
@@ -54,8 +55,8 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
||||
)}
|
||||
{character.tags && character.tags.length > 0 && (
|
||||
<div className="character-tags">
|
||||
{character.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="tag">{tag}</span>
|
||||
{character.tags.slice(0, 3).map((tag, index) => (
|
||||
<span key={`tag-${character.id}-${tag}-${index}`} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -86,13 +87,14 @@ const CharacterCard = () => {
|
||||
|
||||
// ✅ 从 CharacterCardUIStore 获取 UI 状态
|
||||
const {
|
||||
filterTag,
|
||||
filterTags,
|
||||
isEditing,
|
||||
editForm,
|
||||
currentPage,
|
||||
pageSize,
|
||||
setFilterTag,
|
||||
clearFilter,
|
||||
toggleFilterTag,
|
||||
removeFilterTag,
|
||||
clearAllFilters,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
updateEditForm,
|
||||
@@ -102,13 +104,17 @@ const CharacterCard = () => {
|
||||
prevPage
|
||||
} = useCharacterCardUIStore();
|
||||
|
||||
// 引入世界书 Store
|
||||
const { worldBooks, fetchWorldBooks } = useWorldBookStore();
|
||||
|
||||
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
|
||||
const fileInputRef = useRef(null);
|
||||
const clickTimeoutRef = useRef(null);
|
||||
|
||||
// 加载角色列表
|
||||
// 加载角色列表和世界书列表
|
||||
useEffect(() => {
|
||||
fetchCharacters();
|
||||
fetchWorldBooks(); // 加载世界书列表
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
@@ -121,10 +127,31 @@ const CharacterCard = () => {
|
||||
// 获取所有唯一的 tags
|
||||
const allTags = [...new Set(characters.flatMap(char => char.tags || []))];
|
||||
|
||||
// 过滤角色
|
||||
const filteredCharacters = filterTag
|
||||
? characters.filter(char => (char.tags || []).includes(filterTag))
|
||||
: characters;
|
||||
// 过滤角色(支持多标签交集筛选)
|
||||
const filteredCharacters = React.useMemo(() => {
|
||||
if (filterTags.length === 0) return characters;
|
||||
|
||||
// 分离包含和排除标签
|
||||
const includeTags = filterTags
|
||||
.filter(f => f.startsWith('include:'))
|
||||
.map(f => f.substring(8));
|
||||
|
||||
const excludeTags = filterTags
|
||||
.filter(f => f.startsWith('exclude:'))
|
||||
.map(f => f.substring(8));
|
||||
|
||||
return characters.filter(char => {
|
||||
const charTags = char.tags || [];
|
||||
|
||||
// 检查是否包含所有必须的标签(交集)
|
||||
const hasAllInclude = includeTags.every(tag => charTags.includes(tag));
|
||||
|
||||
// 检查是否不包含所有排除的标签
|
||||
const hasNoExclude = excludeTags.every(tag => !charTags.includes(tag));
|
||||
|
||||
return hasAllInclude && hasNoExclude;
|
||||
});
|
||||
}, [characters, filterTags]);
|
||||
|
||||
// ✅ 计算当前页和总页数
|
||||
const totalPages = Math.ceil(filteredCharacters.length / pageSize) || 1;
|
||||
@@ -314,6 +341,13 @@ const CharacterCard = () => {
|
||||
updateEditForm(field, value); // ✅ 使用 store 方法
|
||||
};
|
||||
|
||||
// 自动调整 textarea 高度
|
||||
const autoResizeTextarea = (e) => {
|
||||
const textarea = e.target;
|
||||
textarea.style.height = 'auto'; // 重置高度
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; // 设置新高度,最大300px
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="character-card-content">
|
||||
{/* 标题栏 */}
|
||||
@@ -338,20 +372,93 @@ const CharacterCard = () => {
|
||||
{allTags.length > 0 && (
|
||||
<div className="tag-filter">
|
||||
<button
|
||||
className={`tag-btn ${!filterTag ? 'active' : ''}`}
|
||||
onClick={() => setFilterTag('')}
|
||||
className={`tag-btn ${filterTags.length === 0 ? 'active' : ''}`}
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{allTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
className={`tag-btn ${filterTag === tag ? 'active' : ''}`}
|
||||
onClick={() => setFilterTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{allTags.map(tag => {
|
||||
// 判断当前标签的状态
|
||||
let status = 'none'; // none, include, exclude
|
||||
const includeFilter = `include:${tag}`;
|
||||
const excludeFilter = `exclude:${tag}`;
|
||||
|
||||
if (filterTags.includes(includeFilter)) {
|
||||
status = 'include';
|
||||
} else if (filterTags.includes(excludeFilter)) {
|
||||
status = 'exclude';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
className={`tag-btn tag-${status}`}
|
||||
onClick={() => toggleFilterTag(tag)}
|
||||
title={
|
||||
status === 'include' ? '包含该标签(再次点击排除)' :
|
||||
status === 'exclude' ? '排除该标签(再次点击取消)' :
|
||||
'筛选包含该标签的角色'
|
||||
}
|
||||
>
|
||||
{status === 'exclude' && <span className="tag-prefix">¬</span>}
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 当前筛选条件显示 */}
|
||||
{filterTags.length > 0 && (
|
||||
<div className="active-filters" style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
padding: '4px 6px',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary)', marginRight: '4px' }}>
|
||||
筛选中:
|
||||
</span>
|
||||
{filterTags.map((filter) => {
|
||||
const mode = filter.startsWith('include:') ? 'include' : 'exclude';
|
||||
const tag = filter.substring(filter.indexOf(':') + 1);
|
||||
return (
|
||||
<span
|
||||
key={`filter-${mode}-${tag}`}
|
||||
className={`active-filter-tag active-filter-${mode}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
background: mode === 'include' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)',
|
||||
border: `1px solid ${mode === 'include' ? '#10b981' : '#ef4444'}`,
|
||||
color: mode === 'include' ? '#10b981' : '#ef4444'
|
||||
}}
|
||||
>
|
||||
{mode === 'exclude' && '¬'}
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => removeFilterTag(tag)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
padding: '0 2px',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1
|
||||
}}
|
||||
title="移除筛选"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -372,21 +479,39 @@ const CharacterCard = () => {
|
||||
{/* 编辑模式 */}
|
||||
{isEditing && selectedCharacter && editForm && (
|
||||
<div className="character-edit-panel">
|
||||
<div className="edit-header">
|
||||
<span className="edit-title">编辑角色: {selectedCharacter.name}</span>
|
||||
<div className="edit-actions">
|
||||
<select
|
||||
className="export-format-selector"
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
>
|
||||
<option value="png">🖼️ 图片</option>
|
||||
<option value="json">📄 JSON</option>
|
||||
</select>
|
||||
<button className="edit-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)}>📤 导出</button>
|
||||
<button className="edit-btn delete" onClick={() => handleDelete(selectedCharacter.name)}>🗑️ 删除</button>
|
||||
<button className="edit-btn save" onClick={handleSaveEdit}>💾 保存</button>
|
||||
<button className="edit-btn cancel" onClick={handleCancelEdit}>❌ 取消</button>
|
||||
{/* 悬浮工具栏 */}
|
||||
<div className="floating-toolbar">
|
||||
<div className="toolbar-content">
|
||||
<div className="toolbar-left">
|
||||
<span className="edit-title">编辑: {selectedCharacter.name}</span>
|
||||
</div>
|
||||
<div className="toolbar-actions">
|
||||
<select
|
||||
className="export-format-selector"
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
title="选择导出格式"
|
||||
>
|
||||
<option value="png">🖼️ PNG</option>
|
||||
<option value="json">📄 JSON</option>
|
||||
</select>
|
||||
<button className="toolbar-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)} title="导出角色卡">
|
||||
📤
|
||||
</button>
|
||||
<button className="toolbar-btn delete" onClick={() => handleDelete(selectedCharacter.name)} title="删除角色">
|
||||
🗑️
|
||||
</button>
|
||||
<button className="toolbar-btn save" onClick={handleSaveEdit} title="保存更改">
|
||||
💾
|
||||
</button>
|
||||
<button className="toolbar-btn cancel" onClick={handleCancelEdit} title="返回列表">
|
||||
←
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 小球指示器 */}
|
||||
<div className="toolbar-indicator">
|
||||
<span className="indicator-icon">⚙️</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -399,13 +524,18 @@ const CharacterCard = () => {
|
||||
onChange={(e) => handleFormChange('name', e.target.value)}
|
||||
placeholder="角色名称"
|
||||
/>
|
||||
<small className="form-hint">💡 提示:修改角色名将同步重命名文件夹和所有相关引用</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) => handleFormChange('description', e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleFormChange('description', e.target.value);
|
||||
autoResizeTextarea(e);
|
||||
}}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder="角色描述"
|
||||
rows={6}
|
||||
/>
|
||||
@@ -415,7 +545,11 @@ const CharacterCard = () => {
|
||||
<label>性格</label>
|
||||
<textarea
|
||||
value={editForm.personality}
|
||||
onChange={(e) => handleFormChange('personality', e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleFormChange('personality', e.target.value);
|
||||
autoResizeTextarea(e);
|
||||
}}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder="角色性格"
|
||||
rows={3}
|
||||
/>
|
||||
@@ -425,7 +559,11 @@ const CharacterCard = () => {
|
||||
<label>场景</label>
|
||||
<textarea
|
||||
value={editForm.scenario}
|
||||
onChange={(e) => handleFormChange('scenario', e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleFormChange('scenario', e.target.value);
|
||||
autoResizeTextarea(e);
|
||||
}}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder="场景设定"
|
||||
rows={3}
|
||||
/>
|
||||
@@ -435,7 +573,11 @@ const CharacterCard = () => {
|
||||
<label>开场白</label>
|
||||
<textarea
|
||||
value={editForm.first_mes}
|
||||
onChange={(e) => handleFormChange('first_mes', e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleFormChange('first_mes', e.target.value);
|
||||
autoResizeTextarea(e);
|
||||
}}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder="第一条消息"
|
||||
rows={4}
|
||||
/>
|
||||
@@ -445,7 +587,11 @@ const CharacterCard = () => {
|
||||
<label>对话示例</label>
|
||||
<textarea
|
||||
value={editForm.mes_example}
|
||||
onChange={(e) => handleFormChange('mes_example', e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleFormChange('mes_example', e.target.value);
|
||||
autoResizeTextarea(e);
|
||||
}}
|
||||
onInput={autoResizeTextarea}
|
||||
placeholder="对话示例"
|
||||
rows={4}
|
||||
/>
|
||||
@@ -462,21 +608,57 @@ const CharacterCard = () => {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>动态表格标签 (SillyTavern 关键字机制)</label>
|
||||
<label>绑定世界书</label>
|
||||
<select
|
||||
value={editForm.worldInfoId || ''}
|
||||
onChange={(e) => handleFormChange('worldInfoId', e.target.value || null)}
|
||||
>
|
||||
<option value="">不绑定</option>
|
||||
{worldBooks.map(book => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small className="form-hint">💡 选择后,该角色将自动加载绑定的世界书内容</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>动态表格数据 (SillyTavern 关键字机制)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.tags?.join(', ') || ''}
|
||||
value={Object.entries(editForm.tableDefaults || {}).map(([k, v]) => `${k}:${v}`).join(';')}
|
||||
onChange={(e) => {
|
||||
// 支持多种分隔符:逗号、分号、空格
|
||||
const tagsList = e.target.value
|
||||
.split(/[,;\s]+/)
|
||||
.map(tag => tag.trim())
|
||||
.filter(tag => tag !== '');
|
||||
handleFormChange('tags', tagsList);
|
||||
// 解析格式:key1:value1;key2:value2,key3:value3
|
||||
// 支持分隔符:分号、逗号
|
||||
const entries = e.target.value
|
||||
.split(/[;,]/)
|
||||
.map(pair => pair.trim())
|
||||
.filter(pair => pair !== '')
|
||||
.map(pair => {
|
||||
const [key, ...valueParts] = pair.split(':');
|
||||
const value = valueParts.join(':').trim(); // 支持值中包含冒号
|
||||
return { key: key.trim(), value };
|
||||
})
|
||||
.filter(({ key, value }) => key && value !== undefined);
|
||||
|
||||
// 构建 tableDefaults 对象
|
||||
const tableDefaults = {};
|
||||
const tableHeaders = [];
|
||||
|
||||
entries.forEach(({ key, value }) => {
|
||||
tableHeaders.push(key);
|
||||
// 尝试转换为数字
|
||||
const numValue = Number(value);
|
||||
tableDefaults[key] = isNaN(numValue) ? value : numValue;
|
||||
});
|
||||
|
||||
handleFormChange('tableHeaders', tableHeaders);
|
||||
handleFormChange('tableDefaults', tableDefaults);
|
||||
}}
|
||||
placeholder="力量:80, 敏捷:65, 智力:90, HP:100"
|
||||
placeholder="力量:80;敏捷:65;智力:90;HP:100"
|
||||
/>
|
||||
<small className="form-hint">每个标签是一个键值对,用逗号/分号/空格分隔。双击右侧表格中的标签可编辑。</small>
|
||||
<small className="form-hint">格式:key:value,用分号或逗号分隔。例如:力量:80;敏捷:65,HP:100</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -488,7 +670,7 @@ const CharacterCard = () => {
|
||||
<div className="character-list">
|
||||
{currentPageCharacters.length === 0 ? (
|
||||
<div className="empty-message">
|
||||
{filterTag ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
|
||||
{filterTags.length > 0 ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
|
||||
</div>
|
||||
) : (
|
||||
currentPageCharacters.map(char => (
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
z-index: 1100; /* ✅ 高于 ChatBox (z-index: 1000) */
|
||||
min-width: 140px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ const PresetPanel = () => {
|
||||
top_k: "随机采样范围,从概率最高的K个词中选择",
|
||||
max_ctx: "上下文窗口大小(Token上限)",
|
||||
max_tokens: "单次回复的最大长度",
|
||||
request_timeout: "LLM请求超时时间(秒)",
|
||||
seed: "随机种子(-1为随机)",
|
||||
n: "生成回复的数量"
|
||||
};
|
||||
@@ -90,6 +91,7 @@ const PresetPanel = () => {
|
||||
top_k: "Top-K",
|
||||
max_ctx: "上下文",
|
||||
max_tokens: "MaxTok",
|
||||
request_timeout: "超时",
|
||||
seed: "种子",
|
||||
n: "数量"
|
||||
};
|
||||
@@ -114,7 +116,7 @@ const PresetPanel = () => {
|
||||
let convertedValue = value;
|
||||
if (name === 'temperature' || name === 'freq_penalty' || name === 'pres_penalty' || name === 'top_p') {
|
||||
convertedValue = parseFloat(value);
|
||||
} else if (name === 'top_k' || name === 'max_ctx' || name === 'max_tokens' || name === 'seed' || name === 'n') {
|
||||
} else if (name === 'top_k' || name === 'max_ctx' || name === 'max_tokens' || name === 'request_timeout' || name === 'seed' || name === 'n') {
|
||||
convertedValue = parseInt(value, 10);
|
||||
}
|
||||
|
||||
@@ -186,7 +188,8 @@ const PresetPanel = () => {
|
||||
top_k: importedPreset.topK,
|
||||
frequency_penalty: importedPreset.frequencyPenalty,
|
||||
presence_penalty: importedPreset.presencePenalty,
|
||||
max_tokens: importedPreset.maxLength
|
||||
max_tokens: importedPreset.maxLength,
|
||||
request_timeout: importedPreset.requestTimeout || 60
|
||||
};
|
||||
|
||||
// 转换 entries 为 promptComponents 格式
|
||||
@@ -213,7 +216,8 @@ const PresetPanel = () => {
|
||||
presence_penalty: importedPreset.presence_penalty,
|
||||
top_p: importedPreset.top_p,
|
||||
top_k: importedPreset.top_k,
|
||||
max_tokens: importedPreset.max_tokens || importedPreset.openai_max_tokens
|
||||
max_tokens: importedPreset.max_tokens || importedPreset.openai_max_tokens,
|
||||
request_timeout: importedPreset.request_timeout || 60
|
||||
};
|
||||
importedComponents = importedPreset.promptComponents || [];
|
||||
}
|
||||
@@ -261,6 +265,7 @@ const PresetPanel = () => {
|
||||
frequencyPenalty: parameters.frequency_penalty,
|
||||
presencePenalty: parameters.presence_penalty,
|
||||
maxLength: parameters.max_tokens,
|
||||
requestTimeout: parameters.request_timeout || 60,
|
||||
isDefault: false,
|
||||
|
||||
// PromptPresetView 部分
|
||||
@@ -744,6 +749,25 @@ const PresetPanel = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 请求超时输入框 */}
|
||||
<div className="parameter-row-compact">
|
||||
<label
|
||||
className="parameter-label-compact"
|
||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.request_timeout)}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
{parameterLabels.request_timeout}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="600"
|
||||
value={parameters.request_timeout || 60}
|
||||
onChange={(e) => handleParameterChange('request_timeout', e.target.value)}
|
||||
className="parameter-input-compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 随机种子输入框 */}
|
||||
<div className="parameter-row-compact">
|
||||
<label
|
||||
|
||||
230
frontend/src/components/SideBarLeft/tabs/Settings/Settings.css
Normal file
230
frontend/src/components/SideBarLeft/tabs/Settings/Settings.css
Normal file
@@ -0,0 +1,230 @@
|
||||
.settings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 消息提示 */
|
||||
.message {
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
border: 1px solid #4caf50;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border: 1px solid #f44336;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
/* 分页标签 */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--accent-color);
|
||||
border-bottom-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 设置区块 */
|
||||
.settings-section {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 设置项 */
|
||||
.setting-item {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.setting-item label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.setting-item input[type="text"],
|
||||
.setting-item input[type="file"] {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.setting-item input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.setting-item small {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.setting-item code {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 文件结构列表 */
|
||||
.file-structure {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.file-structure li {
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 预览框 */
|
||||
.preview-box {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.preview-box .original,
|
||||
.preview-box .processed {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-box .original:last-child,
|
||||
.preview-box .processed:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preview-box strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.preview-box p {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: var(--accent-color);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
266
frontend/src/components/SideBarLeft/tabs/Settings/Settings.jsx
Normal file
266
frontend/src/components/SideBarLeft/tabs/Settings/Settings.jsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './Settings.css';
|
||||
|
||||
/**
|
||||
* 系统设置组件
|
||||
*
|
||||
* 按功能分页展示:
|
||||
* - 正则规则设置
|
||||
* - 思考标签配置
|
||||
* - 其他系统设置
|
||||
*/
|
||||
const Settings = () => {
|
||||
const [activeTab, setActiveTab] = useState('regex');
|
||||
const [settings, setSettings] = useState({
|
||||
thinkingTagPrefix: '<thinking>',
|
||||
thinkingTagSuffix: '</thinking>',
|
||||
currentPresetName: null
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: '', text: '' });
|
||||
|
||||
// 加载系统设置
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/regex/settings');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setSettings(data.settings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载系统设置失败:', error);
|
||||
showMessage('error', '加载设置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/regex/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage('success', '设置已保存');
|
||||
} else {
|
||||
showMessage('error', '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存设置失败:', error);
|
||||
showMessage('error', '保存失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showMessage = (type, text) => {
|
||||
setMessage({ type, text });
|
||||
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
|
||||
};
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-container">
|
||||
{/* 标题 */}
|
||||
<div className="settings-header">
|
||||
<h2>系统设置</h2>
|
||||
</div>
|
||||
|
||||
{/* 消息提示 */}
|
||||
{message.text && (
|
||||
<div className={`message ${message.type}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页标签 */}
|
||||
<div className="settings-tabs">
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'regex' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('regex')}
|
||||
>
|
||||
正则规则
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'thinking' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('thinking')}
|
||||
>
|
||||
思考标签
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'general' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('general')}
|
||||
>
|
||||
通用设置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="settings-content">
|
||||
{loading && <div className="loading">加载中...</div>}
|
||||
|
||||
{/* 正则规则设置 */}
|
||||
{activeTab === 'regex' && (
|
||||
<div className="settings-section">
|
||||
<h3>正则规则管理</h3>
|
||||
<p className="section-description">
|
||||
管理文本处理规则,支持 SillyTavern 格式导入
|
||||
</p>
|
||||
|
||||
<div className="setting-item">
|
||||
<label>规则文件位置</label>
|
||||
<code>data/regex/</code>
|
||||
<ul className="file-structure">
|
||||
<li>📁 global/ - 全局规则</li>
|
||||
<li>📁 characters/ - 角色卡规则</li>
|
||||
<li>📁 presets/ - 预设规则</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<label>导入规则</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/regex/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showMessage('success', data.message);
|
||||
} else {
|
||||
showMessage('error', '导入失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '导入失败');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>支持 SillyTavern 格式的规则文件</small>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<button className="btn-primary" onClick={() => window.open('/api/regex/export/global', '_blank')}>
|
||||
导出全局规则
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 思考标签配置 */}
|
||||
{activeTab === 'thinking' && (
|
||||
<div className="settings-section">
|
||||
<h3>思考标签配置</h3>
|
||||
<p className="section-description">
|
||||
配置 AI 推理/思考内容的标记标签
|
||||
</p>
|
||||
|
||||
<div className="setting-item">
|
||||
<label htmlFor="thinking-prefix">前缀</label>
|
||||
<input
|
||||
id="thinking-prefix"
|
||||
type="text"
|
||||
value={settings.thinkingTagPrefix}
|
||||
onChange={(e) => handleInputChange('thinkingTagPrefix', e.target.value)}
|
||||
placeholder="<thinking>"
|
||||
/>
|
||||
<small>默认值:<thinking></small>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<label htmlFor="thinking-suffix">后缀</label>
|
||||
<input
|
||||
id="thinking-suffix"
|
||||
type="text"
|
||||
value={settings.thinkingTagSuffix}
|
||||
onChange={(e) => handleInputChange('thinkingTagSuffix', e.target.value)}
|
||||
placeholder="</thinking>"
|
||||
/>
|
||||
<small>默认值:</thinking></small>
|
||||
</div>
|
||||
|
||||
<div className="setting-item preview">
|
||||
<label>预览效果</label>
|
||||
<div className="preview-box">
|
||||
<div className="original">
|
||||
<strong>原始内容:</strong>
|
||||
<p>这是回复 {settings.thinkingTagPrefix}思考过程{settings.thinkingTagSuffix} 继续</p>
|
||||
</div>
|
||||
<div className="processed">
|
||||
<strong>处理后:</strong>
|
||||
<p>这是回复 继续</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
|
||||
{loading ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通用设置 */}
|
||||
{activeTab === 'general' && (
|
||||
<div className="settings-section">
|
||||
<h3>通用设置</h3>
|
||||
<p className="section-description">
|
||||
其他系统级配置
|
||||
</p>
|
||||
|
||||
<div className="setting-item">
|
||||
<label htmlFor="current-preset">当前预设</label>
|
||||
<input
|
||||
id="current-preset"
|
||||
type="text"
|
||||
value={settings.currentPresetName || ''}
|
||||
onChange={(e) => handleInputChange('currentPresetName', e.target.value || null)}
|
||||
placeholder="未选择"
|
||||
/>
|
||||
<small>影响全局正则规则的启用</small>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
|
||||
{loading ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Settings';
|
||||
@@ -0,0 +1,207 @@
|
||||
/* Token Usage Styles */
|
||||
.token-usage-container {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.token-usage-loading,
|
||||
.token-usage-error,
|
||||
.token-usage-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.token-usage-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-usage-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.month-selector {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.token-usage-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-reset:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.total {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-card.prompt {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-card.completion {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Request Stats */
|
||||
.request-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat-item.success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-item.interrupted {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.stat-item.failed {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stat-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.daily-stats-section,
|
||||
.role-stats-section,
|
||||
.chat-stats-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.daily-stats-section h4,
|
||||
.role-stats-section h4,
|
||||
.chat-stats-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.daily-stats-list,
|
||||
.role-stats-list,
|
||||
.chat-stats-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.daily-stat-item,
|
||||
.role-stat-item,
|
||||
.chat-stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.day-label,
|
||||
.role-name,
|
||||
.chat-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.day-tokens,
|
||||
.role-tokens,
|
||||
.chat-tokens {
|
||||
margin: 0 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.day-count,
|
||||
.role-count,
|
||||
.chat-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// frontend/src/components/SideBarLeft/tabs/TokenUsage/TokenUsage.jsx
|
||||
import React, { useEffect } from 'react';
|
||||
import useTokenUsageStore from '../../../../Store/SideBarLeft/TokenUsageSlice';
|
||||
import './TokenUsage.css';
|
||||
|
||||
const TokenUsage = () => {
|
||||
const {
|
||||
months,
|
||||
currentMonth,
|
||||
stats,
|
||||
roles,
|
||||
chats,
|
||||
selectedRole,
|
||||
selectedChat,
|
||||
loading,
|
||||
error,
|
||||
fetchMonths,
|
||||
setCurrentMonth,
|
||||
setSelectedRole,
|
||||
setSelectedChat,
|
||||
resetFilters
|
||||
} = useTokenUsageStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMonths();
|
||||
}, []);
|
||||
|
||||
if (loading && !stats) {
|
||||
return <div className="token-usage-loading">加载中...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="token-usage-error">错误: {error}</div>;
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return <div className="token-usage-empty">暂无数据</div>;
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(2) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(2) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="token-usage-container">
|
||||
{/* 月份选择器 */}
|
||||
<div className="token-usage-header">
|
||||
<h3>📊 Token 使用统计</h3>
|
||||
<select
|
||||
className="month-selector"
|
||||
value={currentMonth ? `${currentMonth.year}-${currentMonth.month}` : ''}
|
||||
onChange={(e) => {
|
||||
const [year, month] = e.target.value.split('-').map(Number);
|
||||
setCurrentMonth(year, month);
|
||||
}}
|
||||
>
|
||||
{months.map((m) => (
|
||||
<option key={`${m.year}-${m.month}`} value={`${m.year}-${m.month}`}>
|
||||
{m.year}年{m.month}月
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 筛选器 */}
|
||||
<div className="token-usage-filters">
|
||||
<select
|
||||
className="filter-select"
|
||||
value={selectedRole || ''}
|
||||
onChange={(e) => setSelectedRole(e.target.value || null)}
|
||||
>
|
||||
<option value="">所有角色</option>
|
||||
{roles.map((role) => (
|
||||
<option key={role} value={role}>{role}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="filter-select"
|
||||
value={selectedChat || ''}
|
||||
onChange={(e) => setSelectedChat(e.target.value || null)}
|
||||
>
|
||||
<option value="">所有聊天</option>
|
||||
{chats.map((chat) => (
|
||||
<option key={chat} value={chat}>{chat}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button className="btn-reset" onClick={resetFilters}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="stats-cards">
|
||||
<div className="stat-card total">
|
||||
<div className="stat-label">总 Token</div>
|
||||
<div className="stat-value">{formatNumber(stats.totalTokens)}</div>
|
||||
</div>
|
||||
<div className="stat-card prompt">
|
||||
<div className="stat-label">输入 Token</div>
|
||||
<div className="stat-value">{formatNumber(stats.totalPromptTokens)}</div>
|
||||
</div>
|
||||
<div className="stat-card completion">
|
||||
<div className="stat-label">输出 Token</div>
|
||||
<div className="stat-value">{formatNumber(stats.totalCompletionTokens)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 请求统计 */}
|
||||
<div className="request-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-name">总请求数:</span>
|
||||
<span className="stat-number">{stats.totalRecords}</span>
|
||||
</div>
|
||||
<div className="stat-item success">
|
||||
<span className="stat-name">✅ 成功:</span>
|
||||
<span className="stat-number">{stats.completedCount}</span>
|
||||
</div>
|
||||
<div className="stat-item interrupted">
|
||||
<span className="stat-name">⏸️ 中断:</span>
|
||||
<span className="stat-number">{stats.interruptedCount}</span>
|
||||
</div>
|
||||
<div className="stat-item failed">
|
||||
<span className="stat-name">❌ 失败:</span>
|
||||
<span className="stat-number">{stats.failedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日统计 */}
|
||||
{stats.dailyStats && Object.keys(stats.dailyStats).length > 0 && (
|
||||
<div className="daily-stats-section">
|
||||
<h4>📅 每日统计</h4>
|
||||
<div className="daily-stats-list">
|
||||
{Object.entries(stats.dailyStats)
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.slice(0, 10)
|
||||
.map(([day, data]) => (
|
||||
<div key={day} className="daily-stat-item">
|
||||
<span className="day-label">{day}</span>
|
||||
<span className="day-tokens">{formatNumber(data.totalTokens)} tokens</span>
|
||||
<span className="day-count">{data.count} 次</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 角色统计 */}
|
||||
{stats.roleStats && Object.keys(stats.roleStats).length > 0 && (
|
||||
<div className="role-stats-section">
|
||||
<h4>👤 角色统计</h4>
|
||||
<div className="role-stats-list">
|
||||
{Object.entries(stats.roleStats)
|
||||
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
|
||||
.map(([role, data]) => (
|
||||
<div key={role} className="role-stat-item">
|
||||
<span className="role-name">{role}</span>
|
||||
<span className="role-tokens">{formatNumber(data.totalTokens)} tokens</span>
|
||||
<span className="role-count">{data.count} 次</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 聊天统计 */}
|
||||
{stats.chatStats && Object.keys(stats.chatStats).length > 0 && (
|
||||
<div className="chat-stats-section">
|
||||
<h4>💬 聊天统计</h4>
|
||||
<div className="chat-stats-list">
|
||||
{Object.entries(stats.chatStats)
|
||||
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
|
||||
.slice(0, 10)
|
||||
.map(([chat, data]) => (
|
||||
<div key={chat} className="chat-stat-item">
|
||||
<span className="chat-name">{chat}</span>
|
||||
<span className="chat-tokens">{formatNumber(data.totalTokens)} tokens</span>
|
||||
<span className="chat-count">{data.count} 次</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenUsage;
|
||||
@@ -135,6 +135,10 @@
|
||||
margin-top: 8px; /* 与全局世界书保持间距 */
|
||||
position: relative; /* 确保正常文档流 */
|
||||
z-index: 1; /* 降低层级,不遮挡其他元素 */
|
||||
/* 最小高度 = header(33px) + actions(37px) + selector(37px) + entries最小内容(约200px) */
|
||||
min-height: 307px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.worldbook-header {
|
||||
@@ -183,6 +187,35 @@
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ✅ 排序按钮 - 仿照预设的操作按钮 */
|
||||
.sort-toggle-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sort-toggle-btn:hover {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sort-toggle-btn:active {
|
||||
transform: scale(0.95) translateY(0);
|
||||
}
|
||||
|
||||
/* 世界书选择区域 */
|
||||
.worldbook-selector {
|
||||
display: flex;
|
||||
@@ -320,7 +353,7 @@
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
min-height: 65px;
|
||||
min-height: 40px; /* ✅ 减小高度,单行显示 */
|
||||
}
|
||||
|
||||
.entry-item-compact:hover {
|
||||
@@ -334,17 +367,21 @@
|
||||
border-left: 2px solid var(--color-accent);
|
||||
}
|
||||
|
||||
/* 第一行:名称 + Token + 开关 */
|
||||
.entry-row-top {
|
||||
.entry-item-compact.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 单行布局容器 */
|
||||
.entry-row-single {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entry-name-compact {
|
||||
flex: 1;
|
||||
flex: 1; /* ✅ 占据剩余空间 */
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-primary);
|
||||
@@ -355,28 +392,24 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-tokens {
|
||||
/* Token数量(仅数字)- 固定宽度容纳6位数字 */
|
||||
.entry-tokens-inline {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
font-weight: 600;
|
||||
padding: 2px 5px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
width: 45px; /* ✅ 固定宽度,可容纳6位数字 */
|
||||
text-align: right; /* ✅ 右对齐数字 */
|
||||
}
|
||||
|
||||
/* 第二行:插入位置下拉框 */
|
||||
.entry-row-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.position-select {
|
||||
width: 100%;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
/* 内联位置下拉框 - 固定在右侧 */
|
||||
.position-select-inline {
|
||||
padding: 2px 4px;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -384,62 +417,66 @@
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.15s ease;
|
||||
width: 70px; /* ✅ 去掉箭头后更窄 */
|
||||
flex-shrink: 0;
|
||||
appearance: none; /* ✅ 隐藏默认箭头 */
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.position-select:hover {
|
||||
.position-select-inline:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.position-select:focus {
|
||||
.position-select-inline:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
/* 开关组件 */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-border);
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
/* 状态指示器 - 小圆点 - 固定在右侧(点击切换启用/禁用) */
|
||||
.status-dot {
|
||||
width: 12px; /* ✅ 稍微大一点,更容易点击 */
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3); /* ✅ 更明显的边框 */
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: var(--color-accent);
|
||||
.status-dot.enabled {
|
||||
background: #10b981; /* 绿色 - 启用 */
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5); /* ✅ 更强的发光 */
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(16px);
|
||||
.status-dot.enabled:hover {
|
||||
background: #059669;
|
||||
transform: scale(1.3); /* ✅ 更大的放大效果 */
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
|
||||
.status-dot.enabled:active {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.status-dot.disabled {
|
||||
background: #6b7280; /* 灰色 - 禁用 */
|
||||
opacity: 0.4;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.status-dot.disabled:hover {
|
||||
background: #4b5563;
|
||||
opacity: 0.7;
|
||||
transform: scale(1.3);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.status-dot.disabled:active {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 分页导航 */
|
||||
@@ -626,8 +663,8 @@
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -642,48 +679,85 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-panel-header h2 {
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.saving-indicator {
|
||||
font-size: 12px;
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--color-danger-light);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.edit-panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 美化滚动条 */
|
||||
.edit-panel-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.edit-panel-content::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.edit-panel-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-panel-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scrollbar-hover);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@@ -710,27 +784,33 @@
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 8px 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
transition: all 0.15s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:hover {
|
||||
border-color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-light);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
min-height: 250px;
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
font-family: inherit;
|
||||
line-height: 1.8;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.trigger-config-row {
|
||||
@@ -739,6 +819,34 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ✅ 编辑面板底部操作栏 */
|
||||
.edit-panel-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-panel-footer .btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-panel-footer .btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.edit-panel-footer .btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.trigger-selector {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
@@ -816,3 +924,90 @@
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
/* ✅ 排序下拉菜单 - 仿照预设的操作下拉菜单 */
|
||||
.sort-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1100;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.sort-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sort-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 4px 8px 2px;
|
||||
}
|
||||
|
||||
.sort-option {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sort-option:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sort-option.active {
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sort-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border-light);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sort-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sort-checkbox-label:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sort-checkbox-label input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -68,10 +68,34 @@ const WorldBook = () => {
|
||||
const [showWorldBookDropdown, setShowWorldBookDropdown] = useState(false);
|
||||
const [activeTriggerStrategy, setActiveTriggerStrategy] = useState('constant');
|
||||
|
||||
// ✅ 性能优化:使用本地状态暂存编辑内容,避免频繁API调用
|
||||
const [localEntry, setLocalEntry] = useState(null);
|
||||
const saveTimeoutRef = React.useRef(null);
|
||||
const [isSaving, setIsSaving] = useState(false); // ✅ 保存状态指示
|
||||
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20); // 默认每页20条
|
||||
|
||||
// ✅ 排序状态
|
||||
const [sortBy, setSortBy] = useState('order'); // 'name' | 'name_desc' | 'order'
|
||||
const [activeFirst, setActiveFirst] = useState(false); // 激活条目优先
|
||||
const [showSortMenu, setShowSortMenu] = useState(false); // 显示排序菜单
|
||||
|
||||
// ✅ 点击外部关闭排序下拉菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (showSortMenu && !event.target.closest('.sort-settings-container')) {
|
||||
setShowSortMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showSortMenu]);
|
||||
|
||||
// 获取当前激活的分页
|
||||
const { activeTab } = useSideBarLeftStore();
|
||||
|
||||
@@ -246,6 +270,23 @@ const WorldBook = () => {
|
||||
setShowEditPanel(true);
|
||||
};
|
||||
|
||||
// ✅ 处理条目快速更新(位置和启用状态)
|
||||
const handleEntryUpdate = async (field, value) => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
const updatedEntry = { ...currentEntry, [field]: value };
|
||||
|
||||
try {
|
||||
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
|
||||
setCurrentEntry(updatedEntry);
|
||||
// 更新成功后刷新当前页
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
console.log(`[WorldBook] ✅ 快速更新 ${field} 成功`);
|
||||
} catch (err) {
|
||||
console.error(`[WorldBook] ❌ 快速更新 ${field} 失败:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = async (newPage) => {
|
||||
if (!currentWorldBook) return;
|
||||
@@ -269,22 +310,41 @@ const WorldBook = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEntryUpdate = async (field, value) => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
const updatedEntry = {...currentEntry, [field]: value};
|
||||
try {
|
||||
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
|
||||
setCurrentEntry(updatedEntry);
|
||||
// 更新成功后刷新当前页
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
} catch (err) {
|
||||
console.error('更新条目失败:', err);
|
||||
// ✅ 性能优化:防抖保存,500ms后自动保存
|
||||
const debouncedSave = React.useCallback((updatedEntry) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
setIsSaving(true); // ✅ 显示保存中状态
|
||||
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
if (!currentWorldBook || !updatedEntry) return;
|
||||
|
||||
try {
|
||||
await updateWorldBookEntry(currentWorldBook.name, updatedEntry.uid, updatedEntry);
|
||||
setCurrentEntry(updatedEntry);
|
||||
// 更新成功后刷新当前页
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
console.log('[WorldBook] ✅ 自动保存成功');
|
||||
} catch (err) {
|
||||
console.error('[WorldBook] ❌ 自动保存失败:', err);
|
||||
} finally {
|
||||
setIsSaving(false); // ✅ 隐藏保存中状态
|
||||
}
|
||||
}, 500); // 500ms 防抖
|
||||
}, [currentWorldBook, currentPage, pageSize, updateWorldBookEntry, fetchWorldBookEntries]);
|
||||
|
||||
const handleLocalEntryChange = (field, value) => {
|
||||
if (!localEntry) return;
|
||||
|
||||
const updatedEntry = { ...localEntry, [field]: value };
|
||||
setLocalEntry(updatedEntry);
|
||||
debouncedSave(updatedEntry); // ✅ 防抖保存
|
||||
};
|
||||
|
||||
const handleTriggerStrategyChange = async (strategy) => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
if (!localEntry || !currentWorldBook) return;
|
||||
|
||||
setActiveTriggerStrategy(strategy);
|
||||
|
||||
@@ -317,34 +377,30 @@ const WorldBook = () => {
|
||||
}
|
||||
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: updatedTriggers
|
||||
};
|
||||
|
||||
try {
|
||||
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, {
|
||||
...currentEntry,
|
||||
trigger_config: updatedTriggerConfig
|
||||
});
|
||||
setCurrentEntry({
|
||||
...currentEntry,
|
||||
trigger_config: updatedTriggerConfig
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('更新触发策略失败:', err);
|
||||
}
|
||||
const updatedEntry = {
|
||||
...localEntry,
|
||||
trigger_config: updatedTriggerConfig
|
||||
};
|
||||
|
||||
setLocalEntry(updatedEntry);
|
||||
debouncedSave(updatedEntry); // ✅ 防抖保存
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async () => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
if (!localEntry || !currentWorldBook) return;
|
||||
|
||||
if (!window.confirm('确定要删除此条目吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
|
||||
await deleteWorldBookEntry(currentWorldBook.name, localEntry.uid);
|
||||
setShowEditPanel(false);
|
||||
setLocalEntry(null); // ✅ 清空本地状态
|
||||
setCurrentEntry(null);
|
||||
// 删除成功后刷新当前页
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
@@ -493,6 +549,51 @@ const WorldBook = () => {
|
||||
return positions[position] || positions[0];
|
||||
};
|
||||
|
||||
// ✅ 排序函数
|
||||
const getSortedEntries = () => {
|
||||
if (!currentEntries || currentEntries.length === 0) return [];
|
||||
|
||||
let sorted = [...currentEntries];
|
||||
|
||||
// 1. 先应用激活条目优先(独立于其他排序)
|
||||
if (activeFirst) {
|
||||
sorted.sort((a, b) => {
|
||||
const aActive = !a.disable;
|
||||
const bActive = !b.disable;
|
||||
if (aActive && !bActive) return -1;
|
||||
if (!aActive && bActive) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 再应用主排序规则
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
// 文件名顺序(按comment或key)
|
||||
sorted.sort((a, b) => {
|
||||
const nameA = (a.comment || a.key?.join(',') || '').toLowerCase();
|
||||
const nameB = (b.comment || b.key?.join(',') || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB, 'zh-CN');
|
||||
});
|
||||
break;
|
||||
case 'name_desc':
|
||||
// 文件名逆序
|
||||
sorted.sort((a, b) => {
|
||||
const nameA = (a.comment || a.key?.join(',') || '').toLowerCase();
|
||||
const nameB = (b.comment || b.key?.join(',') || '').toLowerCase();
|
||||
return nameB.localeCompare(nameA, 'zh-CN');
|
||||
});
|
||||
break;
|
||||
case 'order':
|
||||
default:
|
||||
// 插入顺序(按order字段)
|
||||
sorted.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="worldbook-content">
|
||||
{/* 全局世界书区域 */}
|
||||
@@ -543,6 +644,68 @@ const WorldBook = () => {
|
||||
📤 导出
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ✅ 排序设置按钮 - 仿照预设的操作下拉菜单 */}
|
||||
{currentWorldBook && (
|
||||
<div className="sort-settings-container" style={{position: 'relative', marginLeft: 'auto'}}>
|
||||
<button
|
||||
className="sort-toggle-btn"
|
||||
onClick={() => setShowSortMenu(!showSortMenu)}
|
||||
title="排序设置"
|
||||
>
|
||||
🔀
|
||||
</button>
|
||||
|
||||
{/* 排序下拉菜单 */}
|
||||
{showSortMenu && (
|
||||
<div className="sort-dropdown-menu">
|
||||
<div className="sort-section">
|
||||
<div className="sort-label">主排序</div>
|
||||
<button
|
||||
className={`sort-option ${sortBy === 'order' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSortBy('order');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
>
|
||||
📋 插入顺序
|
||||
</button>
|
||||
<button
|
||||
className={`sort-option ${sortBy === 'name' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSortBy('name');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
>
|
||||
🔤 名称升序
|
||||
</button>
|
||||
<button
|
||||
className={`sort-option ${sortBy === 'name_desc' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSortBy('name_desc');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
>
|
||||
🔤 名称降序
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sort-divider"></div>
|
||||
|
||||
<div className="sort-section">
|
||||
<label className="sort-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeFirst}
|
||||
onChange={(e) => setActiveFirst(e.target.checked)}
|
||||
/>
|
||||
<span>🟢 激活条目优先</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 世界书选择区域 */}
|
||||
@@ -598,8 +761,8 @@ const WorldBook = () => {
|
||||
<div className="error">{error}</div>
|
||||
) : currentWorldBook ? (
|
||||
<div className="entries-container">
|
||||
{currentEntries.length > 0 ? (
|
||||
currentEntries.map(entry => {
|
||||
{getSortedEntries().length > 0 ? (
|
||||
getSortedEntries().map(entry => {
|
||||
// 计算 token 数(估算)
|
||||
const tokenCount = Math.ceil((entry.content?.length || 0) / 4);
|
||||
|
||||
@@ -610,41 +773,25 @@ const WorldBook = () => {
|
||||
return (
|
||||
<div
|
||||
key={entry.uid}
|
||||
className={`entry-item-compact ${currentEntry?.uid === entry.uid ? 'active' : ''}`}
|
||||
className={`entry-item-compact ${currentEntry?.uid === entry.uid ? 'active' : ''} ${isDisabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
>
|
||||
{/* 第一行:条目名称 + Token数 + 开关 */}
|
||||
<div className="entry-row-top">
|
||||
{/* 单行布局:名称 + Token数 + 位置下拉框 + 状态指示器 */}
|
||||
<div className="entry-row-single">
|
||||
<span className="entry-name-compact">
|
||||
{entry.comment || entry.key?.join(', ') || `条目 #${entry.uid.substring(0, 8)}`}
|
||||
</span>
|
||||
|
||||
<span className="entry-tokens">
|
||||
{tokenCount} tokens
|
||||
{/* Token数量(仅数字) */}
|
||||
<span className="entry-tokens-inline">
|
||||
{tokenCount}
|
||||
</span>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<label
|
||||
className="toggle-switch"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntryUpdate('disable', !e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 第二行:插入位置下拉框 */}
|
||||
<div className="entry-row-bottom" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 插入位置下拉框 */}
|
||||
<select
|
||||
className="position-select"
|
||||
className="position-select-inline"
|
||||
value={entry.position || 0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntryUpdate('position', parseInt(e.target.value));
|
||||
@@ -659,6 +806,16 @@ const WorldBook = () => {
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
|
||||
{/* 启用状态指示器 - 小圆点 */}
|
||||
<div
|
||||
className={`status-dot ${isEnabled ? 'enabled' : 'disabled'}`}
|
||||
title={isEnabled ? '已启用' : '已禁用'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntryUpdate('disable', !isEnabled);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -717,11 +874,11 @@ const WorldBook = () => {
|
||||
onClick={() => setShowEditPanel(false)}/>
|
||||
|
||||
{/* 编辑面板 */}
|
||||
{showEditPanel && currentEntry && (
|
||||
{showEditPanel && localEntry && (
|
||||
<div className={`edit-panel ${showEditPanel ? 'open' : ''}`}>
|
||||
{/* 头部 */}
|
||||
<div className="edit-panel-header">
|
||||
<h2>编辑条目 - UID: {currentEntry.uid}</h2>
|
||||
<h2>编辑条目 - UID: {localEntry.uid} {isSaving && <span className="saving-indicator">💾 保存中...</span>}</h2>
|
||||
<button className="close-btn" onClick={() => setShowEditPanel(false)}>
|
||||
✕
|
||||
</button>
|
||||
@@ -736,8 +893,8 @@ const WorldBook = () => {
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={currentEntry.comment || ''}
|
||||
onChange={(e) => handleEntryUpdate('comment', e.target.value)}
|
||||
value={localEntry.comment || ''}
|
||||
onChange={(e) => handleLocalEntryChange('comment', e.target.value)}
|
||||
placeholder="可选的备注名称"
|
||||
/>
|
||||
</div>
|
||||
@@ -746,8 +903,8 @@ const WorldBook = () => {
|
||||
<label className="form-label">插入位置</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={currentEntry.position || 0}
|
||||
onChange={(e) => handleEntryUpdate('position', parseInt(e.target.value))}
|
||||
value={localEntry.position || 0}
|
||||
onChange={(e) => handleLocalEntryChange('position', parseInt(e.target.value))}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5].map(pos => {
|
||||
const info = getPositionInfo(pos);
|
||||
@@ -765,8 +922,8 @@ const WorldBook = () => {
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={currentEntry.order || 100}
|
||||
onChange={(e) => handleEntryUpdate('order', parseInt(e.target.value))}
|
||||
value={localEntry.order || 100}
|
||||
onChange={(e) => handleLocalEntryChange('order', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -775,8 +932,8 @@ const WorldBook = () => {
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={currentEntry.depth || 4}
|
||||
onChange={(e) => handleEntryUpdate('depth', parseInt(e.target.value))}
|
||||
value={localEntry.depth || 4}
|
||||
onChange={(e) => handleLocalEntryChange('depth', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -786,8 +943,8 @@ const WorldBook = () => {
|
||||
<label className="form-label">条目内容</label>
|
||||
<textarea
|
||||
className="form-input content-textarea"
|
||||
value={currentEntry.content || ''}
|
||||
onChange={(e) => handleEntryUpdate('content', e.target.value)}
|
||||
value={localEntry.content || ''}
|
||||
onChange={(e) => handleLocalEntryChange('content', e.target.value)}
|
||||
placeholder="在此输入世界书条目的内容..."
|
||||
/>
|
||||
</div>
|
||||
@@ -818,22 +975,22 @@ const WorldBook = () => {
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.key?.join(', ') || ''}
|
||||
value={localEntry.trigger_config?.triggers?.keyword?.[1]?.key?.join(', ') || ''}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
...localEntry.trigger_config?.triggers,
|
||||
keyword: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
...localEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
key: e.target.value.split(',').map(k => k.trim()).filter(k => k)
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
placeholder="例如:魔法, 火焰, 冰霜"
|
||||
/>
|
||||
@@ -844,22 +1001,22 @@ const WorldBook = () => {
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.keysecondary?.join(', ') || ''}
|
||||
value={localEntry.trigger_config?.triggers?.keyword?.[1]?.keysecondary?.join(', ') || ''}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
...localEntry.trigger_config?.triggers,
|
||||
keyword: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
...localEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
keysecondary: e.target.value.split(',').map(k => k.trim()).filter(k => k)
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
placeholder="可选的过滤关键词"
|
||||
/>
|
||||
@@ -869,22 +1026,22 @@ const WorldBook = () => {
|
||||
<label className="form-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.selective || false}
|
||||
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.selective || false}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
...localEntry.trigger_config?.triggers,
|
||||
keyword: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
...localEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
selective: e.target.checked
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
选择性匹配
|
||||
@@ -893,22 +1050,22 @@ const WorldBook = () => {
|
||||
<label className="form-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.matchWholeWords || false}
|
||||
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.matchWholeWords || false}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
...localEntry.trigger_config?.triggers,
|
||||
keyword: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
...localEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
matchWholeWords: e.target.checked
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
全词匹配
|
||||
@@ -917,22 +1074,22 @@ const WorldBook = () => {
|
||||
<label className="form-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.caseSensitive || false}
|
||||
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.caseSensitive || false}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
...localEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
...localEntry.trigger_config?.triggers,
|
||||
keyword: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
...localEntry.trigger_config?.triggers?.keyword?.[1],
|
||||
caseSensitive: e.target.checked
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
区分大小写
|
||||
|
||||
Reference in New Issue
Block a user