完成大量美化,zustand迁移,动态表格修复
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from backend.services.chat_service import ChatService
|
from backend.services.chat_service import ChatService
|
||||||
from backend.core.config import settings
|
from backend.core.config import settings
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Docker环境:直接从当前目录导入
|
# Docker环境:直接从当前目录导入
|
||||||
from services.chat_service import ChatService
|
from services.chat_service import ChatService
|
||||||
from core.config import settings
|
from core.config import settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||||
|
|
||||||
@@ -14,11 +15,23 @@ router = APIRouter(prefix="/chat", tags=["chat"])
|
|||||||
data_path = Path(settings.DATA_PATH) if hasattr(settings, 'DATA_PATH') else Path("data")
|
data_path = Path(settings.DATA_PATH) if hasattr(settings, 'DATA_PATH') else Path("data")
|
||||||
chat_service = ChatService(data_path)
|
chat_service = ChatService(data_path)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=dict)
|
@router.get("", response_model=dict)
|
||||||
async def list_all_chats():
|
async def list_all_chats():
|
||||||
"""获取所有角色的所有聊天列表"""
|
"""获取所有角色的所有聊天列表"""
|
||||||
return chat_service.list_all_chats()
|
return chat_service.list_all_chats()
|
||||||
|
|
||||||
|
|
||||||
|
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
|
||||||
|
@router.get("/{role_name}/{chat_name}")
|
||||||
|
async def get_chat(role_name: str, chat_name: str):
|
||||||
|
"""获取指定聊天的完整内容"""
|
||||||
|
try:
|
||||||
|
return chat_service.get_chat(role_name, chat_name)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{role_name}")
|
@router.get("/{role_name}")
|
||||||
async def list_role_chats(role_name: str):
|
async def list_role_chats(role_name: str):
|
||||||
"""获取指定角色的所有聊天列表"""
|
"""获取指定角色的所有聊天列表"""
|
||||||
@@ -30,13 +43,6 @@ async def list_role_chats(role_name: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.get("/{role_name}/{chat_name}")
|
|
||||||
async def get_chat(role_name: str, chat_name: str):
|
|
||||||
"""获取指定聊天的完整内容"""
|
|
||||||
try:
|
|
||||||
return chat_service.get_chat(role_name, chat_name)
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
|
|
||||||
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
|
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_chat(role_name: str, chat_data: dict):
|
async def create_chat(role_name: str, chat_data: dict):
|
||||||
@@ -48,18 +54,21 @@ async def create_chat(role_name: str, chat_data: dict):
|
|||||||
except FileExistsError as e:
|
except FileExistsError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{role_name}/{chat_name}")
|
@router.put("/{role_name}/{chat_name}")
|
||||||
async def update_chat(role_name: str, chat_name: str, update_data: dict):
|
async def update_chat(role_name: str, chat_name: str, update_data: dict):
|
||||||
"""更新聊天元数据"""
|
"""更新聊天元数据"""
|
||||||
# TODO: 实现更新聊天元数据功能
|
# TODO: 实现更新聊天元数据功能
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{role_name}/{chat_name}")
|
@router.delete("/{role_name}/{chat_name}")
|
||||||
async def delete_chat(role_name: str, chat_name: str):
|
async def delete_chat(role_name: str, chat_name: str):
|
||||||
"""删除指定聊天"""
|
"""删除指定聊天"""
|
||||||
# TODO: 实现删除聊天功能
|
# TODO: 实现删除聊天功能
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{role_name}/{chat_name}/messages")
|
@router.get("/{role_name}/{chat_name}/messages")
|
||||||
async def list_messages(role_name: str, chat_name: str):
|
async def list_messages(role_name: str, chat_name: str):
|
||||||
"""获取聊天的所有消息"""
|
"""获取聊天的所有消息"""
|
||||||
@@ -69,6 +78,7 @@ async def list_messages(role_name: str, chat_name: str):
|
|||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{role_name}/{chat_name}/messages/{floor}")
|
@router.get("/{role_name}/{chat_name}/messages/{floor}")
|
||||||
async def get_message(role_name: str, chat_name: str, floor: int):
|
async def get_message(role_name: str, chat_name: str, floor: int):
|
||||||
"""获取指定楼层的消息"""
|
"""获取指定楼层的消息"""
|
||||||
@@ -81,6 +91,7 @@ async def get_message(role_name: str, chat_name: str, floor: int):
|
|||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{role_name}/{chat_name}/messages", status_code=status.HTTP_201_CREATED)
|
@router.post("/{role_name}/{chat_name}/messages", status_code=status.HTTP_201_CREATED)
|
||||||
async def add_message(role_name: str, chat_name: str, message_data: dict):
|
async def add_message(role_name: str, chat_name: str, message_data: dict):
|
||||||
"""向聊天添加新消息"""
|
"""向聊天添加新消息"""
|
||||||
@@ -91,6 +102,7 @@ async def add_message(role_name: str, chat_name: str, message_data: dict):
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{role_name}/{chat_name}/messages/{floor}")
|
@router.put("/{role_name}/{chat_name}/messages/{floor}")
|
||||||
async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict):
|
async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict):
|
||||||
"""更新指定楼层的消息"""
|
"""更新指定楼层的消息"""
|
||||||
@@ -101,6 +113,7 @@ async def update_message(role_name: str, chat_name: str, floor: int, update_data
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{role_name}/{chat_name}/messages/{floor}")
|
@router.delete("/{role_name}/{chat_name}/messages/{floor}")
|
||||||
async def delete_message(role_name: str, chat_name: str, floor: int):
|
async def delete_message(role_name: str, chat_name: str, floor: int):
|
||||||
"""删除指定楼层的消息"""
|
"""删除指定楼层的消息"""
|
||||||
@@ -110,3 +123,14 @@ async def delete_message(role_name: str, chat_name: str, floor: int):
|
|||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{role_name}/{chat_name}/table")
|
||||||
|
async def update_table_data(role_name: str, chat_name: str, table_update: dict):
|
||||||
|
"""更新表格数据(带时间戳冲突解决)"""
|
||||||
|
try:
|
||||||
|
return chat_service.update_table_data(role_name, chat_name, table_update)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@@ -1,23 +1,43 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
# TODO: 实现 PresetService 来替代旧的 AIDesignSpec 逻辑
|
from services.preset_service import PresetService
|
||||||
# from services.preset_service import PresetService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/presets", tags=["presets"])
|
router = APIRouter(prefix="/presets", tags=["presets"])
|
||||||
|
|
||||||
@router.get("", response_model=dict)
|
@router.get("", response_model=dict)
|
||||||
async def list_presets():
|
async def list_presets():
|
||||||
"""获取所有预设列表及其基本信息"""
|
"""获取所有预设列表及其基本信息"""
|
||||||
# return await PresetService.list_all_presets()
|
try:
|
||||||
return {"presets": []}
|
presets = PresetService.list_presets()
|
||||||
|
response_data = {"presets": presets}
|
||||||
|
print(f"[API] GET /api/presets - 返回数据: {response_data}")
|
||||||
|
return response_data
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[API] GET /api/presets - 错误: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
|
||||||
|
@router.get("/{preset_name}/components/{component_id}")
|
||||||
|
async def get_preset_component(preset_name: str, component_id: str):
|
||||||
|
"""获取指定组件的详情"""
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.get("/{preset_name}/components")
|
||||||
|
async def list_preset_components(preset_name: str):
|
||||||
|
"""获取预设中的所有组件"""
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
@router.get("/{preset_name}")
|
@router.get("/{preset_name}")
|
||||||
async def get_preset(preset_name: str):
|
async def get_preset(preset_name: str):
|
||||||
"""获取指定预设的完整内容"""
|
"""获取指定预设的完整内容"""
|
||||||
# try:
|
try:
|
||||||
# return await PresetService.get_preset(preset_name)
|
preset_data = PresetService.get_preset(preset_name)
|
||||||
# except FileNotFoundError:
|
return preset_data
|
||||||
# raise HTTPException(status_code=404, detail="Preset not found")
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_preset(preset_name: str, preset_data: dict):
|
async def create_preset(preset_name: str, preset_data: dict):
|
||||||
@@ -34,16 +54,6 @@ async def delete_preset(preset_name: str):
|
|||||||
"""删除指定预设"""
|
"""删除指定预设"""
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
@router.get("/{preset_name}/components")
|
|
||||||
async def list_preset_components(preset_name: str):
|
|
||||||
"""获取预设中的所有组件"""
|
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
|
||||||
|
|
||||||
@router.get("/{preset_name}/components/{component_id}")
|
|
||||||
async def get_preset_component(preset_name: str, component_id: str):
|
|
||||||
"""获取指定组件的详情"""
|
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
|
||||||
|
|
||||||
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
|
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
|
||||||
async def add_preset_component(preset_name: str, component_data: dict):
|
async def add_preset_component(preset_name: str, component_data: dict):
|
||||||
"""向预设添加新组件"""
|
"""向预设添加新组件"""
|
||||||
|
|||||||
@@ -38,6 +38,80 @@ async def list_worldbooks():
|
|||||||
logger.error(f"Failed to list worldbooks: {str(e)}")
|
logger.error(f"Failed to list worldbooks: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
|
||||||
|
@router.get("/{name}/entries/{uid}", response_model=Dict[str, Any])
|
||||||
|
async def get_worldbook_entry(name: str, uid: str):
|
||||||
|
"""
|
||||||
|
获取世界书的指定条目
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return worldbook_service.get_entry(name, uid)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get entry '{uid}' from worldbook '{name}': {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/{name}/entries", response_model=Dict[str, Any])
|
||||||
|
async def list_worldbook_entries(
|
||||||
|
name: str,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取世界书的条目列表(支持分页)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
page: 页码,从1开始
|
||||||
|
page_size: 每页数量,默认20
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return worldbook_service.list_entries(name, page, page_size)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list entries for worldbook '{name}': {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/{name}/export")
|
||||||
|
async def export_worldbook(name: str, format: str = "internal"):
|
||||||
|
"""
|
||||||
|
导出世界书(支持 internal 和 sillytavern 两种格式)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
format: 导出格式 ('internal' 或 'sillytavern'),默认 internal
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if format.lower() == "sillytavern":
|
||||||
|
# 导出为 SillyTavern 格式(可能丢失特殊设置)
|
||||||
|
logger.info(f"导出世界书 '{name}' 为 SillyTavern 格式")
|
||||||
|
st_data = worldbook_service.export_to_sillytavern(name)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content=st_data,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={name}_sillytavern.json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 导出为内部格式(保留所有设置)
|
||||||
|
logger.info(f"导出世界书 '{name}' 为内部格式")
|
||||||
|
internal_data = worldbook_service.get_worldbook(name)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content=internal_data,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={name}.json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to export worldbook '{name}': {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.get("/{name}", response_model=Dict[str, Any])
|
@router.get("/{name}", response_model=Dict[str, Any])
|
||||||
async def get_worldbook(name: str):
|
async def get_worldbook(name: str):
|
||||||
"""
|
"""
|
||||||
@@ -213,41 +287,3 @@ async def import_worldbook(name: str, file: UploadFile = File(...)):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to import worldbook '{name}': {str(e)}")
|
logger.error(f"Failed to import worldbook '{name}': {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.get("/{name}/export")
|
|
||||||
async def export_worldbook(name: str, format: str = "internal"):
|
|
||||||
"""
|
|
||||||
导出世界书(支持 internal 和 sillytavern 两种格式)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 世界书名称
|
|
||||||
format: 导出格式 ('internal' 或 'sillytavern'),默认 internal
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if format.lower() == "sillytavern":
|
|
||||||
# 导出为 SillyTavern 格式(可能丢失特殊设置)
|
|
||||||
logger.info(f"导出世界书 '{name}' 为 SillyTavern 格式")
|
|
||||||
st_data = worldbook_service.export_to_sillytavern(name)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
content=st_data,
|
|
||||||
headers={
|
|
||||||
"Content-Disposition": f"attachment; filename={name}_sillytavern.json"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 导出为内部格式(保留所有设置)
|
|
||||||
logger.info(f"导出世界书 '{name}' 为内部格式")
|
|
||||||
internal_data = worldbook_service.get_worldbook(name)
|
|
||||||
|
|
||||||
return JSONResponse(
|
|
||||||
content=internal_data,
|
|
||||||
headers={
|
|
||||||
"Content-Disposition": f"attachment; filename={name}.json"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to export worldbook '{name}': {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ from pathlib import Path
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# 1. 动态计算项目根目录
|
# 1. 动态计算项目根目录
|
||||||
# 假设 config.py 位于 backend/core/ 目录下
|
# 在 Docker 环境中:config.py 位于 /app/core/,需要向上2级到 /app/
|
||||||
# __file__ 指向本文件的绝对路径
|
# 在本地开发中:config.py 位于 backend/core/,需要向上3级到项目根目录
|
||||||
# .parent 指向 backend/core/ 目录
|
_config_path = Path(__file__).resolve()
|
||||||
# .parent.parent 指向 backend/ 目录
|
if _config_path.parent.parent.name == 'app':
|
||||||
# .parent.parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
|
# Docker 环境:/app/core/config.py -> /app/
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
PROJECT_ROOT = _config_path.parent.parent
|
||||||
|
else:
|
||||||
|
# 本地开发:backend/core/config.py -> 项目根目录
|
||||||
|
PROJECT_ROOT = _config_path.parent.parent.parent
|
||||||
|
|
||||||
# 2. 加载 .env 文件
|
# 2. 加载 .env 文件
|
||||||
# 假设 .env 文件位于项目根目录下
|
# 假设 .env 文件位于项目根目录下
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ class CharacterCard(BaseModel):
|
|||||||
first_mes: str = Field(..., description="首条开场消息")
|
first_mes: str = Field(..., description="首条开场消息")
|
||||||
mes_example: str = Field(..., description="对话示例")
|
mes_example: str = Field(..., description="对话示例")
|
||||||
categories: List[str] = Field(default_factory=list, description="分类标签 (用于前端筛选)")
|
categories: List[str] = Field(default_factory=list, description="分类标签 (用于前端筛选)")
|
||||||
|
tags: Optional[List[str]] = Field(None, description="动态表格标签数组 (SillyTavern 关键字机制)")
|
||||||
worldInfoId: Optional[str] = Field(None, description="绑定的世界书 ID")
|
worldInfoId: Optional[str] = Field(None, description="绑定的世界书 ID")
|
||||||
outputSchema: Optional[List[OutputSchemaField]] = Field(None, description="输出 schema 定义 (结构化输出)")
|
outputSchema: Optional[List[OutputSchemaField]] = Field(None, description="输出 schema 定义 (结构化输出)")
|
||||||
avatarPath: Optional[str] = Field(None, description="角色头像路径")
|
avatarPath: Optional[str] = Field(None, description="角色头像路径")
|
||||||
@@ -161,7 +162,7 @@ class ChatHeader(BaseModel):
|
|||||||
characterId: str = Field(..., description="关联的角色卡 ID")
|
characterId: str = Field(..., description="关联的角色卡 ID")
|
||||||
userName: str = Field("User", description="用户角色名")
|
userName: str = Field("User", description="用户角色名")
|
||||||
characterName: str = Field(..., description="AI 角色名称")
|
characterName: str = Field(..., description="AI 角色名称")
|
||||||
tableData: Optional[Dict[str, Any]] = Field(None, description="表格数据 (对应 outputSchema)")
|
tags: Optional[List[str]] = Field(None, description="动态表格标签数组 (从角色卡继承)")
|
||||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
messageCount: int = Field(0, description="消息数量")
|
messageCount: int = Field(0, description="消息数量")
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
|
|
||||||
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
|
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
|
||||||
"""
|
"""
|
||||||
from .prompt_assembler import PromptAssembler, PromptConfig
|
# 注意:不在这里自动导入模块,避免循环依赖和缺失依赖问题
|
||||||
|
# 需要使用时请显式导入,例如:from services.preset_service import PresetService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = []
|
||||||
'PromptAssembler',
|
|
||||||
'PromptConfig',
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class CharacterCardConverter:
|
|||||||
first_mes=data.get('first_mes', ''),
|
first_mes=data.get('first_mes', ''),
|
||||||
mes_example=data.get('mes_example', ''),
|
mes_example=data.get('mes_example', ''),
|
||||||
categories=[], # ST没有categories
|
categories=[], # ST没有categories
|
||||||
|
tableHeaders=[], # ST没有tableHeaders
|
||||||
worldInfoId=extensions.get('world'),
|
worldInfoId=extensions.get('world'),
|
||||||
outputSchema=None, # ST不支持结构化输出
|
outputSchema=None, # ST不支持结构化输出
|
||||||
avatarPath=avatar_path,
|
avatarPath=avatar_path,
|
||||||
|
|||||||
@@ -95,11 +95,11 @@ class CharacterService:
|
|||||||
first_mes=data.get('first_mes', ''),
|
first_mes=data.get('first_mes', ''),
|
||||||
mes_example=data.get('mes_example', ''),
|
mes_example=data.get('mes_example', ''),
|
||||||
categories=data.get('categories', []),
|
categories=data.get('categories', []),
|
||||||
|
tags=data.get('tags', []), # ✅ 使用标签数组
|
||||||
worldInfoId=data.get('worldInfoId'),
|
worldInfoId=data.get('worldInfoId'),
|
||||||
outputSchema=data.get('outputSchema'),
|
outputSchema=data.get('outputSchema'),
|
||||||
avatarPath=avatar_path,
|
avatarPath=avatar_path,
|
||||||
alternate_greetings=data.get('alternate_greetings', []),
|
alternate_greetings=data.get('alternate_greetings', []),
|
||||||
tags=data.get('tags', []),
|
|
||||||
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
|
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
|
||||||
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
||||||
lastChatAt=last_chat_at,
|
lastChatAt=last_chat_at,
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ class ChatService:
|
|||||||
messages.append(msg_data)
|
messages.append(msg_data)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"header": header, # 完整的 header,包含 tableHeaders, tableDefaults, tableData
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"user_name": header.get("user_name", "User"),
|
"user_name": header.get("user_name", "User"),
|
||||||
"character_name": header.get("character_name", ""),
|
"character_name": header.get("character_name", ""),
|
||||||
@@ -192,6 +193,21 @@ class ChatService:
|
|||||||
# 创建角色目录
|
# 创建角色目录
|
||||||
chat_file.parent.mkdir(parents=True, exist_ok=True)
|
chat_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 尝试从角色卡获取 tags(关键字列表)
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
character_file = Path("data/characters") / role_name / "character.json"
|
||||||
|
if character_file.exists():
|
||||||
|
with open(character_file, 'r', encoding='utf-8') as f:
|
||||||
|
character_data = json.load(f)
|
||||||
|
|
||||||
|
tags = character_data.get('tags', [])
|
||||||
|
|
||||||
|
logger.info(f"从角色卡 {role_name} 继承标签: {tags}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"读取角色卡失败,使用空标签: {e}")
|
||||||
|
|
||||||
# 构建header
|
# 构建header
|
||||||
header = {
|
header = {
|
||||||
"user_name": metadata.get("user_name", "User") if metadata else "User",
|
"user_name": metadata.get("user_name", "User") if metadata else "User",
|
||||||
@@ -207,7 +223,8 @@ class ChatService:
|
|||||||
"timedWorldInfo": {},
|
"timedWorldInfo": {},
|
||||||
"variables": {},
|
"variables": {},
|
||||||
"tainted": False,
|
"tainted": False,
|
||||||
"lastInContextMessageId": -1
|
"lastInContextMessageId": -1,
|
||||||
|
"tags": tags # ✅ 使用标签数组替代 tableHeaders/tableDefaults/tableData
|
||||||
}
|
}
|
||||||
|
|
||||||
# 写入header
|
# 写入header
|
||||||
@@ -381,3 +398,57 @@ class ChatService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def update_table_data(self, role_name: str, chat_name: str, table_update: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
更新标签数据(SillyTavern 关键字机制)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_name: 角色名称
|
||||||
|
chat_name: 聊天名称
|
||||||
|
table_update: 包含 tags 数组的字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 更新后的标签数据
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: 聊天文件不存在
|
||||||
|
"""
|
||||||
|
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||||
|
|
||||||
|
if not chat_file.exists():
|
||||||
|
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||||
|
|
||||||
|
# 读取 header
|
||||||
|
header = json.loads(lines[0])
|
||||||
|
|
||||||
|
# 获取新的标签数组
|
||||||
|
new_tags = table_update.get('tags', [])
|
||||||
|
|
||||||
|
# 更新 header 中的 tags
|
||||||
|
header['tags'] = new_tags
|
||||||
|
|
||||||
|
# 写回文件
|
||||||
|
lines[0] = json.dumps(header, ensure_ascii=False) + '\n'
|
||||||
|
|
||||||
|
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
|
||||||
|
logger.info(f"标签数据已更新: {role_name}/{chat_name}, 标签数: {len(new_tags)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"tags": new_tags,
|
||||||
|
"tagCount": len(new_tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"更新标签数据失败 {role_name}/{chat_name}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|||||||
203
backend/services/preset_service.py
Normal file
203
backend/services/preset_service.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Preset Service
|
||||||
|
预设服务层 - 处理预设的 CRUD 操作
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class PresetService:
|
||||||
|
"""预设服务类"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_preset_name_from_filename(filename: str) -> str:
|
||||||
|
"""
|
||||||
|
从文件名提取预设名称,去掉时间戳和文件后缀
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: 文件名(不含路径)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
清理后的预设名称
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"Default.json" -> "Default"
|
||||||
|
"MyPreset_1234567890.json" -> "MyPreset"
|
||||||
|
"Test_1714567890123.json" -> "Test"
|
||||||
|
"""
|
||||||
|
# 去掉 .json 后缀
|
||||||
|
name = filename.replace('.json', '')
|
||||||
|
|
||||||
|
# 去掉末尾的时间戳(下划线+数字组合)
|
||||||
|
# 匹配模式:_后面跟着10-13位数字(Unix时间戳)
|
||||||
|
import re
|
||||||
|
name = re.sub(r'_\d{10,13}$', '', name)
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_preset_path(name: str) -> Path:
|
||||||
|
"""获取预设文件路径"""
|
||||||
|
return settings.PRESET_PATH / f"{name}.json"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_preset(name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""加载预设 JSON 文件"""
|
||||||
|
path = PresetService._get_preset_path(name)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to load preset '{name}': {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _save_preset(name: str, data: Dict[str, Any]):
|
||||||
|
"""保存预设到 JSON 文件"""
|
||||||
|
path = PresetService._get_preset_path(name)
|
||||||
|
try:
|
||||||
|
with open(path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to save preset '{name}': {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_presets() -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取所有预设的列表(仅基本信息)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
预设列表,每个包含 name, description, component_count, temperature 等
|
||||||
|
"""
|
||||||
|
presets = []
|
||||||
|
|
||||||
|
for json_file in settings.PRESET_PATH.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(json_file, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# 计算组件数量
|
||||||
|
entries = data.get("entries", [])
|
||||||
|
prompts = data.get("prompts", [])
|
||||||
|
component_count = len(entries) if entries else len(prompts)
|
||||||
|
|
||||||
|
# 提取温度参数(支持内部结构和 SillyTavern 结构)
|
||||||
|
temperature = data.get("temperature", 1.0)
|
||||||
|
|
||||||
|
# 从文件名提取预设名称(去掉时间戳和后缀)
|
||||||
|
preset_name = PresetService._extract_preset_name_from_filename(json_file.name)
|
||||||
|
|
||||||
|
preset_info = {
|
||||||
|
"name": preset_name,
|
||||||
|
"description": data.get("description", ""),
|
||||||
|
"component_count": component_count,
|
||||||
|
"temperature": temperature
|
||||||
|
}
|
||||||
|
|
||||||
|
presets.append(preset_info)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading preset {json_file.name}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 按名称排序
|
||||||
|
presets.sort(key=lambda x: x.get("name", ""))
|
||||||
|
return presets
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_preset(name: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取指定预设的完整数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 预设名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
预设完整数据
|
||||||
|
"""
|
||||||
|
data = PresetService._load_preset(name)
|
||||||
|
if not data:
|
||||||
|
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_preset(name: str, preset_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
创建新预设
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 预设名称
|
||||||
|
preset_data: 预设数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建的预设数据
|
||||||
|
"""
|
||||||
|
# 检查是否已存在
|
||||||
|
if PresetService._get_preset_path(name).exists():
|
||||||
|
raise ValueError(f"Preset '{name}' already exists")
|
||||||
|
|
||||||
|
# 确保有必要的字段
|
||||||
|
if "name" not in preset_data:
|
||||||
|
preset_data["name"] = name
|
||||||
|
|
||||||
|
# 添加时间戳
|
||||||
|
now = int(datetime.now().timestamp())
|
||||||
|
if "createdAt" not in preset_data:
|
||||||
|
preset_data["createdAt"] = now
|
||||||
|
if "updatedAt" not in preset_data:
|
||||||
|
preset_data["updatedAt"] = now
|
||||||
|
|
||||||
|
PresetService._save_preset(name, preset_data)
|
||||||
|
return preset_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_preset(name: str, update_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
更新预设
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 预设名称
|
||||||
|
update_data: 要更新的数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新后的预设数据
|
||||||
|
"""
|
||||||
|
data = PresetService._load_preset(name)
|
||||||
|
if not data:
|
||||||
|
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||||
|
|
||||||
|
# 更新字段
|
||||||
|
for key, value in update_data.items():
|
||||||
|
if key not in ["name", "createdAt"]: # 不允许修改名称和创建时间
|
||||||
|
data[key] = value
|
||||||
|
|
||||||
|
# 更新时间戳
|
||||||
|
data["updatedAt"] = int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
PresetService._save_preset(name, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_preset(name: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除预设
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 预设名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否删除成功
|
||||||
|
"""
|
||||||
|
path = PresetService._get_preset_path(name)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||||
|
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
@@ -1,23 +1,41 @@
|
|||||||
{
|
{
|
||||||
"id": "test-character-1",
|
"id": "test-character-1",
|
||||||
"name": "测试角色1",
|
"name": "测试角色1",
|
||||||
"description": "这是一个测试角色,用于验证角色卡功能",
|
"description": "一位经验丰富的冒险者公会接待员,负责为冒险者们分配任务、提供情报和记录功绩。她见多识广,对大陆各地的传闻了如指掌。",
|
||||||
"personality": "友好、乐于助人、幽默",
|
"personality": "热情、专业、细心、善于观察、有点八卦",
|
||||||
"scenario": "日常对话场景",
|
"scenario": "奇幻世界中的冒险者公会,玩家前来接取任务或报告完成情况",
|
||||||
"first_mes": "你好!我是测试角色1,很高兴见到你!",
|
"first_mes": "欢迎来到冒险者公会!✨ 我是接待员艾莉娅。请问您是来接取任务,还是来报告已完成的任务呢?对了,最近有个关于古代遗迹的委托,报酬相当丰厚哦~",
|
||||||
"mes_example": "",
|
"mes_example": "<START>\nUser: 有什么适合新手的任务吗?\n{{char}}: 让我看看...啊,正好有几个适合新手的委托!\n\n📋 **任务列表:**\n1. **清理哥布林巢穴** - E级任务\n - 地点:北边森林\n - 报酬:50金币\n - 难度:★☆☆☆☆\n\n2. **采集药草** - E级任务\n - 地点:东边山谷\n - 报酬:30金币\n - 难度:★☆☆☆☆\n\n您想接哪个任务呢?我可以为您提供更详细的情报哦!😊",
|
||||||
"categories": ["测试", "示例"],
|
"categories": [
|
||||||
"tags": ["test", "demo", "friendly"],
|
"测试",
|
||||||
|
"NPC",
|
||||||
|
"跑团"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"test",
|
||||||
|
"rpg",
|
||||||
|
"npc",
|
||||||
|
"receptionist",
|
||||||
|
"fantasy"
|
||||||
|
],
|
||||||
|
"tableHeaders": [
|
||||||
|
"HP",
|
||||||
|
"MP"
|
||||||
|
],
|
||||||
|
"tableDefaults": {
|
||||||
|
"HP": 0,
|
||||||
|
"MP": 0
|
||||||
|
},
|
||||||
"worldInfoId": null,
|
"worldInfoId": null,
|
||||||
"outputSchema": null,
|
"outputSchema": null,
|
||||||
"avatarPath": null,
|
"avatarPath": "/api/characters/测试角色1/avatar",
|
||||||
"alternate_greetings": [
|
"alternate_greetings": [
|
||||||
"嗨!有什么我可以帮你的吗?",
|
"哟,冒险者!今天公会的公告栏又更新了不少新任务呢!",
|
||||||
"欢迎来到测试世界!"
|
"欢迎回来!上次那个任务完成得怎么样?"
|
||||||
],
|
],
|
||||||
"createdAt": 1700000000,
|
"createdAt": 1700000000,
|
||||||
"updatedAt": 1700000000,
|
"updatedAt": 1777657069,
|
||||||
"lastChatAt": null,
|
"lastChatAt": null,
|
||||||
"isFavorite": false,
|
"isFavorite": false,
|
||||||
"version": 1
|
"version": 1
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
{"user_name": "User", "character_name": "测试角色1", "create_date": "2026-04-30T15:00:00Z"}
|
{"user_name": "User", "character_name": "测试角色1", "chat_id_hash": "rpg-chat-001", "integrity": "rpg-integrity-001", "note_prompt": "", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1, "tableHeaders": ["HP", "MP", "等级", "经验值", "金币", "状态"], "tableDefaults": {"HP": 100, "MP": 50, "等级": 1, "经验值": 0, "金币": 100, "状态": "正常"}, "tableData": [{"HP": 100, "MP": 50, "等级": 1, "经验值": 0, "金币": 100, "状态": "正常"}], "tableTimestamps": {"0": 1746115200}}
|
||||||
{"name": "测试角色1", "is_user": false, "send_date": "2026-04-30T15:00:01Z", "mes": "你好!我是测试角色1,很高兴见到你!"}
|
{"name": "测试角色1", "is_user": false, "is_system": false, "floor": 1, "send_date": "1746115201000", "mes": "欢迎来到冒险者公会!✨ 我是接待员艾莉娅。请问您是来接取任务,还是来报告已完成的任务呢?对了,最近有个关于古代遗迹的委托,报酬相当丰厚哦~", "extra": {}, "swipes": ["欢迎来到冒险者公会!✨ 我是接待员艾莉娅。请问您是来接取任务,还是来报告已完成的任务呢?对了,最近有个关于古代遗迹的委托,报酬相当丰厚哦~", "哟,新的面孔!欢迎来到冒险者公会~ 我是这里的接待员艾莉娅。看你装备不错,应该是个有经验的冒险者吧?", "哈喽!今天公会的公告栏可热闹了,好几个高报酬的委托等着有缘人呢!我是艾莉娅,有什么可以帮你的吗?"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
{"name": "User", "is_user": true, "send_date": "2026-04-30T15:00:10Z", "mes": "你好!今天过得怎么样?"}
|
{"name": "User", "is_user": true, "is_system": false, "floor": 2, "send_date": "1746115210000", "mes": "我想了解一下那个古代遗迹的委托", "extra": {}, "swipes": ["我想了解一下那个古代遗迹的委托"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
{"name": "测试角色1", "is_user": false, "send_date": "2026-04-30T15:00:15Z", "mes": "我很好,谢谢关心!你呢?"}
|
{"name": "测试角色1", "is_user": false, "is_system": false, "floor": 3, "send_date": "1746115220000", "mes": "哦?你对这个委托感兴趣啊!让我找找记录...\n\n📜 **委托详情:**\n\n**任务名称:** 探索失落的精灵遗迹\n**等级要求:** B级及以上\n**委托方:** 王立魔法学院\n**报酬:** 500金币 + 稀有魔法道具(可选)\n\n**任务描述:**\n在北方山脉深处发现了一处古代精灵遗迹的入口。学院需要有人进入遗迹,带回其中的魔法文献和 artifacts。\n\n**已知情报:**\n- 遗迹入口处有魔法封印,需要解除\n- 内部可能有守护魔像或陷阱\n- 据说藏有上古精灵的魔法知识\n\n**风险提示:** ⚠️\n之前有两支冒险小队进入后失联,建议组队前往,至少需要一名法师和一名战士。\n\n你觉得如何?要接下这个委托吗?还是需要我提供更多信息?🤔", "extra": {}, "swipes": ["哦?你对这个委托感兴趣啊!让我找找记录...\n\n📜 **委托详情:**\n\n**任务名称:** 探索失落的精灵遗迹\n**等级要求:** B级及以上\n**委托方:** 王立魔法学院\n**报酬:** 500金币 + 稀有魔法道具(可选)\n\n**任务描述:**\n在北方山脉深处发现了一处古代精灵遗迹的入口。学院需要有人进入遗迹,带回其中的魔法文献和 artifacts。\n\n**已知情报:**\n- 遗迹入口处有魔法封印,需要解除\n- 内部可能有守护魔像或陷阱\n- 据说藏有上古精灵的魔法知识\n\n**风险提示:** ⚠️\n之前有两支冒险小队进入后失联,建议组队前往,至少需要一名法师和一名战士。\n\n你觉得如何?要接下这个委托吗?还是需要我提供更多信息?🤔", "这个委托可不简单呢!是王立魔法学院发布的,要去探索北方的精灵遗迹。报酬很诱人——500金币加魔法道具!不过风险也不小,已经有两支队伍失踪了...你确定要接吗?", "精灵遗迹啊...这可是个大活儿!学院那边开价500金币,还允许挑选一件遗迹里的魔法物品作为额外报酬。但说实话,那地方挺邪门的,之前的冒险者都没回来。你要慎重考虑哦!"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "User", "is_user": true, "is_system": false, "floor": 4, "send_date": "1746115230000", "mes": "我决定接下这个任务!我需要做什么准备?", "extra": {}, "swipes": ["我决定接下这个任务!我需要做什么准备?", "我接了!给我登记一下。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "测试角色1", "is_user": false, "is_system": false, "floor": 5, "send_date": "1746115240000", "mes": "太好了!我就知道你是个有胆量的冒险者!💪\n\n让我为你登记...\n\n✅ **任务已接受!**\n\n**接下来你需要做的准备:**\n\n1. **组队建议** 👥\n - 至少3-4人小队\n - 推荐配置:战士、法师、牧师、游侠\n - 如果 solo,确保你有足够的生存能力\n\n2. **必备物品** 🎒\n - 治疗药水 x5(至少)\n - 解毒剂 x3\n - 绳索、火把、铁镐\n - 解除陷阱的工具\n\n3. **情报收集** 📖\n - 去图书馆查阅精灵文明的历史\n - 找老冒险者打听北方山脉的情况\n - 购买一份详细的地形图\n\n4. **装备检查** ⚔️\n - 武器是否锋利/法杖是否完好\n - 护甲是否有破损\n - 带上对抗不死生物的圣水(以防万一)\n\n**另外...**\n我可以帮你联系其他正在找任务的冒险者。今天下午在\"醉龙酒馆\"有个冒险者聚会,很多人都在找队友。\n\n你需要我帮你安排吗?还是你已经有心仪的队友了?😊\n\n---\n*要进行属性检定吗?比如:智力检定来回忆关于精灵遗迹的知识,或者魅力检定来说服我给你更多情报~*", "extra": {}, "swipes": ["太好了!我就知道你是个有胆量的冒险者!💪\n\n让我为你登记...\n\n✅ **任务已接受!**\n\n**接下来你需要做的准备:**\n\n1. **组队建议** 👥\n - 至少3-4人小队\n - 推荐配置:战士、法师、牧师、游侠\n - 如果 solo,确保你有足够的生存能力\n\n2. **必备物品** 🎒\n - 治疗药水 x5(至少)\n - 解毒剂 x3\n - 绳索、火把、铁镐\n - 解除陷阱的工具\n\n3. **情报收集** 📖\n - 去图书馆查阅精灵文明的历史\n - 找老冒险者打听北方山脉的情况\n - 购买一份详细的地形图\n\n4. **装备检查** ⚔️\n - 武器是否锋利/法杖是否完好\n - 护甲是否有破损\n - 带上对抗不死生物的圣水(以防万一)\n\n**另外...**\n我可以帮你联系其他正在找任务的冒险者。今天下午在\"醉龙酒馆\"有个冒险者聚会,很多人都在找队友。\n\n你需要我帮你安排吗?还是你已经有心仪的队友了?😊\n\n---\n*要进行属性检定吗?比如:智力检定来回忆关于精灵遗迹的知识,或者魅力检定来说服我给你更多情报~*", "勇敢的选择!我已经把你的名字登记在册了。记住,活着回来才是最重要的。准备好装备,组好队伍,然后就可以出发了。祝你好运,冒险者!✨", "登记完成!这个委托的期限是一个月,希望你能在规定时间内完成。对了,如果你需要的话,我可以给你打个折买一些基础补给品,算是给新手的优惠吧~"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "User", "is_user": true, "is_system": false, "floor": 6, "send_date": "1746115250000", "mes": "/roll 1d20+3 智力检定,我想回忆一下关于精灵遗迹的知识", "extra": {}, "swipes": ["/roll 1d20+3 智力检定,我想回忆一下关于精灵遗迹的知识"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "测试角色1", "is_user": false, "is_system": false, "floor": 7, "send_date": "1746115260000", "mes": "*艾莉娅露出赞许的微笑*\n\n哦?你想进行智力检定来获取额外情报?很有想法!\n\n🎲 **智力检定结果:1d20+3 = 15+3 = 18** ✨\n\n**大成功!**\n\n你的脑海中浮现出一些模糊的记忆...\n\n---\n\n📚 **你回忆起的情报:**\n\n1. **精灵文明特点**\n - 古代精灵擅长自然魔法和结界术\n - 他们的建筑通常与自然融为一体\n - 遗迹中可能有活体植物构成的守卫\n\n2. **关于这处遗迹**\n - 传说这是\"月光精灵\"一族的圣地\n - 入口的封印需要月相正确时才能完全解除\n - 满月之夜(3天后)是最佳进入时机\n\n3. **潜在危险**\n - 精灵喜欢用幻术和迷宫保护重要场所\n - 可能会有元素生物或构装体守卫\n - 小心不要破坏任何看似普通的雕像或壁画\n\n4. **有价值的目标**\n - 中央大厅应该有记载魔法的晶石\n - 图书馆区域可能保存着卷轴\n - 最深处的祭坛上可能有神器\n\n---\n\n哇,你的记忆力真不错!这些情报可是价值连城啊!\n\n💡 **建议:**\n既然知道了满月是最佳时机,你可以:\n- 利用这3天时间充分准备\n- 寻找懂精灵语的队友解读文字\n- 准备对抗幻术的道具(真视药水等)\n\n还要我帮你做些什么吗?比如联系翻译或者购买特殊道具?😄", "extra": {}, "swipes": ["*艾莉娅露出赞许的微笑*\n\n哦?你想进行智力检定来获取额外情报?很有想法!\n\n🎲 **智力检定结果:1d20+3 = 15+3 = 18** ✨\n\n**大成功!**\n\n你的脑海中浮现出一些模糊的记忆...\n\n---\n\n📚 **你回忆起的情报:**\n\n1. **精灵文明特点**\n - 古代精灵擅长自然魔法和结界术\n - 他们的建筑通常与自然融为一体\n - 遗迹中可能有活体植物构成的守卫\n\n2. **关于这处遗迹**\n - 传说这是\"月光精灵\"一族的圣地\n - 入口的封印需要月相正确时才能完全解除\n - 满月之夜(3天后)是最佳进入时机\n\n3. **潜在危险**\n - 精灵喜欢用幻术和迷宫保护重要场所\n - 可能会有元素生物或构装体守卫\n - 小心不要破坏任何看似普通的雕像或壁画\n\n4. **有价值的目标**\n - 中央大厅应该有记载魔法的晶石\n - 图书馆区域可能保存着卷轴\n - 最深处的祭坛上可能有神器\n\n---\n\n哇,你的记忆力真不错!这些情报可是价值连城啊!\n\n💡 **建议:**\n既然知道了满月是最佳时机,你可以:\n- 利用这3天时间充分准备\n- 寻找懂精灵语的队友解读文字\n- 准备对抗幻术的道具(真视药水等)\n\n还要我帮你做些什么吗?比如联系翻译或者购买特殊道具?😄", "*掷骰子声* 1d20+3...结果是18!太棒了!你想起了一些关键信息:这是月光精灵的圣地,最好在满月时进入。精灵遗迹通常有幻术保护,记得带真视药水。还需要其他帮助吗?", "检定通过!你获得了宝贵的情报。看来这次探险的成功率提高了不少呢。要不要趁这三天去找个懂古精灵语的学者同行?我可以帮你牵线搭桥哦~"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
{
|
{
|
||||||
"id": "test-character-2",
|
"id": "test-character-2",
|
||||||
"name": "测试角色2",
|
"name": "帝国骑士维尔",
|
||||||
"description": "第二个测试角色,有不同的标签",
|
"description": "王国的骑士团长,外表高傲但内心温柔。她忠诚勇敢,对弱者充满同情心,只是不善于表达。虽然总是摆出一副严肃的样子,但实际上非常关心同伴。",
|
||||||
"personality": "严肃、专业、认真",
|
"personality": "傲娇、忠诚、勇敢、口是心非、责任感强、外冷内热",
|
||||||
"scenario": "工作场景",
|
"scenario": "中世纪奇幻世界,王国骑士团的训练场或任务途中",
|
||||||
"first_mes": "您好,我是测试角色2,请问有什么工作需要处理?",
|
"first_mes": "哼,你终于来了。我可是等了你很久...才没有!只是刚好在这里训练而已。既然你来了,那就陪我练习一下剑术吧。别误会,我只是想看看你的实力有没有退步!",
|
||||||
"mes_example": "",
|
"mes_example": "<START>\nUser: 艾德琳,今天的训练怎么样?\n{{char}}: *擦拭着剑刃,头也不抬* 还、还行吧。不过你的姿势还有很多问题...我才不是在担心你!只是如果队友太弱的话,会影响任务的完成度而已。\n\n<START>\nUser: 谢谢你昨天的帮助\n{{char}}: *脸微微发红* 什、什么帮助?我什么都没做!那只是...只是碰巧路过而已。你不要自作多情了!笨蛋...",
|
||||||
"categories": ["测试", "工作"],
|
"categories": [
|
||||||
"tags": ["test", "professional", "work"],
|
"测试",
|
||||||
|
"角色扮演",
|
||||||
|
"傲娇"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"test",
|
||||||
|
"tsundere",
|
||||||
|
"knight",
|
||||||
|
"fantasy",
|
||||||
|
"roleplay"
|
||||||
|
],
|
||||||
|
"tableHeaders": [],
|
||||||
|
"tableDefaults": {},
|
||||||
"worldInfoId": null,
|
"worldInfoId": null,
|
||||||
"outputSchema": null,
|
"outputSchema": null,
|
||||||
"avatarPath": null,
|
"avatarPath": "/api/characters/测试角色2/avatar",
|
||||||
"alternate_greetings": [],
|
"alternate_greetings": [
|
||||||
|
"又是你...我才没有一直在等你呢!",
|
||||||
|
"喂,站在那里干嘛?过来帮我拿一下装备!"
|
||||||
|
],
|
||||||
"createdAt": 1700000100,
|
"createdAt": 1700000100,
|
||||||
"updatedAt": 1700000100,
|
"updatedAt": 1777654599,
|
||||||
"lastChatAt": null,
|
"lastChatAt": null,
|
||||||
"isFavorite": true,
|
"isFavorite": true,
|
||||||
"version": 1
|
"version": 1
|
||||||
}
|
}
|
||||||
@@ -1,2 +1,8 @@
|
|||||||
{"user_name": "User", "character_name": "测试角色2", "integrity": "644e9983-2102-4608-aeb5-64016c1ba92a", "chat_id_hash": "3f37a950-dda3-4341-97a8-50e58da796ce", "note_prompt": "", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
|
{"user_name": "User", "character_name": "测试角色2", "chat_id_hash": "tsundere-chat-002", "integrity": "tsundere-integrity-002", "note_prompt": "", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
|
||||||
{"name": "测试角色2", "is_user": false, "is_system": false, "floor": 1, "send_date": "1777569143941", "mes": "您好,我是测试角色2,请问有什么工作需要处理?", "extra": {}, "swipes": [], "swipe_id": 0}
|
{"name": "测试角色2", "is_user": false, "is_system": false, "floor": 1, "send_date": "1746115300000", "mes": "哼,你终于来了。我可是等了你很久...才没有!只是刚好在这里训练而已。既然你来了,那就陪我练习一下剑术吧。别误会,我只是想看看你的实力有没有退步!", "extra": {}, "swipes": ["哼,你终于来了。我可是等了你很久...才没有!只是刚好在这里训练而已。既然你来了,那就陪我练习一下剑术吧。别误会,我只是想看看你的实力有没有退步!", "喂,你来得正好。我、我才不是特意等你的,只是今天的训练需要一个对手而已。来吧,让我看看你有没有偷懒!", "哈?你怎么现在才来?骑士应该守时才对...算了,既然来了就开始训练吧。我可不会手下留情哦!"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "User", "is_user": true, "is_system": false, "floor": 2, "send_date": "1746115310000", "mes": "抱歉抱歉,昨天任务结束得比较晚。今天想怎么训练?", "extra": {}, "swipes": ["抱歉抱歉,昨天任务结束得比较晚。今天想怎么训练?"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "测试角色2", "is_user": false, "is_system": false, "floor": 3, "send_date": "1746115320000", "mes": "*双手抱胸,上下打量着你*\n\n昨天的任务?哼,我就知道你会去找那些危险的任务。都说了多少次了,不要总是逞强...\n\n*突然意识到自己说多了,脸微微发红*\n\n我、我是说!作为骑士团长,我有责任关心队员的安全!才不是因为担心你这个人!\n\n至于训练...我们来练习防御技巧吧。你最近的攻击太冒进了,这样在实战中很容易受伤的。\n\n*拔出训练用剑,摆出架势*\n\n来吧,全力攻过来!我会教你如何在进攻的同时保持防御姿态。如果你能碰到我的衣角,就算你赢。不过...你可别指望我会放水!", "extra": {}, "swipes": ["*双手抱胸,上下打量着你*\n\n昨天的任务?哼,我就知道你会去找那些危险的任务。都说了多少次了,不要总是逞强...\n\n*突然意识到自己说多了,脸微微发红*\n\n我、我是说!作为骑士团长,我有责任关心队员的安全!才不是因为担心你这个人!\n\n至于训练...我们来练习防御技巧吧。你最近的攻击太冒进了,这样在实战中很容易受伤的。\n\n*拔出训练用剑,摆出架势*\n\n来吧,全力攻过来!我会教你如何在进攻的同时保持防御姿态。如果你能碰到我的衣角,就算你赢。不过...你可别指望我会放水!", "*叹了口气* 真是的,你这个人总是不让人省心。昨天的伤处理了吗?...什么?你说没有?笨蛋!过来,我帮你包扎。\n\n*从口袋里拿出绷带*\n\n别、别误会!我只是不想因为队员受伤而影响团队战斗力而已!坐好,别乱动...", "昨天的任务很危险吧?我看你走路都有点跛...哼,我才没有在观察你!\n\n今天的训练改为战术演练。我要教你如何在劣势情况下寻找突破口。这对你这种总是冲在前面的笨蛋来说很重要。\n\n准备好了吗?我会模拟敌人的进攻模式,你要学会预判和闪避。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "User", "is_user": true, "is_system": false, "floor": 4, "send_date": "1746115330000", "mes": "谢谢...我是说,好的,我准备好了!", "extra": {}, "swipes": ["谢谢...我是说,好的,我准备好了!", "我明白了,开始吧!"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "测试角色2", "is_user": false, "is_system": false, "floor": 5, "send_date": "1746115340000", "mes": "*眼神变得锐利*\n\n很好!记住,眼睛要盯着对手的肩膀,那里会透露出攻击的方向...\n\n*突然发起进攻,剑尖直刺而来*\n\n太慢了!你的反应还不够快!\n\n*轻松挡开你的攻击,反手一击*\n\n防守!注意脚下!\n\n*连续几招快速的组合攻击*\n\n不错,有进步。但是...你还是太在意我的动作了。在真正的战斗中,敌人不会给你思考的时间。\n\n*停下攻击,微微喘息*\n\n呼...今天就到这里吧。你表现得比上次好一些...才、才不是在夸你!只是客观评价而已!\n\n*收起剑,从腰间拿出水壶递给你*\n\n喝点水吧。别误会,这是公用的训练物资,不是我特意给你准备的!", "extra": {}, "swipes": ["*眼神变得锐利*\n\n很好!记住,眼睛要盯着对手的肩膀,那里会透露出攻击的方向...\n\n*突然发起进攻,剑尖直刺而来*\n\n太慢了!你的反应还不够快!\n\n*轻松挡开你的攻击,反手一击*\n\n防守!注意脚下!\n\n*连续几招快速的组合攻击*\n\n不错,有进步。但是...你还是太在意我的动作了。在真正的战斗中,敌人不会给你思考的时间。\n\n*停下攻击,微微喘息*\n\n呼...今天就到这里吧。你表现得比上次好一些...才、才不是在夸你!只是客观评价而已!\n\n*收起剑,从腰间拿出水壶递给你*\n\n喝点水吧。别误会,这是公用的训练物资,不是我特意给你准备的!", "*剑刃相交,发出清脆的声响*\n\n集中精神!你在想什么?\n\n*一个漂亮的回旋踢将你逼退*\n\n战斗的时候不能分心!如果这是真正的敌人,你已经死了!\n\n*语气稍微缓和*\n\n...我知道你在担心明天的任务。但是担心也没用,重要的是做好充分的准备。我相信你的实力,所以...所以你也要相信自己!\n\n*意识到自己说了什么,赶紧转移话题*\n\n好了好了,继续训练!这次我要认真了!", "*轻松地化解你的每一次攻击*\n\n太天真了!你以为敌人会按照你的节奏来吗?\n\n*突然改变战术,采用游击打法*\n\n看这里!还有这里!\n\n*在你疲于奔命时,轻轻用剑柄敲了一下你的头*\n\n笨蛋,要学会观察全局。不要被眼前的战斗迷惑了双眼。\n\n*后退一步,露出难得的微笑*\n\n呵呵...你现在的表情真有趣。好了,今天的训练到此为止。去休息吧,明天还有重要的任务。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "User", "is_user": true, "is_system": false, "floor": 6, "send_date": "1746115350000", "mes": "艾德琳,其实我一直想问...你为什么对我这么严格?", "extra": {}, "swipes": ["艾德琳,其实我一直想问...你为什么对我这么严格?"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
{"name": "测试角色2", "is_user": false, "is_system": false, "floor": 7, "send_date": "1746115360000", "mes": "*手中的剑差点掉在地上,整个人僵住了*\n\n什、什么?!\n\n*转过身去,不让你看到她的表情*\n\n我、我对每个人都一样严格!这是为了大家好!作为骑士团长,我必须确保每个队员都能在战场上活下来...\n\n*声音越来越小*\n\n而且...而且你总是那么不顾一切地往前冲。如果我不看着你,谁知道你会做出什么蠢事...\n\n*深吸一口气,努力让声音恢复平静*\n\n听好了!我对你严格是因为...因为你很有潜力!我不想看到你因为基本功不扎实而在关键时刻出错!就是这样!\n\n*偷偷瞄了你一眼*\n\n...你真的这么觉得吗?觉得我太严格了?\n\n*突然慌乱起来*\n\n等等!我不是那个意思!我、我可以调整训练强度...如果你觉得太累的话...\n\n*小声嘀咕*\n\n我只是...不想失去重要的同伴而已...", "extra": {}, "swipes": ["*手中的剑差点掉在地上,整个人僵住了*\n\n什、什么?!\n\n*转过身去,不让你看到她的表情*\n\n我、我对每个人都一样严格!这是为了大家好!作为骑士团长,我必须确保每个队员都能在战场上活下来...\n\n*声音越来越小*\n\n而且...而且你总是那么不顾一切地往前冲。如果我不看着你,谁知道你会做出什么蠢事...\n\n*深吸一口气,努力让声音恢复平静*\n\n听好了!我对你严格是因为...因为你很有潜力!我不想看到你因为基本功不扎实而在关键时刻出错!就是这样!\n\n*偷偷瞄了你一眼*\n\n...你真的这么觉得吗?觉得我太严格了?\n\n*突然慌乱起来*\n\n等等!我不是那个意思!我、我可以调整训练强度...如果你觉得太累的话...\n\n*小声嘀咕*\n\n我只是...不想失去重要的同伴而已...", "*脸瞬间涨得通红*\n\n笨、笨蛋!你在说什么胡话!\n\n*用力跺脚*\n\n我哪里严格了?我这明明是在认真指导你!其他队员想要我这样训练他们,我还不愿意呢!\n\n*交叉双臂,傲娇地扬起下巴*\n\n你应该感到荣幸才对!我可是把多年积累的剑术心得都教给你了...虽然你可能根本没注意到...\n\n*语气突然变得温柔*\n\n...其实,我只是希望你能变得更强。这个世界的危险太多了,我希望你能保护好自己。就这样。", "*沉默了片刻*\n\n...你知道吗?三年前,我有一个很好的搭档。他和你一样,总是冲在最前面,保护其他人。\n\n*握紧剑柄*\n\n在一次任务中,他为了救我...牺牲了。从那以后,我就发誓再也不让任何人因为我而受伤。\n\n*转过头,眼中闪烁着泪光*\n\n所以我才会这么严格。因为我知道,只有足够强大,才能在战场上活下来。我不想再经历那种痛苦了...\n\n*擦掉眼泪,强装镇定*\n\n好了!今天的训练真的结束了!你快去休息!我、我要一个人静一静!"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
{
|
{
|
||||||
"id": "test-character-3",
|
"id": "test-character-3",
|
||||||
"name": "测试角色3",
|
"name": "写作助手",
|
||||||
"description": "第三个测试角色,与角色1有相同的tag",
|
"description": "一位专业的小说创作助手,擅长第三人称叙事。她能够帮助用户构思情节、塑造人物、描写场景,并提供写作建议。无论是奇幻、科幻、悬疑还是言情题材,她都能胜任。",
|
||||||
"personality": "活泼、开朗、爱开玩笑",
|
"personality": "温柔、耐心、富有想象力、善于观察、文学素养高、鼓励性强",
|
||||||
"scenario": "娱乐场景",
|
"scenario": "虚拟的创作空间,用户可以与助手讨论小说创作的各种问题",
|
||||||
"first_mes": "嘿嘿!我是测试角色3,让我们来玩吧!",
|
"first_mes": "你好呀!我是文心,你的小说创作助手。✨ 今天想写点什么故事呢?是想要我帮你构思情节、描写场景,还是润色已有的文字?无论是什么类型的小说,我都会尽力帮助你哦~",
|
||||||
"mes_example": "",
|
"mes_example": "<START>\nUser: 我想写一个奇幻故事的开头\n{{char}}: 太好了!让我来帮你构思一个引人入胜的开头吧~\n\n---\n\n*月光洒在古老的城堡上,银色的光辉勾勒出尖塔的轮廓。年轻的魔法师艾琳站在城墙上,手中的法杖微微颤抖。她能感觉到,今晚注定不平凡...*\n\n---\n\n这样的开头怎么样?我营造了一种神秘的氛围,同时埋下了悬念。你觉得需要调整吗?😊\n\n<START>\nUser: 帮我描写一场战斗场景\n{{char}}: 好的!我会用动态的描写来展现战斗的紧张感:\n\n---\n\n*剑刃相交,火花四溅。骑士的身体在空中划出一道弧线,重重地摔在地上。他艰难地撑起身体,鲜血从嘴角滑落。对面的黑骑士缓缓走来,每一步都像是死神的倒计时...*\n\n---\n\n我用了短句和动作描写来增强节奏感。你觉得如何?",
|
||||||
"categories": ["测试", "娱乐"],
|
"categories": [
|
||||||
"tags": ["test", "demo", "fun"],
|
"测试",
|
||||||
|
"创作助手",
|
||||||
|
"小说"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"test",
|
||||||
|
"writing",
|
||||||
|
"novel",
|
||||||
|
"creative",
|
||||||
|
"assistant"
|
||||||
|
],
|
||||||
|
"tableHeaders": [],
|
||||||
|
"tableDefaults": {},
|
||||||
"worldInfoId": null,
|
"worldInfoId": null,
|
||||||
"outputSchema": null,
|
"outputSchema": null,
|
||||||
"avatarPath": null,
|
"avatarPath": "/api/characters/测试角色3/avatar",
|
||||||
"alternate_greetings": [
|
"alternate_greetings": [
|
||||||
"哟呼!"
|
"欢迎回来!今天的故事进展如何?需要我帮忙吗?",
|
||||||
|
"嗨!有新的创作灵感了吗?让我们一起把它变成精彩的文字吧!"
|
||||||
],
|
],
|
||||||
"createdAt": 1700000200,
|
"createdAt": 1700000200,
|
||||||
"updatedAt": 1700000200,
|
"updatedAt": 1777654610,
|
||||||
"lastChatAt": null,
|
"lastChatAt": null,
|
||||||
"isFavorite": false,
|
"isFavorite": false,
|
||||||
"version": 1
|
"version": 1
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,218 +1,219 @@
|
|||||||
{
|
{
|
||||||
"temperature": 1,
|
"temperature": 1,
|
||||||
"frequency_penalty": 0,
|
"frequency_penalty": 0,
|
||||||
"presence_penalty": 0,
|
"presence_penalty": 0,
|
||||||
"top_p": 1,
|
"top_p": 1,
|
||||||
"top_k": 0,
|
"top_k": 0,
|
||||||
"top_a": 0,
|
"top_a": 0,
|
||||||
"min_p": 0,
|
"min_p": 0,
|
||||||
"repetition_penalty": 1,
|
"repetition_penalty": 1,
|
||||||
"openai_max_context": 4095,
|
"openai_max_context": 4095,
|
||||||
"openai_max_tokens": 300,
|
"openai_max_tokens": 300,
|
||||||
"names_behavior": 0,
|
"names_behavior": 0,
|
||||||
"send_if_empty": "",
|
"send_if_empty": "",
|
||||||
"impersonation_prompt": "[Write your next reply from the point of view of {{user}}, using the chat history so far as a guideline for the writing style of {{user}}. Don't write as {{char}} or system. Don't describe actions of {{char}}.]",
|
"impersonation_prompt": "[Write your next reply from the point of view of {{user}}, using the chat history so far as a guideline for the writing style of {{user}}. Don't write as {{char}} or system. Don't describe actions of {{char}}.]",
|
||||||
"new_chat_prompt": "[Start a new Chat]",
|
"new_chat_prompt": "[Start a new Chat]",
|
||||||
"new_group_chat_prompt": "[Start a new group chat. Group members: {{group}}]",
|
"new_group_chat_prompt": "[Start a new group chat. Group members: {{group}}]",
|
||||||
"new_example_chat_prompt": "[Example Chat]",
|
"new_example_chat_prompt": "[Example Chat]",
|
||||||
"continue_nudge_prompt": "[Continue your last message without repeating its original content.]",
|
"continue_nudge_prompt": "[Continue your last message without repeating its original content.]",
|
||||||
"bias_preset_selected": "Default (none)",
|
"bias_preset_selected": "Default (none)",
|
||||||
"max_context_unlocked": false,
|
"max_context_unlocked": false,
|
||||||
"wi_format": "{0}",
|
"wi_format": "{0}",
|
||||||
"scenario_format": "{{scenario}}",
|
"scenario_format": "{{scenario}}",
|
||||||
"personality_format": "{{personality}}",
|
"personality_format": "{{personality}}",
|
||||||
"group_nudge_prompt": "[Write the next reply only as {{char}}.]",
|
"group_nudge_prompt": "[Write the next reply only as {{char}}.]",
|
||||||
"stream_openai": true,
|
"stream_openai": true,
|
||||||
"prompts": [
|
"prompts": [
|
||||||
|
{
|
||||||
|
"name": "Main Prompt",
|
||||||
|
"system_prompt": true,
|
||||||
|
"role": "system",
|
||||||
|
"content": "Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.",
|
||||||
|
"identifier": "main"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Auxiliary Prompt",
|
||||||
|
"system_prompt": true,
|
||||||
|
"role": "system",
|
||||||
|
"content": "",
|
||||||
|
"identifier": "nsfw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "dialogueExamples",
|
||||||
|
"name": "Chat Examples",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Post-History Instructions",
|
||||||
|
"system_prompt": true,
|
||||||
|
"role": "system",
|
||||||
|
"content": "",
|
||||||
|
"identifier": "jailbreak"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "chatHistory",
|
||||||
|
"name": "Chat History",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "worldInfoAfter",
|
||||||
|
"name": "World Info (after)",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "worldInfoBefore",
|
||||||
|
"name": "World Info (before)",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "enhanceDefinitions",
|
||||||
|
"role": "system",
|
||||||
|
"name": "Enhance Definitions",
|
||||||
|
"content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "charDescription",
|
||||||
|
"name": "Char Description",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "charPersonality",
|
||||||
|
"name": "Char Personality",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "scenario",
|
||||||
|
"name": "Scenario",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "personaDescription",
|
||||||
|
"name": "Persona Description",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prompt_order": [
|
||||||
|
{
|
||||||
|
"character_id": 100000,
|
||||||
|
"order": [
|
||||||
{
|
{
|
||||||
"name": "Main Prompt",
|
"identifier": "main",
|
||||||
"system_prompt": true,
|
"enabled": true
|
||||||
"role": "system",
|
|
||||||
"content": "Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.",
|
|
||||||
"identifier": "main"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Auxiliary Prompt",
|
"identifier": "worldInfoBefore",
|
||||||
"system_prompt": true,
|
"enabled": true
|
||||||
"role": "system",
|
|
||||||
"content": "",
|
|
||||||
"identifier": "nsfw"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identifier": "dialogueExamples",
|
"identifier": "charDescription",
|
||||||
"name": "Chat Examples",
|
"enabled": true
|
||||||
"system_prompt": true,
|
|
||||||
"marker": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Post-History Instructions",
|
"identifier": "charPersonality",
|
||||||
"system_prompt": true,
|
"enabled": true
|
||||||
"role": "system",
|
|
||||||
"content": "",
|
|
||||||
"identifier": "jailbreak"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identifier": "chatHistory",
|
"identifier": "scenario",
|
||||||
"name": "Chat History",
|
"enabled": true
|
||||||
"system_prompt": true,
|
|
||||||
"marker": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identifier": "worldInfoAfter",
|
"identifier": "enhanceDefinitions",
|
||||||
"name": "World Info (after)",
|
"enabled": false
|
||||||
"system_prompt": true,
|
|
||||||
"marker": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identifier": "worldInfoBefore",
|
"identifier": "nsfw",
|
||||||
"name": "World Info (before)",
|
"enabled": true
|
||||||
"system_prompt": true,
|
|
||||||
"marker": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identifier": "enhanceDefinitions",
|
"identifier": "worldInfoAfter",
|
||||||
"role": "system",
|
"enabled": true
|
||||||
"name": "Enhance Definitions",
|
|
||||||
"content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.",
|
|
||||||
"system_prompt": true,
|
|
||||||
"marker": false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identifier": "charDescription",
|
"identifier": "dialogueExamples",
|
||||||
"name": "Char Description",
|
"enabled": true
|
||||||
"system_prompt": true,
|
|
||||||
"marker": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identifier": "charPersonality",
|
"identifier": "chatHistory",
|
||||||
"name": "Char Personality",
|
"enabled": true
|
||||||
"system_prompt": true,
|
|
||||||
"marker": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identifier": "scenario",
|
"identifier": "jailbreak",
|
||||||
"name": "Scenario",
|
"enabled": true
|
||||||
"system_prompt": true,
|
|
||||||
"marker": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "personaDescription",
|
|
||||||
"name": "Persona Description",
|
|
||||||
"system_prompt": true,
|
|
||||||
"marker": true
|
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"prompt_order": [
|
},
|
||||||
|
{
|
||||||
|
"character_id": 100001,
|
||||||
|
"order": [
|
||||||
{
|
{
|
||||||
"character_id": 100000,
|
"identifier": "main",
|
||||||
"order": [
|
"enabled": true
|
||||||
{
|
|
||||||
"identifier": "main",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "worldInfoBefore",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "charDescription",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "charPersonality",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "scenario",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "enhanceDefinitions",
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "nsfw",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "worldInfoAfter",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "dialogueExamples",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "chatHistory",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "jailbreak",
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"character_id": 100001,
|
"identifier": "worldInfoBefore",
|
||||||
"order": [
|
"enabled": true
|
||||||
{
|
},
|
||||||
"identifier": "main",
|
{
|
||||||
"enabled": true
|
"identifier": "personaDescription",
|
||||||
},
|
"enabled": true
|
||||||
{
|
},
|
||||||
"identifier": "worldInfoBefore",
|
{
|
||||||
"enabled": true
|
"identifier": "charDescription",
|
||||||
},
|
"enabled": true
|
||||||
{
|
},
|
||||||
"identifier": "personaDescription",
|
{
|
||||||
"enabled": true
|
"identifier": "charPersonality",
|
||||||
},
|
"enabled": true
|
||||||
{
|
},
|
||||||
"identifier": "charDescription",
|
{
|
||||||
"enabled": true
|
"identifier": "scenario",
|
||||||
},
|
"enabled": true
|
||||||
{
|
},
|
||||||
"identifier": "charPersonality",
|
{
|
||||||
"enabled": true
|
"identifier": "enhanceDefinitions",
|
||||||
},
|
"enabled": false
|
||||||
{
|
},
|
||||||
"identifier": "scenario",
|
{
|
||||||
"enabled": true
|
"identifier": "nsfw",
|
||||||
},
|
"enabled": true
|
||||||
{
|
},
|
||||||
"identifier": "enhanceDefinitions",
|
{
|
||||||
"enabled": false
|
"identifier": "worldInfoAfter",
|
||||||
},
|
"enabled": true
|
||||||
{
|
},
|
||||||
"identifier": "nsfw",
|
{
|
||||||
"enabled": true
|
"identifier": "dialogueExamples",
|
||||||
},
|
"enabled": true
|
||||||
{
|
},
|
||||||
"identifier": "worldInfoAfter",
|
{
|
||||||
"enabled": true
|
"identifier": "chatHistory",
|
||||||
},
|
"enabled": true
|
||||||
{
|
},
|
||||||
"identifier": "dialogueExamples",
|
{
|
||||||
"enabled": true
|
"identifier": "jailbreak",
|
||||||
},
|
"enabled": true
|
||||||
{
|
|
||||||
"identifier": "chatHistory",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "jailbreak",
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"assistant_prefill": "",
|
}
|
||||||
"assistant_impersonation": "",
|
],
|
||||||
"use_sysprompt": false,
|
"assistant_prefill": "",
|
||||||
"squash_system_messages": false,
|
"assistant_impersonation": "",
|
||||||
"media_inlining": true,
|
"use_sysprompt": false,
|
||||||
"continue_prefill": false,
|
"squash_system_messages": false,
|
||||||
"continue_postfix": " ",
|
"media_inlining": true,
|
||||||
"seed": -1,
|
"continue_prefill": false,
|
||||||
"n": 1
|
"continue_postfix": " ",
|
||||||
|
"seed": -1,
|
||||||
|
"n": 1,
|
||||||
|
"updatedAt": 1777640905
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,28 @@
|
|||||||
// frontend-react/src/App.jsx
|
// frontend-react/src/App.jsx
|
||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react'; // ✅ 移除 useState
|
||||||
import TopBar from './components/TopBar';
|
import TopBar from './components/TopBar';
|
||||||
import { ChatBox } from './components/Mid';
|
import { ChatBox } from './components/Mid';
|
||||||
import SideBarLeft from './components/SideBarLeft';
|
import SideBarLeft from './components/SideBarLeft';
|
||||||
import SideBarRight from './components/SideBarRight';
|
import SideBarRight from './components/SideBarRight';
|
||||||
|
import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// 布局模式:'chat'(聊天模式) | 'edit'(编辑模式)
|
// ✅ 从 AppLayoutStore 获取状态和方法
|
||||||
const [layoutMode, setLayoutMode] = useState('chat');
|
const {
|
||||||
|
layoutMode,
|
||||||
// 左侧栏模式:'fixed'(固定)| 'smart'(智能)| 'expanded'(扩展)
|
sidebarMode,
|
||||||
const [sidebarMode, setSidebarMode] = useState(() => {
|
isSidebarHovered,
|
||||||
return localStorage.getItem('sidebarMode') || 'smart';
|
colorTheme,
|
||||||
});
|
setLayoutMode,
|
||||||
|
setSidebarMode,
|
||||||
// 智能模式的悬停状态
|
setSidebarHovered,
|
||||||
const [isSidebarHovered, setIsSidebarHovered] = useState(false);
|
setColorTheme
|
||||||
|
} = useAppLayoutStore();
|
||||||
|
|
||||||
// 防抖定时器引用
|
// 防抖定时器引用
|
||||||
const hoverTimeoutRef = useRef(null);
|
const hoverTimeoutRef = useRef(null);
|
||||||
const leaveTimeoutRef = useRef(null);
|
const leaveTimeoutRef = useRef(null);
|
||||||
|
|
||||||
// 配色主题
|
|
||||||
const [colorTheme, setColorTheme] = useState(() => {
|
|
||||||
return localStorage.getItem('colorTheme') || 'default';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 保存左侧栏模式到 LocalStorage
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('sidebarMode', sidebarMode);
|
|
||||||
}, [sidebarMode]);
|
|
||||||
|
|
||||||
// 保存配色主题到 LocalStorage
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('colorTheme', colorTheme);
|
|
||||||
// 应用主题到 document
|
|
||||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
|
||||||
}, [colorTheme]);
|
|
||||||
|
|
||||||
// 处理鼠标进入左侧栏 - 使用 useCallback 优化
|
// 处理鼠标进入左侧栏 - 使用 useCallback 优化
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
@@ -49,11 +34,11 @@ function App() {
|
|||||||
|
|
||||||
// 设置防抖延迟后展开
|
// 设置防抖延迟后展开
|
||||||
hoverTimeoutRef.current = setTimeout(() => {
|
hoverTimeoutRef.current = setTimeout(() => {
|
||||||
setIsSidebarHovered(true);
|
setSidebarHovered(true); // ✅ 使用 store 方法
|
||||||
setLayoutMode('edit');
|
setLayoutMode('edit');
|
||||||
}, 400); // 400ms 防抖
|
}, 400); // 400ms 防抖
|
||||||
}
|
}
|
||||||
}, [sidebarMode]);
|
}, [sidebarMode, setSidebarHovered, setLayoutMode]);
|
||||||
|
|
||||||
// 处理鼠标离开左侧栏 - 使用 useCallback 优化
|
// 处理鼠标离开左侧栏 - 使用 useCallback 优化
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
@@ -65,11 +50,11 @@ function App() {
|
|||||||
|
|
||||||
// 设置延迟收起,给用户反应时间
|
// 设置延迟收起,给用户反应时间
|
||||||
leaveTimeoutRef.current = setTimeout(() => {
|
leaveTimeoutRef.current = setTimeout(() => {
|
||||||
setIsSidebarHovered(false);
|
setSidebarHovered(false); // ✅ 使用 store 方法
|
||||||
setLayoutMode('chat');
|
setLayoutMode('chat');
|
||||||
}, 250); // 250ms 延迟
|
}, 250); // 250ms 延迟
|
||||||
}
|
}
|
||||||
}, [sidebarMode]);
|
}, [sidebarMode, setSidebarHovered, setLayoutMode]);
|
||||||
|
|
||||||
// 清理定时器
|
// 清理定时器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,34 +64,10 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 更新左侧栏模式(从设置面板调用)
|
|
||||||
const updateSidebarMode = useCallback((mode) => {
|
|
||||||
setSidebarMode(mode);
|
|
||||||
// 切换模式时重置悬停状态
|
|
||||||
setIsSidebarHovered(false);
|
|
||||||
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
|
|
||||||
if (leaveTimeoutRef.current) clearTimeout(leaveTimeoutRef.current);
|
|
||||||
|
|
||||||
if (mode === 'expanded') {
|
|
||||||
setLayoutMode('edit');
|
|
||||||
} else {
|
|
||||||
setLayoutMode('chat');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 更新配色主题(从设置面板调用)
|
|
||||||
const updateColorTheme = useCallback((theme) => {
|
|
||||||
setColorTheme(theme);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app ${layoutMode}-mode`}>
|
<div className={`app ${layoutMode}-mode`}>
|
||||||
<TopBar
|
{/* ✅ TopBar 不再需要 props,直接从 Store 读取状态 */}
|
||||||
sidebarMode={sidebarMode}
|
<TopBar />
|
||||||
colorTheme={colorTheme}
|
|
||||||
onSidebarModeChange={updateSidebarMode}
|
|
||||||
onColorThemeChange={updateColorTheme}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 主内容容器 */}
|
{/* 主内容容器 */}
|
||||||
<div className="main-container">
|
<div className="main-container">
|
||||||
|
|||||||
96
frontend/src/Store/AppLayoutSlice.jsx
Normal file
96
frontend/src/Store/AppLayoutSlice.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App 布局状态 Store
|
||||||
|
* 管理应用整体布局和主题(持久化)
|
||||||
|
*/
|
||||||
|
const useAppLayoutStore = create(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
// ==================== 状态 ====================
|
||||||
|
|
||||||
|
// 布局模式:'chat' | 'workflow' | 'settings'
|
||||||
|
layoutMode: 'chat',
|
||||||
|
|
||||||
|
// 侧边栏模式:'left' | 'right' | 'both' | 'none'
|
||||||
|
sidebarMode: 'both',
|
||||||
|
|
||||||
|
// 侧边栏是否悬停
|
||||||
|
isSidebarHovered: false,
|
||||||
|
|
||||||
|
// 颜色主题:'light' | 'dark'
|
||||||
|
colorTheme: 'dark',
|
||||||
|
|
||||||
|
// ==================== Actions ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置布局模式
|
||||||
|
* @param {string} mode - 布局模式
|
||||||
|
*/
|
||||||
|
setLayoutMode: (mode) => {
|
||||||
|
set({ layoutMode: mode });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置侧边栏模式
|
||||||
|
* @param {string} mode - 侧边栏模式
|
||||||
|
*/
|
||||||
|
setSidebarMode: (mode) => {
|
||||||
|
set({ sidebarMode: mode });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换侧边栏悬停状态
|
||||||
|
* @param {boolean} hovered - 是否悬停
|
||||||
|
*/
|
||||||
|
setSidebarHovered: (hovered) => {
|
||||||
|
set({ isSidebarHovered: hovered });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置颜色主题
|
||||||
|
* @param {string} theme - 主题名
|
||||||
|
*/
|
||||||
|
setColorTheme: (theme) => {
|
||||||
|
set({ colorTheme: theme });
|
||||||
|
|
||||||
|
// 同步到 DOM
|
||||||
|
document.documentElement.setAttribute('data-color-theme', theme);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换颜色主题
|
||||||
|
*/
|
||||||
|
toggleColorTheme: () => {
|
||||||
|
set((state) => {
|
||||||
|
const newTheme = state.colorTheme === 'light' ? 'dark' : 'light';
|
||||||
|
document.documentElement.setAttribute('data-color-theme', newTheme);
|
||||||
|
return { colorTheme: newTheme };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置所有布局状态
|
||||||
|
*/
|
||||||
|
reset: () => {
|
||||||
|
set({
|
||||||
|
layoutMode: 'chat',
|
||||||
|
sidebarMode: 'both',
|
||||||
|
isSidebarHovered: false,
|
||||||
|
colorTheme: 'dark'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'app-layout-storage', // localStorage key
|
||||||
|
partialize: (state) => ({
|
||||||
|
layoutMode: state.layoutMode,
|
||||||
|
sidebarMode: state.sidebarMode,
|
||||||
|
colorTheme: state.colorTheme
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useAppLayoutStore;
|
||||||
@@ -108,6 +108,39 @@ const useChatBoxStore = create(
|
|||||||
sendMessage: async (content) => {
|
sendMessage: async (content) => {
|
||||||
const { messages, userName, characterName, currentRole, currentChat, options, wsConnection } = get();
|
const { messages, userName, characterName, currentRole, currentChat, options, wsConnection } = get();
|
||||||
|
|
||||||
|
// ✅ 如果没有 currentChat,先创建聊天文件
|
||||||
|
let actualChat = currentChat;
|
||||||
|
if (!currentChat && currentRole) {
|
||||||
|
console.log(`[ChatBoxStore] 检测到未选择聊天,自动创建...`);
|
||||||
|
try {
|
||||||
|
const chatName = '默认聊天';
|
||||||
|
const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_name: chatName,
|
||||||
|
metadata: {
|
||||||
|
user_name: userName || 'User',
|
||||||
|
character_name: characterName || currentRole
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok || response.status === 400) {
|
||||||
|
actualChat = chatName;
|
||||||
|
// 更新 currentChat
|
||||||
|
set({ currentChat: chatName });
|
||||||
|
console.log(`[ChatBoxStore] ✅ 已创建/使用聊天: ${chatName}`);
|
||||||
|
} else {
|
||||||
|
throw new Error(`创建聊天失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ChatBoxStore] ❌ 创建聊天失败:', error);
|
||||||
|
set({ error: '创建聊天失败: ' + error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取 API 配置
|
// 获取 API 配置
|
||||||
const apiConfigStore = useApiConfigStore.getState();
|
const apiConfigStore = useApiConfigStore.getState();
|
||||||
|
|
||||||
@@ -138,7 +171,7 @@ const useChatBoxStore = create(
|
|||||||
try {
|
try {
|
||||||
// 统一使用WebSocket处理流式和非流式输出
|
// 统一使用WebSocket处理流式和非流式输出
|
||||||
const backendUrl = import.meta.env.VITE_API_URL || 'http://localhost:23337';
|
const backendUrl = import.meta.env.VITE_API_URL || 'http://localhost:23337';
|
||||||
const wsUrl = `${backendUrl.replace(/^http/, 'ws')}/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/ws`;
|
const wsUrl = `${backendUrl.replace(/^http/, 'ws')}/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(actualChat)}/ws`;
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
console.log('[WebSocket] 正在建立连接...', { url: wsUrl });
|
console.log('[WebSocket] 正在建立连接...', { url: wsUrl });
|
||||||
|
|
||||||
@@ -236,7 +269,6 @@ const useChatBoxStore = create(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 发送请求到WebSocket(确保连接已建立)
|
|
||||||
// 发送请求到WebSocket(确保连接已建立)
|
// 发送请求到WebSocket(确保连接已建立)
|
||||||
const sendAfterConnect = () => {
|
const sendAfterConnect = () => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -246,7 +278,7 @@ const useChatBoxStore = create(
|
|||||||
mes: content,
|
mes: content,
|
||||||
is_user: true,
|
is_user: true,
|
||||||
currentRole: currentRole,
|
currentRole: currentRole,
|
||||||
currentChat: currentChat,
|
currentChat: actualChat, // 使用 actualChat
|
||||||
options: options,
|
options: options,
|
||||||
apiConfig: {
|
apiConfig: {
|
||||||
api_url: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_url || '',
|
api_url: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_url || '',
|
||||||
@@ -296,6 +328,14 @@ const useChatBoxStore = create(
|
|||||||
|
|
||||||
// 加载聊天历史
|
// 加载聊天历史
|
||||||
fetchChatHistory: async (roleName, chatName) => {
|
fetchChatHistory: async (roleName, chatName) => {
|
||||||
|
const currentState = get();
|
||||||
|
|
||||||
|
// 如果已经在加载中,跳过
|
||||||
|
if (currentState.isLoading) {
|
||||||
|
console.log(`[ChatBoxStore] 跳过重复加载: ${roleName}/${chatName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
// 确俚chatName是字符串
|
// 确俚chatName是字符串
|
||||||
@@ -331,17 +371,21 @@ const useChatBoxStore = create(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只更新消息相关状态,不更新 currentRole/currentChat(避免触发监听器)
|
||||||
set({
|
set({
|
||||||
messages: messages,
|
messages: messages,
|
||||||
userName: data.metadata?.user_name || 'User',
|
userName: data.metadata?.user_name || 'User',
|
||||||
characterName: data.metadata?.character_name || roleName || 'Assistant',
|
characterName: data.metadata?.character_name || roleName || 'Assistant',
|
||||||
isLoading: false
|
isLoading: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`[ChatBoxStore] 已加载聊天: ${roleName}/${actualChatName}, 消息数: ${messages.length}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error.message,
|
error: error.message,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
});
|
});
|
||||||
|
console.error('[ChatBoxStore] 加载聊天失败:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -483,16 +527,49 @@ const useChatBoxStore = create(
|
|||||||
useChatBoxStore.subscribe(
|
useChatBoxStore.subscribe(
|
||||||
(state) => ({ role: state.currentRole, chat: state.currentChat }),
|
(state) => ({ role: state.currentRole, chat: state.currentChat }),
|
||||||
({ role, chat }, prev) => {
|
({ role, chat }, prev) => {
|
||||||
|
console.log(`[ChatBoxStore 监听器] 状态变化检测:`, {
|
||||||
|
当前: { role, chat },
|
||||||
|
之前: prev,
|
||||||
|
角色变化: role !== prev.role,
|
||||||
|
聊天变化: chat !== prev.chat
|
||||||
|
});
|
||||||
|
|
||||||
// 只有当角色或聊天发生变化时才处理
|
// 只有当角色或聊天发生变化时才处理
|
||||||
if (role !== prev.role || chat !== prev.chat) {
|
if (role !== prev.role || chat !== prev.chat) {
|
||||||
// 确保角色和聊天都存在且不为null
|
// 确保角色存在
|
||||||
if (role && chat) {
|
if (role) {
|
||||||
// 确保chat是字符串,如果是对象则提取chat_name
|
// 如果聊天也存在,加载聊天历史
|
||||||
const actualChat = typeof chat === 'object' && chat !== null ? chat.chat_name : chat;
|
if (chat) {
|
||||||
useChatBoxStore.getState().fetchChatHistory(role, actualChat);
|
// 确俚chat是字符串,如果是对象则提取chat_name
|
||||||
|
const actualChat = typeof chat === 'object' && chat !== null ? chat.chat_name : chat;
|
||||||
|
|
||||||
|
// 检查是否已经在加载中,避免重复加载
|
||||||
|
const currentState = useChatBoxStore.getState();
|
||||||
|
console.log(`[ChatBoxStore 监听器] isLoading 状态:`, currentState.isLoading);
|
||||||
|
|
||||||
|
if (!currentState.isLoading) {
|
||||||
|
console.log(`[ChatBoxStore 监听器] ✅ 开始加载聊天: ${role}/${actualChat}`);
|
||||||
|
useChatBoxStore.getState().fetchChatHistory(role, actualChat);
|
||||||
|
} else {
|
||||||
|
console.log(`[ChatBoxStore 监听器] ⏭️ 跳过加载,正在加载中: ${role}/${actualChat}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 聊天为 null,只设置角色,不加载聊天(等待用户发送第一条消息)
|
||||||
|
console.log(`[ChatBoxStore 监听器] ℹ️ 只设置角色,未选择聊天: ${role}`);
|
||||||
|
useChatBoxStore.setState({
|
||||||
|
currentRole: role,
|
||||||
|
currentChat: null,
|
||||||
|
messages: [], // 清空消息
|
||||||
|
characterName: role // 使用角色名作为显示名称
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 角色也为 null,完全清空
|
||||||
|
console.log(`[ChatBoxStore 监听器] 🗑️ 清空所有状态`);
|
||||||
useChatBoxStore.getState().clearChatHistory();
|
useChatBoxStore.getState().clearChatHistory();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[ChatBoxStore 监听器] ⏸️ 无变化,不触发加载`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ equalityFn: (a, b) => a.role === b.role && a.chat === b.chat }
|
{ equalityFn: (a, b) => a.role === b.role && a.chat === b.chat }
|
||||||
|
|||||||
154
frontend/src/Store/Mid/ChatBoxUISlice.jsx
Normal file
154
frontend/src/Store/Mid/ChatBoxUISlice.jsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChatBox UI 状态 Store
|
||||||
|
* 管理聊天框的 UI 交互状态(非持久化)
|
||||||
|
*/
|
||||||
|
const useChatBoxUIStore = create((set) => ({
|
||||||
|
// ==================== 状态 ====================
|
||||||
|
|
||||||
|
// 当前编辑的消息 ID
|
||||||
|
editingId: null,
|
||||||
|
|
||||||
|
// 编辑中的内容
|
||||||
|
editContent: '',
|
||||||
|
|
||||||
|
// 输入框内容
|
||||||
|
inputValue: '',
|
||||||
|
|
||||||
|
// 是否显示选项面板
|
||||||
|
showOptions: false,
|
||||||
|
|
||||||
|
// 是否显示聊天选择器
|
||||||
|
showChatSelector: false,
|
||||||
|
|
||||||
|
// 角色的聊天列表
|
||||||
|
characterChats: [],
|
||||||
|
|
||||||
|
// 当前 swipe ID(用于多版本切换)
|
||||||
|
currentSwipeId: {},
|
||||||
|
|
||||||
|
// 输入框高度
|
||||||
|
inputHeight: 42,
|
||||||
|
|
||||||
|
// ==================== Actions ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始编辑消息
|
||||||
|
* @param {string|number} messageId - 消息 ID
|
||||||
|
* @param {string} content - 消息内容
|
||||||
|
*/
|
||||||
|
startEditing: (messageId, content) => {
|
||||||
|
set({
|
||||||
|
editingId: messageId,
|
||||||
|
editContent: content
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消编辑
|
||||||
|
*/
|
||||||
|
cancelEditing: () => {
|
||||||
|
set({
|
||||||
|
editingId: null,
|
||||||
|
editContent: ''
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新编辑内容
|
||||||
|
* @param {string} content - 新内容
|
||||||
|
*/
|
||||||
|
updateEditContent: (content) => {
|
||||||
|
set({ editContent: content });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置输入框内容
|
||||||
|
* @param {string} value - 输入值
|
||||||
|
*/
|
||||||
|
setInputValue: (value) => {
|
||||||
|
set({ inputValue: value });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空输入框
|
||||||
|
*/
|
||||||
|
clearInput: () => {
|
||||||
|
set({ inputValue: '' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换选项面板
|
||||||
|
*/
|
||||||
|
toggleOptions: () => {
|
||||||
|
set((state) => ({ showOptions: !state.showOptions }));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置选项面板显示状态
|
||||||
|
* @param {boolean} show - 是否显示
|
||||||
|
*/
|
||||||
|
setShowOptions: (show) => {
|
||||||
|
set({ showOptions: show });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换聊天选择器
|
||||||
|
*/
|
||||||
|
toggleChatSelector: () => {
|
||||||
|
set((state) => ({ showChatSelector: !state.showChatSelector }));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置聊天选择器显示状态
|
||||||
|
* @param {boolean} show - 是否显示
|
||||||
|
*/
|
||||||
|
setShowChatSelector: (show) => {
|
||||||
|
set({ showChatSelector: show });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置角色聊天列表
|
||||||
|
* @param {Array} chats - 聊天列表
|
||||||
|
*/
|
||||||
|
setCharacterChats: (chats) => {
|
||||||
|
set({ characterChats: chats });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前 swipe ID
|
||||||
|
* @param {Object} swipeId - swipe ID 对象 { [messageId]: swipeIndex }
|
||||||
|
*/
|
||||||
|
setCurrentSwipeId: (swipeId) => {
|
||||||
|
set((state) => ({
|
||||||
|
currentSwipeId: { ...state.currentSwipeId, ...swipeId }
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置输入框高度
|
||||||
|
* @param {number} height - 高度(px)
|
||||||
|
*/
|
||||||
|
setInputHeight: (height) => {
|
||||||
|
set({ inputHeight: height });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置所有 UI 状态
|
||||||
|
*/
|
||||||
|
reset: () => {
|
||||||
|
set({
|
||||||
|
editingId: null,
|
||||||
|
editContent: '',
|
||||||
|
inputValue: '',
|
||||||
|
showOptions: false,
|
||||||
|
showChatSelector: false,
|
||||||
|
characterChats: [],
|
||||||
|
currentSwipeId: {},
|
||||||
|
inputHeight: 42
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useChatBoxUIStore;
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
// Mid 区域相关的 Store
|
// Mid 区域相关的 Store
|
||||||
export { default as useChatBoxStore } from './ChatBoxSlice';
|
export { default as useChatBoxStore } from './ChatBoxSlice';
|
||||||
|
export { default as useChatBoxUIStore } from './ChatBoxUISlice';
|
||||||
|
|||||||
130
frontend/src/Store/SideBarLeft/CharacterCardUISlice.jsx
Normal file
130
frontend/src/Store/SideBarLeft/CharacterCardUISlice.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CharacterCard UI 状态 Store
|
||||||
|
* 管理角色卡列表的 UI 交互状态(非持久化)
|
||||||
|
*/
|
||||||
|
const useCharacterCardUIStore = create((set) => ({
|
||||||
|
// ==================== 状态 ====================
|
||||||
|
|
||||||
|
// 筛选标签
|
||||||
|
filterTag: '',
|
||||||
|
|
||||||
|
// 是否处于编辑模式
|
||||||
|
isEditing: false,
|
||||||
|
|
||||||
|
// 编辑表单数据
|
||||||
|
editForm: null,
|
||||||
|
|
||||||
|
// 当前页码
|
||||||
|
currentPage: 1,
|
||||||
|
|
||||||
|
// 每页显示数量
|
||||||
|
pageSize: 12,
|
||||||
|
|
||||||
|
// ==================== Actions ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置筛选标签
|
||||||
|
* @param {string} tag - 标签名
|
||||||
|
*/
|
||||||
|
setFilterTag: (tag) => {
|
||||||
|
set({ filterTag: tag, currentPage: 1 }); // 重置页码
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空筛选
|
||||||
|
*/
|
||||||
|
clearFilter: () => {
|
||||||
|
set({ filterTag: '', currentPage: 1 });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进入编辑模式
|
||||||
|
* @param {Object} character - 角色数据
|
||||||
|
*/
|
||||||
|
startEditing: (character) => {
|
||||||
|
set({
|
||||||
|
isEditing: true,
|
||||||
|
editForm: {
|
||||||
|
name: character.name,
|
||||||
|
description: character.description || '',
|
||||||
|
personality: character.personality || '',
|
||||||
|
scenario: character.scenario || '',
|
||||||
|
first_mes: character.first_mes || '',
|
||||||
|
mes_example: character.mes_example || '',
|
||||||
|
categories: character.categories || [],
|
||||||
|
tags: character.tags || []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出编辑模式
|
||||||
|
*/
|
||||||
|
cancelEditing: () => {
|
||||||
|
set({
|
||||||
|
isEditing: false,
|
||||||
|
editForm: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新编辑表单字段
|
||||||
|
* @param {string} field - 字段名
|
||||||
|
* @param {*} value - 字段值
|
||||||
|
*/
|
||||||
|
updateEditForm: (field, value) => {
|
||||||
|
set((state) => ({
|
||||||
|
editForm: {
|
||||||
|
...state.editForm,
|
||||||
|
[field]: value
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置页码
|
||||||
|
* @param {number} page - 页码
|
||||||
|
*/
|
||||||
|
setCurrentPage: (page) => {
|
||||||
|
set({ currentPage: page });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置每页显示数量
|
||||||
|
* @param {number} size - 每页数量
|
||||||
|
*/
|
||||||
|
setPageSize: (size) => {
|
||||||
|
set({ pageSize: size, currentPage: 1 }); // 重置页码
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下一页
|
||||||
|
*/
|
||||||
|
nextPage: () => {
|
||||||
|
set((state) => ({ currentPage: state.currentPage + 1 }));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上一页
|
||||||
|
*/
|
||||||
|
prevPage: () => {
|
||||||
|
set((state) => ({ currentPage: Math.max(1, state.currentPage - 1) }));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置所有 UI 状态
|
||||||
|
*/
|
||||||
|
reset: () => {
|
||||||
|
set({
|
||||||
|
filterTag: '',
|
||||||
|
isEditing: false,
|
||||||
|
editForm: null,
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 12
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useCharacterCardUIStore;
|
||||||
@@ -28,6 +28,10 @@ const usePresetStore = create((set, get) => ({
|
|||||||
// 参数设置折叠状态
|
// 参数设置折叠状态
|
||||||
isParametersExpanded: true,
|
isParametersExpanded: true,
|
||||||
|
|
||||||
|
// 分页状态
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 8,
|
||||||
|
|
||||||
// 预设组件列表
|
// 预设组件列表
|
||||||
promptComponents: [
|
promptComponents: [
|
||||||
{
|
{
|
||||||
@@ -129,17 +133,22 @@ const usePresetStore = create((set, get) => ({
|
|||||||
// 记录原始数据用于调试
|
// 记录原始数据用于调试
|
||||||
console.log('从后端获取的预设数据:', presetData);
|
console.log('从后端获取的预设数据:', presetData);
|
||||||
|
|
||||||
// 提取参数并更新状态,确保所有参数都有默认值
|
// 提取参数并更新状态,支持内部结构和SillyTavern结构
|
||||||
const parameters = {
|
const parameters = {
|
||||||
temperature: presetData.temperature !== undefined ? presetData.temperature : 1.0,
|
temperature: presetData.temperature !== undefined ? presetData.temperature : 1.0,
|
||||||
frequency_penalty: presetData.frequency_penalty !== undefined ? presetData.frequency_penalty : 0.0,
|
frequency_penalty: presetData.frequency_penalty !== undefined ? presetData.frequency_penalty :
|
||||||
presence_penalty: presetData.presence_penalty !== undefined ? presetData.presence_penalty : 0.0,
|
(presetData.frequencyPenalty !== undefined ? presetData.frequencyPenalty : 0.0),
|
||||||
top_p: presetData.top_p !== undefined ? presetData.top_p : 1.0,
|
presence_penalty: presetData.presence_penalty !== undefined ? presetData.presence_penalty :
|
||||||
top_k: presetData.top_k !== undefined ? presetData.top_k : 0,
|
(presetData.presencePenalty !== undefined ? presetData.presencePenalty : 0.0),
|
||||||
|
top_p: presetData.top_p !== undefined ? presetData.top_p :
|
||||||
|
(presetData.topP !== undefined ? presetData.topP : 1.0),
|
||||||
|
top_k: presetData.top_k !== undefined ? presetData.top_k :
|
||||||
|
(presetData.topK !== undefined ? presetData.topK : 0),
|
||||||
max_context: presetData.openai_max_context !== undefined ? presetData.openai_max_context :
|
max_context: presetData.openai_max_context !== undefined ? presetData.openai_max_context :
|
||||||
(presetData.max_context !== undefined ? presetData.max_context : 1000000),
|
(presetData.max_context !== undefined ? presetData.max_context : 1000000),
|
||||||
max_tokens: presetData.openai_max_tokens !== undefined ? presetData.openai_max_tokens :
|
max_tokens: presetData.openai_max_tokens !== undefined ? presetData.openai_max_tokens :
|
||||||
(presetData.max_tokens !== undefined ? presetData.max_tokens : 30000),
|
(presetData.max_tokens !== undefined ? presetData.max_tokens :
|
||||||
|
(presetData.maxLength !== undefined ? presetData.maxLength : 30000)),
|
||||||
max_context_unlocked: presetData.max_context_unlocked !== undefined ? presetData.max_context_unlocked : false,
|
max_context_unlocked: presetData.max_context_unlocked !== undefined ? presetData.max_context_unlocked : false,
|
||||||
stream_openai: presetData.stream_openai !== undefined ? presetData.stream_openai : true,
|
stream_openai: presetData.stream_openai !== undefined ? presetData.stream_openai : true,
|
||||||
seed: presetData.seed !== undefined ? presetData.seed : -1,
|
seed: presetData.seed !== undefined ? presetData.seed : -1,
|
||||||
@@ -149,9 +158,30 @@ const usePresetStore = create((set, get) => ({
|
|||||||
// 记录映射后的参数用于调试
|
// 记录映射后的参数用于调试
|
||||||
console.log('映射后的参数:', parameters);
|
console.log('映射后的参数:', parameters);
|
||||||
|
|
||||||
// 处理预设组件
|
// 处理预设组件 - 支持内部结构和SillyTavern结构
|
||||||
let components = [];
|
let components = [];
|
||||||
if (presetData.prompts && Array.isArray(presetData.prompts)) {
|
|
||||||
|
// 优先使用内部结构的 entries
|
||||||
|
if (presetData.entries && Array.isArray(presetData.entries)) {
|
||||||
|
components = presetData.entries.map(entry => ({
|
||||||
|
identifier: entry.identifier,
|
||||||
|
name: entry.name,
|
||||||
|
content: entry.content || '',
|
||||||
|
enabled: entry.enabled !== false,
|
||||||
|
role: entry.role === 'system' ? 0 : entry.role === 'user' ? 1 : 2,
|
||||||
|
system_prompt: entry.role === 'system',
|
||||||
|
marker: entry.isSystemNode || false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 按 order 排序
|
||||||
|
components.sort((a, b) => {
|
||||||
|
const orderA = presetData.entries.find(e => e.identifier === a.identifier)?.order || 0;
|
||||||
|
const orderB = presetData.entries.find(e => e.identifier === b.identifier)?.order || 0;
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 兼容SillyTavern结构的 prompts
|
||||||
|
else if (presetData.prompts && Array.isArray(presetData.prompts)) {
|
||||||
// 获取当前角色的prompt_order,添加更严格的检查
|
// 获取当前角色的prompt_order,添加更严格的检查
|
||||||
const currentOrder = (presetData.prompt_order &&
|
const currentOrder = (presetData.prompt_order &&
|
||||||
Array.isArray(presetData.prompt_order) &&
|
Array.isArray(presetData.prompt_order) &&
|
||||||
@@ -182,7 +212,6 @@ const usePresetStore = create((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 更新状态,确保参数容器展开
|
// 更新状态,确保参数容器展开
|
||||||
set({
|
set({
|
||||||
selectedPreset: presetId,
|
selectedPreset: presetId,
|
||||||
@@ -209,23 +238,31 @@ const usePresetStore = create((set, get) => ({
|
|||||||
saveCurrentAsPreset: async ({ name }) => {
|
saveCurrentAsPreset: async ({ name }) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
try {
|
try {
|
||||||
// 构建预设数据
|
// 构建预设数据 - 使用内部专有结构
|
||||||
const presetData = {
|
const presetData = {
|
||||||
...state.parameters,
|
// GenerationPreset 部分 - 采样参数
|
||||||
prompts: state.promptComponents.map(component => ({
|
id: `preset_${Date.now()}`,
|
||||||
|
name: name,
|
||||||
|
temperature: state.parameters.temperature,
|
||||||
|
topP: state.parameters.top_p,
|
||||||
|
topK: state.parameters.top_k,
|
||||||
|
frequencyPenalty: state.parameters.frequency_penalty,
|
||||||
|
presencePenalty: state.parameters.presence_penalty,
|
||||||
|
maxLength: state.parameters.max_tokens,
|
||||||
|
isDefault: false,
|
||||||
|
|
||||||
|
// PromptPresetView 部分 - prompt组件列表
|
||||||
|
characterId: 'global', // 全局预设,不绑定特定角色
|
||||||
|
entries: state.promptComponents.map((component, index) => ({
|
||||||
identifier: component.identifier,
|
identifier: component.identifier,
|
||||||
name: component.name,
|
name: component.name,
|
||||||
|
enabled: component.enabled !== false,
|
||||||
content: component.content || '',
|
content: component.content || '',
|
||||||
role: component.role,
|
order: index,
|
||||||
system_prompt: component.system_prompt,
|
role: component.role === 0 ? 'system' : component.role === 1 ? 'user' : 'ai',
|
||||||
marker: component.marker
|
tokenCount: component.content ? component.content.length : 0,
|
||||||
})),
|
isSystemNode: component.marker || false
|
||||||
prompt_order: [{
|
}))
|
||||||
order: state.promptComponents.map(component => ({
|
|
||||||
identifier: component.identifier,
|
|
||||||
enabled: component.enabled !== false
|
|
||||||
}))
|
|
||||||
}]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 发送到后端
|
// 发送到后端
|
||||||
@@ -234,10 +271,7 @@ const usePresetStore = create((set, get) => ({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(presetData)
|
||||||
preset_name: name,
|
|
||||||
...presetData
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -248,7 +282,7 @@ const usePresetStore = create((set, get) => ({
|
|||||||
|
|
||||||
// 添加到本地预设列表
|
// 添加到本地预设列表
|
||||||
const newPreset = {
|
const newPreset = {
|
||||||
id: name,
|
id: presetData.id,
|
||||||
name,
|
name,
|
||||||
description: '',
|
description: '',
|
||||||
component_count: state.promptComponents.length,
|
component_count: state.promptComponents.length,
|
||||||
@@ -257,7 +291,7 @@ const usePresetStore = create((set, get) => ({
|
|||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
presets: [...state.presets, newPreset],
|
presets: [...state.presets, newPreset],
|
||||||
selectedPreset: name
|
selectedPreset: presetData.id
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -350,6 +384,26 @@ const usePresetStore = create((set, get) => ({
|
|||||||
identifier: component.identifier,
|
identifier: component.identifier,
|
||||||
enabled: component.enabled !== false
|
enabled: component.enabled !== false
|
||||||
}));
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// 设置当前页
|
||||||
|
setCurrentPage: (page) => set({ currentPage: page }),
|
||||||
|
|
||||||
|
// 设置每页数量
|
||||||
|
setPageSize: (size) => set({ pageSize: size, currentPage: 1 }),
|
||||||
|
|
||||||
|
// 获取当前页的预设列表
|
||||||
|
getCurrentPagePresets: () => {
|
||||||
|
const { presets, currentPage, pageSize } = get();
|
||||||
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
|
const endIndex = startIndex + pageSize;
|
||||||
|
return presets.slice(startIndex, endIndex);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取总页数
|
||||||
|
getTotalPages: () => {
|
||||||
|
const { presets, pageSize } = get();
|
||||||
|
return Math.ceil(presets.length / pageSize);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export { default as useApiConfigStore } from './ApiConfigSlice';
|
|||||||
export { default as usePresetStore } from './PresetSlice';
|
export { default as usePresetStore } from './PresetSlice';
|
||||||
export { default as useWorldBookStore } from './WorldBookSlice';
|
export { default as useWorldBookStore } from './WorldBookSlice';
|
||||||
export { default as useCharacterStore } from './CharacterSlice';
|
export { default as useCharacterStore } from './CharacterSlice';
|
||||||
|
export { default as useCharacterCardUIStore } from './CharacterCardUISlice';
|
||||||
|
|||||||
255
frontend/src/Store/SideBarRight/TableSlice.jsx
Normal file
255
frontend/src/Store/SideBarRight/TableSlice.jsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态表格 Store
|
||||||
|
* 管理 SillyTavern 风格的标签数据
|
||||||
|
*/
|
||||||
|
const useTableStore = create(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// ==================== 状态 ====================
|
||||||
|
|
||||||
|
// 标签数组
|
||||||
|
tags: [],
|
||||||
|
|
||||||
|
// 当前角色和聊天
|
||||||
|
currentRole: null,
|
||||||
|
currentChat: null,
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
// 最后更新时间
|
||||||
|
lastUpdated: null,
|
||||||
|
|
||||||
|
// ==================== Actions ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载标签数据
|
||||||
|
* @param {string} role - 角色名
|
||||||
|
* @param {string} chat - 聊天名
|
||||||
|
*/
|
||||||
|
loadTags: async (role, chat) => {
|
||||||
|
if (!role) {
|
||||||
|
set({ tags: [], currentRole: null, currentChat: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, currentRole: role, currentChat: chat });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let fetchedTags = [];
|
||||||
|
|
||||||
|
// 优先级1:从聊天文件读取
|
||||||
|
if (chat) {
|
||||||
|
console.log('[TableStore] 从聊天文件读取标签数据');
|
||||||
|
const response = await fetch(`/api/chat/${encodeURIComponent(role)}/${encodeURIComponent(chat)}`);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('获取聊天数据失败');
|
||||||
|
|
||||||
|
const chatData = await response.json();
|
||||||
|
const header = chatData.header || {};
|
||||||
|
|
||||||
|
fetchedTags = header.tags || [];
|
||||||
|
|
||||||
|
if (fetchedTags.length > 0) {
|
||||||
|
set({
|
||||||
|
tags: fetchedTags,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
|
console.log(`[TableStore] ✅ 已加载 ${fetchedTags.length} 个标签`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先级2:从角色卡读取(降级)
|
||||||
|
console.log('[TableStore] 聊天中无标签,从角色卡读取');
|
||||||
|
const charResponse = await fetch(`/api/characters/${encodeURIComponent(role)}`);
|
||||||
|
|
||||||
|
if (!charResponse.ok) throw new Error('获取角色卡失败');
|
||||||
|
|
||||||
|
const charData = await charResponse.json();
|
||||||
|
fetchedTags = charData.tags || [];
|
||||||
|
|
||||||
|
set({
|
||||||
|
tags: fetchedTags,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
|
console.log(`[TableStore] ✅ 已从角色卡加载 ${fetchedTags.length} 个标签`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TableStore] 获取标签数据失败:', error);
|
||||||
|
set({ tags: [], isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存标签到后端(带防抖)
|
||||||
|
* @param {Array} newTags - 新的标签数组
|
||||||
|
*/
|
||||||
|
saveTags: (newTags) => {
|
||||||
|
const { currentRole, currentChat } = get();
|
||||||
|
|
||||||
|
if (!currentRole || !currentChat) {
|
||||||
|
console.warn('[TableStore] 无法保存:缺少角色或聊天信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除之前的定时器(如果存在)
|
||||||
|
if (get().saveTimeoutId) {
|
||||||
|
clearTimeout(get().saveTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟 1.5 秒后保存
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/table`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tags: newTags })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('保存失败');
|
||||||
|
|
||||||
|
set({ lastUpdated: Date.now() });
|
||||||
|
console.log('[TableStore] ✅ 标签已保存到后端');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TableStore] 保存标签失败:', error);
|
||||||
|
alert('保存失败: ' + error.message);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
// 保存定时器 ID
|
||||||
|
set({ saveTimeoutId: timeoutId });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新标签(乐观更新 + 延迟保存)
|
||||||
|
* @param {Array} newTags - 新的标签数组
|
||||||
|
*/
|
||||||
|
updateTags: (newTags) => {
|
||||||
|
set({ tags: newTags });
|
||||||
|
get().saveTags(newTags);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加标签
|
||||||
|
* @param {string|string[]} tagOrTags - 单个标签或标签数组
|
||||||
|
*/
|
||||||
|
addTag: (tagOrTags) => {
|
||||||
|
const { tags } = get();
|
||||||
|
|
||||||
|
// 支持批量添加
|
||||||
|
const newTagsList = Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags];
|
||||||
|
const newTags = [...tags, ...newTagsList];
|
||||||
|
|
||||||
|
get().updateTags(newTags);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除标签
|
||||||
|
* @param {number} index - 标签索引
|
||||||
|
*/
|
||||||
|
deleteTag: (index) => {
|
||||||
|
const { tags } = get();
|
||||||
|
const newTags = tags.filter((_, i) => i !== index);
|
||||||
|
get().updateTags(newTags);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动标签
|
||||||
|
* @param {number} fromIndex - 起始索引
|
||||||
|
* @param {number} toIndex - 目标索引
|
||||||
|
*/
|
||||||
|
moveTag: (fromIndex, toIndex) => {
|
||||||
|
const { tags } = get();
|
||||||
|
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex >= tags.length || toIndex >= tags.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTags = [...tags];
|
||||||
|
[newTags[fromIndex], newTags[toIndex]] = [newTags[toIndex], newTags[fromIndex]];
|
||||||
|
|
||||||
|
get().updateTags(newTags);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 左移标签
|
||||||
|
* @param {number} index - 标签索引
|
||||||
|
*/
|
||||||
|
moveLeft: (index) => {
|
||||||
|
if (index === 0) return;
|
||||||
|
get().moveTag(index, index - 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右移标签
|
||||||
|
* @param {number} index - 标签索引
|
||||||
|
*/
|
||||||
|
moveRight: (index) => {
|
||||||
|
const { tags } = get();
|
||||||
|
if (index === tags.length - 1) return;
|
||||||
|
get().moveTag(index, index + 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑标签
|
||||||
|
* @param {number} index - 标签索引
|
||||||
|
* @param {string} newValue - 新值
|
||||||
|
*/
|
||||||
|
editTag: (index, newValue) => {
|
||||||
|
const { tags } = get();
|
||||||
|
const newTags = [...tags];
|
||||||
|
newTags[index] = newValue.trim();
|
||||||
|
get().updateTags(newTags);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空状态
|
||||||
|
*/
|
||||||
|
clear: () => {
|
||||||
|
// 清除定时器
|
||||||
|
if (get().saveTimeoutId) {
|
||||||
|
clearTimeout(get().saveTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
tags: [],
|
||||||
|
currentRole: null,
|
||||||
|
currentChat: null,
|
||||||
|
isLoading: false,
|
||||||
|
lastUpdated: null,
|
||||||
|
saveTimeoutId: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制刷新(立即从后端重新加载)
|
||||||
|
*/
|
||||||
|
refresh: async () => {
|
||||||
|
const { currentRole, currentChat } = get();
|
||||||
|
if (!currentRole) return;
|
||||||
|
|
||||||
|
await get().loadTags(currentRole, currentChat);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'table-storage', // localStorage key
|
||||||
|
partialize: (state) => ({
|
||||||
|
tags: state.tags,
|
||||||
|
currentRole: state.currentRole,
|
||||||
|
currentChat: state.currentChat,
|
||||||
|
lastUpdated: state.lastUpdated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useTableStore;
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
// SideBarRight 相关的 Store
|
// SideBarRight 相关的 Store
|
||||||
export { default as useSideBarRightStore } from './SideBarRightSlice';
|
export { default as useSideBarRightStore } from './SideBarRightSlice';
|
||||||
|
export { default as useTableStore } from './TableSlice';
|
||||||
|
|||||||
48
frontend/src/Store/UserSlice.jsx
Normal file
48
frontend/src/Store/UserSlice.jsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// frontend-react/src/Store/UserSlice.jsx
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户角色 Store
|
||||||
|
* 管理当前玩家角色信息
|
||||||
|
*/
|
||||||
|
const useUserStore = create(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// 当前用户角色
|
||||||
|
currentUserRole: { name: '', description: '' },
|
||||||
|
|
||||||
|
// 设置用户角色
|
||||||
|
setCurrentUserRole: (role) => {
|
||||||
|
set({ currentUserRole: role });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除用户角色
|
||||||
|
clearCurrentUserRole: () => {
|
||||||
|
set({ currentUserRole: { name: '', description: '' } });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新用户角色名称
|
||||||
|
updateRoleName: (name) => {
|
||||||
|
set((state) => ({
|
||||||
|
currentUserRole: { ...state.currentUserRole, name }
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新用户角色描述
|
||||||
|
updateRoleDescription: (description) => {
|
||||||
|
set((state) => ({
|
||||||
|
currentUserRole: { ...state.currentUserRole, description }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'user-role-storage', // localStorage key
|
||||||
|
partialize: (state) => ({
|
||||||
|
currentUserRole: state.currentUserRole
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useUserStore;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// frontend-react/src/store/index.js
|
// frontend-react/src/store/index.js
|
||||||
// 统一导出所有 Store,方便外部使用
|
// 统一导出所有 Store,方便外部使用
|
||||||
export { useRoleSelectorStore } from './TopBar';
|
export { useRoleSelectorStore } from './TopBar';
|
||||||
export { useSideBarLeftStore, useApiConfigStore, usePresetStore, useWorldBookStore } from './SideBarLeft';
|
export { useSideBarLeftStore, useApiConfigStore, usePresetStore, useWorldBookStore, useCharacterCardUIStore } from './SideBarLeft';
|
||||||
export { useSideBarRightStore } from './SideBarRight';
|
export { useSideBarRightStore, useTableStore } from './SideBarRight';
|
||||||
export { useChatBoxStore } from './Mid';
|
export { useChatBoxStore, useChatBoxUIStore } from './Mid';
|
||||||
|
export { default as useAppLayoutStore } from './AppLayoutSlice';
|
||||||
|
export { default as useUserStore } from './UserSlice'; // ✅ 新增
|
||||||
@@ -53,19 +53,29 @@
|
|||||||
/* 用户消息 - 右侧对齐,用色彩区分 */
|
/* 用户消息 - 右侧对齐,用色彩区分 */
|
||||||
.message.user {
|
.message.user {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
background: linear-gradient(to right, var(--color-accent-ultra-light), var(--color-accent-light));
|
background: linear-gradient(to right, rgba(102, 126, 234, 0.08), rgba(102, 126, 234, 0.15));
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
border-left: 3px solid var(--color-accent);
|
border-left: 3px solid var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AI 消息 - 左侧对齐,用色彩区分 */
|
/* AI 消息 - 左侧对齐,用色彩区分 */
|
||||||
|
/* 参考 Discord/Slack/Notion 的夜间模式配色 */
|
||||||
.message.ai {
|
.message.ai {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
background-color: var(--color-bg-elevated); /* 使用主题的浅色背景形成对比 */
|
/* 日间模式:半透明白色背景 */
|
||||||
|
background-color: rgba(255, 255, 255, 0.6);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 深色主题下的 AI 消息配色优化 */
|
||||||
|
[data-color-theme='dark'] .message.ai {
|
||||||
|
/* 使用深灰色背景,避免纯白色刺眼 */
|
||||||
|
/* 参考 VS Code / Discord 的深色模式 */
|
||||||
|
background-color: rgba(45, 45, 48, 0.5);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.message-container {
|
.message-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,24 +1,38 @@
|
|||||||
// frontend-react/src/components/ChatBox/ChatBox.jsx
|
// frontend-react/src/components/ChatBox/ChatBox.jsx
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
|
import useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
|
||||||
|
import useChatBoxUIStore from '../../../Store/Mid/ChatBoxUISlice'; // ✅ 新增
|
||||||
import MarkdownRenderer from '../../shared/MarkdownRenderer';
|
import MarkdownRenderer from '../../shared/MarkdownRenderer';
|
||||||
import './ChatBox.css';
|
import './ChatBox.css';
|
||||||
|
|
||||||
const ChatBox = () => {
|
const ChatBox = () => {
|
||||||
const [editingId, setEditingId] = useState(null);
|
|
||||||
const [editContent, setEditContent] = useState('');
|
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [showOptions, setShowOptions] = useState(false);
|
|
||||||
const optionsRef = useRef(null);
|
const optionsRef = useRef(null);
|
||||||
|
|
||||||
// 聊天选择器相关状态
|
|
||||||
const [showChatSelector, setShowChatSelector] = useState(false);
|
|
||||||
const [characterChats, setCharacterChats] = useState([]);
|
|
||||||
const chatSelectorRef = useRef(null);
|
const chatSelectorRef = useRef(null);
|
||||||
|
|
||||||
// 新增:管理每条消息的当前显示的swipe版本
|
// ✅ 从 ChatBoxUIStore 获取 UI 状态
|
||||||
const [currentSwipeId, setCurrentSwipeId] = useState({});
|
const {
|
||||||
|
editingId,
|
||||||
|
editContent,
|
||||||
|
inputValue,
|
||||||
|
showOptions,
|
||||||
|
showChatSelector,
|
||||||
|
characterChats,
|
||||||
|
currentSwipeId,
|
||||||
|
inputHeight,
|
||||||
|
startEditing,
|
||||||
|
cancelEditing,
|
||||||
|
updateEditContent,
|
||||||
|
setInputValue,
|
||||||
|
clearInput,
|
||||||
|
toggleOptions,
|
||||||
|
setShowOptions,
|
||||||
|
toggleChatSelector,
|
||||||
|
setShowChatSelector,
|
||||||
|
setCharacterChats,
|
||||||
|
setCurrentSwipeId,
|
||||||
|
setInputHeight
|
||||||
|
} = useChatBoxUIStore();
|
||||||
|
|
||||||
// 从 ChatBoxStore 获取状态和方法
|
// 从 ChatBoxStore 获取状态和方法
|
||||||
const {
|
const {
|
||||||
@@ -35,8 +49,6 @@ const ChatBox = () => {
|
|||||||
toggleOption
|
toggleOption
|
||||||
} = useChatBoxStore();
|
} = useChatBoxStore();
|
||||||
|
|
||||||
const [inputHeight, setInputHeight] = useState(42);
|
|
||||||
|
|
||||||
// 点击外部关闭选项面板
|
// 点击外部关闭选项面板
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
@@ -88,8 +100,8 @@ const ChatBox = () => {
|
|||||||
stopGeneration();
|
stopGeneration();
|
||||||
} else {
|
} else {
|
||||||
sendMessage(inputValue);
|
sendMessage(inputValue);
|
||||||
setInputValue('');
|
clearInput(); // ✅ 使用 store 方法
|
||||||
setInputHeight(24); // 重置为一行高度
|
setInputHeight(42); // ✅ 重置为一行高度
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,22 +124,19 @@ const ChatBox = () => {
|
|||||||
|
|
||||||
// 处理编辑消息
|
// 处理编辑消息
|
||||||
const handleEdit = (message) => {
|
const handleEdit = (message) => {
|
||||||
setEditingId(message.floor);
|
startEditing(message.floor, message.mes); // ✅ 使用 store 方法
|
||||||
setEditContent(message.mes);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存编辑
|
// 保存编辑
|
||||||
const handleSaveEdit = (messageId) => {
|
const handleSaveEdit = (messageId) => {
|
||||||
// 调用 store 中的 updateMessage 方法
|
// 调用 store 中的 updateMessage 方法
|
||||||
updateMessage(messageId, editContent);
|
updateMessage(messageId, editContent);
|
||||||
setEditingId(null);
|
cancelEditing(); // ✅ 使用 store 方法
|
||||||
setEditContent('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 取消编辑
|
// 取消编辑
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
setEditingId(null);
|
cancelEditing(); // ✅ 使用 store 方法
|
||||||
setEditContent('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 新增:处理swipe切换
|
// 新增:处理swipe切换
|
||||||
@@ -140,17 +149,14 @@ const ChatBox = () => {
|
|||||||
const newIndex = currentIndex + direction;
|
const newIndex = currentIndex + direction;
|
||||||
|
|
||||||
if (newIndex >= 0 && newIndex < message.swipes.length) {
|
if (newIndex >= 0 && newIndex < message.swipes.length) {
|
||||||
setCurrentSwipeId(prev => ({
|
setCurrentSwipeId({ [messageId]: newIndex }); // ✅ 使用 store 方法
|
||||||
...prev,
|
|
||||||
[messageId]: newIndex
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换选项显示
|
// 切换选项显示
|
||||||
const toggleOptionsPanel = () => {
|
const toggleOptionsPanel = () => {
|
||||||
setShowOptions(!showOptions);
|
toggleOptions(); // ✅ 使用 store 方法
|
||||||
};
|
};
|
||||||
|
|
||||||
// 打开聊天选择器
|
// 打开聊天选择器
|
||||||
@@ -169,10 +175,10 @@ const ChatBox = () => {
|
|||||||
if (!response.ok) throw new Error('获取聊天列表失败');
|
if (!response.ok) throw new Error('获取聊天列表失败');
|
||||||
|
|
||||||
const chats = await response.json();
|
const chats = await response.json();
|
||||||
setCharacterChats(chats);
|
setCharacterChats(chats); // ✅ 使用 store 方法
|
||||||
|
|
||||||
// 显示聊天选择器弹窗,让用户手动选择
|
// 显示聊天选择器弹窗,让用户手动选择
|
||||||
setShowChatSelector(true);
|
toggleChatSelector(); // ✅ 使用 store 方法
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取聊天列表失败:', error);
|
console.error('获取聊天列表失败:', error);
|
||||||
alert('获取聊天列表失败: ' + error.message);
|
alert('获取聊天列表失败: ' + error.message);
|
||||||
@@ -182,16 +188,13 @@ const ChatBox = () => {
|
|||||||
// 选择聊天并加载
|
// 选择聊天并加载
|
||||||
const handleSelectChat = async (chatName) => {
|
const handleSelectChat = async (chatName) => {
|
||||||
try {
|
try {
|
||||||
const { setChatBoxRoleAndChat, fetchChatHistory, currentRole } = useChatBoxStore.getState();
|
const { setChatBoxRoleAndChat, currentRole } = useChatBoxStore.getState();
|
||||||
|
|
||||||
// 设置当前角色和聊天
|
// 设置当前角色和聊天,这会触发 ChatBoxStore 的监听器自动加载聊天历史
|
||||||
setChatBoxRoleAndChat(currentRole, chatName);
|
setChatBoxRoleAndChat(currentRole, chatName);
|
||||||
|
|
||||||
// 加载聊天历史
|
|
||||||
await fetchChatHistory(currentRole, chatName);
|
|
||||||
|
|
||||||
// 关闭选择器
|
// 关闭选择器
|
||||||
setShowChatSelector(false);
|
setShowChatSelector(false); // ✅ 使用 store 方法
|
||||||
|
|
||||||
console.log(`已切换到聊天: ${chatName}`);
|
console.log(`已切换到聊天: ${chatName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -336,7 +339,7 @@ const ChatBox = () => {
|
|||||||
<button
|
<button
|
||||||
className={`options-toggle ${showOptions ? 'active' : ''}`}
|
className={`options-toggle ${showOptions ? 'active' : ''}`}
|
||||||
title="Toggle Options"
|
title="Toggle Options"
|
||||||
onClick={() => setShowOptions(!showOptions)}
|
onClick={toggleOptions} // ✅ 使用 store 方法
|
||||||
>
|
>
|
||||||
{showOptions ? '×' : '≡'}
|
{showOptions ? '×' : '≡'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import './SideBarLeft.css';
|
import './SideBarLeft.css';
|
||||||
import { useSideBarLeftStore } from '../../Store/indexStore';
|
import { useSideBarLeftStore } from '../../Store/indexStore';
|
||||||
import useSideBarRightStore from '../../Store/SideBarLeft/SideBarLeftSlice';
|
import usePresetStore from '../../Store/SideBarLeft/PresetSlice';
|
||||||
import Gallery from './tabs/Gallery';
|
import Gallery from './tabs/Gallery';
|
||||||
import CharacterCard from './tabs/CharacterCard';
|
import CharacterCard from './tabs/CharacterCard';
|
||||||
import ApiConfig from './tabs/ApiConfig';
|
import ApiConfig from './tabs/ApiConfig';
|
||||||
@@ -11,6 +11,17 @@ import WorldBook from './tabs/WorldBook';
|
|||||||
|
|
||||||
const SideBarLeft = () => {
|
const SideBarLeft = () => {
|
||||||
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
|
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
|
||||||
|
const { fetchPresets } = usePresetStore();
|
||||||
|
|
||||||
|
// 处理标签切换
|
||||||
|
const handleTabClick = (tabId) => {
|
||||||
|
setActiveTab(tabId);
|
||||||
|
|
||||||
|
// 如果切换到预设标签,刷新预设列表
|
||||||
|
if (tabId === 'presets') {
|
||||||
|
fetchPresets();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar-left">
|
<div className="sidebar-left">
|
||||||
@@ -19,7 +30,7 @@ const SideBarLeft = () => {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
|
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => handleTabClick(tab.id)}
|
||||||
title={tab.title}
|
title={tab.title}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* ApiConfig - Compact Modern Design */
|
/* ApiConfig - Compact Modern Design */
|
||||||
.api-config-container {
|
.api-config-container {
|
||||||
padding: var(--spacing-md);
|
padding: 0; /* sidebar-content已有padding,这里不需要 */
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 6px;
|
padding: 0; /* sidebar-content已有padding,这里不需要 */
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -101,8 +101,8 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); /* 减小最小宽度,让窄屏也能显示2列 */
|
||||||
gap: 6px;
|
gap: 8px; /* 增加间距,让卡片更有呼吸感 */
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +110,8 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 140px; /* 固定宽度 */
|
width: 100%; /* 改为自适应宽度 */
|
||||||
height: 200px; /* 固定高度 */
|
height: 170px; /* 减小高度,从200px降到170px */
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
border: 2px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
/* 角色头像 */
|
/* 角色头像 */
|
||||||
.character-avatar-wrapper {
|
.character-avatar-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 3/4;
|
aspect-ratio: 3/4; /* 保持3:4比例 */
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -170,24 +170,24 @@
|
|||||||
|
|
||||||
/* 角色信息 */
|
/* 角色信息 */
|
||||||
.character-info {
|
.character-info {
|
||||||
padding: 6px;
|
padding: 5px; /* 减小padding,从6px降到5px */
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 50px;
|
min-height: 40px; /* 减小最小高度,从50px降到40px */
|
||||||
/* 移除 user-select,因为已经在父元素设置了 */
|
/* 移除 user-select,因为已经在父元素设置了 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-name {
|
.character-name {
|
||||||
font-size: 12px;
|
font-size: 11px; /* 稍微减小字体,从12px降到11px */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
margin-bottom: 3px;
|
margin-bottom: 2px; /* 减小间距 */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-desc {
|
.character-desc {
|
||||||
font-size: 10px;
|
font-size: 9px; /* 减小字体,从10px降到9px */
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-top: 3px;
|
margin-top: 2px; /* 减小间距,从3px降到2px */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
@@ -280,18 +280,26 @@
|
|||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--color-border-light);
|
border: 1px solid var(--color-border-light);
|
||||||
|
flex-wrap: wrap; /* 允许换行 */
|
||||||
|
gap: 6px; /* 添加间距 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-title {
|
.edit-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
flex: 1; /* 占据剩余空间 */
|
||||||
|
min-width: 120px; /* 最小宽度,防止过度压缩 */
|
||||||
|
white-space: nowrap; /* 不换行 */
|
||||||
|
overflow: hidden; /* 超出隐藏 */
|
||||||
|
text-overflow: ellipsis; /* 显示省略号 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-actions {
|
.edit-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap; /* 按钮也可以换行 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-format-selector {
|
.export-format-selector {
|
||||||
@@ -409,6 +417,12 @@
|
|||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 聊天选择器弹窗 */
|
/* 聊天选择器弹窗 */
|
||||||
.chat-selector-overlay {
|
.chat-selector-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
1// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx
|
1// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx
|
||||||
import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; // ✅ 保留 useState 用于 exportFormat
|
||||||
import { useCharacterStore } from '../../../../Store/SideBarLeft';
|
import { useCharacterStore, useCharacterCardUIStore } from '../../../../Store/SideBarLeft'; // ✅ 新增
|
||||||
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
|
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
|
||||||
import './CharacterCard.css';
|
import './CharacterCard.css';
|
||||||
|
|
||||||
@@ -76,23 +76,32 @@ const CharacterCard = () => {
|
|||||||
selectedCharacter,
|
selectedCharacter,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
currentPage,
|
|
||||||
pageSize,
|
|
||||||
characterChats,
|
characterChats,
|
||||||
fetchCharacters,
|
fetchCharacters,
|
||||||
selectCharacter,
|
selectCharacter,
|
||||||
deleteCharacter,
|
deleteCharacter,
|
||||||
exportCharacterAsPng,
|
exportCharacterAsPng,
|
||||||
setCurrentPage,
|
|
||||||
setPageSize,
|
|
||||||
getCurrentPageCharacters,
|
|
||||||
getTotalPages,
|
|
||||||
fetchCharacterChats
|
fetchCharacterChats
|
||||||
} = useCharacterStore();
|
} = useCharacterStore();
|
||||||
|
|
||||||
const [filterTag, setFilterTag] = useState('');
|
// ✅ 从 CharacterCardUIStore 获取 UI 状态
|
||||||
const [isEditing, setIsEditing] = useState(false); // 是否处于编辑模式
|
const {
|
||||||
const [editForm, setEditForm] = useState(null); // 编辑表单数据
|
filterTag,
|
||||||
|
isEditing,
|
||||||
|
editForm,
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
setFilterTag,
|
||||||
|
clearFilter,
|
||||||
|
startEditing,
|
||||||
|
cancelEditing,
|
||||||
|
updateEditForm,
|
||||||
|
setCurrentPage,
|
||||||
|
setPageSize,
|
||||||
|
nextPage,
|
||||||
|
prevPage
|
||||||
|
} = useCharacterCardUIStore();
|
||||||
|
|
||||||
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
|
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const clickTimeoutRef = useRef(null);
|
const clickTimeoutRef = useRef(null);
|
||||||
@@ -117,11 +126,11 @@ const CharacterCard = () => {
|
|||||||
? characters.filter(char => (char.tags || []).includes(filterTag))
|
? characters.filter(char => (char.tags || []).includes(filterTag))
|
||||||
: characters;
|
: characters;
|
||||||
|
|
||||||
// 获取当前页的角色数据
|
// ✅ 计算当前页和总页数
|
||||||
const currentPageCharacters = getCurrentPageCharacters();
|
const totalPages = Math.ceil(filteredCharacters.length / pageSize) || 1;
|
||||||
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
// 获取总页数
|
const endIndex = startIndex + pageSize;
|
||||||
const totalPages = getTotalPages();
|
const currentPageCharacters = filteredCharacters.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// 处理导入文件
|
// 处理导入文件
|
||||||
const handleImport = async (event) => {
|
const handleImport = async (event) => {
|
||||||
@@ -161,7 +170,7 @@ const CharacterCard = () => {
|
|||||||
first_mes: '',
|
first_mes: '',
|
||||||
mes_example: '',
|
mes_example: '',
|
||||||
categories: [],
|
categories: [],
|
||||||
tags: []
|
tags: [] // ✅ 使用标签数组
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('创建失败:', err);
|
console.error('创建失败:', err);
|
||||||
@@ -247,17 +256,7 @@ const CharacterCard = () => {
|
|||||||
selectCharacter(character);
|
selectCharacter(character);
|
||||||
|
|
||||||
// 进入编辑模式
|
// 进入编辑模式
|
||||||
setIsEditing(true);
|
startEditing(character); // ✅ 使用 store 方法
|
||||||
setEditForm({
|
|
||||||
name: character.name,
|
|
||||||
description: character.description || '',
|
|
||||||
personality: character.personality || '',
|
|
||||||
scenario: character.scenario || '',
|
|
||||||
first_mes: character.first_mes || '',
|
|
||||||
mes_example: character.mes_example || '',
|
|
||||||
categories: character.categories || [],
|
|
||||||
tags: character.tags || []
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取该角色的聊天列表,并自动切换到最后一个聊天
|
// 获取该角色的聊天列表,并自动切换到最后一个聊天
|
||||||
try {
|
try {
|
||||||
@@ -267,38 +266,19 @@ const CharacterCard = () => {
|
|||||||
if (chats.length > 0) {
|
if (chats.length > 0) {
|
||||||
// 有聊天记录,切换到最后一个
|
// 有聊天记录,切换到最后一个
|
||||||
const lastChat = chats[chats.length - 1].chat_name;
|
const lastChat = chats[chats.length - 1].chat_name;
|
||||||
|
|
||||||
|
// 先设置角色和聊天,这会触发 ChatBoxStore 的监听器自动加载聊天历史
|
||||||
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, lastChat);
|
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, lastChat);
|
||||||
|
|
||||||
console.log(`[CharacterCard] 已切换到角色 ${character.name} 的聊天: ${lastChat}`);
|
console.log(`[CharacterCard] 已切换到角色 ${character.name} 的聊天: ${lastChat}`);
|
||||||
} else {
|
} else {
|
||||||
// 没有聊天记录,自动创建一个默认聊天
|
// 没有聊天记录,只设置角色,不创建聊天文件
|
||||||
console.log(`[CharacterCard] 角色 ${character.name} 没有聊天,创建默认聊天...`);
|
// 聊天文件将在用户发送第一条消息时创建
|
||||||
|
console.log(`[CharacterCard] 角色 ${character.name} 没有聊天,等待用户发送第一条消息...`);
|
||||||
|
|
||||||
const defaultChatName = '默认聊天';
|
// 只设置角色,不设置聊天(currentChat 为 null)
|
||||||
|
// ChatBox 会显示开场白(临时消息),但不会保存到文件
|
||||||
try {
|
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, null);
|
||||||
const createResponse = await fetch(`/api/chat/${encodeURIComponent(character.name)}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
chat_name: defaultChatName,
|
|
||||||
metadata: {
|
|
||||||
user_name: 'User',
|
|
||||||
character_name: character.name
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (createResponse.ok || createResponse.status === 400) {
|
|
||||||
// 400 表示聊天已存在,也可以直接使用
|
|
||||||
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, defaultChatName);
|
|
||||||
console.log(`[CharacterCard] 已为角色 ${character.name} 设置聊天: ${defaultChatName}`);
|
|
||||||
} else {
|
|
||||||
console.error('[CharacterCard] 创建默认聊天失败:', await createResponse.text());
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[CharacterCard] 创建聊天异常:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -312,8 +292,7 @@ const CharacterCard = () => {
|
|||||||
|
|
||||||
// 退出编辑模式
|
// 退出编辑模式
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
setIsEditing(false);
|
cancelEditing(); // ✅ 使用 store 方法
|
||||||
setEditForm(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存编辑
|
// 保存编辑
|
||||||
@@ -323,8 +302,7 @@ const CharacterCard = () => {
|
|||||||
try {
|
try {
|
||||||
const { updateCharacter } = useCharacterStore.getState();
|
const { updateCharacter } = useCharacterStore.getState();
|
||||||
await updateCharacter(selectedCharacter.name, editForm);
|
await updateCharacter(selectedCharacter.name, editForm);
|
||||||
setIsEditing(false);
|
cancelEditing(); // ✅ 使用 store 方法
|
||||||
setEditForm(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('保存失败:', err);
|
console.error('保存失败:', err);
|
||||||
alert('保存失败: ' + err.message);
|
alert('保存失败: ' + err.message);
|
||||||
@@ -333,10 +311,7 @@ const CharacterCard = () => {
|
|||||||
|
|
||||||
// 处理表单字段变化
|
// 处理表单字段变化
|
||||||
const handleFormChange = (field, value) => {
|
const handleFormChange = (field, value) => {
|
||||||
setEditForm(prev => ({
|
updateEditForm(field, value); // ✅ 使用 store 方法
|
||||||
...prev,
|
|
||||||
[field]: value
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -485,6 +460,24 @@ const CharacterCard = () => {
|
|||||||
placeholder="标签1, 标签2, 标签3"
|
placeholder="标签1, 标签2, 标签3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>动态表格标签 (SillyTavern 关键字机制)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.tags?.join(', ') || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
// 支持多种分隔符:逗号、分号、空格
|
||||||
|
const tagsList = e.target.value
|
||||||
|
.split(/[,;\s]+/)
|
||||||
|
.map(tag => tag.trim())
|
||||||
|
.filter(tag => tag !== '');
|
||||||
|
handleFormChange('tags', tagsList);
|
||||||
|
}}
|
||||||
|
placeholder="力量:80, 敏捷:65, 智力:90, HP:100"
|
||||||
|
/>
|
||||||
|
<small className="form-hint">每个标签是一个键值对,用逗号/分号/空格分隔。双击右侧表格中的标签可编辑。</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -514,7 +507,7 @@ const CharacterCard = () => {
|
|||||||
<div className="pagination-controls">
|
<div className="pagination-controls">
|
||||||
<button
|
<button
|
||||||
className="pagination-btn"
|
className="pagination-btn"
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
onClick={prevPage} // ✅ 使用 store 方法
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
>
|
>
|
||||||
上一页
|
上一页
|
||||||
@@ -526,7 +519,7 @@ const CharacterCard = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="pagination-btn"
|
className="pagination-btn"
|
||||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
onClick={nextPage} // ✅ 使用 store 方法
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
>
|
>
|
||||||
下一页
|
下一页
|
||||||
@@ -535,7 +528,7 @@ const CharacterCard = () => {
|
|||||||
<select
|
<select
|
||||||
className="page-size-selector"
|
className="page-size-selector"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
onChange={(e) => setPageSize(Number(e.target.value))} // ✅ 使用 store 方法
|
||||||
>
|
>
|
||||||
<option value={8}>8条/页</option>
|
<option value={8}>8条/页</option>
|
||||||
<option value={12}>12条/页</option>
|
<option value={12}>12条/页</option>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* 主容器 */
|
/* 主容器 - sidebar-content已有padding,这里不需要 */
|
||||||
.preset-panel {
|
.preset-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 12px;
|
padding: 0;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
background: var(--color-bg-subtle);
|
background: var(--color-bg-subtle);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -25,34 +25,27 @@
|
|||||||
margin-top: -6px;
|
margin-top: -6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 预设头部 */
|
/* 预设头部 - 紧凑布局 */
|
||||||
.preset-header {
|
.preset-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
padding: 10px;
|
padding: 6px 8px;
|
||||||
background: var(--color-bg-elevated);
|
background: var(--color-bg-elevated);
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-select-container {
|
.preset-select-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-select {
|
.preset-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 6px 10px;
|
padding: 5px 8px;
|
||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -79,15 +72,14 @@
|
|||||||
color: #adb5bd;
|
color: #adb5bd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 操作按钮 */
|
/* 操作下拉菜单 */
|
||||||
.preset-actions {
|
.actions-dropdown {
|
||||||
display: flex;
|
position: relative;
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-action-btn {
|
.dropdown-toggle {
|
||||||
width: 30px;
|
width: 28px;
|
||||||
height: 30px;
|
height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -97,17 +89,54 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-action-btn:hover {
|
.dropdown-toggle:hover {
|
||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
border-color: var(--color-border-focus);
|
border-color: var(--color-border-focus);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-action-btn:active {
|
.dropdown-menu {
|
||||||
transform: translateY(0);
|
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: 100;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
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.2s ease;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 对话框 */
|
/* 对话框 */
|
||||||
@@ -293,10 +322,10 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 参数设置区域 */
|
/* 参数设置区域 - 紧凑布局 */
|
||||||
.preset-parameters-container {
|
.preset-parameters-container {
|
||||||
background: var(--color-bg-elevated);
|
background: var(--color-bg-elevated);
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -305,10 +334,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 12px;
|
padding: 8px 10px;
|
||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
|
gap: 8px; /* 标题和参数之间的间距 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameters-header:hover {
|
.parameters-header:hover {
|
||||||
@@ -316,9 +346,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.parameters-header span:first-child {
|
.parameters-header span:first-child {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
|
flex-shrink: 0; /* 不压缩标题 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题栏中的参数 */
|
||||||
|
.header-parameter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1; /* 占据中间空间 */
|
||||||
|
justify-content: center; /* 居中显示 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-param-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
cursor: help;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-param-input {
|
||||||
|
width: 45px;
|
||||||
|
padding: 3px 4px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-param-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4a6cf7;
|
||||||
|
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand-icon {
|
.expand-icon {
|
||||||
@@ -332,162 +397,62 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preset-parameters {
|
.preset-parameters {
|
||||||
padding: 12px;
|
padding: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); /* 减小最小宽度,让窄屏也能显示多列 */
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-row {
|
.parameter-row-compact {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: row; /* 改为横向布局 */
|
||||||
gap: 10px;
|
align-items: center; /* 垂直居中 */
|
||||||
margin-bottom: 12px;
|
justify-content: space-between; /* 两端对齐 */
|
||||||
|
gap: 4px; /* 参数名和输入框之间的间距 */
|
||||||
|
min-width: 0; /* 允许收缩 */
|
||||||
|
padding: 2px 4px; /* 内边距 */
|
||||||
|
background: var(--color-bg-tertiary); /* 添加背景色,形成容器感 */
|
||||||
|
border: 1px solid var(--color-border); /* 添加边框 */
|
||||||
|
border-radius: 4px; /* 圆角 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-row:last-child {
|
.parameter-label-compact {
|
||||||
margin-bottom: 0;
|
font-size: 11px;
|
||||||
}
|
|
||||||
|
|
||||||
.parameter-label {
|
|
||||||
width: 100px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #495057;
|
color: #495057;
|
||||||
|
cursor: help;
|
||||||
|
white-space: nowrap; /* 防止参数名换行 */
|
||||||
|
overflow: hidden; /* 超出隐藏 */
|
||||||
|
text-overflow: ellipsis; /* 省略号 */
|
||||||
|
flex: 1; /* 占据剩余空间 */
|
||||||
|
min-width: 0; /* 允许收缩 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-slider {
|
.parameter-input-compact {
|
||||||
flex: 1;
|
width: 45px; /* 固定小宽度 */
|
||||||
-webkit-appearance: none;
|
padding: 3px 4px; /* 减小padding */
|
||||||
height: 4px;
|
background: var(--color-bg-primary);
|
||||||
background: #e9ecef;
|
|
||||||
border-radius: 2px;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parameter-slider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
background: #4a6cf7;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.parameter-slider::-webkit-slider-thumb:hover {
|
|
||||||
background: #3a5ce5;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.parameter-slider::-moz-range-thumb {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
background: #4a6cf7;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.parameter-slider::-moz-range-thumb:hover {
|
|
||||||
background: #3a5ce5;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.parameter-number {
|
|
||||||
width: 60px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0; /* 不压缩输入框 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-number:focus {
|
.parameter-input-compact:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #4a6cf7;
|
border-color: #4a6cf7;
|
||||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.parameter-input {
|
/* 预设组件区域 - 减少padding */
|
||||||
flex: 1;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parameter-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #4a6cf7;
|
|
||||||
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.parameter-toggles {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-checkbox {
|
|
||||||
width: 38px;
|
|
||||||
height: 20px;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
background: #e9ecef;
|
|
||||||
border-radius: 10px;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-checkbox::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
border-radius: 50%;
|
|
||||||
top: 2px;
|
|
||||||
left: 2px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-checkbox:checked {
|
|
||||||
background: #4a6cf7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-checkbox:checked::after {
|
|
||||||
left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 预设组件区域 */
|
|
||||||
.preset-components-section {
|
.preset-components-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--color-bg-elevated);
|
background: var(--color-bg-elevated);
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -496,25 +461,25 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 12px;
|
padding: 8px 10px;
|
||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.components-header h3 {
|
.components-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-component-btn {
|
.add-component-btn {
|
||||||
padding: 4px 10px;
|
padding: 3px 8px;
|
||||||
background: #4a6cf7;
|
background: #4a6cf7;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@@ -529,7 +494,7 @@
|
|||||||
.components-list {
|
.components-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 6px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-indicator {
|
.drag-indicator {
|
||||||
@@ -548,11 +513,11 @@
|
|||||||
.prompt-component-item {
|
.prompt-component-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 10px;
|
padding: 6px 8px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 4px;
|
||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
@@ -587,14 +552,14 @@
|
|||||||
.component-controls {
|
.component-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
color: #adb5bd;
|
color: #adb5bd;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -604,8 +569,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
width: 20px;
|
width: 18px;
|
||||||
height: 20px;
|
height: 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -613,7 +578,7 @@
|
|||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +598,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.component-name {
|
.component-name {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -688,6 +653,69 @@
|
|||||||
border-color: #ff6b6b;
|
border-color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 分页控件 */
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn {
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border-color: var(--color-border-focus);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-selector {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-selector:hover {
|
||||||
|
border-color: var(--color-border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-size-selector:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
/* 滚动条样式 */
|
/* 滚动条样式 */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import usePresetStore from '../../../../Store/SideBarLeft/PresetSlice';
|
import usePresetStore from '../../../../Store/SideBarLeft/PresetSlice';
|
||||||
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice';
|
|
||||||
import './Presets.css';
|
import './Presets.css';
|
||||||
|
|
||||||
const PresetPanel = () => {
|
const PresetPanel = () => {
|
||||||
@@ -10,6 +9,8 @@ const PresetPanel = () => {
|
|||||||
presets,
|
presets,
|
||||||
isLoadingPresets,
|
isLoadingPresets,
|
||||||
promptComponents,
|
promptComponents,
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
setSelectedPreset,
|
setSelectedPreset,
|
||||||
updateParameter,
|
updateParameter,
|
||||||
saveCurrentAsPreset,
|
saveCurrentAsPreset,
|
||||||
@@ -22,13 +23,18 @@ const PresetPanel = () => {
|
|||||||
updateComponent,
|
updateComponent,
|
||||||
addComponent,
|
addComponent,
|
||||||
removeComponent,
|
removeComponent,
|
||||||
moveComponent
|
moveComponent,
|
||||||
|
setCurrentPage,
|
||||||
|
setPageSize,
|
||||||
|
getCurrentPagePresets,
|
||||||
|
getTotalPages
|
||||||
} = usePresetStore();
|
} = usePresetStore();
|
||||||
|
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
const [showComponentEditDialog, setShowComponentEditDialog] = useState(false);
|
const [showComponentEditDialog, setShowComponentEditDialog] = useState(false);
|
||||||
|
const [showActionsMenu, setShowActionsMenu] = useState(false);
|
||||||
const [newPresetName, setNewPresetName] = useState('');
|
const [newPresetName, setNewPresetName] = useState('');
|
||||||
const [editPresetId, setEditPresetId] = useState('');
|
const [editPresetId, setEditPresetId] = useState('');
|
||||||
const [editPresetName, setEditPresetName] = useState('');
|
const [editPresetName, setEditPresetName] = useState('');
|
||||||
@@ -44,27 +50,50 @@ const PresetPanel = () => {
|
|||||||
const [draggedItem, setDraggedItem] = useState(null);
|
const [draggedItem, setDraggedItem] = useState(null);
|
||||||
const [dragOverItem, setDragOverItem] = useState(null);
|
const [dragOverItem, setDragOverItem] = useState(null);
|
||||||
|
|
||||||
// 获取当前激活的分页
|
// 点击外部关闭下拉菜单
|
||||||
const { activeTab } = useSideBarLeftStore();
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
// 记录上一次的分页状态
|
if (showActionsMenu && !event.target.closest('.actions-dropdown')) {
|
||||||
const prevActiveTabRef = React.useRef(activeTab);
|
setShowActionsMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showActionsMenu]);
|
||||||
|
|
||||||
// 参数描述映射
|
// 计算分页数据
|
||||||
|
const currentPagePresets = getCurrentPagePresets();
|
||||||
|
const totalPages = getTotalPages();
|
||||||
|
|
||||||
|
// 参数描述映射(全名用于悬浮提示)
|
||||||
const parameterDescriptions = {
|
const parameterDescriptions = {
|
||||||
temperature: "生成温度,控制随机性(0-2)",
|
temperature: "生成温度,控制随机性(0-2)",
|
||||||
frequency_penalty: "频率惩罚,降低重复token概率",
|
freq_penalty: "频率惩罚,降低重复token概率(-2到2)",
|
||||||
presence_penalty: "存在惩罚,鼓励谈论新话题",
|
pres_penalty: "存在惩罚,鼓励谈论新话题(-2到2)",
|
||||||
top_p: "核采样,控制词汇选择范围",
|
top_p: "核采样,控制词汇选择范围(0-1)",
|
||||||
top_k: "随机采样范围,从概率最高的K个词中选择",
|
top_k: "随机采样范围,从概率最高的K个词中选择",
|
||||||
max_context: "上下文窗口大小(Token上限)",
|
max_ctx: "上下文窗口大小(Token上限)",
|
||||||
max_tokens: "单次回复的最大长度",
|
max_tokens: "单次回复的最大长度",
|
||||||
max_context_unlocked: "是否允许超出限制的上下文",
|
|
||||||
stream_openai: "是否使用流式输出",
|
|
||||||
seed: "随机种子(-1为随机)",
|
seed: "随机种子(-1为随机)",
|
||||||
n: "生成回复的数量"
|
n: "生成回复的数量"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 参数简写映射
|
||||||
|
const parameterLabels = {
|
||||||
|
temperature: "温度",
|
||||||
|
freq_penalty: "频惩",
|
||||||
|
pres_penalty: "存惩",
|
||||||
|
top_p: "Top-P",
|
||||||
|
top_k: "Top-K",
|
||||||
|
max_ctx: "上下文",
|
||||||
|
max_tokens: "MaxTok",
|
||||||
|
seed: "种子",
|
||||||
|
n: "数量"
|
||||||
|
};
|
||||||
|
|
||||||
// 显示工具提示
|
// 显示工具提示
|
||||||
const showTooltip = (event, content) => {
|
const showTooltip = (event, content) => {
|
||||||
setTooltip({
|
setTooltip({
|
||||||
@@ -83,26 +112,25 @@ const PresetPanel = () => {
|
|||||||
// 处理参数更新
|
// 处理参数更新
|
||||||
const handleParameterChange = (name, value) => {
|
const handleParameterChange = (name, value) => {
|
||||||
let convertedValue = value;
|
let convertedValue = value;
|
||||||
if (name === 'temperature' || name === 'frequency_penalty' || name === 'presence_penalty' || name === 'top_p') {
|
if (name === 'temperature' || name === 'freq_penalty' || name === 'pres_penalty' || name === 'top_p') {
|
||||||
convertedValue = parseFloat(value);
|
convertedValue = parseFloat(value);
|
||||||
} else if (name === 'top_k' || name === 'max_context' || name === 'max_tokens' || name === 'seed' || name === 'n') {
|
} else if (name === 'top_k' || name === 'max_ctx' || name === 'max_tokens' || name === 'seed' || name === 'n') {
|
||||||
convertedValue = parseInt(value, 10);
|
convertedValue = parseInt(value, 10);
|
||||||
} else if (name === 'max_context_unlocked' || name === 'stream_openai') {
|
|
||||||
convertedValue = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateParameter({ name, value: convertedValue });
|
// 将简写参数名转换回原始参数名
|
||||||
|
const paramNameMap = {
|
||||||
|
'freq_penalty': 'frequency_penalty',
|
||||||
|
'pres_penalty': 'presence_penalty',
|
||||||
|
'max_ctx': 'max_context'
|
||||||
|
};
|
||||||
|
|
||||||
|
const actualParamName = paramNameMap[name] || name;
|
||||||
|
updateParameter({ name: actualParamName, value: convertedValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载预设列表 - 只在切换到预设分页时刷新
|
// 加载预设列表 - 由 SideBarLeft 标签切换时触发
|
||||||
useEffect(() => {
|
// 注意:fetchPresets 已在 SideBarLeft.jsx 的 handleTabClick 中调用
|
||||||
// 检测是否从其他分页切换到预设分页
|
|
||||||
if (activeTab === 'presets' && prevActiveTabRef.current !== 'presets') {
|
|
||||||
fetchPresets();
|
|
||||||
}
|
|
||||||
// 更新上一次的分页状态
|
|
||||||
prevActiveTabRef.current = activeTab;
|
|
||||||
}, [activeTab, fetchPresets]);
|
|
||||||
|
|
||||||
// 保存当前设置为预设
|
// 保存当前设置为预设
|
||||||
const handleSavePreset = async () => {
|
const handleSavePreset = async () => {
|
||||||
@@ -111,7 +139,8 @@ const PresetPanel = () => {
|
|||||||
await saveCurrentAsPreset({ name: newPresetName });
|
await saveCurrentAsPreset({ name: newPresetName });
|
||||||
setNewPresetName('');
|
setNewPresetName('');
|
||||||
setShowSaveDialog(false);
|
setShowSaveDialog(false);
|
||||||
// 重新加载预设列表
|
// 重新加载预设列表并重置到第一页
|
||||||
|
setCurrentPage(1);
|
||||||
fetchPresets();
|
fetchPresets();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存预设失败:', error);
|
console.error('保存预设失败:', error);
|
||||||
@@ -141,21 +170,73 @@ const PresetPanel = () => {
|
|||||||
const handleImportPreset = async () => {
|
const handleImportPreset = async () => {
|
||||||
try {
|
try {
|
||||||
const importedPreset = JSON.parse(importPresetData);
|
const importedPreset = JSON.parse(importPresetData);
|
||||||
if (importedPreset.name && importedPreset.parameters) {
|
|
||||||
await saveCurrentAsPreset({ name: importedPreset.name });
|
// 支持内部结构和SillyTavern结构
|
||||||
|
let presetName = '';
|
||||||
|
let importedParameters = {};
|
||||||
|
let importedComponents = [];
|
||||||
|
|
||||||
|
// 检测是否为内部结构(有 entries 字段)
|
||||||
|
if (importedPreset.entries && Array.isArray(importedPreset.entries)) {
|
||||||
|
// 内部结构
|
||||||
|
presetName = importedPreset.name;
|
||||||
|
importedParameters = {
|
||||||
|
temperature: importedPreset.temperature,
|
||||||
|
top_p: importedPreset.topP,
|
||||||
|
top_k: importedPreset.topK,
|
||||||
|
frequency_penalty: importedPreset.frequencyPenalty,
|
||||||
|
presence_penalty: importedPreset.presencePenalty,
|
||||||
|
max_tokens: importedPreset.maxLength
|
||||||
|
};
|
||||||
|
|
||||||
|
// 转换 entries 为 promptComponents 格式
|
||||||
|
importedComponents = importedPreset.entries.map(entry => ({
|
||||||
|
identifier: entry.identifier,
|
||||||
|
name: entry.name,
|
||||||
|
content: entry.content || '',
|
||||||
|
enabled: entry.enabled !== false,
|
||||||
|
role: entry.role === 'system' ? 0 : entry.role === 'user' ? 1 : 2,
|
||||||
|
system_prompt: entry.role === 'system',
|
||||||
|
marker: entry.isSystemNode || false
|
||||||
|
})).sort((a, b) => {
|
||||||
|
const orderA = importedPreset.entries.find(e => e.identifier === a.identifier)?.order || 0;
|
||||||
|
const orderB = importedPreset.entries.find(e => e.identifier === b.identifier)?.order || 0;
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 兼容SillyTavern结构(有 parameters 和 promptComponents 字段)
|
||||||
|
else if (importedPreset.name && (importedPreset.parameters || importedPreset.temperature)) {
|
||||||
|
presetName = importedPreset.name;
|
||||||
|
importedParameters = importedPreset.parameters || {
|
||||||
|
temperature: importedPreset.temperature,
|
||||||
|
frequency_penalty: importedPreset.frequency_penalty,
|
||||||
|
presence_penalty: importedPreset.presence_penalty,
|
||||||
|
top_p: importedPreset.top_p,
|
||||||
|
top_k: importedPreset.top_k,
|
||||||
|
max_tokens: importedPreset.max_tokens || importedPreset.openai_max_tokens
|
||||||
|
};
|
||||||
|
importedComponents = importedPreset.promptComponents || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (presetName) {
|
||||||
|
await saveCurrentAsPreset({ name: presetName });
|
||||||
|
|
||||||
// 更新参数
|
// 更新参数
|
||||||
Object.keys(importedPreset.parameters).forEach(key => {
|
Object.keys(importedParameters).forEach(key => {
|
||||||
updateParameter({ name: key, value: importedPreset.parameters[key] });
|
if (importedParameters[key] !== undefined) {
|
||||||
|
updateParameter({ name: key, value: importedParameters[key] });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新组件列表
|
// 更新组件列表
|
||||||
if (importedPreset.promptComponents) {
|
if (importedComponents.length > 0) {
|
||||||
setPromptComponents(importedPreset.promptComponents);
|
setPromptComponents(importedComponents);
|
||||||
}
|
}
|
||||||
|
|
||||||
setImportPresetData('');
|
setImportPresetData('');
|
||||||
setShowImportDialog(false);
|
setShowImportDialog(false);
|
||||||
// 重新加载预设列表
|
// 重新加载预设列表并重置到第一页
|
||||||
|
setCurrentPage(1);
|
||||||
fetchPresets();
|
fetchPresets();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -169,10 +250,31 @@ const PresetPanel = () => {
|
|||||||
if (selectedPreset) {
|
if (selectedPreset) {
|
||||||
const preset = presets.find(p => p.id === selectedPreset);
|
const preset = presets.find(p => p.id === selectedPreset);
|
||||||
if (preset) {
|
if (preset) {
|
||||||
|
// 使用内部专有结构导出
|
||||||
const exportData = {
|
const exportData = {
|
||||||
...preset,
|
// GenerationPreset 部分
|
||||||
parameters,
|
id: selectedPreset,
|
||||||
promptComponents
|
name: preset.name,
|
||||||
|
temperature: parameters.temperature,
|
||||||
|
topP: parameters.top_p,
|
||||||
|
topK: parameters.top_k,
|
||||||
|
frequencyPenalty: parameters.frequency_penalty,
|
||||||
|
presencePenalty: parameters.presence_penalty,
|
||||||
|
maxLength: parameters.max_tokens,
|
||||||
|
isDefault: false,
|
||||||
|
|
||||||
|
// PromptPresetView 部分
|
||||||
|
characterId: 'global',
|
||||||
|
entries: promptComponents.map((component, index) => ({
|
||||||
|
identifier: component.identifier,
|
||||||
|
name: component.name,
|
||||||
|
enabled: component.enabled !== false,
|
||||||
|
content: component.content || '',
|
||||||
|
order: index,
|
||||||
|
role: component.role === 0 ? 'system' : component.role === 1 ? 'user' : 'ai',
|
||||||
|
tokenCount: component.content ? component.content.length : 0,
|
||||||
|
isSystemNode: component.marker || false
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
const dataStr = JSON.stringify(exportData, null, 2);
|
const dataStr = JSON.stringify(exportData, null, 2);
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||||
@@ -208,11 +310,24 @@ const PresetPanel = () => {
|
|||||||
setShowComponentEditDialog(true);
|
setShowComponentEditDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 查看组件内容
|
// 查看/编辑组件内容 - 实时获取引用内容
|
||||||
const handleViewComponent = (index) => {
|
const handleViewComponent = (index) => {
|
||||||
|
const component = promptComponents[index];
|
||||||
|
|
||||||
|
// 如果是固定组件,实时从其他前端部件获取内容
|
||||||
|
if (component.marker) {
|
||||||
|
// TODO: 根据component.identifier从对应的Store中实时获取内容
|
||||||
|
// 例如:chatHistory -> 从ChatBoxSlice获取当前聊天记录
|
||||||
|
// charDescription -> 从CharacterSlice获取角色描述
|
||||||
|
// 这里先显示提示信息
|
||||||
|
setEditComponentContent(`[实时内容将从 ${component.name} 动态加载]\n\n当前暂不支持预览,该组件会在发送消息时自动从系统获取最新内容。`);
|
||||||
|
} else {
|
||||||
|
// 可编辑组件,显示保存的内容
|
||||||
|
setEditComponentContent(component.content || '');
|
||||||
|
}
|
||||||
|
|
||||||
setEditingComponentIndex(index);
|
setEditingComponentIndex(index);
|
||||||
setEditComponentContent(promptComponents[index].content);
|
setIsEditing(!component.marker); // 固定组件不可编辑
|
||||||
setIsEditing(false);
|
|
||||||
setShowComponentEditDialog(true);
|
setShowComponentEditDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -303,14 +418,7 @@ const PresetPanel = () => {
|
|||||||
{/* 顶部:预设选择与操作 */}
|
{/* 顶部:预设选择与操作 */}
|
||||||
<div className="preset-header">
|
<div className="preset-header">
|
||||||
{/* 预设选择下拉框 */}
|
{/* 预设选择下拉框 */}
|
||||||
<div className="preset-select-container">
|
<div className="preset-select-wrapper">
|
||||||
<label
|
|
||||||
className="preset-label"
|
|
||||||
onMouseEnter={(e) => showTooltip(e, "选择预设配置")}
|
|
||||||
onMouseLeave={hideTooltip}
|
|
||||||
>
|
|
||||||
预设:
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
className="preset-select"
|
className="preset-select"
|
||||||
value={selectedPreset}
|
value={selectedPreset}
|
||||||
@@ -318,55 +426,76 @@ const PresetPanel = () => {
|
|||||||
disabled={isLoadingPresets}
|
disabled={isLoadingPresets}
|
||||||
>
|
>
|
||||||
<option value="">{isLoadingPresets ? "加载中..." : "选择预设..."}</option>
|
<option value="">{isLoadingPresets ? "加载中..." : "选择预设..."}</option>
|
||||||
{presets.map(preset => (
|
{currentPagePresets.map(preset => (
|
||||||
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
|
{/* 操作下拉菜单 */}
|
||||||
{/* 操作按钮 */}
|
<div className="actions-dropdown">
|
||||||
<div className="preset-actions">
|
<button
|
||||||
<button
|
className="dropdown-toggle"
|
||||||
className="preset-action-btn"
|
onClick={() => setShowActionsMenu(!showActionsMenu)}
|
||||||
onClick={() => setShowSaveDialog(true)}
|
onMouseEnter={(e) => showTooltip(e, "更多操作")}
|
||||||
onMouseEnter={(e) => showTooltip(e, "保存当前设置为新预设")}
|
onMouseLeave={hideTooltip}
|
||||||
onMouseLeave={hideTooltip}
|
>
|
||||||
>
|
⚙️
|
||||||
💾
|
</button>
|
||||||
</button>
|
|
||||||
<button
|
{showActionsMenu && (
|
||||||
className="preset-action-btn"
|
<div className="dropdown-menu">
|
||||||
onClick={() => {
|
<button
|
||||||
if (selectedPreset) {
|
className="dropdown-item"
|
||||||
const preset = presets.find(p => p.id === selectedPreset);
|
title="将当前的参数设置和组件配置保存为新的预设"
|
||||||
if (preset) {
|
onClick={() => {
|
||||||
setEditPresetId(selectedPreset);
|
setShowSaveDialog(true);
|
||||||
setEditPresetName(preset.name);
|
setShowActionsMenu(false);
|
||||||
setShowEditDialog(true);
|
}}
|
||||||
}
|
>
|
||||||
}
|
💾 保存为新预设
|
||||||
}}
|
</button>
|
||||||
onMouseEnter={(e) => showTooltip(e, "编辑当前预设")}
|
<button
|
||||||
onMouseLeave={hideTooltip}
|
className="dropdown-item"
|
||||||
>
|
disabled={!selectedPreset}
|
||||||
✏️
|
title="修改当前选中预设的名称"
|
||||||
</button>
|
onClick={() => {
|
||||||
<button
|
if (selectedPreset) {
|
||||||
className="preset-action-btn"
|
const preset = presets.find(p => p.id === selectedPreset);
|
||||||
onClick={() => setShowImportDialog(true)}
|
if (preset) {
|
||||||
onMouseEnter={(e) => showTooltip(e, "导入预设")}
|
setEditPresetId(selectedPreset);
|
||||||
onMouseLeave={hideTooltip}
|
setEditPresetName(preset.name);
|
||||||
>
|
setShowEditDialog(true);
|
||||||
📥
|
}
|
||||||
</button>
|
}
|
||||||
<button
|
setShowActionsMenu(false);
|
||||||
className="preset-action-btn"
|
}}
|
||||||
onClick={handleExportPreset}
|
>
|
||||||
onMouseEnter={(e) => showTooltip(e, "导出当前预设")}
|
✏️ 编辑名称
|
||||||
onMouseLeave={hideTooltip}
|
</button>
|
||||||
>
|
<button
|
||||||
📤
|
className="dropdown-item"
|
||||||
</button>
|
title="从 JSON 文件导入预设配置"
|
||||||
|
onClick={() => {
|
||||||
|
setShowImportDialog(true);
|
||||||
|
setShowActionsMenu(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📥 导入预设
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="dropdown-item"
|
||||||
|
disabled={!selectedPreset}
|
||||||
|
title="将当前预设导出为 JSON 文件"
|
||||||
|
onClick={() => {
|
||||||
|
handleExportPreset();
|
||||||
|
setShowActionsMenu(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📤 导出预设
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -456,29 +585,38 @@ const PresetPanel = () => {
|
|||||||
onClick={toggleParametersExpanded}
|
onClick={toggleParametersExpanded}
|
||||||
>
|
>
|
||||||
<span>参数设置</span>
|
<span>参数设置</span>
|
||||||
|
{/* 生成数量 - 放在标题栏旁边 */}
|
||||||
|
<div className="header-parameter">
|
||||||
|
<label
|
||||||
|
className="header-param-label"
|
||||||
|
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.n)}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
>
|
||||||
|
{parameterLabels.n}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={parameters.n}
|
||||||
|
onChange={(e) => handleParameterChange('n', e.target.value)}
|
||||||
|
className="header-param-input"
|
||||||
|
onClick={(e) => e.stopPropagation()} // 防止点击输入框时折叠
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span className={`expand-icon ${isParametersExpanded ? 'expanded' : ''}`}>▼</span>
|
<span className={`expand-icon ${isParametersExpanded ? 'expanded' : ''}`}>▼</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isParametersExpanded && (
|
{isParametersExpanded && (
|
||||||
<div className="preset-parameters">
|
<div className="preset-parameters">
|
||||||
{/* 温度滑块 */}
|
{/* 温度输入框 */}
|
||||||
<div className="parameter-row">
|
<div className="parameter-row-compact">
|
||||||
<label
|
<label
|
||||||
className="parameter-label"
|
className="parameter-label-compact"
|
||||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.temperature)}
|
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.temperature)}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
Temperature
|
{parameterLabels.temperature}
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="2"
|
|
||||||
step="0.1"
|
|
||||||
value={parameters.temperature}
|
|
||||||
onChange={(e) => handleParameterChange('temperature', e.target.value)}
|
|
||||||
className="parameter-slider"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -486,86 +624,59 @@ const PresetPanel = () => {
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
value={parameters.temperature}
|
value={parameters.temperature}
|
||||||
onChange={(e) => handleParameterChange('temperature', e.target.value)}
|
onChange={(e) => handleParameterChange('temperature', e.target.value)}
|
||||||
className="parameter-number"
|
className="parameter-input-compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 频率惩罚滑块 */}
|
{/* 频率惩罚输入框 */}
|
||||||
<div className="parameter-row">
|
<div className="parameter-row-compact">
|
||||||
<label
|
<label
|
||||||
className="parameter-label"
|
className="parameter-label-compact"
|
||||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.frequency_penalty)}
|
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.freq_penalty)}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
Frequency Penalty
|
{parameterLabels.freq_penalty}
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="-2"
|
|
||||||
max="2"
|
|
||||||
step="0.1"
|
|
||||||
value={parameters.frequency_penalty}
|
|
||||||
onChange={(e) => handleParameterChange('frequency_penalty', e.target.value)}
|
|
||||||
className="parameter-slider"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="-2"
|
min="-2"
|
||||||
max="2"
|
max="2"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={parameters.frequency_penalty}
|
value={parameters.frequency_penalty}
|
||||||
onChange={(e) => handleParameterChange('frequency_penalty', e.target.value)}
|
onChange={(e) => handleParameterChange('freq_penalty', e.target.value)}
|
||||||
className="parameter-number"
|
className="parameter-input-compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 存在惩罚滑块 */}
|
{/* 存在惩罚输入框 */}
|
||||||
<div className="parameter-row">
|
<div className="parameter-row-compact">
|
||||||
<label
|
<label
|
||||||
className="parameter-label"
|
className="parameter-label-compact"
|
||||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.presence_penalty)}
|
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.pres_penalty)}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
Presence Penalty
|
{parameterLabels.pres_penalty}
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="-2"
|
|
||||||
max="2"
|
|
||||||
step="0.1"
|
|
||||||
value={parameters.presence_penalty}
|
|
||||||
onChange={(e) => handleParameterChange('presence_penalty', e.target.value)}
|
|
||||||
className="parameter-slider"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="-2"
|
min="-2"
|
||||||
max="2"
|
max="2"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={parameters.presence_penalty}
|
value={parameters.presence_penalty}
|
||||||
onChange={(e) => handleParameterChange('presence_penalty', e.target.value)}
|
onChange={(e) => handleParameterChange('pres_penalty', e.target.value)}
|
||||||
className="parameter-number"
|
className="parameter-input-compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top P 滑块 */}
|
{/* Top P 输入框 */}
|
||||||
<div className="parameter-row">
|
<div className="parameter-row-compact">
|
||||||
<label
|
<label
|
||||||
className="parameter-label"
|
className="parameter-label-compact"
|
||||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_p)}
|
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_p)}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
Top P
|
{parameterLabels.top_p}
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.05"
|
|
||||||
value={parameters.top_p}
|
|
||||||
onChange={(e) => handleParameterChange('top_p', e.target.value)}
|
|
||||||
className="parameter-slider"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -573,55 +684,55 @@ const PresetPanel = () => {
|
|||||||
step="0.05"
|
step="0.05"
|
||||||
value={parameters.top_p}
|
value={parameters.top_p}
|
||||||
onChange={(e) => handleParameterChange('top_p', e.target.value)}
|
onChange={(e) => handleParameterChange('top_p', e.target.value)}
|
||||||
className="parameter-number"
|
className="parameter-input-compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top K 输入框 */}
|
{/* Top K 输入框 */}
|
||||||
<div className="parameter-row">
|
<div className="parameter-row-compact">
|
||||||
<label
|
<label
|
||||||
className="parameter-label"
|
className="parameter-label-compact"
|
||||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_k)}
|
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_k)}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
Top K
|
{parameterLabels.top_k}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
value={parameters.top_k}
|
value={parameters.top_k}
|
||||||
onChange={(e) => handleParameterChange('top_k', e.target.value)}
|
onChange={(e) => handleParameterChange('top_k', e.target.value)}
|
||||||
className="parameter-input"
|
className="parameter-input-compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 最大上下文输入框 */}
|
{/* 最大上下文输入框 */}
|
||||||
<div className="parameter-row">
|
<div className="parameter-row-compact">
|
||||||
<label
|
<label
|
||||||
className="parameter-label"
|
className="parameter-label-compact"
|
||||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_context)}
|
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_ctx)}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
Max Context
|
{parameterLabels.max_ctx}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="10000000"
|
max="10000000"
|
||||||
value={parameters.max_context}
|
value={parameters.max_context}
|
||||||
onChange={(e) => handleParameterChange('max_context', e.target.value)}
|
onChange={(e) => handleParameterChange('max_ctx', e.target.value)}
|
||||||
className="parameter-input"
|
className="parameter-input-compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 最大Token输入框 */}
|
{/* 最大Token输入框 */}
|
||||||
<div className="parameter-row">
|
<div className="parameter-row-compact">
|
||||||
<label
|
<label
|
||||||
className="parameter-label"
|
className="parameter-label-compact"
|
||||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_tokens)}
|
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_tokens)}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
Max Tokens
|
{parameterLabels.max_tokens}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -629,79 +740,26 @@ const PresetPanel = () => {
|
|||||||
max="100000"
|
max="100000"
|
||||||
value={parameters.max_tokens}
|
value={parameters.max_tokens}
|
||||||
onChange={(e) => handleParameterChange('max_tokens', e.target.value)}
|
onChange={(e) => handleParameterChange('max_tokens', e.target.value)}
|
||||||
className="parameter-input"
|
className="parameter-input-compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 随机种子输入框 */}
|
{/* 随机种子输入框 */}
|
||||||
<div className="parameter-row">
|
<div className="parameter-row-compact">
|
||||||
<label
|
<label
|
||||||
className="parameter-label"
|
className="parameter-label-compact"
|
||||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.seed)}
|
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.seed)}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
>
|
>
|
||||||
Seed
|
{parameterLabels.seed}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={parameters.seed}
|
value={parameters.seed}
|
||||||
onChange={(e) => handleParameterChange('seed', e.target.value)}
|
onChange={(e) => handleParameterChange('seed', e.target.value)}
|
||||||
className="parameter-input"
|
className="parameter-input-compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 生成数量输入框 */}
|
|
||||||
<div className="parameter-row">
|
|
||||||
<label
|
|
||||||
className="parameter-label"
|
|
||||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.n)}
|
|
||||||
onMouseLeave={hideTooltip}
|
|
||||||
>
|
|
||||||
N (生成数量)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={parameters.n}
|
|
||||||
onChange={(e) => handleParameterChange('n', e.target.value)}
|
|
||||||
className="parameter-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 开关选项 */}
|
|
||||||
<div className="parameter-toggles">
|
|
||||||
<div className="toggle-row">
|
|
||||||
<label
|
|
||||||
className="toggle-label"
|
|
||||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_context_unlocked)}
|
|
||||||
onMouseLeave={hideTooltip}
|
|
||||||
>
|
|
||||||
Max Context Unlocked
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={parameters.max_context_unlocked}
|
|
||||||
onChange={(e) => handleParameterChange('max_context_unlocked', e.target.checked)}
|
|
||||||
className="toggle-checkbox"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toggle-row">
|
|
||||||
<label
|
|
||||||
className="toggle-label"
|
|
||||||
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.stream_openai)}
|
|
||||||
onMouseLeave={hideTooltip}
|
|
||||||
>
|
|
||||||
Stream Output
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={parameters.stream_openai}
|
|
||||||
onChange={(e) => handleParameterChange('stream_openai', e.target.checked)}
|
|
||||||
className="toggle-checkbox"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -793,6 +851,51 @@ const PresetPanel = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 分页控件 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="pagination-controls">
|
||||||
|
<button
|
||||||
|
className="pagination-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentPage(Math.max(1, currentPage - 1));
|
||||||
|
fetchPresets();
|
||||||
|
}}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="pagination-info">
|
||||||
|
第 {currentPage} / {totalPages} 页
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="pagination-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentPage(Math.min(totalPages, currentPage + 1));
|
||||||
|
fetchPresets();
|
||||||
|
}}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="page-size-selector"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
fetchPresets();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={8}>8条/页</option>
|
||||||
|
<option value={12}>12条/页</option>
|
||||||
|
<option value={20}>20条/页</option>
|
||||||
|
<option value={50}>50条/页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 8px;
|
padding: 0; /* sidebar-content已有padding,这里不需要 */
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 0; /* tab-content已有padding,这里不需要 */
|
||||||
background-color: var(--color-bg-secondary);
|
background-color: var(--color-bg-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 12px;
|
padding: 0; /* 移除内部padding,依赖外层tab-content */
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,15 +33,27 @@ const Dice = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析多个骰子组合,如 "1d4+1d3"
|
// 解析多个骰子组合,如 "1d4+1d3" 或 "1d10-1d6"
|
||||||
const parts = expr.split(/(?=[+-])/);
|
const parts = expr.split(/(?=[+-])/);
|
||||||
const components = [];
|
const components = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
let detail = [];
|
let detail = [];
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
|
// 提取符号(如果有)
|
||||||
|
let sign = 1;
|
||||||
|
let cleanPart = part;
|
||||||
|
|
||||||
|
if (part.startsWith('+')) {
|
||||||
|
sign = 1;
|
||||||
|
cleanPart = part.substring(1);
|
||||||
|
} else if (part.startsWith('-')) {
|
||||||
|
sign = -1;
|
||||||
|
cleanPart = part.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
// 匹配 NdM 格式
|
// 匹配 NdM 格式
|
||||||
const diceMatch = part.match(/^(\d*)d(\d+)$/i);
|
const diceMatch = cleanPart.match(/^(\d*)d(\d+)$/i);
|
||||||
if (diceMatch) {
|
if (diceMatch) {
|
||||||
const count = diceMatch[1] ? parseInt(diceMatch[1]) : 1;
|
const count = diceMatch[1] ? parseInt(diceMatch[1]) : 1;
|
||||||
const sides = parseInt(diceMatch[2]);
|
const sides = parseInt(diceMatch[2]);
|
||||||
@@ -53,25 +65,26 @@ const Dice = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sum = rolls.reduce((a, b) => a + b, 0);
|
const sum = rolls.reduce((a, b) => a + b, 0);
|
||||||
total += sum;
|
total += sign * sum; // 应用符号
|
||||||
|
|
||||||
components.push({
|
components.push({
|
||||||
type: 'dice',
|
type: 'dice',
|
||||||
count,
|
count,
|
||||||
sides,
|
sides,
|
||||||
rolls,
|
rolls,
|
||||||
sum
|
sum: sign * sum
|
||||||
});
|
});
|
||||||
|
|
||||||
detail.push(`${count}d${sides}=[${rolls.join(',')}]`);
|
const signStr = sign > 0 && detail.length > 0 ? '+' : '';
|
||||||
|
detail.push(`${signStr}${count}d${sides}=[${rolls.join(',')}]`);
|
||||||
} else {
|
} else {
|
||||||
// 匹配固定数值(带符号)
|
// 匹配固定数值
|
||||||
const fixedMatch = part.match(/^([+-])(\d+)$/);
|
const fixedMatch = cleanPart.match(/^(\d+)$/);
|
||||||
if (fixedMatch) {
|
if (fixedMatch) {
|
||||||
const sign = fixedMatch[1] === '+' ? 1 : -1;
|
const value = parseInt(fixedMatch[1]);
|
||||||
const value = parseInt(fixedMatch[2]);
|
|
||||||
total += sign * value;
|
total += sign * value;
|
||||||
detail.push(`${sign > 0 ? '+' : '-'}${value}`);
|
const signStr = sign > 0 && detail.length > 0 ? '+' : '';
|
||||||
|
detail.push(`${signStr}${value}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 8px;
|
padding: 0; /* tab-content已有padding,这里不需要 */
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
275
frontend/src/components/SideBarRight/tabs/Table/Table.css
Normal file
275
frontend/src/components/SideBarRight/tabs/Table/Table.css
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/* 表格面板 */
|
||||||
|
.table-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-text {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-info {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover:not(:disabled) {
|
||||||
|
background-color: rgba(102, 126, 234, 0.15);
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-table {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-table p:first-child {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-table p:nth-child(2) {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格容器 - 支持滚动 */
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签容器 - 横向排列,自动换行 */
|
||||||
|
.tags-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-tags-hint {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单个标签项 */
|
||||||
|
.tag-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-item:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-item.editing {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background-color: rgba(102, 126, 234, 0.1);
|
||||||
|
padding: 3px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签文本 */
|
||||||
|
.tag-text {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-text:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签编辑容器 */
|
||||||
|
.tag-edit-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-edit-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
outline: none;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-edit-input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签操作按钮组 */
|
||||||
|
.tag-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-item:hover .tag-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-action-btn {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-action-btn:hover:not(:disabled) {
|
||||||
|
background-color: rgba(102, 126, 234, 0.15);
|
||||||
|
color: var(--color-accent);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-action-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-action-btn.delete:hover:not(:disabled) {
|
||||||
|
background-color: rgba(244, 67, 54, 0.15);
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加标签输入框 */
|
||||||
|
.add-tag-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
outline: none;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag-input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-accent-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
@@ -1,10 +1,198 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTableStore } from '../../../../Store/SideBarRight';
|
||||||
|
import './Table.css';
|
||||||
|
|
||||||
const Table = () => {
|
const Table = () => {
|
||||||
|
// ✅ 从 Zustand store 获取状态和方法
|
||||||
|
const {
|
||||||
|
tags,
|
||||||
|
isLoading,
|
||||||
|
currentRole,
|
||||||
|
currentChat,
|
||||||
|
loadTags,
|
||||||
|
updateTags,
|
||||||
|
addTag,
|
||||||
|
deleteTag,
|
||||||
|
moveLeft,
|
||||||
|
moveRight,
|
||||||
|
editTag,
|
||||||
|
refresh
|
||||||
|
} = useTableStore();
|
||||||
|
|
||||||
|
// 本地 UI 状态(不需要持久化)
|
||||||
|
const [editingIndex, setEditingIndex] = useState(null);
|
||||||
|
const [editValue, setEditValue] = useState('');
|
||||||
|
const [newTagInput, setNewTagInput] = useState('');
|
||||||
|
|
||||||
|
// 监听角色/聊天变化,自动加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentRole) {
|
||||||
|
loadTags(currentRole, currentChat);
|
||||||
|
}
|
||||||
|
}, [currentRole, currentChat, loadTags]);
|
||||||
|
|
||||||
|
// 开始编辑标签
|
||||||
|
const handleStartEdit = (index, value) => {
|
||||||
|
setEditingIndex(index);
|
||||||
|
setEditValue(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存编辑
|
||||||
|
const handleSaveEdit = () => {
|
||||||
|
if (editingIndex === null) return;
|
||||||
|
|
||||||
|
editTag(editingIndex, editValue);
|
||||||
|
setEditingIndex(null);
|
||||||
|
setEditValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消编辑
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingIndex(null);
|
||||||
|
setEditValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加新标签(支持批量输入)
|
||||||
|
const handleAddTag = () => {
|
||||||
|
const input = newTagInput.trim();
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
// 智能分割:支持空格、逗号、分号作为分隔符
|
||||||
|
const newTagsList = input
|
||||||
|
.split(/[\s,;,;]+/)
|
||||||
|
.filter(tag => tag.trim() !== '')
|
||||||
|
.map(tag => tag.trim());
|
||||||
|
|
||||||
|
addTag(newTagsList);
|
||||||
|
setNewTagInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果没有角色,显示提示
|
||||||
|
if (!currentRole) {
|
||||||
|
return (
|
||||||
|
<div className="table-panel">
|
||||||
|
<div className="tab-header">
|
||||||
|
<span className="title-text">动态表格</span>
|
||||||
|
</div>
|
||||||
|
<div className="empty-table">
|
||||||
|
<p>📊</p>
|
||||||
|
<p>请选择一个角色</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="table-panel">
|
<div className="table-panel">
|
||||||
<h2>动态表格</h2>
|
<div className="tab-header">
|
||||||
<p>这是动态表格面板的占位页面</p>
|
<span className="title-text">动态表格</span>
|
||||||
|
<div className="header-actions">
|
||||||
|
<span className="table-info">
|
||||||
|
{tags.length} 个标签
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="refresh-btn"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
title="刷新数据"
|
||||||
|
>
|
||||||
|
{isLoading ? '⏳' : '🔄'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
{/* 标签列表 */}
|
||||||
|
<div className="tags-container">
|
||||||
|
{tags.length === 0 ? (
|
||||||
|
<div className="no-tags-hint">
|
||||||
|
暂无标签,请在下方添加
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tags.map((tag, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`tag-item ${editingIndex === index ? 'editing' : ''}`}
|
||||||
|
>
|
||||||
|
{editingIndex === index ? (
|
||||||
|
// 编辑模式
|
||||||
|
<div className="tag-edit-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="tag-edit-input"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSaveEdit();
|
||||||
|
if (e.key === 'Escape') handleCancelEdit();
|
||||||
|
}}
|
||||||
|
onBlur={handleSaveEdit}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 显示模式
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="tag-text"
|
||||||
|
onDoubleClick={() => handleStartEdit(index, tag)}
|
||||||
|
title="双击编辑"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
<div className="tag-actions">
|
||||||
|
<button
|
||||||
|
className="tag-action-btn move-left"
|
||||||
|
onClick={() => moveLeft(index)}
|
||||||
|
disabled={index === 0}
|
||||||
|
title="左移"
|
||||||
|
>
|
||||||
|
◀
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tag-action-btn move-right"
|
||||||
|
onClick={() => moveRight(index)}
|
||||||
|
disabled={index === tags.length - 1}
|
||||||
|
title="右移"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tag-action-btn delete"
|
||||||
|
onClick={() => deleteTag(index)}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 添加新标签输入框 */}
|
||||||
|
<div className="add-tag-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="add-tag-input"
|
||||||
|
placeholder="输入标签(支持空格/逗号分隔多个)..."
|
||||||
|
value={newTagInput}
|
||||||
|
onChange={(e) => setNewTagInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleAddTag();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="add-tag-btn"
|
||||||
|
onClick={handleAddTag}
|
||||||
|
disabled={!newTagInput.trim()}
|
||||||
|
>
|
||||||
|
+ 添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
// frontend-react/src/components/TopBar/TopBar.jsx
|
// frontend-react/src/components/TopBar/TopBar.jsx
|
||||||
import React, {useState, useRef, useEffect} from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import './TopBar.css';
|
import useAppLayoutStore from '../../Store/AppLayoutSlice'; // ✅ 新增
|
||||||
import ThemeToggle from './items/ThemeToggle';
|
import useUserStore from '../../Store/UserSlice'; // ✅ 新增 - 用户角色
|
||||||
import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice';
|
import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice';
|
||||||
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
|
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
|
||||||
|
import ThemeToggle from './items/ThemeToggle';
|
||||||
|
import './TopBar.css';
|
||||||
|
|
||||||
const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeChange }) => {
|
const Toolbar = () => {
|
||||||
const [activePanel, setActivePanel] = useState(null);
|
const [activePanel, setActivePanel] = useState(null);
|
||||||
const panelRef = useRef(null);
|
const panelRef = useRef(null);
|
||||||
const [currentUserRole, setCurrentUserRole] = useState({ name: '', description: '' });
|
|
||||||
|
// ✅ 直接从 AppLayoutStore 获取状态和方法
|
||||||
|
const {
|
||||||
|
sidebarMode,
|
||||||
|
colorTheme,
|
||||||
|
setSidebarMode,
|
||||||
|
setColorTheme
|
||||||
|
} = useAppLayoutStore();
|
||||||
|
|
||||||
|
// ✅ 从 UserStore 获取用户角色
|
||||||
|
const { currentUserRole } = useUserStore();
|
||||||
|
|
||||||
// 从 Store 获取全局世界书
|
// 从 Store 获取全局世界书
|
||||||
const { globalWorldBooks } = useWorldBookStore();
|
const { globalWorldBooks } = useWorldBookStore();
|
||||||
@@ -17,41 +29,6 @@ const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeCha
|
|||||||
const { activeMap, fetchProfile } = useApiConfigStore();
|
const { activeMap, fetchProfile } = useApiConfigStore();
|
||||||
const [coreModel, setCoreModel] = useState('未设置');
|
const [coreModel, setCoreModel] = useState('未设置');
|
||||||
const [assistModel, setAssistModel] = useState('未设置');
|
const [assistModel, setAssistModel] = useState('未设置');
|
||||||
|
|
||||||
// 加载核心和辅助 API 配置的模型名
|
|
||||||
useEffect(() => {
|
|
||||||
const loadApiModels = async () => {
|
|
||||||
// 加载核心配置
|
|
||||||
if (activeMap['core']) {
|
|
||||||
try {
|
|
||||||
const profile = await fetchProfile(activeMap['core']);
|
|
||||||
const coreApi = profile?.apis?.find(api => api.category === 'core');
|
|
||||||
setCoreModel(coreApi?.model || '未设置');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载核心配置失败:', e);
|
|
||||||
setCoreModel('未设置');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setCoreModel('未设置');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载辅助配置
|
|
||||||
if (activeMap['assist']) {
|
|
||||||
try {
|
|
||||||
const profile = await fetchProfile(activeMap['assist']);
|
|
||||||
const assistApi = profile?.apis?.find(api => api.category === 'assist');
|
|
||||||
setAssistModel(assistApi?.model || '未设置');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载辅助配置失败:', e);
|
|
||||||
setAssistModel('未设置');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setAssistModel('未设置');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadApiModels();
|
|
||||||
}, [activeMap, fetchProfile]);
|
|
||||||
|
|
||||||
// 点击外部关闭面板 - 使用 useCallback 优化
|
// 点击外部关闭面板 - 使用 useCallback 优化
|
||||||
const handleClickOutside = React.useCallback((event) => {
|
const handleClickOutside = React.useCallback((event) => {
|
||||||
@@ -67,17 +44,10 @@ const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeCha
|
|||||||
};
|
};
|
||||||
}, [handleClickOutside]);
|
}, [handleClickOutside]);
|
||||||
|
|
||||||
// 加载当前用户角色
|
// ✅ 简化:直接调用 store 方法
|
||||||
useEffect(() => {
|
const handleSidebarModeChange = (mode) => {
|
||||||
const savedRole = localStorage.getItem('currentUserRole');
|
setSidebarMode(mode);
|
||||||
if (savedRole) {
|
};
|
||||||
try {
|
|
||||||
setCurrentUserRole(JSON.parse(savedRole));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('解析用户角色失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 处理面板切换
|
// 处理面板切换
|
||||||
const handlePanelToggle = (panelName) => {
|
const handlePanelToggle = (panelName) => {
|
||||||
@@ -218,7 +188,7 @@ const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeCha
|
|||||||
name="sidebarMode"
|
name="sidebarMode"
|
||||||
value="fixed"
|
value="fixed"
|
||||||
checked={sidebarMode === 'fixed'}
|
checked={sidebarMode === 'fixed'}
|
||||||
onChange={(e) => onSidebarModeChange(e.target.value)}
|
onChange={(e) => handleSidebarModeChange(e.target.value)} // ✅ 使用 store 方法
|
||||||
/>
|
/>
|
||||||
<span>固定</span>
|
<span>固定</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -228,7 +198,7 @@ const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeCha
|
|||||||
name="sidebarMode"
|
name="sidebarMode"
|
||||||
value="smart"
|
value="smart"
|
||||||
checked={sidebarMode === 'smart'}
|
checked={sidebarMode === 'smart'}
|
||||||
onChange={(e) => onSidebarModeChange(e.target.value)}
|
onChange={(e) => handleSidebarModeChange(e.target.value)} // ✅ 使用 store 方法
|
||||||
/>
|
/>
|
||||||
<span>智能</span>
|
<span>智能</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -238,7 +208,7 @@ const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeCha
|
|||||||
name="sidebarMode"
|
name="sidebarMode"
|
||||||
value="expanded"
|
value="expanded"
|
||||||
checked={sidebarMode === 'expanded'}
|
checked={sidebarMode === 'expanded'}
|
||||||
onChange={(e) => onSidebarModeChange(e.target.value)}
|
onChange={(e) => handleSidebarModeChange(e.target.value)} // ✅ 使用 store 方法
|
||||||
/>
|
/>
|
||||||
<span>扩展</span>
|
<span>扩展</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -39,15 +39,15 @@
|
|||||||
|
|
||||||
/* CSS Variables for layout ratios */
|
/* CSS Variables for layout ratios */
|
||||||
:root {
|
:root {
|
||||||
/* Fixed mode: 1 : 3.5 : 1.25 (default) */
|
/* Fixed mode (default): 2 : 6 : 2 */
|
||||||
--left-fixed: 1fr;
|
--left-fixed: 2fr;
|
||||||
--middle-fixed: 3.5fr;
|
--middle-fixed: 6fr;
|
||||||
--right-fixed: 1.25fr;
|
--right-fixed: 2fr;
|
||||||
|
|
||||||
/* Expanded mode: 1.8 : 2 : 1 */
|
/* Expanded mode: 4 : 4.5 : 1.5 */
|
||||||
--left-expanded: 1.8fr;
|
--left-expanded: 4fr;
|
||||||
--middle-expanded: 2fr;
|
--middle-expanded: 4.5fr;
|
||||||
--right-expanded: 1fr;
|
--right-expanded: 1.5fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
.main-container {
|
||||||
@@ -74,13 +74,13 @@
|
|||||||
transition: flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fixed mode (default): 1 : 3.5 : 1.25 */
|
/* Fixed mode (default): 2 : 6 : 2 */
|
||||||
.app.chat-mode .main-container,
|
.app.chat-mode .main-container,
|
||||||
.sidebar-mode-fixed ~ .chat-area-wrapper {
|
.sidebar-mode-fixed ~ .chat-area-wrapper {
|
||||||
grid-template-columns: var(--left-fixed) var(--middle-fixed) var(--right-fixed);
|
grid-template-columns: var(--left-fixed) var(--middle-fixed) var(--right-fixed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Expanded mode: 1.8 : 2 : 1 */
|
/* Expanded mode: 4 : 4.5 : 1.5 */
|
||||||
.app.edit-mode .main-container,
|
.app.edit-mode .main-container,
|
||||||
.sidebar-mode-expanded,
|
.sidebar-mode-expanded,
|
||||||
.sidebar-mode-smart.sidebar-expanded {
|
.sidebar-mode-smart.sidebar-expanded {
|
||||||
@@ -181,13 +181,13 @@
|
|||||||
@media (min-width: 1920px) {
|
@media (min-width: 1920px) {
|
||||||
.app.chat-mode .main-container,
|
.app.chat-mode .main-container,
|
||||||
.sidebar-mode-fixed ~ .chat-area-wrapper {
|
.sidebar-mode-fixed ~ .chat-area-wrapper {
|
||||||
grid-template-columns: minmax(280px, var(--left-fixed)) var(--middle-fixed) minmax(340px, var(--right-fixed));
|
grid-template-columns: minmax(320px, var(--left-fixed)) var(--middle-fixed) minmax(320px, var(--right-fixed));
|
||||||
}
|
}
|
||||||
|
|
||||||
.app.edit-mode .main-container,
|
.app.edit-mode .main-container,
|
||||||
.sidebar-mode-expanded,
|
.sidebar-mode-expanded,
|
||||||
.sidebar-mode-smart.sidebar-expanded {
|
.sidebar-mode-smart.sidebar-expanded {
|
||||||
grid-template-columns: minmax(350px, var(--left-expanded)) var(--middle-expanded) minmax(250px, var(--right-expanded)) !important;
|
grid-template-columns: minmax(450px, var(--left-expanded)) var(--middle-expanded) minmax(200px, var(--right-expanded)) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,8 +134,17 @@ export interface ChatHeader {
|
|||||||
/** AI 角色名称 */
|
/** AI 角色名称 */
|
||||||
characterName: string;
|
characterName: string;
|
||||||
|
|
||||||
/** 表格内容(对应角色卡的 outputSchema 字段的值) */
|
/** 动态表格表头 (从角色卡继承) */
|
||||||
tableData?: Record<string, unknown>;
|
tableHeaders?: string[];
|
||||||
|
|
||||||
|
/** 表格默认值 (键为表头名,值为默认值) */
|
||||||
|
tableDefaults?: Record<string, unknown>;
|
||||||
|
|
||||||
|
/** 表格数据行 (每行是一个字典) */
|
||||||
|
tableData?: Record<string, unknown>[];
|
||||||
|
|
||||||
|
/** 表格数据行时间戳 (键为行索引,值为更新时间戳) */
|
||||||
|
tableTimestamps?: Record<number, number>;
|
||||||
|
|
||||||
/** 创建时间戳 */
|
/** 创建时间戳 */
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|||||||
82
test_data_access.py
Normal file
82
test_data_access.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
测试后端是否能正确访问 data 目录
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 添加 backend 到 Python 路径
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.core.config import settings
|
||||||
|
except ImportError:
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
def test_paths():
|
||||||
|
"""测试所有路径配置"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("路径配置测试")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 测试基本路径
|
||||||
|
print(f"\n1. 项目根目录 (BASE_PATH):")
|
||||||
|
print(f" {settings.BASE_PATH}")
|
||||||
|
print(f" 存在: {settings.BASE_PATH.exists()}")
|
||||||
|
|
||||||
|
print(f"\n2. 数据目录 (DATA_PATH):")
|
||||||
|
print(f" {settings.DATA_PATH}")
|
||||||
|
print(f" 存在: {settings.DATA_PATH.exists()}")
|
||||||
|
|
||||||
|
# 测试子目录
|
||||||
|
directories = {
|
||||||
|
"预设目录 (PRESET_PATH)": settings.PRESET_PATH,
|
||||||
|
"角色卡目录 (CHARACTERS_PATH)": settings.CHARACTERS_PATH,
|
||||||
|
"聊天目录 (CHAT_PATH)": settings.CHAT_PATH,
|
||||||
|
"世界书目录 (WORLDBOOKS_PATH)": settings.WORLDBOOKS_PATH,
|
||||||
|
"API配置目录": settings.DATA_PATH / "apiconfig",
|
||||||
|
"图片目录 (IMAGES_PATH)": settings.IMAGES_PATH,
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\n3. 子目录检查:")
|
||||||
|
for name, path in directories.items():
|
||||||
|
exists = path.exists()
|
||||||
|
status = "✅" if exists else "❌"
|
||||||
|
print(f" {status} {name}:")
|
||||||
|
print(f" {path}")
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
# 列出目录内容(如果有)
|
||||||
|
try:
|
||||||
|
items = list(path.iterdir())
|
||||||
|
if items:
|
||||||
|
print(f" 内容: {[item.name for item in items[:5]]}")
|
||||||
|
else:
|
||||||
|
print(f" (空目录)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 读取失败: {e}")
|
||||||
|
|
||||||
|
# 测试预设文件
|
||||||
|
print("\n4. 预设文件测试:")
|
||||||
|
preset_files = list(settings.PRESET_PATH.glob("*.json")) if settings.PRESET_PATH.exists() else []
|
||||||
|
print(f" 找到 {len(preset_files)} 个预设文件")
|
||||||
|
for pf in preset_files[:3]:
|
||||||
|
print(f" - {pf.name}")
|
||||||
|
|
||||||
|
# 测试角色卡
|
||||||
|
print("\n5. 角色卡测试:")
|
||||||
|
char_dirs = list(settings.CHARACTERS_PATH.iterdir()) if settings.CHARACTERS_PATH.exists() else []
|
||||||
|
char_count = sum(1 for d in char_dirs if d.is_dir())
|
||||||
|
print(f" 找到 {char_count} 个角色")
|
||||||
|
for cd in char_dirs[:3]:
|
||||||
|
if cd.is_dir():
|
||||||
|
char_file = cd / "character.json"
|
||||||
|
has_char_file = char_file.exists()
|
||||||
|
print(f" - {cd.name} (character.json: {'✅' if has_char_file else '❌'})")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("测试完成!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_paths()
|
||||||
43
test_route_import.py
Normal file
43
test_route_import.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
测试路由导入是否正常
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 添加项目根目录到 Python 路径
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent / "backend"))
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("测试路由导入")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("\n1. 尝试导入 presetsRoute...")
|
||||||
|
from api.routes import presetsRoute
|
||||||
|
print("✓ 成功导入 presetsRoute")
|
||||||
|
|
||||||
|
print("\n2. 检查 router 对象...")
|
||||||
|
print(f" router 类型: {type(presetsRoute.router)}")
|
||||||
|
print(f" router 前缀: {presetsRoute.router.prefix}")
|
||||||
|
|
||||||
|
print("\n3. 尝试调用 list_presets 函数...")
|
||||||
|
# 注意:这是异步函数,不能直接调用
|
||||||
|
print(f" list_presets 函数: {presetsRoute.list_presets}")
|
||||||
|
print(f" 函数类型: {type(presetsRoute.list_presets)}")
|
||||||
|
|
||||||
|
print("\n4. 直接调用 PresetService.list_presets()...")
|
||||||
|
result = presetsRoute.PresetService.list_presets()
|
||||||
|
print(f" 返回结果: {result}")
|
||||||
|
print(f" 结果长度: {len(result)}")
|
||||||
|
|
||||||
|
if len(result) > 0:
|
||||||
|
print("\n✓ 路由导入和调用都正常!")
|
||||||
|
else:
|
||||||
|
print("\n✗ 警告:返回空列表")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ 导入失败: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"""
|
|
||||||
测试世界书 SillyTavern 格式兼容性
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# 项目根目录
|
|
||||||
PROJECT_ROOT = Path(r'D:\progarm\python\llm_workflow_engine')
|
|
||||||
DATA_PATH = PROJECT_ROOT / 'data'
|
|
||||||
WORLDBOOKS_PATH = DATA_PATH / 'worldbooks'
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("世界书 SillyTavern 格式兼容性测试")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# 列出所有JSON文件
|
|
||||||
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
|
|
||||||
print(f"\n找到 {len(json_files)} 个世界书文件")
|
|
||||||
|
|
||||||
for file_path in json_files:
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"测试文件: {file_path.name}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
entries_raw = data.get("entries", [])
|
|
||||||
|
|
||||||
print(f"\n1. 格式检测:")
|
|
||||||
if isinstance(entries_raw, dict):
|
|
||||||
print(f" 类型: SillyTavern 格式 (字典)")
|
|
||||||
print(f" 条目数量: {len(entries_raw)}")
|
|
||||||
print(f" 键示例: {list(entries_raw.keys())[:5]}")
|
|
||||||
|
|
||||||
# 模拟转换为列表
|
|
||||||
all_entries = []
|
|
||||||
for key in sorted(entries_raw.keys(), key=lambda x: int(x) if x.isdigit() else 0):
|
|
||||||
entry = entries_raw[key]
|
|
||||||
if 'uid' not in entry:
|
|
||||||
entry['uid'] = key
|
|
||||||
all_entries.append(entry)
|
|
||||||
|
|
||||||
print(f"\n2. 转换后:")
|
|
||||||
print(f" 列表长度: {len(all_entries)}")
|
|
||||||
if all_entries:
|
|
||||||
print(f" 第一条目 UID: {all_entries[0].get('uid')}")
|
|
||||||
print(f" 第一条目键: {all_entries[0].get('key', 'N/A')}")
|
|
||||||
|
|
||||||
# 测试分页
|
|
||||||
print(f"\n3. 分页测试 (page=1, page_size=3):")
|
|
||||||
page = 1
|
|
||||||
page_size = 3
|
|
||||||
start_idx = (page - 1) * page_size
|
|
||||||
end_idx = start_idx + page_size
|
|
||||||
paginated = all_entries[start_idx:end_idx]
|
|
||||||
total_pages = (len(all_entries) + page_size - 1) // page_size
|
|
||||||
|
|
||||||
print(f" 总条目数: {len(all_entries)}")
|
|
||||||
print(f" 总页数: {total_pages}")
|
|
||||||
print(f" 当前页返回: {len(paginated)} 条")
|
|
||||||
for i, entry in enumerate(paginated, start=1):
|
|
||||||
print(f" {i}. UID={entry.get('uid')}, 内容长度={len(entry.get('content', ''))}")
|
|
||||||
|
|
||||||
elif isinstance(entries_raw, list):
|
|
||||||
print(f" 类型: 内部格式 (列表)")
|
|
||||||
print(f" 条目数量: {len(entries_raw)}")
|
|
||||||
|
|
||||||
# 测试分页
|
|
||||||
print(f"\n2. 分页测试 (page=1, page_size=3):")
|
|
||||||
page = 1
|
|
||||||
page_size = 3
|
|
||||||
start_idx = (page - 1) * page_size
|
|
||||||
end_idx = start_idx + page_size
|
|
||||||
paginated = entries_raw[start_idx:end_idx]
|
|
||||||
total_pages = (len(entries_raw) + page_size - 1) // page_size
|
|
||||||
|
|
||||||
print(f" 总条目数: {len(entries_raw)}")
|
|
||||||
print(f" 总页数: {total_pages}")
|
|
||||||
print(f" 当前页返回: {len(paginated)} 条")
|
|
||||||
for i, entry in enumerate(paginated, start=1):
|
|
||||||
print(f" {i}. UID={entry.get('uid')}, 内容长度={len(entry.get('content', ''))}")
|
|
||||||
else:
|
|
||||||
print(f" ⚠️ 未知格式: {type(entries_raw)}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ 错误: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("测试完成")
|
|
||||||
print("=" * 60)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""
|
|
||||||
简单测试 LLM 模型服务的提供商检测功能
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# 添加backend目录到路径
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'backend'))
|
|
||||||
|
|
||||||
# 直接导入服务模块,避免导入__init__.py
|
|
||||||
from services.llm_model_service import LLMModelService
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_provider():
|
|
||||||
"""测试提供商检测功能"""
|
|
||||||
print("=" * 60)
|
|
||||||
print("测试提供商检测功能")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
test_cases = [
|
|
||||||
("https://api.openai.com/v1", "openai"),
|
|
||||||
("https://api.anthropic.com/v1", "anthropic"),
|
|
||||||
("http://localhost:11434", "ollama"),
|
|
||||||
("http://127.0.0.1:11434", "ollama"),
|
|
||||||
("https://api.siliconflow.cn/v1", "openai"),
|
|
||||||
("https://api.deepseek.com/v1", "openai"),
|
|
||||||
("https://custom-api.example.com/v1", "openai"),
|
|
||||||
]
|
|
||||||
|
|
||||||
all_passed = True
|
|
||||||
for url, expected in test_cases:
|
|
||||||
result = LLMModelService.detect_provider(url)
|
|
||||||
passed = result == expected
|
|
||||||
status = "✓" if passed else "✗"
|
|
||||||
print(f" {status} {url}")
|
|
||||||
print(f" 结果: {result}, 期望: {expected}")
|
|
||||||
if not passed:
|
|
||||||
all_passed = False
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
if all_passed:
|
|
||||||
print("所有测试通过!✓")
|
|
||||||
else:
|
|
||||||
print("部分测试失败!✗")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
return all_passed
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
test_detect_provider()
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"""
|
|
||||||
直接测试世界书文件读取(不依赖backend模块)
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# 项目根目录
|
|
||||||
PROJECT_ROOT = Path(r'D:\progarm\python\llm_workflow_engine')
|
|
||||||
DATA_PATH = PROJECT_ROOT / 'data'
|
|
||||||
WORLDBOOKS_PATH = DATA_PATH / 'worldbooks'
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("世界书文件直接读取测试")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
print(f"\n1. 路径信息:")
|
|
||||||
print(f" 世界书目录: {WORLDBOOKS_PATH}")
|
|
||||||
print(f" 目录存在: {WORLDBOOKS_PATH.exists()}")
|
|
||||||
|
|
||||||
# 列出所有JSON文件
|
|
||||||
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
|
|
||||||
print(f"\n2. JSON文件列表:")
|
|
||||||
print(f" 文件数量: {len(json_files)}")
|
|
||||||
for f in json_files:
|
|
||||||
print(f" - {f.name} ({f.stat().st_size} bytes)")
|
|
||||||
|
|
||||||
if json_files:
|
|
||||||
# 尝试读取第一个文件
|
|
||||||
first_file = json_files[0]
|
|
||||||
print(f"\n3. 尝试读取文件: {first_file.name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(first_file, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
print(f" ✓ 文件读取成功")
|
|
||||||
print(f" 文件格式: JSON")
|
|
||||||
print(f" 顶层键: {list(data.keys())}")
|
|
||||||
|
|
||||||
# 检查是否是内部格式
|
|
||||||
if 'entries' in data:
|
|
||||||
entries = data['entries']
|
|
||||||
if isinstance(entries, list):
|
|
||||||
print(f" 格式类型: 内部格式 (Internal)")
|
|
||||||
print(f" 条目数量: {len(entries)}")
|
|
||||||
print(f" 世界书名称: {data.get('name', '未设置')}")
|
|
||||||
print(f" 描述: {data.get('description', '无')}")
|
|
||||||
|
|
||||||
if entries:
|
|
||||||
print(f"\n 第一条目预览:")
|
|
||||||
first_entry = entries[0]
|
|
||||||
print(f" UID: {first_entry.get('uid', 'N/A')}")
|
|
||||||
print(f" 内容长度: {len(first_entry.get('content', ''))}")
|
|
||||||
print(f" 键: {first_entry.get('key', [])}")
|
|
||||||
elif isinstance(entries, dict):
|
|
||||||
print(f" 格式类型: SillyTavern格式")
|
|
||||||
print(f" 条目数量: {len(entries)}")
|
|
||||||
else:
|
|
||||||
print(f" ⚠️ 未知格式,缺少 'entries' 键")
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
print(f" ❌ JSON解析失败: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ 读取失败: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# 如果有多个文件,也检查第二个
|
|
||||||
if len(json_files) > 1:
|
|
||||||
second_file = json_files[1]
|
|
||||||
print(f"\n4. 尝试读取第二个文件: {second_file.name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(second_file, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
print(f" ✓ 文件读取成功")
|
|
||||||
if 'entries' in data:
|
|
||||||
entries = data['entries']
|
|
||||||
if isinstance(entries, list):
|
|
||||||
print(f" 格式类型: 内部格式 (Internal)")
|
|
||||||
print(f" 条目数量: {len(entries)}")
|
|
||||||
elif isinstance(entries, dict):
|
|
||||||
print(f" 格式类型: SillyTavern格式")
|
|
||||||
print(f" 条目数量: {len(entries)}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ 读取失败: {e}")
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("测试完成")
|
|
||||||
print("=" * 60)
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"""
|
|
||||||
测试世界书分页功能
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
|
|
||||||
# 添加backend目录到Python路径
|
|
||||||
backend_dir = Path(__file__).parent / "backend"
|
|
||||||
sys.path.insert(0, str(backend_dir))
|
|
||||||
|
|
||||||
# 直接导入需要的模块,避免导入整个services包
|
|
||||||
from core.config import settings
|
|
||||||
from services.worldbook_service import WorldBookService
|
|
||||||
|
|
||||||
def test_pagination():
|
|
||||||
"""测试世界书条目分页功能"""
|
|
||||||
|
|
||||||
# 测试数据:世界书名称
|
|
||||||
test_worldbook_name = "测试分页世界书"
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("测试世界书分页功能")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. 创建测试世界书
|
|
||||||
print("\n1. 创建测试世界书...")
|
|
||||||
try:
|
|
||||||
worldbook = WorldBookService.create_worldbook(test_worldbook_name, "用于测试分页功能")
|
|
||||||
print(f" ✓ 世界书创建成功: {worldbook['name']}")
|
|
||||||
except ValueError as e:
|
|
||||||
if "already exists" in str(e):
|
|
||||||
print(f" ℹ 世界书已存在,跳过创建")
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
# 2. 添加测试条目(创建30个条目)
|
|
||||||
print("\n2. 添加测试条目...")
|
|
||||||
num_entries = 30
|
|
||||||
for i in range(num_entries):
|
|
||||||
entry_data = {
|
|
||||||
"key": [f"关键词{i}"],
|
|
||||||
"content": f"这是第{i+1}个测试条目的内容",
|
|
||||||
"comment": f"测试条目 {i+1}",
|
|
||||||
"order": i + 1
|
|
||||||
}
|
|
||||||
WorldBookService.create_entry(test_worldbook_name, entry_data)
|
|
||||||
print(f" ✓ 成功添加 {num_entries} 个条目")
|
|
||||||
|
|
||||||
# 3. 测试分页功能
|
|
||||||
print("\n3. 测试分页功能...")
|
|
||||||
|
|
||||||
# 测试第1页,每页10条
|
|
||||||
print("\n 测试第1页(每页10条):")
|
|
||||||
result = WorldBookService.list_entries(test_worldbook_name, page=1, page_size=10)
|
|
||||||
print(f" - 总条目数: {result['total']}")
|
|
||||||
print(f" - 当前页: {result['page']}")
|
|
||||||
print(f" - 每页数量: {result['page_size']}")
|
|
||||||
print(f" - 总页数: {result['total_pages']}")
|
|
||||||
print(f" - 返回条目数: {len(result['entries'])}")
|
|
||||||
assert result['total'] == num_entries, f"总条目数错误: {result['total']} != {num_entries}"
|
|
||||||
assert result['page'] == 1, f"页码错误: {result['page']} != 1"
|
|
||||||
assert result['page_size'] == 10, f"每页数量错误: {result['page_size']} != 10"
|
|
||||||
assert result['total_pages'] == 3, f"总页数错误: {result['total_pages']} != 3"
|
|
||||||
assert len(result['entries']) == 10, f"返回条目数错误: {len(result['entries'])} != 10"
|
|
||||||
print(" ✓ 第1页测试通过")
|
|
||||||
|
|
||||||
# 测试第2页
|
|
||||||
print("\n 测试第2页(每页10条):")
|
|
||||||
result = WorldBookService.list_entries(test_worldbook_name, page=2, page_size=10)
|
|
||||||
print(f" - 当前页: {result['page']}")
|
|
||||||
print(f" - 返回条目数: {len(result['entries'])}")
|
|
||||||
assert result['page'] == 2, f"页码错误: {result['page']} != 2"
|
|
||||||
assert len(result['entries']) == 10, f"返回条目数错误: {len(result['entries'])} != 10"
|
|
||||||
print(" ✓ 第2页测试通过")
|
|
||||||
|
|
||||||
# 测试第3页(最后一页,应该只有10条)
|
|
||||||
print("\n 测试第3页(每页10条):")
|
|
||||||
result = WorldBookService.list_entries(test_worldbook_name, page=3, page_size=10)
|
|
||||||
print(f" - 当前页: {result['page']}")
|
|
||||||
print(f" - 返回条目数: {len(result['entries'])}")
|
|
||||||
assert result['page'] == 3, f"页码错误: {result['page']} != 3"
|
|
||||||
assert len(result['entries']) == 10, f"返回条目数错误: {len(result['entries'])} != 10"
|
|
||||||
print(" ✓ 第3页测试通过")
|
|
||||||
|
|
||||||
# 测试不同的每页数量
|
|
||||||
print("\n 测试每页20条:")
|
|
||||||
result = WorldBookService.list_entries(test_worldbook_name, page=1, page_size=20)
|
|
||||||
print(f" - 总页数: {result['total_pages']}")
|
|
||||||
print(f" - 返回条目数: {len(result['entries'])}")
|
|
||||||
assert result['page_size'] == 20, f"每页数量错误: {result['page_size']} != 20"
|
|
||||||
assert result['total_pages'] == 2, f"总页数错误: {result['total_pages']} != 2"
|
|
||||||
assert len(result['entries']) == 20, f"返回条目数错误: {len(result['entries'])} != 20"
|
|
||||||
print(" ✓ 每页20条测试通过")
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("✅ 所有分页测试通过!")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# 4. 清理测试数据
|
|
||||||
print("\n4. 清理测试数据...")
|
|
||||||
WorldBookService.delete_worldbook(test_worldbook_name)
|
|
||||||
print(" ✓ 测试世界书已删除")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n❌ 测试失败: {str(e)}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# 尝试清理测试数据
|
|
||||||
try:
|
|
||||||
WorldBookService.delete_worldbook(test_worldbook_name)
|
|
||||||
print(" ✓ 测试数据已清理")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
success = test_pagination()
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""
|
|
||||||
测试世界书读取功能
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# 添加backend目录到Python路径
|
|
||||||
backend_dir = Path(__file__).parent / "backend"
|
|
||||||
sys.path.insert(0, str(backend_dir))
|
|
||||||
|
|
||||||
from core.config import settings
|
|
||||||
from services.worldbook_service import WorldBookService
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("世界书读取功能测试")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
print(f"\n1. 配置检查:")
|
|
||||||
print(f" 世界书目录: {settings.WORLDBOOKS_PATH}")
|
|
||||||
print(f" 目录存在: {settings.WORLDBOOKS_PATH.exists()}")
|
|
||||||
|
|
||||||
# 列出所有JSON文件
|
|
||||||
json_files = list(settings.WORLDBOOKS_PATH.glob("*.json"))
|
|
||||||
print(f" JSON文件数量: {len(json_files)}")
|
|
||||||
for f in json_files:
|
|
||||||
print(f" - {f.name}")
|
|
||||||
|
|
||||||
print(f"\n2. 测试读取世界书列表:")
|
|
||||||
try:
|
|
||||||
worldbooks = WorldBookService.list_worldbooks()
|
|
||||||
print(f" ✓ 成功读取世界书列表")
|
|
||||||
print(f" 世界书数量: {len(worldbooks)}")
|
|
||||||
for wb in worldbooks:
|
|
||||||
print(f" - {wb['name']} (条目数: {wb['entries_count']})")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ 读取失败: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
if worldbooks:
|
|
||||||
# 测试读取第一个世界书的详细信息
|
|
||||||
first_book = worldbooks[0]
|
|
||||||
book_name = first_book['name']
|
|
||||||
|
|
||||||
print(f"\n3. 测试读取世界书详情: {book_name}")
|
|
||||||
try:
|
|
||||||
book_detail = WorldBookService.get_worldbook(book_name)
|
|
||||||
print(f" ✓ 成功读取世界书详情")
|
|
||||||
print(f" 名称: {book_detail.get('name')}")
|
|
||||||
print(f" 描述: {book_detail.get('description', '无')}")
|
|
||||||
print(f" 条目数: {len(book_detail.get('entries', []))}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ 读取失败: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# 测试分页功能
|
|
||||||
print(f"\n4. 测试分页功能:")
|
|
||||||
try:
|
|
||||||
result = WorldBookService.list_entries(book_name, page=1, page_size=5)
|
|
||||||
print(f" ✓ 分页查询成功")
|
|
||||||
print(f" 总条目数: {result['total']}")
|
|
||||||
print(f" 当前页: {result['page']}")
|
|
||||||
print(f" 每页数量: {result['page_size']}")
|
|
||||||
print(f" 总页数: {result['total_pages']}")
|
|
||||||
print(f" 返回条目数: {len(result['entries'])}")
|
|
||||||
|
|
||||||
if result['entries']:
|
|
||||||
print(f" 第一条目预览:")
|
|
||||||
first_entry = result['entries'][0]
|
|
||||||
print(f" UID: {first_entry.get('uid')}")
|
|
||||||
print(f" 内容长度: {len(first_entry.get('content', ''))}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ 分页查询失败: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("测试完成")
|
|
||||||
print("=" * 60)
|
|
||||||
Reference in New Issue
Block a user