完成大量美化,zustand迁移,动态表格修复

This commit is contained in:
2026-05-02 02:08:53 +08:00
parent f0e7e75ffb
commit 7fc9e10c99
53 changed files with 2954 additions and 1462 deletions

View File

@@ -1,12 +1,13 @@
from fastapi import APIRouter, HTTPException, status
from pathlib import Path
try:
from backend.services.chat_service import ChatService
from backend.core.config import settings
from backend.services.chat_service import ChatService
from backend.core.config import settings
except ImportError:
# Docker环境直接从当前目录导入
from services.chat_service import ChatService
from core.config import settings
# Docker环境直接从当前目录导入
from services.chat_service import ChatService
from core.config import settings
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")
chat_service = ChatService(data_path)
@router.get("", response_model=dict)
async def 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}")
async def list_role_chats(role_name: str):
"""获取指定角色的所有聊天列表"""
@@ -30,13 +43,6 @@ async def list_role_chats(role_name: str):
except Exception as 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)
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:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{role_name}/{chat_name}")
async def update_chat(role_name: str, chat_name: str, update_data: dict):
"""更新聊天元数据"""
# TODO: 实现更新聊天元数据功能
raise HTTPException(status_code=501, detail="Not Implemented")
@router.delete("/{role_name}/{chat_name}")
async def delete_chat(role_name: str, chat_name: str):
"""删除指定聊天"""
# TODO: 实现删除聊天功能
raise HTTPException(status_code=501, detail="Not Implemented")
@router.get("/{role_name}/{chat_name}/messages")
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:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/{role_name}/{chat_name}/messages/{floor}")
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:
raise HTTPException(status_code=404, detail=str(e))
@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):
"""向聊天添加新消息"""
@@ -91,6 +102,7 @@ async def add_message(role_name: str, chat_name: str, message_data: dict):
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{role_name}/{chat_name}/messages/{floor}")
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:
raise HTTPException(status_code=404, detail=str(e))
@router.delete("/{role_name}/{chat_name}/messages/{floor}")
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))
except ValueError as 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))

View File

@@ -1,23 +1,43 @@
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.get("", response_model=dict)
async def list_presets():
"""获取所有预设列表及其基本信息"""
# return await PresetService.list_all_presets()
return {"presets": []}
try:
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}")
async def get_preset(preset_name: str):
"""获取指定预设的完整内容"""
# try:
# return await PresetService.get_preset(preset_name)
# except FileNotFoundError:
# raise HTTPException(status_code=404, detail="Preset not found")
raise HTTPException(status_code=501, detail="Not Implemented")
try:
preset_data = PresetService.get_preset(preset_name)
return preset_data
except FileNotFoundError:
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)
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")
@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)
async def add_preset_component(preset_name: str, component_data: dict):
"""向预设添加新组件"""

View File

@@ -38,6 +38,80 @@ async def list_worldbooks():
logger.error(f"Failed to list worldbooks: {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])
async def get_worldbook(name: str):
"""
@@ -213,41 +287,3 @@ async def import_worldbook(name: str, file: UploadFile = File(...)):
except Exception as e:
logger.error(f"Failed to import 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))

View File

@@ -3,12 +3,15 @@ from pathlib import Path
from dotenv import load_dotenv
# 1. 动态计算项目根目录
# 假设 config.py 位于 backend/core/ 目录下
# __file__ 指向本文件的绝对路径
# .parent 指向 backend/core/ 目录
# .parent.parent 指向 backend/ 目录
# .parent.parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# 在 Docker 环境中config.py 位于 /app/core/需要向上2级到 /app/
# 在本地开发中config.py 位于 backend/core/需要向上3级到项目根目录
_config_path = Path(__file__).resolve()
if _config_path.parent.parent.name == 'app':
# Docker 环境:/app/core/config.py -> /app/
PROJECT_ROOT = _config_path.parent.parent
else:
# 本地开发backend/core/config.py -> 项目根目录
PROJECT_ROOT = _config_path.parent.parent.parent
# 2. 加载 .env 文件
# 假设 .env 文件位于项目根目录下

View File

@@ -136,6 +136,7 @@ class CharacterCard(BaseModel):
first_mes: str = Field(..., description="首条开场消息")
mes_example: str = Field(..., description="对话示例")
categories: List[str] = Field(default_factory=list, description="分类标签 (用于前端筛选)")
tags: Optional[List[str]] = Field(None, description="动态表格标签数组 (SillyTavern 关键字机制)")
worldInfoId: Optional[str] = Field(None, description="绑定的世界书 ID")
outputSchema: Optional[List[OutputSchemaField]] = Field(None, description="输出 schema 定义 (结构化输出)")
avatarPath: Optional[str] = Field(None, description="角色头像路径")
@@ -161,7 +162,7 @@ class ChatHeader(BaseModel):
characterId: str = Field(..., description="关联的角色卡 ID")
userName: str = Field("User", description="用户角色名")
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="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
messageCount: int = Field(0, description="消息数量")

View File

@@ -3,9 +3,7 @@
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
"""
from .prompt_assembler import PromptAssembler, PromptConfig
# 注意:不在这里自动导入模块,避免循环依赖和缺失依赖问题
# 需要使用时请显式导入例如from services.preset_service import PresetService
__all__ = [
'PromptAssembler',
'PromptConfig',
]
__all__ = []

View File

@@ -50,6 +50,7 @@ class CharacterCardConverter:
first_mes=data.get('first_mes', ''),
mes_example=data.get('mes_example', ''),
categories=[], # ST没有categories
tableHeaders=[], # ST没有tableHeaders
worldInfoId=extensions.get('world'),
outputSchema=None, # ST不支持结构化输出
avatarPath=avatar_path,

View File

@@ -95,11 +95,11 @@ class CharacterService:
first_mes=data.get('first_mes', ''),
mes_example=data.get('mes_example', ''),
categories=data.get('categories', []),
tags=data.get('tags', []), # ✅ 使用标签数组
worldInfoId=data.get('worldInfoId'),
outputSchema=data.get('outputSchema'),
avatarPath=avatar_path,
alternate_greetings=data.get('alternate_greetings', []),
tags=data.get('tags', []),
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
lastChatAt=last_chat_at,

View File

@@ -156,6 +156,7 @@ class ChatService:
messages.append(msg_data)
return {
"header": header, # 完整的 header包含 tableHeaders, tableDefaults, tableData
"metadata": {
"user_name": header.get("user_name", "User"),
"character_name": header.get("character_name", ""),
@@ -192,6 +193,21 @@ class ChatService:
# 创建角色目录
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 = {
"user_name": metadata.get("user_name", "User") if metadata else "User",
@@ -207,7 +223,8 @@ class ChatService:
"timedWorldInfo": {},
"variables": {},
"tainted": False,
"lastInContextMessageId": -1
"lastInContextMessageId": -1,
"tags": tags # ✅ 使用标签数组替代 tableHeaders/tableDefaults/tableData
}
# 写入header
@@ -381,3 +398,57 @@ class ChatService:
except Exception as e:
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
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

View 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