Просмотр исходного кода

新增群聊与话题归档恢复

samien 2 месяцев назад
Родитель
Сommit
5f0a93156d

+ 101 - 0
hermes-plugins/yaoyao/dashboard/group_orchestrator.py

@@ -131,6 +131,16 @@ class _Store(Protocol):
         self, room_id: str, command: Mapping[str, object]
     ) -> dict[str, object]: ...
 
+    def restore_room(self, room_id: str, command: Mapping[str, object]) -> dict[str, object]: ...
+
+    def archive_topic(
+        self, room_id: str, topic_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]: ...
+
+    def restore_topic(
+        self, room_id: str, topic_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]: ...
+
     def create_room(self, command: Mapping[str, object]) -> dict[str, object]: ...
 
     def update_room(
@@ -235,6 +245,7 @@ class _GatewayEvent:
 class _RuntimeState:
     run_id: str
     room_id: str
+    topic_id: str
     agent_id: str
     runtime_id: str
     generation: int
@@ -782,6 +793,90 @@ class GroupOrchestrator:
             self.wake()
             return dict(result)
 
+    async def archive_topic(
+        self, *, room_id: str, topic_id: str, request_id: str
+    ) -> dict[str, object]:
+        _required_string(room_id, "roomId")
+        _required_string(topic_id, "topicId")
+        _required_string(request_id, "requestId")
+        return await self._tracked_control(
+            lambda: self._archive_topic(room_id, topic_id, request_id),
+            task_name=f"yaoyao-group-archive-topic-{request_id}",
+            failure_message="YaoYao group topic archive failed",
+        )
+
+    async def _archive_topic(
+        self, room_id: str, topic_id: str, request_id: str
+    ) -> dict[str, object]:
+        async with self._room_serial(room_id):
+            with self._mapping_lock:
+                states = sorted(
+                    (state for _generation, state in self._runtime_states.values()
+                     if state.room_id == room_id and state.topic_id == topic_id),
+                    key=lambda state: (state.agent_id, state.runtime_id, state.generation),
+                )
+            async with AsyncExitStack() as stack:
+                for state in states:
+                    await stack.enter_async_context(self._submission_serial(state.agent_id))
+                    await stack.enter_async_context(state.mutation_lock)
+                current_states = self._current_owned_states(states)
+                envelope = await self._run_states_blocking(
+                    current_states, self.store.archive_topic, room_id, topic_id,
+                    {"requestId": request_id},
+                )
+                if not isinstance(envelope, Mapping):
+                    raise GroupOrchestratorError("Archived topic is invalid")
+                result = dict(envelope)
+                if result.get("id") != topic_id or result.get("archived") is not True:
+                    raise GroupOrchestratorError("Archived topic identity changed")
+                self.wake()
+                cleanup_errors = await self._cleanup_lifecycle_targets_locked(
+                    current_states, tuple(), room_id=room_id, topic_id=topic_id,
+                )
+                if cleanup_errors:
+                    raise GroupOrchestratorError(
+                        "Topic was archived durably, but Gateway cleanup failed: "
+                        + "; ".join(cleanup_errors)
+                    )
+                return result
+
+    async def restore_topic(
+        self, *, room_id: str, topic_id: str, request_id: str
+    ) -> dict[str, object]:
+        return await self._topic_lifecycle(
+            room_id, topic_id, request_id, self.store.restore_topic, "restore"
+        )
+
+    async def restore_room(
+        self, *, room_id: str, request_id: str
+    ) -> dict[str, object]:
+        _required_string(room_id, "roomId")
+        _required_string(request_id, "requestId")
+        async with self._room_serial(room_id):
+            result = await asyncio.to_thread(
+                self.store.restore_room, room_id, {"requestId": request_id}
+            )
+            if not isinstance(result, Mapping):
+                raise GroupOrchestratorError("Restored room is invalid")
+            self.wake()
+            return dict(result)
+
+    async def _topic_lifecycle(
+        self, room_id: str, topic_id: str, request_id: str,
+        function: Callable[..., object], label: str,
+    ) -> dict[str, object]:
+        _required_string(room_id, "roomId")
+        _required_string(topic_id, "topicId")
+        _required_string(request_id, "requestId")
+        async with self._room_serial(room_id):
+            result = await asyncio.to_thread(
+                function, room_id, topic_id, {"requestId": request_id}
+            )
+            if not isinstance(result, Mapping):
+                raise GroupOrchestratorError(f"{label.title()}d topic is invalid")
+            self.wake()
+            return dict(result)
+
     async def mark_topic_read(
         self, *, room_id: str, topic_id: str, command: Mapping[str, object]
     ) -> dict[str, object]:
@@ -1161,12 +1256,14 @@ class GroupOrchestrator:
         *,
         room_id: str,
         agent_id: str | None = None,
+        topic_id: str | None = None,
     ) -> list[str]:
         owned = {
             state.runtime_id: state
             for state in states
             if state.room_id == room_id
             and (agent_id is None or state.agent_id == agent_id)
+            and (topic_id is None or state.topic_id == topic_id)
         }
         cleanup_errors: list[str] = []
         replay_runtime_ids = set(runtime_ids)
@@ -2061,6 +2158,7 @@ class GroupOrchestrator:
         room = _required_mapping(projection.get("room"), "projection.room")
         agent = _required_mapping(projection.get("agent"), "projection.agent")
         room_id = _required_string(room.get("id"), "projection.room.id")
+        topic_id = _required_string(run.get("topicId"), "projection.run.topicId")
         agent_id = _required_string(agent.get("id"), "projection.agent.id")
         if _required_string(run.get("id"), "projection.run.id") != run_id:
             raise GroupOrchestratorError("projection run identity changed")
@@ -2226,6 +2324,7 @@ class GroupOrchestrator:
                 state = self._install_runtime(
                     run_id=run_id,
                     room_id=room_id,
+                    topic_id=topic_id,
                     agent_id=agent_id,
                     identity=identity,
                     expected_stored_id=expected_stored_id,
@@ -2417,6 +2516,7 @@ class GroupOrchestrator:
         *,
         run_id: str,
         room_id: str,
+        topic_id: str,
         agent_id: str,
         identity: SessionIdentity,
         expected_stored_id: str | None,
@@ -2435,6 +2535,7 @@ class GroupOrchestrator:
             state = _RuntimeState(
                 run_id=run_id,
                 room_id=room_id,
+                topic_id=topic_id,
                 agent_id=agent_id,
                 runtime_id=identity.runtime_id,
                 generation=self._next_generation,

+ 52 - 6
hermes-plugins/yaoyao/dashboard/group_plugin_api.py

@@ -354,6 +354,15 @@ class _UnavailableRuntime:
     async def archive_room(self, **_: object) -> dict[str, object]:
         raise _package._yaoyao_group_runtime_unavailable_error()
 
+    async def restore_room(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+    async def archive_topic(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+    async def restore_topic(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
     async def add_agent(self, **_: object) -> dict[str, object]:
         raise _package._yaoyao_group_runtime_unavailable_error()
 
@@ -1045,8 +1054,11 @@ async def capabilities() -> dict[str, object]:
 async def list_rooms(
     limit: int = Query(default=50, ge=1, le=100),
     cursor: str | None = Query(default=None, min_length=1, max_length=4096),
+    archived: bool = Query(default=False),
 ) -> dict[str, object]:
-    page = await _store_call("list_rooms", limit=limit, cursor=cursor)
+    page = await _store_call(
+        "list_rooms", limit=limit, cursor=cursor, archived=archived
+    )
     return {"items": page.items, "nextCursor": page.next_cursor}
 
 
@@ -1054,9 +1066,11 @@ async def list_topics(
     room_id: UUID,
     limit: int = Query(default=50, ge=1, le=100),
     cursor: str | None = Query(default=None, min_length=1, max_length=4096),
+    archived: bool = Query(default=False),
 ) -> dict[str, object]:
     page = await _store_call(
-        "list_topics", str(room_id), limit=limit, cursor=cursor
+        "list_topics", str(room_id), limit=limit, cursor=cursor,
+        archived=archived,
     )
     return {"items": page.items, "nextCursor": page.next_cursor}
 
@@ -1072,6 +1086,24 @@ async def update_topic(
     )
 
 
+async def archive_topic(
+    room_id: UUID, topic_id: UUID, request: RequestIDRequest
+) -> dict[str, object]:
+    return await _runtime_call(
+        "archive_topic", room_id=str(room_id), topic_id=str(topic_id),
+        request_id=str(request.request_id),
+    )
+
+
+async def restore_topic(
+    room_id: UUID, topic_id: UUID, request: RequestIDRequest
+) -> dict[str, object]:
+    return await _runtime_call(
+        "restore_topic", room_id=str(room_id), topic_id=str(topic_id),
+        request_id=str(request.request_id),
+    )
+
+
 async def mark_topic_read(
     room_id: UUID, topic_id: UUID, request: MarkTopicReadRequest
 ) -> dict[str, object]:
@@ -1107,6 +1139,12 @@ async def archive_room(room_id: UUID, request: RequestIDRequest) -> dict[str, ob
     )
 
 
+async def restore_room(room_id: UUID, request: RequestIDRequest) -> dict[str, object]:
+    return await _runtime_call(
+        "restore_room", room_id=str(room_id), request_id=str(request.request_id)
+    )
+
+
 async def add_agent(room_id: UUID, request: AddAgentRequest) -> dict[str, object]:
     return await _runtime_call(
         "add_agent", room_id=str(room_id), command=_add_agent_command(request)
@@ -1264,6 +1302,18 @@ def _build_router() -> APIRouter:
     built.add_api_route("/rooms/{room_id}", get_room, methods=["GET"])
     built.add_api_route("/rooms/{room_id}", update_room, methods=["PATCH"])
     built.add_api_route("/rooms/{room_id}", archive_room, methods=["DELETE"])
+    built.add_api_route("/rooms/{room_id}/restore", restore_room, methods=["POST"])
+    built.add_api_route("/rooms/{room_id}/topics", list_topics, methods=["GET"])
+    built.add_api_route(
+        "/rooms/{room_id}/topics/{topic_id}", update_topic, methods=["PATCH"]
+    )
+    built.add_api_route(
+        "/rooms/{room_id}/topics/{topic_id}", archive_topic, methods=["DELETE"]
+    )
+    built.add_api_route(
+        "/rooms/{room_id}/topics/{topic_id}/restore", restore_topic,
+        methods=["POST"],
+    )
     built.add_api_route("/rooms/{room_id}/agents", add_agent, methods=["POST"])
     built.add_api_route(
         "/rooms/{room_id}/agents/{agent_id}", update_agent, methods=["PATCH"]
@@ -1276,10 +1326,6 @@ def _build_router() -> APIRouter:
         interrupt_agent,
         methods=["POST"],
     )
-    built.add_api_route("/rooms/{room_id}/topics", list_topics, methods=["GET"])
-    built.add_api_route(
-        "/rooms/{room_id}/topics/{topic_id}", update_topic, methods=["PATCH"]
-    )
     built.add_api_route(
         "/rooms/{room_id}/topics/{topic_id}/read",
         mark_topic_read,

+ 4 - 1
hermes-plugins/yaoyao/dashboard/group_protocol.py

@@ -18,7 +18,7 @@ from pydantic import (
 )
 
 
-PROTOCOL_VERSION = 8
+PROTOCOL_VERSION = 9
 MAX_AGENTS_PER_ROOM = 8
 MAX_MESSAGE_BYTES = 64 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024
@@ -51,12 +51,15 @@ EVENT_TYPES = frozenset({
     "room.created",
     "room.updated",
     "room.deleted",
+    "room.restored",
     "agent.created",
     "agent.updated",
     "agent.deleted",
     "agent.status",
     "message.upsert",
     "topic.updated",
+    "topic.archived",
+    "topic.restored",
     "room.activity",
     "interaction.requested",
     "interaction.resolved",

+ 156 - 10
hermes-plugins/yaoyao/dashboard/group_store.py

@@ -41,7 +41,7 @@ from .group_protocol import (
 )
 
 
-SCHEMA_VERSION = 8
+SCHEMA_VERSION = 9
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
@@ -123,6 +123,7 @@ _SCHEMA_STATEMENTS = (
         last_read_message_seq INTEGER NOT NULL DEFAULT 0,
         created_at REAL NOT NULL,
         updated_at REAL NOT NULL,
+        archived INTEGER NOT NULL DEFAULT 0,
         UNIQUE(room_id, id)
     )""",
     """CREATE TABLE IF NOT EXISTS group_agents (
@@ -234,7 +235,7 @@ _SCHEMA_STATEMENTS = (
         created_at REAL NOT NULL
     )""",
     "CREATE INDEX IF NOT EXISTS idx_group_rooms_ordering ON group_rooms(archived, updated_at DESC, id DESC)",
-    "CREATE INDEX IF NOT EXISTS idx_group_topics_room_ordering ON group_topics(room_id, updated_at DESC, id DESC)",
+    "CREATE INDEX IF NOT EXISTS idx_group_topics_room_ordering ON group_topics(room_id, archived, updated_at DESC, id DESC)",
     "CREATE INDEX IF NOT EXISTS idx_group_messages_room_seq ON group_messages(room_id, seq)",
     "CREATE INDEX IF NOT EXISTS idx_group_messages_topic_seq ON group_messages(topic_id, seq)",
     "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_room ON group_agent_runs(room_id)",
@@ -479,6 +480,9 @@ class GroupStore:
             raw_version = "7"
         if raw_version == "7":
             self._migrate_v7_to_v8(connection)
+            raw_version = "8"
+        if raw_version == "8":
+            self._migrate_v8_to_v9(connection)
         self._validated_schema_version(
             self._metadata_value_from_connection(connection, "schema_version")
         )
@@ -1155,12 +1159,32 @@ class GroupStore:
             "UPDATE group_meta SET value = '8' WHERE key = 'schema_version'"
         )
 
+    @staticmethod
+    def _migrate_v8_to_v9(connection: sqlite3.Connection) -> None:
+        """Make topics independently archivable without removing history."""
+        columns = {
+            str(row["name"])
+            for row in connection.execute("PRAGMA table_info(group_topics)")
+        }
+        if "archived" not in columns:
+            connection.execute(
+                "ALTER TABLE group_topics ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"
+            )
+        connection.execute("DROP INDEX IF EXISTS idx_group_topics_room_ordering")
+        connection.execute(
+            "CREATE INDEX IF NOT EXISTS idx_group_topics_room_ordering "
+            "ON group_topics(room_id, archived, updated_at DESC, id DESC)"
+        )
+        connection.execute(
+            "UPDATE group_meta SET value = '9' WHERE key = 'schema_version'"
+        )
+
     @staticmethod
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
             "group_meta": {"key", "value"},
             "group_rooms": {"id", "name", "cwd", "max_reply_rounds", "created_at", "updated_at", "archived"},
-            "group_topics": {"id", "room_id", "title", "last_read_message_seq", "created_at", "updated_at"},
+            "group_topics": {"id", "room_id", "title", "last_read_message_seq", "created_at", "updated_at", "archived"},
             "group_agents": {"id", "room_id", "profile", "display_name", "display_name_key", "description", "stored_session_id", "last_context_message_seq", "enabled", "reply_without_mention", "is_host", "model_override", "provider_override", "reasoning_effort_override", "fast_mode_override", "session_config_json", "created_at", "updated_at"},
             "group_agent_topic_state": {"agent_id", "topic_id", "last_context_message_seq", "created_at", "updated_at"},
             "group_messages": {"seq", "id", "room_id", "topic_id", "sender_kind", "sender_id", "sender_name", "root_message_id", "reply_to_message_id", "client_message_id", "content", "reasoning", "tool_state_json", "status", "error", "visible", "created_at", "updated_at"},
@@ -1181,6 +1205,7 @@ class GroupStore:
             ("group_interactions", "topic_id"): ("TEXT", 1, None),
             ("group_agent_topic_state", "last_context_message_seq"): ("INTEGER", 1, "0"),
             ("group_topics", "last_read_message_seq"): ("INTEGER", 1, "0"),
+            ("group_topics", "archived"): ("INTEGER", 1, "0"),
         }
         for table, required in expected_columns.items():
             rows = list(connection.execute(f"PRAGMA table_info({table})"))
@@ -1681,7 +1706,9 @@ class GroupStore:
             result["latestCursor"] = int(cursor_row["cursor"])
             return result
 
-    def list_rooms(self, *, limit: int, cursor: str | None) -> CursorPage:
+    def list_rooms(
+        self, *, limit: int, cursor: str | None, archived: bool = False
+    ) -> CursorPage:
         if (
             not isinstance(limit, int)
             or isinstance(limit, bool)
@@ -1692,8 +1719,8 @@ class GroupStore:
         query = """SELECT id, name, cwd, max_reply_rounds, created_at, updated_at, archived,
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id) AS agent_count,
             (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id) AS topic_count
-            FROM group_rooms WHERE archived = 0"""
-        params: list[object] = []
+            FROM group_rooms WHERE archived = ?"""
+        params: list[object] = [int(archived)]
         if cursor_values is not None:
             query += " AND ((updated_at < ?) OR (updated_at = ? AND id < ?))"
             params.extend((cursor_values[0], cursor_values[0], cursor_values[1]))
@@ -1721,7 +1748,8 @@ class GroupStore:
         return CursorPage(items=items, next_cursor=next_cursor)
 
     def list_topics(
-        self, room_id: str, *, limit: int, cursor: str | None
+        self, room_id: str, *, limit: int, cursor: str | None,
+        archived: bool = False,
     ) -> CursorPage:
         """Return one room's topics in stable most-recently-active order."""
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
@@ -1755,8 +1783,9 @@ class GroupStore:
              FROM group_messages AS visible_message
              WHERE visible_message.topic_id = topic.id
                AND visible_message.visible = 1), 0) AS latest_message_seq
-            FROM group_topics AS topic WHERE topic.room_id = ?"""
-        params: list[object] = [canonical_room_id]
+            FROM group_topics AS topic
+            WHERE topic.room_id = ? AND topic.archived = ?"""
+        params: list[object] = [canonical_room_id, int(archived)]
         if cursor_values is not None:
             query += " AND ((topic.updated_at < ?) OR (topic.updated_at = ? AND topic.id < ?))"
             params.extend((cursor_values[0], cursor_values[0], cursor_values[1]))
@@ -1873,7 +1902,7 @@ class GroupStore:
 
         def action(connection: sqlite3.Connection) -> dict[str, object]:
             self._active_room(connection, canonical_room_id)
-            self._topic_row(connection, canonical_room_id, canonical_topic_id)
+            self._active_topic(connection, canonical_room_id, canonical_topic_id)
             now = self._now()
             connection.execute(
                 """UPDATE group_topics SET title = ?, updated_at = ?
@@ -1896,6 +1925,78 @@ class GroupStore:
             action,
         )
 
+    def archive_topic(
+        self, room_id: str, topic_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        canonical_topic_id = self._canonical_uuid(topic_id, "topicId")
+        command = self._command(command, {"requestId"})
+        request_id = self._command_request_id(command)
+
+        def action(connection: sqlite3.Connection) -> dict[str, object]:
+            self._active_room(connection, canonical_room_id)
+            self._active_topic(connection, canonical_room_id, canonical_topic_id)
+            now = self._now()
+            self._interrupt_runs_for_scope(
+                connection, room_id=canonical_room_id, topic_id=canonical_topic_id,
+                reason="Topic archived", now=now,
+            )
+            connection.execute(
+                "UPDATE group_topics SET archived = 1, updated_at = ? "
+                "WHERE id = ? AND room_id = ?",
+                (now, canonical_topic_id, canonical_room_id),
+            )
+            topic = self._topic_summary_by_id(
+                connection, canonical_room_id, canonical_topic_id
+            )
+            self._append_event(
+                connection, canonical_room_id, "topic.archived", topic,
+                created_at=now,
+            )
+            return topic
+
+        return self.execute_idempotent(
+            "topic.archived", request_id,
+            self._scoped_payload(command, canonical_room_id, canonical_topic_id),
+            action,
+        )
+
+    def restore_topic(
+        self, room_id: str, topic_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        canonical_topic_id = self._canonical_uuid(topic_id, "topicId")
+        command = self._command(command, {"requestId"})
+        request_id = self._command_request_id(command)
+
+        def action(connection: sqlite3.Connection) -> dict[str, object]:
+            self._active_room(connection, canonical_room_id)
+            topic_row = self._topic_row(
+                connection, canonical_room_id, canonical_topic_id
+            )
+            if not topic_row["archived"]:
+                raise GroupConflictError("Topic is already active")
+            now = self._now()
+            connection.execute(
+                "UPDATE group_topics SET archived = 0, updated_at = ? "
+                "WHERE id = ? AND room_id = ?",
+                (now, canonical_topic_id, canonical_room_id),
+            )
+            topic = self._topic_summary_by_id(
+                connection, canonical_room_id, canonical_topic_id
+            )
+            self._append_event(
+                connection, canonical_room_id, "topic.restored", topic,
+                created_at=now,
+            )
+            return topic
+
+        return self.execute_idempotent(
+            "topic.restored", request_id,
+            self._scoped_payload(command, canonical_room_id, canonical_topic_id),
+            action,
+        )
+
     def mark_topic_read(
         self, room_id: str, topic_id: str, command: Mapping[str, object]
     ) -> dict[str, object]:
@@ -1993,6 +2094,36 @@ class GroupStore:
         )
         return self._runtime_envelope(response)
 
+    def restore_room(self, room_id: str, command: Mapping[str, object]) -> dict[str, object]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        command = self._command(command, {"requestId"})
+        request_id = self._command_request_id(command)
+
+        def action(connection: sqlite3.Connection) -> dict[str, object]:
+            room = self._room_detail(
+                connection, canonical_room_id, include_archived=True
+            )
+            if not room["archived"]:
+                raise GroupConflictError("Room is already active")
+            now = self._now()
+            connection.execute(
+                "UPDATE group_rooms SET archived = 0, updated_at = ? WHERE id = ?",
+                (now, canonical_room_id),
+            )
+            restored = self._room_detail(
+                connection, canonical_room_id, include_archived=True
+            )
+            self._append_event(
+                connection, canonical_room_id, "room.restored", restored,
+                created_at=now,
+            )
+            return restored
+
+        return self.execute_idempotent(
+            "room.restored", request_id,
+            self._scoped_payload(command, canonical_room_id), action,
+        )
+
     def add_agent(
         self, room_id: str, command: Mapping[str, object]
     ) -> dict[str, object]:
@@ -4927,6 +5058,7 @@ class GroupStore:
         reason: str,
         now: float,
         agent_id: str | None = None,
+        topic_id: str | None = None,
         include_queued: bool = True,
     ) -> dict[str, list[str]]:
         statuses = (
@@ -4942,6 +5074,9 @@ class GroupStore:
         if agent_id is not None:
             query += " AND run.agent_id = ?"
             params.append(agent_id)
+        if topic_id is not None:
+            query += " AND run.topic_id = ?"
+            params.append(topic_id)
         query += " ORDER BY response.seq ASC, run.id ASC"
         runs = connection.execute(query, params).fetchall()
         changed_ids: list[str] = []
@@ -5421,6 +5556,14 @@ class GroupStore:
             raise GroupNotFoundError("Topic not found in room")
         return topic
 
+    def _active_topic(
+        self, connection: sqlite3.Connection, room_id: str, topic_id: str
+    ) -> sqlite3.Row:
+        topic = self._topic_row(connection, room_id, topic_id)
+        if topic["archived"]:
+            raise GroupNotFoundError("Topic not found in room")
+        return topic
+
     @classmethod
     def _topic_summary_by_id(
         cls, connection: sqlite3.Connection, room_id: str, topic_id: str
@@ -5467,6 +5610,8 @@ class GroupStore:
         if existing is not None:
             if existing["room_id"] != room_id:
                 raise GroupConflictError("Topic does not belong to room")
+            if existing["archived"]:
+                raise GroupConflictError("Topic is archived")
             return existing
         connection.execute(
             """INSERT INTO group_topics
@@ -6333,6 +6478,7 @@ class GroupStore:
             "unreadCount": int(row["unread_count"]),
             "latestMessageSeq": int(row["latest_message_seq"]),
             "lastReadMessageSeq": int(row["last_read_message_seq"]),
+            "archived": bool(row["archived"]),
             "createdAt": row["created_at"],
             "updatedAt": row["updated_at"],
         }

+ 4 - 3
hermes-plugins/yaoyao/dashboard/tests/test_group_topics_contract.py

@@ -37,7 +37,7 @@ def new_id() -> str:
 
 class GroupTopicsContractTests(unittest.TestCase):
     def test_protocol_and_router_advertise_topics(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 8)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 9)
         self.assertIn("topic.updated", PROTOCOL.EVENT_TYPES)
         self.assertIn("room.activity", PROTOCOL.EVENT_TYPES)
         request = PROTOCOL.SendMessageRequest.model_validate({
@@ -268,7 +268,7 @@ class GroupTopicsContractTests(unittest.TestCase):
             migrated = GroupStore(path)
             migrated.initialize()
 
-            self.assertEqual(migrated.schema_version(), 8)
+            self.assertEqual(migrated.schema_version(), 9)
             with migrated.connection() as connection:
                 topic = connection.execute(
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
@@ -453,6 +453,7 @@ class GroupTopicsContractTests(unittest.TestCase):
                     "unreadCount",
                     "latestMessageSeq",
                     "lastReadMessageSeq",
+                    "archived",
                     "createdAt",
                     "updatedAt",
                 },
@@ -1502,7 +1503,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 8)
+            self.assertEqual(store.schema_version(), 9)
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()

+ 22 - 4
src/client/api/groups.ts

@@ -16,8 +16,8 @@ export async function getGroupCapabilities(): Promise<GroupCapabilities> {
   return normalizeGroupCapabilities(unwrapData(await apiRequest<unknown>(`${BASE}/capabilities`)))
 }
 
-export async function getGroupRooms(cursor?: string, limit = 50): Promise<GroupRoomPage> {
-  const payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms`, { cursor, limit: Math.max(1, Math.min(100, limit)) })))
+export async function getGroupRooms(cursor?: string, limit = 50, archived = false): Promise<GroupRoomPage> {
+  const payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms`, { cursor, limit: Math.max(1, Math.min(100, limit)), archived })))
   const source = record(payload)
   return {
     items: values(source.items ?? source.rooms ?? payload).map(normalizeGroupRoom).filter(room => room.id),
@@ -29,9 +29,9 @@ export async function getGroupRoom(roomId: string): Promise<GroupRoomDetail> {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}`)))
 }
 
-export async function getGroupTopics(roomId: string, cursor?: string, limit = 50): Promise<GroupTopicPage> {
+export async function getGroupTopics(roomId: string, cursor?: string, limit = 50, archived = false): Promise<GroupTopicPage> {
   const payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms/${encodeURIComponent(roomId)}/topics`, {
-    cursor, limit: Math.max(1, Math.min(100, limit)),
+    cursor, limit: Math.max(1, Math.min(100, limit)), archived,
   })))
   const source = record(payload)
   return {
@@ -46,6 +46,18 @@ export async function updateGroupTopic(roomId: string, topicId: string, input: {
   })))
 }
 
+export async function archiveGroupTopic(roomId: string, topicId: string): Promise<GroupTopicSummary> {
+  return normalizeGroupTopic(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/topics/${encodeURIComponent(topicId)}`, {
+    method: 'DELETE', body: { requestId: requestId() } as JsonValue,
+  })))
+}
+
+export async function restoreGroupTopic(roomId: string, topicId: string): Promise<GroupTopicSummary> {
+  return normalizeGroupTopic(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/topics/${encodeURIComponent(topicId)}/restore`, {
+    method: 'POST', body: { requestId: requestId() } as JsonValue,
+  })))
+}
+
 export async function markGroupTopicRead(roomId: string, topicId: string, throughSeq: number): Promise<void> {
   await apiRequest(`${BASE}/rooms/${encodeURIComponent(roomId)}/topics/${encodeURIComponent(topicId)}/read`, {
     method: 'PATCH', body: { requestId: requestId(), throughSeq } as JsonValue,
@@ -82,6 +94,12 @@ export async function archiveGroupRoom(roomId: string): Promise<GroupRoomDetail>
   })))
 }
 
+export async function restoreGroupRoom(roomId: string): Promise<GroupRoomDetail> {
+  return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/restore`, {
+    method: 'POST', body: { requestId: requestId() } as JsonValue,
+  })))
+}
+
 export async function addGroupAgent(roomId: string, input: AgentSeed): Promise<GroupAgent> {
   return normalizeGroupAgent(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/agents`, {
     method: 'POST', body: { requestId: requestId(), description: '', replyWithoutMention: false, ...input } as JsonValue,

+ 45 - 5
src/client/stores/groups.ts

@@ -5,10 +5,10 @@ import type {
   GroupAgent, GroupCapabilities, GroupMessage, GroupMessagePage, GroupRoomDetail, GroupRoomSummary, GroupRun, GroupSocketEnvelope, GroupTopicSummary, RealtimeConnectionState,
 } from '@shared/types'
 import {
-  addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi,
+  addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi, archiveGroupTopic as archiveTopicApi,
   clarifyGroupInteraction as clarifyApi, createGroupRoom as createApi, getGroupCapabilities,
   getGroupMessages, getGroupRoom, getGroupRooms, getGroupTopics, interruptGroupAgent as interruptApi, markGroupTopicRead,
-  removeGroupAgent as removeAgentApi, sendGroupMessage as sendApi, updateGroupAgent as updateAgentApi,
+  removeGroupAgent as removeAgentApi, restoreGroupRoom as restoreRoomApi, restoreGroupTopic as restoreTopicApi, sendGroupMessage as sendApi, updateGroupAgent as updateAgentApi,
   updateGroupRoom as updateRoomApi, uploadGroupFiles, type AgentSeed,
 } from '@/api/groups'
 import { GroupEventSocket } from '@/api/realtime'
@@ -94,7 +94,7 @@ export const useGroupsStore = defineStore('groups', () => {
     return loaded.sort((a, b) => b.updatedAt - a.updatedAt)
   }
 
-  async function loadTopics(roomId: string): Promise<GroupTopicSummary[]> {
+  async function loadTopics(roomId: string, archived = false): Promise<GroupTopicSummary[]> {
     const loaded: GroupTopicSummary[] = []
     const seen = new Set<string>()
     let cursor: string | undefined
@@ -102,7 +102,7 @@ export const useGroupsStore = defineStore('groups', () => {
       const key = cursor ?? '<first-page>'
       if (seen.has(key)) throw new Error('群聊话题分页返回了重复游标')
       seen.add(key)
-      const page = await getGroupTopics(roomId, cursor, 50)
+      const page = await getGroupTopics(roomId, cursor, 50, archived)
       loaded.push(...page.items)
       cursor = page.nextCursor ?? undefined
     } while (cursor && loaded.length < 2_000)
@@ -459,6 +459,46 @@ export const useGroupsStore = defineStore('groups', () => {
     await saveCache()
   }
 
+  async function restoreRoom(roomId: string): Promise<GroupRoomDetail> {
+    const detail = await restoreRoomApi(roomId)
+    protocol.value = snapshotGroupRoom(protocol.value, detail, [], topicProtocol.value ? { topics: [] } : {})
+    await saveCache()
+    return detail
+  }
+
+  async function archiveTopic(roomId: string, topicId: string): Promise<void> {
+    const topic = await archiveTopicApi(roomId, topicId)
+    const current = protocol.value.topicsByRoom[roomId] ?? []
+    protocol.value = { ...protocol.value, topicsByRoom: { ...protocol.value.topicsByRoom, [roomId]: current.map(item => item.id === topic.id ? topic : item) } }
+    if (selectedRoomId.value === roomId && selectedTopicId.value === topicId) {
+      selectedTopicId.value = current.find(item => item.id !== topicId && !item.archived)?.id
+    }
+    await saveCache()
+  }
+
+  async function restoreTopic(roomId: string, topicId: string): Promise<GroupTopicSummary> {
+    const topic = await restoreTopicApi(roomId, topicId)
+    const current = protocol.value.topicsByRoom[roomId] ?? []
+    protocol.value = { ...protocol.value, topicsByRoom: { ...protocol.value.topicsByRoom, [roomId]: upsertGroupTopic(current, topic) } }
+    await saveCache()
+    return topic
+  }
+
+  async function archivedRooms(): Promise<GroupRoomSummary[]> {
+    const loaded: GroupRoomSummary[] = []
+    let cursor: string | undefined
+    do {
+      const page = await getGroupRooms(cursor, 50, true)
+      loaded.push(...page.items)
+      cursor = page.nextCursor ?? undefined
+    } while (cursor && loaded.length < 2_000)
+    return loaded.sort((a, b) => b.updatedAt - a.updatedAt)
+  }
+
+  async function archivedTopics(roomId: string): Promise<GroupTopicSummary[]> {
+    return loadTopics(roomId, true)
+  }
+
   function publishAgent(roomId: string, agent: GroupAgent): void {
     const detail = protocol.value.roomDetails[roomId]
     if (!detail) return
@@ -675,7 +715,7 @@ export const useGroupsStore = defineStore('groups', () => {
     availability, capabilities, topicProtocol, hostProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
     messages, agents, pendingInteractions,
     connectionState, isLoading, isSending, error, hasMoreBefore,
-    start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, addAgent, updateAgent,
+    start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, restoreRoom, archiveTopic, restoreTopic, archivedRooms, archivedTopics, addAgent, updateAgent,
     removeAgent, sendMessage, loadOlder, interruptAgent, approveInteraction, clarifyInteraction,
   }
 })

+ 5 - 2
src/client/utils/groupProtocol.ts

@@ -77,7 +77,8 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
   }
   switch (envelope.event) {
     case 'room.created':
-    case 'room.updated': {
+    case 'room.updated':
+    case 'room.restored': {
       const room = normalizeGroupRoom(payload)
       if (room.id) next.rooms = upsert(next.rooms, room).sort((a, b) => b.updatedAt - a.updatedAt)
       if (roomId && next.roomDetails[roomId] && envelope.event === 'room.updated') {
@@ -125,7 +126,9 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
         : room)
       break
     }
-    case 'topic.updated': {
+    case 'topic.updated':
+    case 'topic.archived':
+    case 'topic.restored': {
       const topic = normalizeGroupTopic(payload)
       if (!topic.id || !topic.roomId) break
       next.topicsByRoom[topic.roomId] = upsertGroupTopic(next.topicsByRoom[topic.roomId] ?? [], topic)

+ 1 - 0
src/client/utils/normalize.ts

@@ -421,6 +421,7 @@ export function normalizeGroupTopic(value: unknown): GroupTopicSummary {
     latestMessageSeq: number(pick(source, 'latestMessageSeq', 'latest_message_seq')),
     createdAt: number(pick(source, 'createdAt', 'created_at')),
     updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
+    archived: bool(source.archived),
   }
 }
 

+ 58 - 1
src/client/views/GroupsView.vue

@@ -2,7 +2,7 @@
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
-import type { GroupAgent, ModelOption } from '@shared/types'
+import type { GroupAgent, GroupRoomSummary, GroupTopicSummary, ModelOption } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
@@ -47,6 +47,10 @@ const agentUpdateError = ref<Record<string, string>>({})
 const managerError = ref('')
 const roomActionMenu = ref<{ roomId: string; x: number; y: number } | null>(null)
 const expandedRoomIds = ref(new Set<string>())
+const showArchivedRooms = ref(false)
+const archivedRoomList = ref<GroupRoomSummary[]>([])
+const showArchivedTopics = ref(false)
+const archivedTopicList = ref<GroupTopicSummary[]>([])
 
 const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
 const roomSidebarItems = computed(() => activeRooms.value.map(roomSidebarItem))
@@ -352,6 +356,37 @@ async function archiveRoom() {
   await router.replace(groupRoute())
 }
 
+async function toggleArchivedRooms() {
+  showArchivedRooms.value = !showArchivedRooms.value
+  if (showArchivedRooms.value) archivedRoomList.value = await groups.archivedRooms()
+}
+
+async function restoreArchivedRoom(roomId: string) {
+  await groups.restoreRoom(roomId)
+  archivedRoomList.value = await groups.archivedRooms()
+}
+
+async function archiveSelectedTopic() {
+  const roomId = groups.selectedRoomId
+  const topic = groups.selectedTopic
+  if (!roomId || !topic || !window.confirm(`归档“${topic.title}”?历史消息会保留。`)) return
+  await groups.archiveTopic(roomId, topic.id)
+}
+
+async function toggleArchivedTopics() {
+  const roomId = groups.selectedRoomId
+  if (!roomId) return
+  showArchivedTopics.value = !showArchivedTopics.value
+  if (showArchivedTopics.value) archivedTopicList.value = await groups.archivedTopics(roomId)
+}
+
+async function restoreArchivedTopic(topicId: string) {
+  const roomId = groups.selectedRoomId
+  if (!roomId) return
+  await groups.restoreTopic(roomId, topicId)
+  archivedTopicList.value = await groups.archivedTopics(roomId)
+}
+
 function openLocalFile({ name, url }: { name: string; url: string }) {
   const item = previewItemFromUrl(name, url)
   if (item.kind === 'image' || item.kind === 'video') openMedia(item)
@@ -438,9 +473,14 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         <YaoYaoSidebarIcon name="add" />
         <span>新建群聊</span>
       </button>
+      <button class="sidebar-primary-action" type="button" :disabled="groups.availability !== 'available'" @click="toggleArchivedRooms">
+        <AppIcon name="archive" :size="15" />
+        <span>{{ showArchivedRooms ? '活跃群聊' : '已归档' }}</span>
+      </button>
     </template>
     <template #sidebar>
       <ResourceSidebar
+        v-if="!showArchivedRooms"
         :items="sidebarItems"
         :active-id="groups.selectedRoomId"
         :loading="groups.isLoading"
@@ -453,12 +493,26 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         @context-menu="openRoomActions"
         @toggle="toggleRoomTopics"
       />
+      <section v-else class="archived-list" aria-label="已归档群聊">
+        <p v-if="!archivedRoomList.length">没有已归档群聊</p>
+        <article v-for="archived in archivedRoomList" :key="archived.id">
+          <span>{{ archived.name }}</span>
+          <button type="button" @click="restoreArchivedRoom(archived.id)">恢复</button>
+        </article>
+      </section>
     </template>
 
     <div v-if="groups.availability === 'unsupported' || groups.availability === 'unavailable'" class="groups-unavailable">
       <EmptyState icon="alert" :title="groups.availability === 'unsupported' ? '群聊协议版本不兼容' : '群聊服务暂不可用'" :description="groups.error || `请确认 9119 已安装 YaoYao 群聊 protocol ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL} 插件。`" action-label="重新检查" @action="groups.refresh" />
     </div>
     <div v-else class="groups-workspace">
+      <section v-if="showArchivedTopics" class="archived-topic-list" aria-label="已归档话题">
+        <p v-if="!archivedTopicList.length">此群聊没有已归档话题</p>
+        <article v-for="topic in archivedTopicList" :key="topic.id">
+          <span>{{ topic.title }}</span>
+          <button type="button" @click="restoreArchivedTopic(topic.id)">恢复</button>
+        </article>
+      </section>
       <MessageTimeline
         :messages="messages"
         :title="groups.topicProtocol ? (groups.selectedTopic?.title || '新话题') : (groups.selectedRoom?.name || '群聊')"
@@ -484,6 +538,8 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
             <span v-if="hostAgent" class="group-host-chip" :title="`用户未明确 @ 时由 ${hostAgent.displayName || hostAgent.profile} 负责回应`">主持人 {{ hostAgent.displayName || hostAgent.profile }}</span>
             <div class="group-avatars" aria-label="群聊成员"><span v-for="agent in agents.slice(0, 4)" :key="agent.id" :title="agent.isHost ? `${agent.name} · 主持人` : agent.name" :class="{ host: agent.isHost }">{{ agent.name.slice(0, 1).toUpperCase() }}</span><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
             <button class="icon-button" type="button" title="管理群聊" aria-label="管理群聊" :disabled="!groups.selectedRoom" @click="managerOpen = true"><AppIcon name="dots" /></button>
+            <button v-if="groups.selectedTopic" class="icon-button" type="button" title="归档话题" aria-label="归档话题" @click="archiveSelectedTopic"><AppIcon name="archive" /></button>
+            <button v-if="groups.selectedRoom" class="icon-button" type="button" title="已归档话题" aria-label="已归档话题" @click="toggleArchivedTopics"><AppIcon name="archive" /></button>
           </div>
         </template>
       </MessageTimeline>
@@ -556,6 +612,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
 .sidebar-primary-action:hover, .sidebar-primary-action:focus-visible { background: var(--surface-hover); outline: 0; }
 .sidebar-primary-action:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
 .sidebar-primary-action:disabled { cursor: not-allowed; opacity: .35; }
+.archived-list, .archived-topic-list { display: grid; gap: 8px; padding: 10px; color: var(--text-secondary); font-size: 12px; }.archived-list article, .archived-topic-list article { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px; border: 1px solid var(--line); border-radius: 8px; }.archived-list button, .archived-topic-list button { border: 0; border-radius: 6px; background: var(--surface-hover); color: var(--text-primary); cursor: pointer; padding: 4px 8px; }
 .group-header-actions { display: flex; align-items: center; gap: 8px; }.group-host-chip { max-width: 150px; overflow: hidden; padding: 4px 7px; border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--accent); font-size: 9px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }.group-avatars { display: flex; align-items: center; }.group-avatars span, .group-avatars em { display: grid; width: 24px; height: 24px; margin-left: -5px; place-items: center; border: 2px solid var(--canvas); border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 8px; font-style: normal; font-weight: 650; }.group-avatars span.host { box-shadow: 0 0 0 1px var(--accent); }.group-avatars span:first-child { margin-left: 0; }.group-avatars em { background: var(--surface-hover); color: var(--text-secondary); }
 .group-error { display: flex; width: min(760px, calc(100% - 32px)); margin: 0 auto 4px; align-items: center; gap: 6px; color: var(--danger); font-size: 9px; }
 @media (max-width: 480px) { .group-avatars, .group-host-chip { display: none; } }

+ 5 - 1
src/shared/types.ts

@@ -241,7 +241,7 @@ export interface GroupLimits {
 }
 
 export const MIN_SUPPORTED_GROUP_PROTOCOL_VERSION = 2
-export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 8
+export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 9
 export const SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL = `v${MIN_SUPPORTED_GROUP_PROTOCOL_VERSION}–v${MAX_SUPPORTED_GROUP_PROTOCOL_VERSION}`
 
 export function isSupportedGroupProtocolVersion(value: number): boolean {
@@ -367,6 +367,7 @@ export interface GroupTopicSummary {
   latestMessageSeq?: number
   createdAt: number
   updatedAt: number
+  archived?: boolean
 }
 
 export interface GroupRoomActivity {
@@ -389,12 +390,15 @@ export type GroupEventType =
   | 'room.created'
   | 'room.updated'
   | 'room.deleted'
+  | 'room.restored'
   | 'agent.created'
   | 'agent.updated'
   | 'agent.deleted'
   | 'agent.status'
   | 'message.upsert'
   | 'topic.updated'
+  | 'topic.archived'
+  | 'topic.restored'
   | 'room.activity'
   | 'interaction.requested'
   | 'interaction.resolved'