diff --git a/backend/app/api/characters.py b/backend/app/api/characters.py index 30fabc4..fe7921d 100644 --- a/backend/app/api/characters.py +++ b/backend/app/api/characters.py @@ -246,11 +246,50 @@ async def update_character( # 更新字段 update_data = character_update.model_dump(exclude_unset=True) + + # 如果是组织,需要同步更新 Organization 表的字段 + org_fields = {} + if character.is_organization: + # 提取需要同步到 Organization 表的字段 + if 'power_level' in update_data: + org_fields['power_level'] = update_data.pop('power_level') + if 'location' in update_data: + org_fields['location'] = update_data.pop('location') + if 'motto' in update_data: + org_fields['motto'] = update_data.pop('motto') + if 'color' in update_data: + org_fields['color'] = update_data.pop('color') + + # 更新 Character 表字段 for field, value in update_data.items(): setattr(character, field, value) + # 如果是组织且有需要同步的字段,更新 Organization 表 + if character.is_organization and org_fields: + org_result = await db.execute( + select(Organization).where(Organization.character_id == character_id) + ) + org = org_result.scalar_one_or_none() + + if org: + for field, value in org_fields.items(): + setattr(org, field, value) + logger.info(f"同步更新组织详情:{character.name}") + else: + # 如果 Organization 记录不存在,自动创建 + org = Organization( + character_id=character_id, + project_id=character.project_id, + member_count=0, + **org_fields + ) + db.add(org) + logger.info(f"自动创建组织详情:{character.name}") + await db.commit() await db.refresh(character) + + logger.info(f"更新角色/组织成功:{character.name} (ID: {character_id})") return character diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py index 63e1c4f..8a5cdb8 100644 --- a/backend/app/api/organizations.py +++ b/backend/app/api/organizations.py @@ -194,7 +194,7 @@ async def update_organization( user_id = getattr(request.state, 'user_id', None) await verify_project_access(db_org.project_id, user_id, db) - # 更新字段 + # 更新 Organization 表字段 update_data = organization.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(db_org, field, value) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 2de4cc5..a992b9e 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -4,14 +4,21 @@ from fastapi import APIRouter, HTTPException, Request, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional from pathlib import Path from pydantic import BaseModel +from datetime import datetime import httpx +import json +import time from app.database import get_db from app.models.settings import Settings -from app.schemas.settings import SettingsCreate, SettingsUpdate, SettingsResponse +from app.schemas.settings import ( + SettingsCreate, SettingsUpdate, SettingsResponse, + APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest, + PresetUpdateRequest, PresetResponse, PresetListResponse +) from app.user_manager import User from app.logger import get_logger from app.config import settings as app_settings, PROJECT_ROOT @@ -462,4 +469,329 @@ async def test_api_connection(data: ApiTestRequest): "error": error_msg, "error_type": error_type, "suggestions": suggestions - } \ No newline at end of file + } + + +# ========== API配置预设管理(零数据库改动方案)========== + +async def get_user_settings(user_id: str, db: AsyncSession) -> Settings: + """获取用户settings,如果不存在则创建""" + result = await db.execute( + select(Settings).where(Settings.user_id == user_id) + ) + settings = result.scalar_one_or_none() + + if not settings: + # 创建默认设置 + env_defaults = read_env_defaults() + settings = Settings( + user_id=user_id, + **env_defaults, + preferences='{}' # 初始化为空JSON + ) + db.add(settings) + await db.commit() + await db.refresh(settings) + logger.info(f"用户 {user_id} 首次访问,已创建默认设置") + + return settings + + +@router.get("/presets", response_model=PresetListResponse) +async def get_presets( + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 获取所有API配置预设 + + 从preferences字段读取预设列表 + """ + settings = await get_user_settings(user.user_id, db) + + # 解析preferences + try: + prefs = json.loads(settings.preferences or '{}') + except json.JSONDecodeError: + logger.warning(f"用户 {user.user_id} 的preferences字段JSON格式错误,重置为空") + prefs = {} + + api_presets = prefs.get('api_presets', {'presets': [], 'version': '1.0'}) + presets = api_presets.get('presets', []) + + # 找到激活的预设 + active_preset_id = next( + (p['id'] for p in presets if p.get('is_active')), + None + ) + + logger.info(f"用户 {user.user_id} 获取预设列表,共 {len(presets)} 个") + + return { + "presets": presets, + "total": len(presets), + "active_preset_id": active_preset_id + } + + +@router.post("/presets", response_model=PresetResponse) +async def create_preset( + data: PresetCreateRequest, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 创建新预设 + + 将预设添加到preferences字段的JSON中 + """ + settings = await get_user_settings(user.user_id, db) + + # 解析preferences + try: + prefs = json.loads(settings.preferences or '{}') + except json.JSONDecodeError: + prefs = {} + + api_presets = prefs.get('api_presets', {'presets': [], 'version': '1.0'}) + presets = api_presets.get('presets', []) + + # 创建新预设 + new_preset = { + "id": f"preset_{int(datetime.now().timestamp() * 1000)}", + "name": data.name, + "description": data.description, + "is_active": False, + "created_at": datetime.now().isoformat(), + "config": data.config.model_dump() + } + + presets.append(new_preset) + + # 保存回preferences + api_presets['presets'] = presets + prefs['api_presets'] = api_presets + settings.preferences = json.dumps(prefs, ensure_ascii=False) + + await db.commit() + + logger.info(f"用户 {user.user_id} 创建预设: {data.name}") + return new_preset + + +@router.put("/presets/{preset_id}", response_model=PresetResponse) +async def update_preset( + preset_id: str, + data: PresetUpdateRequest, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 更新预设 + + 在preferences字段的JSON中更新指定预设 + """ + settings = await get_user_settings(user.user_id, db) + + # 解析preferences + try: + prefs = json.loads(settings.preferences or '{}') + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="配置数据格式错误") + + api_presets = prefs.get('api_presets', {'presets': [], 'version': '1.0'}) + presets = api_presets.get('presets', []) + + # 找到并更新预设 + target_preset = next((p for p in presets if p['id'] == preset_id), None) + if not target_preset: + raise HTTPException(status_code=404, detail="预设不存在") + + # 更新字段 + if data.name is not None: + target_preset['name'] = data.name + if data.description is not None: + target_preset['description'] = data.description + if data.config is not None: + target_preset['config'] = data.config.model_dump() + + # 保存回preferences + prefs['api_presets'] = api_presets + settings.preferences = json.dumps(prefs, ensure_ascii=False) + + await db.commit() + + logger.info(f"用户 {user.user_id} 更新预设: {preset_id}") + return target_preset + + +@router.delete("/presets/{preset_id}") +async def delete_preset( + preset_id: str, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 删除预设 + + 从preferences字段的JSON中删除指定预设 + """ + settings = await get_user_settings(user.user_id, db) + + # 解析preferences + try: + prefs = json.loads(settings.preferences or '{}') + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="配置数据格式错误") + + api_presets = prefs.get('api_presets', {'presets': [], 'version': '1.0'}) + presets = api_presets.get('presets', []) + + # 找到预设 + target_preset = next((p for p in presets if p['id'] == preset_id), None) + if not target_preset: + raise HTTPException(status_code=404, detail="预设不存在") + + # 检查是否是激活的预设 + if target_preset.get('is_active'): + raise HTTPException(status_code=400, detail="无法删除激活中的预设,请先激活其他预设") + + # 删除预设 + presets = [p for p in presets if p['id'] != preset_id] + + # 保存回preferences + api_presets['presets'] = presets + prefs['api_presets'] = api_presets + settings.preferences = json.dumps(prefs, ensure_ascii=False) + + await db.commit() + + logger.info(f"用户 {user.user_id} 删除预设: {preset_id}") + return {"message": "预设已删除", "preset_id": preset_id} + + +@router.post("/presets/{preset_id}/activate") +async def activate_preset( + preset_id: str, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 激活预设 + + 将预设的配置应用到Settings主字段 + """ + settings = await get_user_settings(user.user_id, db) + + # 解析preferences + try: + prefs = json.loads(settings.preferences or '{}') + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="配置数据格式错误") + + api_presets = prefs.get('api_presets', {'presets': [], 'version': '1.0'}) + presets = api_presets.get('presets', []) + + # 找到目标预设 + target_preset = next((p for p in presets if p['id'] == preset_id), None) + if not target_preset: + raise HTTPException(status_code=404, detail="预设不存在") + + # 应用配置到Settings主字段 + config = target_preset['config'] + settings.api_provider = config['api_provider'] + settings.api_key = config['api_key'] + settings.api_base_url = config.get('api_base_url') + settings.llm_model = config['llm_model'] + settings.temperature = config['temperature'] + settings.max_tokens = config['max_tokens'] + + # 更新所有预设的is_active状态 + for preset in presets: + preset['is_active'] = (preset['id'] == preset_id) + + # 保存回preferences + prefs['api_presets'] = api_presets + settings.preferences = json.dumps(prefs, ensure_ascii=False) + + await db.commit() + + logger.info(f"用户 {user.user_id} 激活预设: {target_preset['name']}") + return { + "message": "预设已激活", + "preset_id": preset_id, + "preset_name": target_preset['name'] + } + + +@router.post("/presets/{preset_id}/test") +async def test_preset( + preset_id: str, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 测试预设的API连接 + """ + settings = await get_user_settings(user.user_id, db) + + # 解析preferences + try: + prefs = json.loads(settings.preferences or '{}') + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="配置数据格式错误") + + api_presets = prefs.get('api_presets', {'presets': [], 'version': '1.0'}) + presets = api_presets.get('presets', []) + + # 找到预设 + target_preset = next((p for p in presets if p['id'] == preset_id), None) + if not target_preset: + raise HTTPException(status_code=404, detail="预设不存在") + + # 使用现有的test_api_connection逻辑 + config = target_preset['config'] + test_request = ApiTestRequest( + api_key=config['api_key'], + api_base_url=config.get('api_base_url', ''), + provider=config['api_provider'], + llm_model=config['llm_model'] + ) + + logger.info(f"用户 {user.user_id} 测试预设: {target_preset['name']}") + return await test_api_connection(test_request) + + +@router.post("/presets/from-current", response_model=PresetResponse) +async def create_preset_from_current( + name: str, + description: Optional[str] = None, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 从当前配置创建新预设 + + 快捷方式:将当前激活的配置保存为新预设 + """ + settings = await get_user_settings(user.user_id, db) + + # 从当前Settings主字段读取配置 + current_config = APIKeyPresetConfig( + api_provider=settings.api_provider, + api_key=settings.api_key, + api_base_url=settings.api_base_url, + llm_model=settings.llm_model, + temperature=settings.temperature, + max_tokens=settings.max_tokens + ) + + # 创建预设 + create_request = PresetCreateRequest( + name=name, + description=description, + config=current_config + ) + + logger.info(f"用户 {user.user_id} 从当前配置创建预设: {name}") + return await create_preset(create_request, user, db) \ No newline at end of file diff --git a/backend/app/schemas/character.py b/backend/app/schemas/character.py index 369c7fd..0ee4f8e 100644 --- a/backend/app/schemas/character.py +++ b/backend/app/schemas/character.py @@ -61,6 +61,12 @@ class CharacterUpdate(BaseModel): organization_purpose: Optional[str] = None organization_members: Optional[str] = None traits: Optional[str] = None + + # 组织额外字段(会同步到Organization表) + power_level: Optional[int] = Field(None, description="组织势力等级(0-100)") + location: Optional[str] = Field(None, description="组织所在地") + motto: Optional[str] = Field(None, description="组织格言/口号") + color: Optional[str] = Field(None, description="组织代表颜色") class CharacterResponse(CharacterBase): diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 03b48f6..21005ce 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -1,6 +1,6 @@ """设置相关的Pydantic模型""" from pydantic import BaseModel, Field, ConfigDict -from typing import Optional +from typing import Optional, List from datetime import datetime @@ -34,4 +34,62 @@ class SettingsResponse(SettingsBase): id: str user_id: str created_at: datetime - updated_at: datetime \ No newline at end of file + updated_at: datetime + + +# ========== API配置预设相关模型 ========== + +class APIKeyPresetConfig(BaseModel): + """预设配置内容""" + model_config = ConfigDict(protected_namespaces=()) + + api_provider: str = Field(..., description="API提供商") + api_key: str = Field(..., description="API密钥") + api_base_url: Optional[str] = Field(None, description="自定义API地址") + llm_model: str = Field(..., description="模型名称") + temperature: float = Field(default=0.7, ge=0.0, le=2.0, description="温度参数") + max_tokens: int = Field(default=2000, ge=1, description="最大token数") + + +class APIKeyPreset(BaseModel): + """API配置预设""" + model_config = ConfigDict(protected_namespaces=()) + + id: str = Field(..., description="预设ID") + name: str = Field(..., min_length=1, max_length=50, description="预设名称") + description: Optional[str] = Field(None, max_length=200, description="预设描述") + is_active: bool = Field(default=False, description="是否激活") + created_at: datetime = Field(..., description="创建时间") + config: APIKeyPresetConfig = Field(..., description="配置内容") + + +class PresetCreateRequest(BaseModel): + """创建预设请求""" + model_config = ConfigDict(protected_namespaces=()) + + name: str = Field(..., min_length=1, max_length=50, description="预设名称") + description: Optional[str] = Field(None, max_length=200, description="预设描述") + config: APIKeyPresetConfig = Field(..., description="配置内容") + + +class PresetUpdateRequest(BaseModel): + """更新预设请求""" + model_config = ConfigDict(protected_namespaces=()) + + name: Optional[str] = Field(None, min_length=1, max_length=50, description="预设名称") + description: Optional[str] = Field(None, max_length=200, description="预设描述") + config: Optional[APIKeyPresetConfig] = Field(None, description="配置内容") + + +class PresetResponse(APIKeyPreset): + """预设响应""" + pass + + +class PresetListResponse(BaseModel): + """预设列表响应""" + model_config = ConfigDict(protected_namespaces=()) + + presets: List[PresetResponse] = Field(..., description="预设列表") + total: int = Field(..., description="总数") + active_preset_id: Optional[str] = Field(None, description="当前激活的预设ID") \ No newline at end of file diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index 906b6ba..103b5d4 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -374,7 +374,10 @@ class PromptService: 2. **关系约束**:relationships_array只能引用本批次中已经出现的角色名称 3. **组织约束**:organization_memberships只能引用本批次中is_organization=true的实体名称 4. **禁止幻觉**:不要引用任何不存在的角色或组织,如果没有可引用的就留空数组[] -5. intimacy_level是-100到100的整数(负值表示敌对仇恨关系),loyalty是0-100的整数 +5. **数值范围约束**: + - intimacy_level:-100到100的整数(负值表示敌对仇恨关系) + - loyalty:0到100的整数 + - **rank:0到10的整数**(职位等级,0最低,10最高) 6. 角色之间要形成合理的关系网络 **示例说明**: @@ -1584,14 +1587,16 @@ class PromptService: 1. **relationships数组必填**:至少要有1-3个与已有角色的关系(除非确实没有合理的关联) 2. **target_character_name必须精确匹配**:只能引用【已有角色】列表中的角色名称 3. organization_memberships只能引用已存在的组织名称 -4. intimacy_level是-100到100的整数: - - 80-100:至亲、挚友、深爱 - - 50-79:亲密、友好 - - 0-49:一般、普通 - - -1到-49:不和、敌视 - - -50到-100:仇恨、死敌 -5. loyalty是0-100的整数(仅用于组织成员) -6. status默认为"active",表示当前关系状态 +4. **数值范围约束**: + - intimacy_level:-100到100的整数 + * 80-100:至亲、挚友、深爱 + * 50-79:亲密、友好 + * 0-49:一般、普通 + * -1到-49:不和、敌视 + * -50到-100:仇恨、死敌 + - loyalty:0到100的整数(仅用于组织成员) + - **rank:0到10的整数**(职位等级,0最低,10最高) +5. status默认为"active",表示当前关系状态 **关系建立示例:** - 如果新角色是主角的新队友,应该与主角建立"队友"或"朋友"关系 diff --git a/frontend/src/pages/Characters.tsx b/frontend/src/pages/Characters.tsx index b43f5a0..a167af0 100644 --- a/frontend/src/pages/Characters.tsx +++ b/frontend/src/pages/Characters.tsx @@ -612,8 +612,12 @@ export default function Characters() { - - + + @@ -626,6 +630,10 @@ export default function Characters() {