Browse Source

新增群聊与话题归档恢复

samien 2 months ago
parent
commit
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]
         self, room_id: str, command: Mapping[str, object]
     ) -> dict[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 create_room(self, command: Mapping[str, object]) -> dict[str, object]: ...
 
 
     def update_room(
     def update_room(
@@ -235,6 +245,7 @@ class _GatewayEvent:
 class _RuntimeState:
 class _RuntimeState:
     run_id: str
     run_id: str
     room_id: str
     room_id: str
+    topic_id: str
     agent_id: str
     agent_id: str
     runtime_id: str
     runtime_id: str
     generation: int
     generation: int
@@ -782,6 +793,90 @@ class GroupOrchestrator:
             self.wake()
             self.wake()
             return dict(result)
             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(
     async def mark_topic_read(
         self, *, room_id: str, topic_id: str, command: Mapping[str, object]
         self, *, room_id: str, topic_id: str, command: Mapping[str, object]
     ) -> dict[str, object]:
     ) -> dict[str, object]:
@@ -1161,12 +1256,14 @@ class GroupOrchestrator:
         *,
         *,
         room_id: str,
         room_id: str,
         agent_id: str | None = None,
         agent_id: str | None = None,
+        topic_id: str | None = None,
     ) -> list[str]:
     ) -> list[str]:
         owned = {
         owned = {
             state.runtime_id: state
             state.runtime_id: state
             for state in states
             for state in states
             if state.room_id == room_id
             if state.room_id == room_id
             and (agent_id is None or state.agent_id == agent_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] = []
         cleanup_errors: list[str] = []
         replay_runtime_ids = set(runtime_ids)
         replay_runtime_ids = set(runtime_ids)
@@ -2061,6 +2158,7 @@ class GroupOrchestrator:
         room = _required_mapping(projection.get("room"), "projection.room")
         room = _required_mapping(projection.get("room"), "projection.room")
         agent = _required_mapping(projection.get("agent"), "projection.agent")
         agent = _required_mapping(projection.get("agent"), "projection.agent")
         room_id = _required_string(room.get("id"), "projection.room.id")
         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")
         agent_id = _required_string(agent.get("id"), "projection.agent.id")
         if _required_string(run.get("id"), "projection.run.id") != run_id:
         if _required_string(run.get("id"), "projection.run.id") != run_id:
             raise GroupOrchestratorError("projection run identity changed")
             raise GroupOrchestratorError("projection run identity changed")
@@ -2226,6 +2324,7 @@ class GroupOrchestrator:
                 state = self._install_runtime(
                 state = self._install_runtime(
                     run_id=run_id,
                     run_id=run_id,
                     room_id=room_id,
                     room_id=room_id,
+                    topic_id=topic_id,
                     agent_id=agent_id,
                     agent_id=agent_id,
                     identity=identity,
                     identity=identity,
                     expected_stored_id=expected_stored_id,
                     expected_stored_id=expected_stored_id,
@@ -2417,6 +2516,7 @@ class GroupOrchestrator:
         *,
         *,
         run_id: str,
         run_id: str,
         room_id: str,
         room_id: str,
+        topic_id: str,
         agent_id: str,
         agent_id: str,
         identity: SessionIdentity,
         identity: SessionIdentity,
         expected_stored_id: str | None,
         expected_stored_id: str | None,
@@ -2435,6 +2535,7 @@ class GroupOrchestrator:
             state = _RuntimeState(
             state = _RuntimeState(
                 run_id=run_id,
                 run_id=run_id,
                 room_id=room_id,
                 room_id=room_id,
+                topic_id=topic_id,
                 agent_id=agent_id,
                 agent_id=agent_id,
                 runtime_id=identity.runtime_id,
                 runtime_id=identity.runtime_id,
                 generation=self._next_generation,
                 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]:
     async def archive_room(self, **_: object) -> dict[str, object]:
         raise _package._yaoyao_group_runtime_unavailable_error()
         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]:
     async def add_agent(self, **_: object) -> dict[str, object]:
         raise _package._yaoyao_group_runtime_unavailable_error()
         raise _package._yaoyao_group_runtime_unavailable_error()
 
 
@@ -1045,8 +1054,11 @@ async def capabilities() -> dict[str, object]:
 async def list_rooms(
 async def list_rooms(
     limit: int = Query(default=50, ge=1, le=100),
     limit: int = Query(default=50, ge=1, le=100),
     cursor: str | None = Query(default=None, min_length=1, max_length=4096),
     cursor: str | None = Query(default=None, min_length=1, max_length=4096),
+    archived: bool = Query(default=False),
 ) -> dict[str, object]:
 ) -> 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}
     return {"items": page.items, "nextCursor": page.next_cursor}
 
 
 
 
@@ -1054,9 +1066,11 @@ async def list_topics(
     room_id: UUID,
     room_id: UUID,
     limit: int = Query(default=50, ge=1, le=100),
     limit: int = Query(default=50, ge=1, le=100),
     cursor: str | None = Query(default=None, min_length=1, max_length=4096),
     cursor: str | None = Query(default=None, min_length=1, max_length=4096),
+    archived: bool = Query(default=False),
 ) -> dict[str, object]:
 ) -> dict[str, object]:
     page = await _store_call(
     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}
     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(
 async def mark_topic_read(
     room_id: UUID, topic_id: UUID, request: MarkTopicReadRequest
     room_id: UUID, topic_id: UUID, request: MarkTopicReadRequest
 ) -> dict[str, object]:
 ) -> 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]:
 async def add_agent(room_id: UUID, request: AddAgentRequest) -> dict[str, object]:
     return await _runtime_call(
     return await _runtime_call(
         "add_agent", room_id=str(room_id), command=_add_agent_command(request)
         "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}", get_room, methods=["GET"])
     built.add_api_route("/rooms/{room_id}", update_room, methods=["PATCH"])
     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}", 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", add_agent, methods=["POST"])
     built.add_api_route(
     built.add_api_route(
         "/rooms/{room_id}/agents/{agent_id}", update_agent, methods=["PATCH"]
         "/rooms/{room_id}/agents/{agent_id}", update_agent, methods=["PATCH"]
@@ -1276,10 +1326,6 @@ def _build_router() -> APIRouter:
         interrupt_agent,
         interrupt_agent,
         methods=["POST"],
         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(
     built.add_api_route(
         "/rooms/{room_id}/topics/{topic_id}/read",
         "/rooms/{room_id}/topics/{topic_id}/read",
         mark_topic_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_AGENTS_PER_ROOM = 8
 MAX_MESSAGE_BYTES = 64 * 1024
 MAX_MESSAGE_BYTES = 64 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024
@@ -51,12 +51,15 @@ EVENT_TYPES = frozenset({
     "room.created",
     "room.created",
     "room.updated",
     "room.updated",
     "room.deleted",
     "room.deleted",
+    "room.restored",
     "agent.created",
     "agent.created",
     "agent.updated",
     "agent.updated",
     "agent.deleted",
     "agent.deleted",
     "agent.status",
     "agent.status",
     "message.upsert",
     "message.upsert",
     "topic.updated",
     "topic.updated",
+    "topic.archived",
+    "topic.restored",
     "room.activity",
     "room.activity",
     "interaction.requested",
     "interaction.requested",
     "interaction.resolved",
     "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)
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
 MIN_RETAINED_EVENTS = 100_000
@@ -123,6 +123,7 @@ _SCHEMA_STATEMENTS = (
         last_read_message_seq INTEGER NOT NULL DEFAULT 0,
         last_read_message_seq INTEGER NOT NULL DEFAULT 0,
         created_at REAL NOT NULL,
         created_at REAL NOT NULL,
         updated_at REAL NOT NULL,
         updated_at REAL NOT NULL,
+        archived INTEGER NOT NULL DEFAULT 0,
         UNIQUE(room_id, id)
         UNIQUE(room_id, id)
     )""",
     )""",
     """CREATE TABLE IF NOT EXISTS group_agents (
     """CREATE TABLE IF NOT EXISTS group_agents (
@@ -234,7 +235,7 @@ _SCHEMA_STATEMENTS = (
         created_at REAL NOT NULL
         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_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_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_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)",
     "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"
             raw_version = "7"
         if raw_version == "7":
         if raw_version == "7":
             self._migrate_v7_to_v8(connection)
             self._migrate_v7_to_v8(connection)
+            raw_version = "8"
+        if raw_version == "8":
+            self._migrate_v8_to_v9(connection)
         self._validated_schema_version(
         self._validated_schema_version(
             self._metadata_value_from_connection(connection, "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'"
             "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
     @staticmethod
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
         expected_columns = {
             "group_meta": {"key", "value"},
             "group_meta": {"key", "value"},
             "group_rooms": {"id", "name", "cwd", "max_reply_rounds", "created_at", "updated_at", "archived"},
             "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_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_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"},
             "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_interactions", "topic_id"): ("TEXT", 1, None),
             ("group_agent_topic_state", "last_context_message_seq"): ("INTEGER", 1, "0"),
             ("group_agent_topic_state", "last_context_message_seq"): ("INTEGER", 1, "0"),
             ("group_topics", "last_read_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():
         for table, required in expected_columns.items():
             rows = list(connection.execute(f"PRAGMA table_info({table})"))
             rows = list(connection.execute(f"PRAGMA table_info({table})"))
@@ -1681,7 +1706,9 @@ class GroupStore:
             result["latestCursor"] = int(cursor_row["cursor"])
             result["latestCursor"] = int(cursor_row["cursor"])
             return result
             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 (
         if (
             not isinstance(limit, int)
             not isinstance(limit, int)
             or isinstance(limit, bool)
             or isinstance(limit, bool)
@@ -1692,8 +1719,8 @@ class GroupStore:
         query = """SELECT id, name, cwd, max_reply_rounds, created_at, updated_at, archived,
         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_agents WHERE room_id = group_rooms.id) AS agent_count,
             (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id) AS topic_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:
         if cursor_values is not None:
             query += " AND ((updated_at < ?) OR (updated_at = ? AND id < ?))"
             query += " AND ((updated_at < ?) OR (updated_at = ? AND id < ?))"
             params.extend((cursor_values[0], cursor_values[0], cursor_values[1]))
             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)
         return CursorPage(items=items, next_cursor=next_cursor)
 
 
     def list_topics(
     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:
     ) -> CursorPage:
         """Return one room's topics in stable most-recently-active order."""
         """Return one room's topics in stable most-recently-active order."""
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
@@ -1755,8 +1783,9 @@ class GroupStore:
              FROM group_messages AS visible_message
              FROM group_messages AS visible_message
              WHERE visible_message.topic_id = topic.id
              WHERE visible_message.topic_id = topic.id
                AND visible_message.visible = 1), 0) AS latest_message_seq
                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:
         if cursor_values is not None:
             query += " AND ((topic.updated_at < ?) OR (topic.updated_at = ? AND topic.id < ?))"
             query += " AND ((topic.updated_at < ?) OR (topic.updated_at = ? AND topic.id < ?))"
             params.extend((cursor_values[0], cursor_values[0], cursor_values[1]))
             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]:
         def action(connection: sqlite3.Connection) -> dict[str, object]:
             self._active_room(connection, canonical_room_id)
             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()
             now = self._now()
             connection.execute(
             connection.execute(
                 """UPDATE group_topics SET title = ?, updated_at = ?
                 """UPDATE group_topics SET title = ?, updated_at = ?
@@ -1896,6 +1925,78 @@ class GroupStore:
             action,
             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(
     def mark_topic_read(
         self, room_id: str, topic_id: str, command: Mapping[str, object]
         self, room_id: str, topic_id: str, command: Mapping[str, object]
     ) -> dict[str, object]:
     ) -> dict[str, object]:
@@ -1993,6 +2094,36 @@ class GroupStore:
         )
         )
         return self._runtime_envelope(response)
         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(
     def add_agent(
         self, room_id: str, command: Mapping[str, object]
         self, room_id: str, command: Mapping[str, object]
     ) -> dict[str, object]:
     ) -> dict[str, object]:
@@ -4927,6 +5058,7 @@ class GroupStore:
         reason: str,
         reason: str,
         now: float,
         now: float,
         agent_id: str | None = None,
         agent_id: str | None = None,
+        topic_id: str | None = None,
         include_queued: bool = True,
         include_queued: bool = True,
     ) -> dict[str, list[str]]:
     ) -> dict[str, list[str]]:
         statuses = (
         statuses = (
@@ -4942,6 +5074,9 @@ class GroupStore:
         if agent_id is not None:
         if agent_id is not None:
             query += " AND run.agent_id = ?"
             query += " AND run.agent_id = ?"
             params.append(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"
         query += " ORDER BY response.seq ASC, run.id ASC"
         runs = connection.execute(query, params).fetchall()
         runs = connection.execute(query, params).fetchall()
         changed_ids: list[str] = []
         changed_ids: list[str] = []
@@ -5421,6 +5556,14 @@ class GroupStore:
             raise GroupNotFoundError("Topic not found in room")
             raise GroupNotFoundError("Topic not found in room")
         return topic
         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
     @classmethod
     def _topic_summary_by_id(
     def _topic_summary_by_id(
         cls, connection: sqlite3.Connection, room_id: str, topic_id: str
         cls, connection: sqlite3.Connection, room_id: str, topic_id: str
@@ -5467,6 +5610,8 @@ class GroupStore:
         if existing is not None:
         if existing is not None:
             if existing["room_id"] != room_id:
             if existing["room_id"] != room_id:
                 raise GroupConflictError("Topic does not belong to room")
                 raise GroupConflictError("Topic does not belong to room")
+            if existing["archived"]:
+                raise GroupConflictError("Topic is archived")
             return existing
             return existing
         connection.execute(
         connection.execute(
             """INSERT INTO group_topics
             """INSERT INTO group_topics
@@ -6333,6 +6478,7 @@ class GroupStore:
             "unreadCount": int(row["unread_count"]),
             "unreadCount": int(row["unread_count"]),
             "latestMessageSeq": int(row["latest_message_seq"]),
             "latestMessageSeq": int(row["latest_message_seq"]),
             "lastReadMessageSeq": int(row["last_read_message_seq"]),
             "lastReadMessageSeq": int(row["last_read_message_seq"]),
+            "archived": bool(row["archived"]),
             "createdAt": row["created_at"],
             "createdAt": row["created_at"],
             "updatedAt": row["updated_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):
 class GroupTopicsContractTests(unittest.TestCase):
     def test_protocol_and_router_advertise_topics(self) -> None:
     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("topic.updated", PROTOCOL.EVENT_TYPES)
         self.assertIn("room.activity", PROTOCOL.EVENT_TYPES)
         self.assertIn("room.activity", PROTOCOL.EVENT_TYPES)
         request = PROTOCOL.SendMessageRequest.model_validate({
         request = PROTOCOL.SendMessageRequest.model_validate({
@@ -268,7 +268,7 @@ class GroupTopicsContractTests(unittest.TestCase):
             migrated = GroupStore(path)
             migrated = GroupStore(path)
             migrated.initialize()
             migrated.initialize()
 
 
-            self.assertEqual(migrated.schema_version(), 8)
+            self.assertEqual(migrated.schema_version(), 9)
             with migrated.connection() as connection:
             with migrated.connection() as connection:
                 topic = connection.execute(
                 topic = connection.execute(
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
@@ -453,6 +453,7 @@ class GroupTopicsContractTests(unittest.TestCase):
                     "unreadCount",
                     "unreadCount",
                     "latestMessageSeq",
                     "latestMessageSeq",
                     "lastReadMessageSeq",
                     "lastReadMessageSeq",
+                    "archived",
                     "createdAt",
                     "createdAt",
                     "updatedAt",
                     "updatedAt",
                 },
                 },
@@ -1502,7 +1503,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
 
             store = GroupStore(path)
             store = GroupStore(path)
             store.initialize()
             store.initialize()
-            self.assertEqual(store.schema_version(), 8)
+            self.assertEqual(store.schema_version(), 9)
             with store.connection() as migrated:
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").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`)))
   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)
   const source = record(payload)
   return {
   return {
     items: values(source.items ?? source.rooms ?? payload).map(normalizeGroupRoom).filter(room => room.id),
     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)}`)))
   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`, {
   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)
   const source = record(payload)
   return {
   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> {
 export async function markGroupTopicRead(roomId: string, topicId: string, throughSeq: number): Promise<void> {
   await apiRequest(`${BASE}/rooms/${encodeURIComponent(roomId)}/topics/${encodeURIComponent(topicId)}/read`, {
   await apiRequest(`${BASE}/rooms/${encodeURIComponent(roomId)}/topics/${encodeURIComponent(topicId)}/read`, {
     method: 'PATCH', body: { requestId: requestId(), throughSeq } as JsonValue,
     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> {
 export async function addGroupAgent(roomId: string, input: AgentSeed): Promise<GroupAgent> {
   return normalizeGroupAgent(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/agents`, {
   return normalizeGroupAgent(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/agents`, {
     method: 'POST', body: { requestId: requestId(), description: '', replyWithoutMention: false, ...input } as JsonValue,
     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,
   GroupAgent, GroupCapabilities, GroupMessage, GroupMessagePage, GroupRoomDetail, GroupRoomSummary, GroupRun, GroupSocketEnvelope, GroupTopicSummary, RealtimeConnectionState,
 } from '@shared/types'
 } from '@shared/types'
 import {
 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,
   clarifyGroupInteraction as clarifyApi, createGroupRoom as createApi, getGroupCapabilities,
   getGroupMessages, getGroupRoom, getGroupRooms, getGroupTopics, interruptGroupAgent as interruptApi, markGroupTopicRead,
   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,
   updateGroupRoom as updateRoomApi, uploadGroupFiles, type AgentSeed,
 } from '@/api/groups'
 } from '@/api/groups'
 import { GroupEventSocket } from '@/api/realtime'
 import { GroupEventSocket } from '@/api/realtime'
@@ -94,7 +94,7 @@ export const useGroupsStore = defineStore('groups', () => {
     return loaded.sort((a, b) => b.updatedAt - a.updatedAt)
     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 loaded: GroupTopicSummary[] = []
     const seen = new Set<string>()
     const seen = new Set<string>()
     let cursor: string | undefined
     let cursor: string | undefined
@@ -102,7 +102,7 @@ export const useGroupsStore = defineStore('groups', () => {
       const key = cursor ?? '<first-page>'
       const key = cursor ?? '<first-page>'
       if (seen.has(key)) throw new Error('群聊话题分页返回了重复游标')
       if (seen.has(key)) throw new Error('群聊话题分页返回了重复游标')
       seen.add(key)
       seen.add(key)
-      const page = await getGroupTopics(roomId, cursor, 50)
+      const page = await getGroupTopics(roomId, cursor, 50, archived)
       loaded.push(...page.items)
       loaded.push(...page.items)
       cursor = page.nextCursor ?? undefined
       cursor = page.nextCursor ?? undefined
     } while (cursor && loaded.length < 2_000)
     } while (cursor && loaded.length < 2_000)
@@ -459,6 +459,46 @@ export const useGroupsStore = defineStore('groups', () => {
     await saveCache()
     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 {
   function publishAgent(roomId: string, agent: GroupAgent): void {
     const detail = protocol.value.roomDetails[roomId]
     const detail = protocol.value.roomDetails[roomId]
     if (!detail) return
     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,
     availability, capabilities, topicProtocol, hostProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
     messages, agents, pendingInteractions,
     messages, agents, pendingInteractions,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     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,
     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) {
   switch (envelope.event) {
     case 'room.created':
     case 'room.created':
-    case 'room.updated': {
+    case 'room.updated':
+    case 'room.restored': {
       const room = normalizeGroupRoom(payload)
       const room = normalizeGroupRoom(payload)
       if (room.id) next.rooms = upsert(next.rooms, room).sort((a, b) => b.updatedAt - a.updatedAt)
       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') {
       if (roomId && next.roomDetails[roomId] && envelope.event === 'room.updated') {
@@ -125,7 +126,9 @@ export function applyGroupEnvelope(state: GroupProtocolState, envelope: GroupSoc
         : room)
         : room)
       break
       break
     }
     }
-    case 'topic.updated': {
+    case 'topic.updated':
+    case 'topic.archived':
+    case 'topic.restored': {
       const topic = normalizeGroupTopic(payload)
       const topic = normalizeGroupTopic(payload)
       if (!topic.id || !topic.roomId) break
       if (!topic.id || !topic.roomId) break
       next.topicsByRoom[topic.roomId] = upsertGroupTopic(next.topicsByRoom[topic.roomId] ?? [], topic)
       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')),
     latestMessageSeq: number(pick(source, 'latestMessageSeq', 'latest_message_seq')),
     createdAt: number(pick(source, 'createdAt', 'created_at')),
     createdAt: number(pick(source, 'createdAt', 'created_at')),
     updatedAt: number(pick(source, 'updatedAt', 'updated_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 { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { useRoute, useRouter } from 'vue-router'
 import { SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
 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 AppIcon from '@/components/common/AppIcon.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
@@ -47,6 +47,10 @@ const agentUpdateError = ref<Record<string, string>>({})
 const managerError = ref('')
 const managerError = ref('')
 const roomActionMenu = ref<{ roomId: string; x: number; y: number } | null>(null)
 const roomActionMenu = ref<{ roomId: string; x: number; y: number } | null>(null)
 const expandedRoomIds = ref(new Set<string>())
 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 activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
 const roomSidebarItems = computed(() => activeRooms.value.map(roomSidebarItem))
 const roomSidebarItems = computed(() => activeRooms.value.map(roomSidebarItem))
@@ -352,6 +356,37 @@ async function archiveRoom() {
   await router.replace(groupRoute())
   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 }) {
 function openLocalFile({ name, url }: { name: string; url: string }) {
   const item = previewItemFromUrl(name, url)
   const item = previewItemFromUrl(name, url)
   if (item.kind === 'image' || item.kind === 'video') openMedia(item)
   if (item.kind === 'image' || item.kind === 'video') openMedia(item)
@@ -438,9 +473,14 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         <YaoYaoSidebarIcon name="add" />
         <YaoYaoSidebarIcon name="add" />
         <span>新建群聊</span>
         <span>新建群聊</span>
       </button>
       </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>
     <template #sidebar>
     <template #sidebar>
       <ResourceSidebar
       <ResourceSidebar
+        v-if="!showArchivedRooms"
         :items="sidebarItems"
         :items="sidebarItems"
         :active-id="groups.selectedRoomId"
         :active-id="groups.selectedRoomId"
         :loading="groups.isLoading"
         :loading="groups.isLoading"
@@ -453,12 +493,26 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         @context-menu="openRoomActions"
         @context-menu="openRoomActions"
         @toggle="toggleRoomTopics"
         @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>
     </template>
 
 
     <div v-if="groups.availability === 'unsupported' || groups.availability === 'unavailable'" class="groups-unavailable">
     <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" />
       <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>
     <div v-else class="groups-workspace">
     <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
       <MessageTimeline
         :messages="messages"
         :messages="messages"
         :title="groups.topicProtocol ? (groups.selectedTopic?.title || '新话题') : (groups.selectedRoom?.name || '群聊')"
         :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>
             <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>
             <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 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>
           </div>
         </template>
         </template>
       </MessageTimeline>
       </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: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:focus-visible { box-shadow: inset 0 0 0 1px var(--line-strong); }
 .sidebar-primary-action:disabled { cursor: not-allowed; opacity: .35; }
 .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-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; }
 .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; } }
 @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 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 const SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL = `v${MIN_SUPPORTED_GROUP_PROTOCOL_VERSION}–v${MAX_SUPPORTED_GROUP_PROTOCOL_VERSION}`
 
 
 export function isSupportedGroupProtocolVersion(value: number): boolean {
 export function isSupportedGroupProtocolVersion(value: number): boolean {
@@ -367,6 +367,7 @@ export interface GroupTopicSummary {
   latestMessageSeq?: number
   latestMessageSeq?: number
   createdAt: number
   createdAt: number
   updatedAt: number
   updatedAt: number
+  archived?: boolean
 }
 }
 
 
 export interface GroupRoomActivity {
 export interface GroupRoomActivity {
@@ -389,12 +390,15 @@ export type GroupEventType =
   | 'room.created'
   | 'room.created'
   | 'room.updated'
   | 'room.updated'
   | 'room.deleted'
   | 'room.deleted'
+  | 'room.restored'
   | 'agent.created'
   | 'agent.created'
   | 'agent.updated'
   | 'agent.updated'
   | 'agent.deleted'
   | 'agent.deleted'
   | 'agent.status'
   | 'agent.status'
   | 'message.upsert'
   | 'message.upsert'
   | 'topic.updated'
   | 'topic.updated'
+  | 'topic.archived'
+  | 'topic.restored'
   | 'room.activity'
   | 'room.activity'
   | 'interaction.requested'
   | 'interaction.requested'
   | 'interaction.resolved'
   | 'interaction.resolved'