diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index bf415ffb5..cf37c4866 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -27,6 +27,7 @@ from astrbot.core.db.po import ( UmoAlias, WebChatThread, ) +from astrbot.core.sentinels import NOT_GIVEN @dataclass @@ -444,11 +445,23 @@ class BaseDatabase(abc.ABC): persona_id: str, system_prompt: str | None = None, begin_dialogs: list[str] | None = None, - tools: list[str] | None = None, - skills: list[str] | None = None, - custom_error_message: str | None = None, + tools: list[str] | None | object = NOT_GIVEN, + skills: list[str] | None | object = NOT_GIVEN, + custom_error_message: str | None | object = NOT_GIVEN, ) -> Persona | None: - """Update a persona's system prompt or begin dialogs.""" + """Update a persona record. + + Args: + persona_id: Persona ID to update. + system_prompt: Optional replacement system prompt. + begin_dialogs: Optional replacement begin dialogs. + tools: Tool names, None for all tools, or NOT_GIVEN to leave unchanged. + skills: Skill names, None for all skills, or NOT_GIVEN to leave unchanged. + custom_error_message: Custom fallback message, None to clear, or NOT_GIVEN to leave unchanged. + + Returns: + Updated persona, or None when no fields were updated. + """ ... @abc.abstractmethod diff --git a/astrbot/dashboard/api/personas.py b/astrbot/dashboard/api/personas.py index ae83e5452..00bdeba6a 100644 --- a/astrbot/dashboard/api/personas.py +++ b/astrbot/dashboard/api/personas.py @@ -45,7 +45,15 @@ async def _json_or_empty(request: Request) -> dict[str, Any]: def _model_dict(payload) -> dict[str, Any]: - return payload.model_dump(exclude_none=True) + """Serialize a request model while preserving explicit null updates. + + Args: + payload: Pydantic request model. + + Returns: + Request data without fields omitted by the caller. + """ + return payload.model_dump(exclude_unset=True) def _raise_persona_error(exc: PersonaServiceError | ValueError) -> None: diff --git a/astrbot/dashboard/services/persona_service.py b/astrbot/dashboard/services/persona_service.py index 31c4f1084..5a319b0e2 100644 --- a/astrbot/dashboard/services/persona_service.py +++ b/astrbot/dashboard/services/persona_service.py @@ -40,8 +40,12 @@ class PersonaService: async def create_persona(self, data: object) -> dict: payload = self._payload(data) - persona_id = str(payload.get("persona_id", "")).strip() - system_prompt = str(payload.get("system_prompt", "")).strip() + raw_persona_id = payload.get("persona_id") + raw_system_prompt = payload.get("system_prompt") + persona_id = str(raw_persona_id).strip() if raw_persona_id is not None else "" + system_prompt = ( + str(raw_system_prompt).strip() if raw_system_prompt is not None else "" + ) begin_dialogs = payload.get("begin_dialogs", []) tools = payload.get("tools") skills = payload.get("skills") diff --git a/tests/test_fastapi_v1_dashboard.py b/tests/test_fastapi_v1_dashboard.py index 937139757..7e73869dd 100644 --- a/tests/test_fastapi_v1_dashboard.py +++ b/tests/test_fastapi_v1_dashboard.py @@ -473,7 +473,7 @@ class FakePersonaManager: async def update_persona(self, persona_id: str, **kwargs) -> None: persona = self.personas[persona_id] for key, value in kwargs.items(): - if value is not None: + if key in ("tools", "skills", "custom_error_message") or value is not None: setattr(persona, key, value) async def delete_persona(self, persona_id: str) -> None: @@ -2476,6 +2476,29 @@ async def test_v1_safe_persona_routes_accept_slash_ids( assert persona_id not in persona_mgr.personas +@pytest.mark.asyncio +async def test_v1_persona_by_id_update_preserves_explicit_null_tools_and_skills( + asgi_client: httpx.AsyncClient, + fake_core_lifecycle, +): + persona_id = "persona/foo" + headers = _jwt_headers() + persona = fake_core_lifecycle.persona_mgr.personas[persona_id] + persona.tools = ["tool-a"] + persona.skills = ["skill-a"] + + response = await asgi_client.put( + "/api/v1/personas/by-id", + json={"persona_id": persona_id, "tools": None, "skills": None}, + headers=headers, + ) + + assert response.status_code == 200 + assert response.json()["data"] == {"message": "人格更新成功"} + assert persona.tools is None + assert persona.skills is None + + @pytest.mark.asyncio async def test_v1_im_routes_use_im_scope_and_running_platform( asgi_client: httpx.AsyncClient,