diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index f32c8cc..18ae97c 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -57,7 +57,7 @@ from app.services.memory_service import memory_service from app.services.foreshadow_service import foreshadow_service from app.services.chapter_regenerator import ChapterRegenerator from app.logger import get_logger -from app.api.settings import get_user_ai_service +from app.api.settings import get_user_ai_service, get_user_ai_service_from_db_by_usage from app.utils.sse_response import SSEResponse, create_sse_response router = APIRouter(prefix="/chapters", tags=["章节管理"]) @@ -797,7 +797,7 @@ async def analyze_chapter_background( user_id: str, project_id: str, task_id: str, - ai_service: AIService + ai_service: Optional[AIService] = None ) -> bool: """ 后台异步分析章节(支持并发,使用锁保护数据库写入) @@ -865,6 +865,13 @@ async def analyze_chapter_background( task.progress = 20 await db_session.commit() + if ai_service is None: + ai_service = await get_user_ai_service_from_db_by_usage( + user_id=user_id, + db=db_session, + usage="chapter_analysis" + ) + # 获取已埋入的伏笔列表(用于回收匹配,传入当前章节号以启用智能标记) existing_foreshadows = await foreshadow_service.get_planted_foreshadows_for_analysis( db=db_session, @@ -2229,8 +2236,7 @@ async def _run_chapter_generation_bg( chapter_id=chapter_id, user_id=user_id, project_id=current_chapter.project_id, - task_id=analysis_task.id, - ai_service=ai_service + task_id=analysis_task.id ) ) @@ -2441,7 +2447,7 @@ async def _run_batch_analysis_in_sequence( tasks_queue: list[dict[str, int | str]], user_id: str, project_id: str, - ai_service: AIService + ai_service: Optional[AIService] = None ) -> None: """按章节顺序逐个执行分析任务。""" for index, task_item in enumerate(tasks_queue, start=1): @@ -2477,8 +2483,7 @@ async def batch_analyze_unanalyzed_chapters( project_id: str, payload: BatchAnalyzeUnanalyzedRequest, request: Request, - db: AsyncSession = Depends(get_db), - user_ai_service: AIService = Depends(get_user_ai_service) + db: AsyncSession = Depends(get_db) ): """自动识别项目中未完成分析的章节,并按章节顺序逐个启动分析。""" user_id = getattr(request.state, "user_id", None) @@ -2585,8 +2590,7 @@ async def batch_analyze_unanalyzed_chapters( _run_batch_analysis_in_sequence( tasks_queue=tasks_queue, user_id=user_id, - project_id=project_id, - ai_service=user_ai_service + project_id=project_id ) ) @@ -2818,8 +2822,7 @@ async def trigger_chapter_analysis( chapter_id: str, request: Request, background_tasks: BackgroundTasks, - db: AsyncSession = Depends(get_db), - user_ai_service: AIService = Depends(get_user_ai_service) + db: AsyncSession = Depends(get_db) ): """ 手动触发章节分析(用于重新分析或分析旧章节) @@ -2876,8 +2879,7 @@ async def trigger_chapter_analysis( chapter_id=chapter_id, user_id=user_id, project_id=project.id, - task_id=task_id, - ai_service=user_ai_service + task_id=task_id ) return { @@ -3274,8 +3276,7 @@ async def execute_batch_generation_in_order( chapter_id=chapter_id, user_id=user_id, project_id=task.project_id, - task_id=analysis_task.id, - ai_service=ai_service + task_id=analysis_task.id ) # 直接根据返回值判断 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 4ad7f4a..6d52b32 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -19,6 +19,7 @@ from app.schemas.settings import ( SettingsCreate, SettingsUpdate, SettingsResponse, APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest, PresetUpdateRequest, PresetResponse, PresetListResponse, + ChapterAnalysisPresetSelectionRequest, SystemSMTPSettingsResponse, SystemSMTPSettingsUpdate, SMTPTestRequest ) from app.user_manager import User @@ -52,6 +53,53 @@ def read_env_defaults() -> Dict[str, Any]: } +def _safe_load_preferences(raw_preferences: Optional[str]) -> Dict[str, Any]: + """安全解析用户偏好设置。""" + try: + return json.loads(raw_preferences or '{}') + except (json.JSONDecodeError, TypeError): + return {} + + +def _get_api_presets_payload(prefs: Dict[str, Any]) -> Dict[str, Any]: + """获取API预设偏好结构。""" + api_presets = prefs.get('api_presets') + if not isinstance(api_presets, dict): + api_presets = {'presets': [], 'version': '1.0'} + if not isinstance(api_presets.get('presets'), list): + api_presets['presets'] = [] + api_presets.setdefault('version', '1.0') + return api_presets + + +def _get_chapter_analysis_preset_id(prefs: Dict[str, Any]) -> Optional[str]: + """读取章节内容分析专用API预设ID。""" + preset_id = prefs.get('chapter_analysis_preset_id') + return preset_id if isinstance(preset_id, str) and preset_id.strip() else None + + +def _build_ai_service_from_config( + *, + config: Dict[str, Any], + user_id: str, + db: AsyncSession, + enable_mcp: bool, +) -> AIService: + """基于指定配置创建AI服务。""" + return create_user_ai_service_with_mcp( + api_provider=normalize_provider(config.get('api_provider')), + api_key=config.get('api_key') or "", + api_base_url=config.get('api_base_url') or "", + model_name=config.get('llm_model') or app_settings.default_model, + temperature=config.get('temperature') if config.get('temperature') is not None else app_settings.default_temperature, + max_tokens=config.get('max_tokens') if config.get('max_tokens') is not None else app_settings.default_max_tokens, + user_id=user_id, + db_session=db, + system_prompt=config.get('system_prompt'), + enable_mcp=enable_mcp, + ) + + def require_login(request: Request): """依赖:要求用户已登录""" if not hasattr(request.state, "user") or not request.state.user: @@ -164,6 +212,15 @@ async def get_user_ai_service_from_db(user_id: str, db: AsyncSession) -> AIServi """ 从数据库直接创建用户AI服务实例(用于后台任务,不依赖FastAPI的Depends) """ + return await get_user_ai_service_from_db_by_usage(user_id, db, usage="default") + + +async def get_user_ai_service_from_db_by_usage( + user_id: str, + db: AsyncSession, + usage: str = "default" +) -> AIService: + """按用途创建用户AI服务实例。""" from app.models.mcp_plugin import MCPPlugin result = await db.execute( @@ -184,6 +241,23 @@ async def get_user_ai_service_from_db(user_id: str, db: AsyncSession) -> AIServi mcp_plugins = mcp_result.scalars().all() enable_mcp = any(plugin.enabled for plugin in mcp_plugins) if mcp_plugins else False + if usage == "chapter_analysis": + prefs = _safe_load_preferences(settings.preferences) + api_presets = _get_api_presets_payload(prefs) + presets = api_presets.get('presets', []) + preset_id = _get_chapter_analysis_preset_id(prefs) + if preset_id: + target_preset = next((p for p in presets if p.get('id') == preset_id), None) + if target_preset and isinstance(target_preset.get('config'), dict): + logger.info(f"用户 {user_id} 使用章节内容分析专用API预设: {target_preset.get('name')}") + return _build_ai_service_from_config( + config=target_preset['config'], + user_id=user_id, + db=db, + enable_mcp=enable_mcp, + ) + logger.warning(f"用户 {user_id} 配置的章节内容分析预设不存在,回退默认API配置: {preset_id}") + return create_user_ai_service_with_mcp( api_provider=settings.api_provider, api_key=settings.api_key, @@ -1043,8 +1117,11 @@ async def get_presets( logger.warning(f"用户 {user.user_id} 的preferences字段JSON格式错误,重置为空") prefs = {} - api_presets = prefs.get('api_presets', {'presets': [], 'version': '1.0'}) + api_presets = _get_api_presets_payload(prefs) presets = api_presets.get('presets', []) + chapter_analysis_preset_id = _get_chapter_analysis_preset_id(prefs) + if chapter_analysis_preset_id and not any(p.get('id') == chapter_analysis_preset_id for p in presets): + chapter_analysis_preset_id = None # 找到激活的预设 active_preset_id = next( @@ -1057,7 +1134,8 @@ async def get_presets( return { "presets": presets, "total": len(presets), - "active_preset_id": active_preset_id + "active_preset_id": active_preset_id, + "chapter_analysis_preset_id": chapter_analysis_preset_id } @@ -1177,7 +1255,7 @@ async def delete_preset( except json.JSONDecodeError: raise HTTPException(status_code=500, detail="配置数据格式错误") - api_presets = prefs.get('api_presets', {'presets': [], 'version': '1.0'}) + api_presets = _get_api_presets_payload(prefs) presets = api_presets.get('presets', []) # 找到预设 @@ -1191,6 +1269,8 @@ async def delete_preset( # 删除预设 presets = [p for p in presets if p['id'] != preset_id] + if prefs.get('chapter_analysis_preset_id') == preset_id: + prefs.pop('chapter_analysis_preset_id', None) # 保存回preferences api_presets['presets'] = presets @@ -1258,6 +1338,41 @@ async def activate_preset( } +@router.put("/presets/usage/chapter-analysis") +async def set_chapter_analysis_preset_selection( + data: ChapterAnalysisPresetSelectionRequest, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """设置章节内容分析专用API预设;为空则使用默认API配置。""" + settings = await get_user_settings(user.user_id, db) + prefs = _safe_load_preferences(settings.preferences) + api_presets = _get_api_presets_payload(prefs) + presets = api_presets.get('presets', []) + + preset_id = data.preset_id.strip() if data.preset_id else None + preset_name = None + if preset_id: + target_preset = next((p for p in presets if p.get('id') == preset_id), None) + if not target_preset: + raise HTTPException(status_code=404, detail="预设不存在") + prefs['chapter_analysis_preset_id'] = preset_id + preset_name = target_preset.get('name') + else: + prefs.pop('chapter_analysis_preset_id', None) + + prefs['api_presets'] = api_presets + settings.preferences = json.dumps(prefs, ensure_ascii=False) + await db.commit() + + logger.info(f"用户 {user.user_id} 设置章节内容分析API预设: {preset_id or '默认配置'}") + return { + "message": "章节内容分析API配置已更新", + "chapter_analysis_preset_id": preset_id, + "preset_name": preset_name + } + + @router.post("/presets/{preset_id}/test") async def test_preset( preset_id: str, diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 1b40369..02370cf 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -140,4 +140,12 @@ class PresetListResponse(BaseModel): presets: List[PresetResponse] = Field(..., description="预设列表") total: int = Field(..., description="总数") - active_preset_id: Optional[str] = Field(None, description="当前激活的预设ID") \ No newline at end of file + active_preset_id: Optional[str] = Field(None, description="当前激活的预设ID") + chapter_analysis_preset_id: Optional[str] = Field(None, description="章节内容分析使用的预设ID,为空则使用默认API配置") + + +class ChapterAnalysisPresetSelectionRequest(BaseModel): + """章节内容分析预设选择请求""" + model_config = ConfigDict(protected_namespaces=()) + + preset_id: Optional[str] = Field(None, description="章节内容分析使用的预设ID;为空则使用默认API配置") \ No newline at end of file diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index b012195..5326015 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -48,6 +48,8 @@ export default function SettingsPage() { const [presets, setPresets] = useState([]); const [presetsLoading, setPresetsLoading] = useState(false); const [activePresetId, setActivePresetId] = useState(); + const [chapterAnalysisPresetId, setChapterAnalysisPresetId] = useState(); + const [savingChapterAnalysisPreset, setSavingChapterAnalysisPreset] = useState(false); const [editingPreset, setEditingPreset] = useState(null); const [isPresetModalVisible, setIsPresetModalVisible] = useState(false); const [testingPresetId, setTestingPresetId] = useState(null); @@ -511,6 +513,7 @@ export default function SettingsPage() { const response = await settingsApi.getPresets(); setPresets(response.presets); setActivePresetId(response.active_preset_id); + setChapterAnalysisPresetId(response.chapter_analysis_preset_id); } catch (error) { message.error('加载预设失败'); console.error(error); @@ -656,6 +659,23 @@ export default function SettingsPage() { } }; + const handleChapterAnalysisPresetChange = async (presetId?: string) => { + setSavingChapterAnalysisPreset(true); + try { + const normalizedPresetId = presetId || undefined; + await settingsApi.setChapterAnalysisPresetSelection(normalizedPresetId); + setChapterAnalysisPresetId(normalizedPresetId); + message.success(normalizedPresetId ? '已设置章节内容分析专用API配置' : '章节内容分析已恢复使用默认API配置'); + loadPresets(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + message.error(error.response?.data?.detail || '设置章节内容分析API配置失败'); + console.error(error); + } finally { + setSavingChapterAnalysisPreset(false); + } + }; + const handlePresetDelete = async (presetId: string) => { try { await settingsApi.deletePreset(presetId); @@ -927,6 +947,38 @@ export default function SettingsPage() { + + + + + 章节内容分析 API 配置 + + 指定章节内容分析使用的预设;未选择时使用默认的文本模型配置。 + + +