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

新增群聊话题置顶与执行信息

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

+ 8 - 0
hermes-plugins/yaoyao/dashboard/group_plugin_api.py

@@ -1075,6 +1075,13 @@ async def list_topics(
     return {"items": page.items, "nextCursor": page.next_cursor}
     return {"items": page.items, "nextCursor": page.next_cursor}
 
 
 
 
+async def list_pinned_topics(
+    limit: int = Query(default=100, ge=1, le=100),
+) -> dict[str, object]:
+    page = await _store_call("list_pinned_topics", limit=limit)
+    return {"items": page.items, "nextCursor": page.next_cursor}
+
+
 async def update_topic(
 async def update_topic(
     room_id: UUID, topic_id: UUID, request: UpdateTopicRequest
     room_id: UUID, topic_id: UUID, request: UpdateTopicRequest
 ) -> dict[str, object]:
 ) -> dict[str, object]:
@@ -1298,6 +1305,7 @@ def _build_router() -> APIRouter:
     )
     )
     built.add_api_route("/capabilities", capabilities, methods=["GET"])
     built.add_api_route("/capabilities", capabilities, methods=["GET"])
     built.add_api_route("/rooms", list_rooms, methods=["GET"])
     built.add_api_route("/rooms", list_rooms, methods=["GET"])
+    built.add_api_route("/topics/pinned", list_pinned_topics, methods=["GET"])
     built.add_api_route("/rooms", create_room, methods=["POST"])
     built.add_api_route("/rooms", create_room, methods=["POST"])
     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"])

+ 12 - 3
hermes-plugins/yaoyao/dashboard/group_protocol.py

@@ -18,7 +18,7 @@ from pydantic import (
 )
 )
 
 
 
 
-PROTOCOL_VERSION = 9
+PROTOCOL_VERSION = 10
 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
@@ -178,16 +178,25 @@ class UpdateRoomRequest(GroupModel):
 
 
 class UpdateTopicRequest(GroupModel):
 class UpdateTopicRequest(GroupModel):
     request_id: UUID = Field(alias="requestId")
     request_id: UUID = Field(alias="requestId")
-    title: str = Field(min_length=1, max_length=120)
+    title: str | None = Field(default=None, min_length=1, max_length=120)
+    pinned: StrictBool | None = None
 
 
     @field_validator("title")
     @field_validator("title")
     @classmethod
     @classmethod
-    def normalize_title(cls, value: str) -> str:
+    def normalize_title(cls, value: str | None) -> str | None:
+        if value is None:
+            return None
         normalized = " ".join(value.split()).strip()
         normalized = " ".join(value.split()).strip()
         if not normalized:
         if not normalized:
             raise ValueError("title must not be blank")
             raise ValueError("title must not be blank")
         return normalized
         return normalized
 
 
+    @model_validator(mode="after")
+    def require_change(self) -> "UpdateTopicRequest":
+        if not ({"title", "pinned"} & self.model_fields_set):
+            raise ValueError("Topic update requires title or pinned")
+        return self
+
 
 
 class MarkTopicReadRequest(GroupModel):
 class MarkTopicReadRequest(GroupModel):
     request_id: UUID = Field(alias="requestId")
     request_id: UUID = Field(alias="requestId")

+ 63 - 11
hermes-plugins/yaoyao/dashboard/group_store.py

@@ -41,7 +41,7 @@ from .group_protocol import (
 )
 )
 
 
 
 
-SCHEMA_VERSION = 9
+SCHEMA_VERSION = 10
 _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
@@ -124,6 +124,7 @@ _SCHEMA_STATEMENTS = (
         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,
         archived INTEGER NOT NULL DEFAULT 0,
+        pinned 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 (
@@ -235,7 +236,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, archived, updated_at DESC, id DESC)",
+    "CREATE INDEX IF NOT EXISTS idx_group_topics_room_ordering ON group_topics(room_id, archived, pinned DESC, 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)",
@@ -483,6 +484,9 @@ class GroupStore:
             raw_version = "8"
             raw_version = "8"
         if raw_version == "8":
         if raw_version == "8":
             self._migrate_v8_to_v9(connection)
             self._migrate_v8_to_v9(connection)
+            raw_version = "9"
+        if raw_version == "9":
+            self._migrate_v9_to_v10(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")
         )
         )
@@ -1179,12 +1183,31 @@ class GroupStore:
             "UPDATE group_meta SET value = '9' WHERE key = 'schema_version'"
             "UPDATE group_meta SET value = '9' WHERE key = 'schema_version'"
         )
         )
 
 
+    @staticmethod
+    def _migrate_v9_to_v10(connection: sqlite3.Connection) -> None:
+        columns = {
+            str(row["name"])
+            for row in connection.execute("PRAGMA table_info(group_topics)")
+        }
+        if "pinned" not in columns:
+            connection.execute(
+                "ALTER TABLE group_topics ADD COLUMN pinned 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, pinned DESC, updated_at DESC, id DESC)"
+        )
+        connection.execute(
+            "UPDATE group_meta SET value = '10' 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", "archived"},
+            "group_topics": {"id", "room_id", "title", "last_read_message_seq", "created_at", "updated_at", "archived", "pinned"},
             "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"},
@@ -1206,6 +1229,7 @@ class GroupStore:
             ("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"),
             ("group_topics", "archived"): ("INTEGER", 1, "0"),
+            ("group_topics", "pinned"): ("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})"))
@@ -1374,8 +1398,8 @@ class GroupStore:
                 "group_rooms(archived, updated_at DESC, id DESC)",
                 "group_rooms(archived, updated_at DESC, id DESC)",
             ),
             ),
             "idx_group_topics_room_ordering": (
             "idx_group_topics_room_ordering": (
-                [("room_id", 0), ("updated_at", 1), ("id", 1)],
-                "group_topics(room_id, updated_at DESC, id DESC)",
+                [("room_id", 0), ("archived", 0), ("pinned", 1), ("updated_at", 1), ("id", 1)],
+                "group_topics(room_id, archived, pinned DESC, updated_at DESC, id DESC)",
             ),
             ),
             "idx_group_messages_room_seq": (
             "idx_group_messages_room_seq": (
                 [("room_id", 0), ("seq", 0)],
                 [("room_id", 0), ("seq", 0)],
@@ -1789,7 +1813,7 @@ class GroupStore:
         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]))
-        query += " ORDER BY topic.updated_at DESC, topic.id DESC LIMIT ?"
+        query += " ORDER BY topic.pinned DESC, topic.updated_at DESC, topic.id DESC LIMIT ?"
         params.append(limit + 1)
         params.append(limit + 1)
         with self.read_transaction() as connection:
         with self.read_transaction() as connection:
             self._room_detail(connection, canonical_room_id, include_archived=True)
             self._room_detail(connection, canonical_room_id, include_archived=True)
@@ -1804,6 +1828,20 @@ class GroupStore:
             )
             )
         return CursorPage(items=items, next_cursor=next_cursor)
         return CursorPage(items=items, next_cursor=next_cursor)
 
 
+    def list_pinned_topics(self, *, limit: int) -> CursorPage:
+        if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 100:
+            raise ValueError("limit must be an integer from 1 to 100")
+        with self.read_transaction() as connection:
+            rows = connection.execute(
+                """SELECT id, room_id FROM group_topics
+                WHERE archived = 0 AND pinned = 1
+                ORDER BY updated_at DESC, id DESC LIMIT ?""",
+                (limit,),
+            ).fetchall()
+            items = [self._topic_summary_by_id(connection, row["room_id"], row["id"])
+                     for row in rows]
+        return CursorPage(items=items, next_cursor=None)
+
     def room_activity(self, room_id: str) -> dict[str, object]:
     def room_activity(self, room_id: str) -> dict[str, object]:
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
         with self.read_transaction() as connection:
         with self.read_transaction() as connection:
@@ -1896,18 +1934,31 @@ class GroupStore:
     ) -> dict[str, object]:
     ) -> dict[str, object]:
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
         canonical_topic_id = self._canonical_uuid(topic_id, "topicId")
         canonical_topic_id = self._canonical_uuid(topic_id, "topicId")
-        command = self._command(command, {"requestId", "title"})
+        command = self._command(command, {"requestId", "title", "pinned"})
         request_id = self._command_request_id(command)
         request_id = self._command_request_id(command)
-        title = self._topic_title(self._string(command, "title"))
+        if not ({"title", "pinned"} & set(command)):
+            raise ValueError("Topic update requires title or pinned")
 
 
         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._active_topic(connection, canonical_room_id, canonical_topic_id)
             self._active_topic(connection, canonical_room_id, canonical_topic_id)
+            assignments: list[str] = []
+            values: list[object] = []
+            if "title" in command:
+                assignments.append("title = ?")
+                values.append(self._topic_title(self._string(command, "title")))
+            if "pinned" in command:
+                if not isinstance(command["pinned"], bool):
+                    raise ValueError("pinned must be a boolean")
+                assignments.append("pinned = ?")
+                values.append(int(command["pinned"]))
             now = self._now()
             now = self._now()
+            assignments.append("updated_at = ?")
+            values.extend((now, canonical_topic_id, canonical_room_id))
             connection.execute(
             connection.execute(
-                """UPDATE group_topics SET title = ?, updated_at = ?
-                WHERE id = ? AND room_id = ?""",
-                (title, now, canonical_topic_id, canonical_room_id),
+                f"UPDATE group_topics SET {', '.join(assignments)} "
+                "WHERE id = ? AND room_id = ?",
+                values,
             )
             )
             topic = self._topic_summary_by_id(
             topic = self._topic_summary_by_id(
                 connection, canonical_room_id, canonical_topic_id
                 connection, canonical_room_id, canonical_topic_id
@@ -6479,6 +6530,7 @@ class GroupStore:
             "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"]),
             "archived": bool(row["archived"]),
+            "pinned": bool(row["pinned"]),
             "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, 9)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 10)
         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(), 9)
+            self.assertEqual(migrated.schema_version(), 10)
             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,)
@@ -454,6 +454,7 @@ class GroupTopicsContractTests(unittest.TestCase):
                     "latestMessageSeq",
                     "latestMessageSeq",
                     "lastReadMessageSeq",
                     "lastReadMessageSeq",
                     "archived",
                     "archived",
+                    "pinned",
                     "createdAt",
                     "createdAt",
                     "updatedAt",
                     "updatedAt",
                 },
                 },
@@ -1503,7 +1504,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
 
             store = GroupStore(path)
             store = GroupStore(path)
             store.initialize()
             store.initialize()
-            self.assertEqual(store.schema_version(), 9)
+            self.assertEqual(store.schema_version(), 10)
             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()

+ 7 - 1
src/client/api/groups.ts

@@ -40,7 +40,13 @@ export async function getGroupTopics(roomId: string, cursor?: string, limit = 50
   }
   }
 }
 }
 
 
-export async function updateGroupTopic(roomId: string, topicId: string, input: { title: string }): Promise<GroupTopicSummary> {
+export async function getPinnedGroupTopics(limit = 100): Promise<GroupTopicPage> {
+  const payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/topics/pinned`, { limit: Math.max(1, Math.min(100, limit)) })))
+  const source = record(payload)
+  return { items: values(source.items ?? source.topics ?? payload).map(normalizeGroupTopic).filter(topic => topic.id && topic.roomId), nextCursor: null }
+}
+
+export async function updateGroupTopic(roomId: string, topicId: string, input: { title?: string; pinned?: boolean }): Promise<GroupTopicSummary> {
   return normalizeGroupTopic(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/topics/${encodeURIComponent(topicId)}`, {
   return normalizeGroupTopic(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}/topics/${encodeURIComponent(topicId)}`, {
     method: 'PATCH', body: { requestId: requestId(), ...input } as JsonValue,
     method: 'PATCH', body: { requestId: requestId(), ...input } as JsonValue,
   })))
   })))

+ 2 - 1
src/client/components/messages/MessageTimeline.vue

@@ -279,6 +279,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
             <template v-else>
             <template v-else>
             <div v-if="message.role !== 'assistant' || showAssistantIdentity" class="message__meta">
             <div v-if="message.role !== 'assistant' || showAssistantIdentity" class="message__meta">
               <strong>{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? 'Agent' : '系统') }}</strong>
               <strong>{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? 'Agent' : '系统') }}</strong>
+              <span v-if="message.metadata" class="message__execution">{{ message.metadata }}</span>
               <time>{{ formatTime(message.createdAt) }}</time>
               <time>{{ formatTime(message.createdAt) }}</time>
               <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: '发送失败', 'unknown-receipt': '回执未知' }[message.status] }}</span>
               <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: '发送失败', 'unknown-receipt': '回执未知' }[message.status] }}</span>
             </div>
             </div>
@@ -387,7 +388,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
 .message { position: relative; display: flex; max-width: 100%; gap: 10px; margin: 0 0 21px; }
 .message { position: relative; display: flex; max-width: 100%; gap: 10px; margin: 0 0 21px; }
 .message__avatar { display: grid; place-items: center; width: 27px; height: 27px; flex: 0 0 27px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 700; }
 .message__avatar { display: grid; place-items: center; width: 27px; height: 27px; flex: 0 0 27px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 700; }
 .message__body { position: relative; min-width: 0; max-width: min(680px, calc(100% - 37px)); }
 .message__body { position: relative; min-width: 0; max-width: min(680px, calc(100% - 37px)); }
-.message__meta { display: flex; min-height: 20px; align-items: center; gap: 7px; color: var(--text-muted); font-size: 9px; }.message__meta strong { color: var(--text-primary); font-size: 11px; font-weight: 600; }.message__meta span { color: var(--warning); }
+.message__meta { display: flex; min-height: 20px; align-items: center; gap: 7px; color: var(--text-muted); font-size: 9px; }.message__meta strong { color: var(--text-primary); font-size: 11px; font-weight: 600; }.message__meta span { color: var(--warning); }.message__meta .message__execution { overflow: hidden; max-width: 230px; color: var(--text-secondary); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
 .message__content { color: var(--text-primary); }
 .message__content { color: var(--text-primary); }
 .message--user { justify-content: flex-end; margin-top: 27px; }
 .message--user { justify-content: flex-end; margin-top: 27px; }
 .message--user .message__body { max-width: min(610px, 76%); padding: 11px 15px; border-radius: 20px 20px 6px 20px; background: #eceff3; }
 .message--user .message__body { max-width: min(610px, 76%); padding: 11px 15px; border-radius: 20px 20px 6px 20px; background: #eceff3; }

+ 1 - 0
src/client/components/messages/types.ts

@@ -34,6 +34,7 @@ export type UiMessage = {
   profile?: string
   profile?: string
   timelineKind?: 'delegation-complete' | 'system'
   timelineKind?: 'delegation-complete' | 'system'
   timelineMetadata?: Record<string, unknown>
   timelineMetadata?: Record<string, unknown>
+  metadata?: string
 }
 }
 
 
 export type UiInteraction = {
 export type UiInteraction = {

+ 6 - 0
src/client/components/workspace/viewModels.ts

@@ -202,6 +202,11 @@ export function groupMessageToUi(message: GroupMessage): UiMessage {
     const status: UiToolCall['status'] = rawStatus === 'completed' ? 'success' : rawStatus === 'failed' ? 'error' : ['running', 'pending', 'success', 'error'].includes(rawStatus) ? rawStatus as UiToolCall['status'] : 'success'
     const status: UiToolCall['status'] = rawStatus === 'completed' ? 'success' : rawStatus === 'failed' ? 'error' : ['running', 'pending', 'success', 'error'].includes(rawStatus) ? rawStatus as UiToolCall['status'] : 'success'
     return { id: string(value.id, `${message.id}:tool:${index}`), name: string(value.name ?? value.tool, '工具调用'), status, input: value.input ?? value.arguments, output: value.output ?? value.result }
     return { id: string(value.id, `${message.id}:tool:${index}`), name: string(value.name ?? value.tool, '工具调用'), status, input: value.input ?? value.arguments, output: value.output ?? value.result }
   })
   })
+  const execution = message.execution
+  const model = execution?.actualModel || execution?.requestedModel
+  const reasoning = execution?.actualReasoningEffort || execution?.requestedReasoningEffort
+  const fast = execution?.actualFastMode ?? execution?.requestedFastMode
+  const metadata = model ? `${fast ? '⚡ ' : ''}${model}${reasoning ? ` · ${reasoning}` : ''}` : undefined
   return {
   return {
     id: message.id,
     id: message.id,
     role: message.senderKind === 'human' ? 'user' : message.senderKind === 'agent' ? 'assistant' : 'system',
     role: message.senderKind === 'human' ? 'user' : message.senderKind === 'agent' ? 'assistant' : 'system',
@@ -212,6 +217,7 @@ export function groupMessageToUi(message: GroupMessage): UiMessage {
     status: message.status === 'completed' ? 'settled' : message.status === 'queued' ? 'pending' : message.status === 'streaming' ? 'streaming' : message.status === 'failed' ? 'failed' : message.status === 'unknown' ? 'unknown-receipt' : 'settled',
     status: message.status === 'completed' ? 'settled' : message.status === 'queued' ? 'pending' : message.status === 'streaming' ? 'streaming' : message.status === 'failed' ? 'failed' : message.status === 'unknown' ? 'unknown-receipt' : 'settled',
     error: message.error,
     error: message.error,
     tools,
     tools,
+    metadata,
   }
   }
 }
 }
 
 

+ 21 - 3
src/client/stores/groups.ts

@@ -7,9 +7,9 @@ import type {
 import {
 import {
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi, archiveGroupTopic as archiveTopicApi,
   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, getPinnedGroupTopics, interruptGroupAgent as interruptApi, markGroupTopicRead,
   removeGroupAgent as removeAgentApi, restoreGroupRoom as restoreRoomApi, restoreGroupTopic as restoreTopicApi, 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, updateGroupTopic as updateTopicApi, uploadGroupFiles, type AgentSeed,
 } from '@/api/groups'
 } from '@/api/groups'
 import { GroupEventSocket } from '@/api/realtime'
 import { GroupEventSocket } from '@/api/realtime'
 import { ScopedCache } from '@/utils/cache'
 import { ScopedCache } from '@/utils/cache'
@@ -42,6 +42,7 @@ export const useGroupsStore = defineStore('groups', () => {
   const protocol = shallowRef<GroupProtocolState>(initialProtocol())
   const protocol = shallowRef<GroupProtocolState>(initialProtocol())
   const selectedRoomId = ref<string>()
   const selectedRoomId = ref<string>()
   const selectedTopicId = ref<string>()
   const selectedTopicId = ref<string>()
+  const pinnedTopics = ref<GroupTopicSummary[]>([])
   const connectionState = ref<RealtimeConnectionState>('idle')
   const connectionState = ref<RealtimeConnectionState>('idle')
   const isLoading = ref(false)
   const isLoading = ref(false)
   const isSending = ref(false)
   const isSending = ref(false)
@@ -131,6 +132,10 @@ export const useGroupsStore = defineStore('groups', () => {
     }
     }
   }
   }
 
 
+  async function loadPinnedTopics(): Promise<void> {
+    pinnedTopics.value = (await getPinnedGroupTopics()).items
+  }
+
   async function connectEvents(anchor: GroupCapabilities, expectedGeneration: number): Promise<void> {
   async function connectEvents(anchor: GroupCapabilities, expectedGeneration: number): Promise<void> {
     await socket.connect({
     await socket.connect({
       epoch: anchor.journalEpoch,
       epoch: anchor.journalEpoch,
@@ -208,6 +213,8 @@ export const useGroupsStore = defineStore('groups', () => {
         }
         }
         const loadedRooms = await loadRooms()
         const loadedRooms = await loadRooms()
         if (expectedGeneration !== generation) return
         if (expectedGeneration !== generation) return
+        await loadPinnedTopics()
+        if (expectedGeneration !== generation) return
         capabilities.value = anchor
         capabilities.value = anchor
         protocol.value = {
         protocol.value = {
           epoch: anchor.journalEpoch,
           epoch: anchor.journalEpoch,
@@ -476,6 +483,17 @@ export const useGroupsStore = defineStore('groups', () => {
     await saveCache()
     await saveCache()
   }
   }
 
 
+  async function setTopicPinned(roomId: string, topicId: string, pinned: boolean): Promise<GroupTopicSummary> {
+    const topic = await updateTopicApi(roomId, topicId, { pinned })
+    const current = protocol.value.topicsByRoom[roomId] ?? []
+    protocol.value = { ...protocol.value, topicsByRoom: { ...protocol.value.topicsByRoom, [roomId]: upsertGroupTopic(current, topic) } }
+    pinnedTopics.value = pinned
+      ? upsertGroupTopic(pinnedTopics.value, topic)
+      : pinnedTopics.value.filter(item => item.id !== topic.id)
+    await saveCache()
+    return topic
+  }
+
   async function restoreTopic(roomId: string, topicId: string): Promise<GroupTopicSummary> {
   async function restoreTopic(roomId: string, topicId: string): Promise<GroupTopicSummary> {
     const topic = await restoreTopicApi(roomId, topicId)
     const topic = await restoreTopicApi(roomId, topicId)
     const current = protocol.value.topicsByRoom[roomId] ?? []
     const current = protocol.value.topicsByRoom[roomId] ?? []
@@ -715,7 +733,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, restoreRoom, archiveTopic, restoreTopic, archivedRooms, archivedTopics, addAgent, updateAgent,
+    pinnedTopics, start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, restoreRoom, archiveTopic, restoreTopic, setTopicPinned, loadPinnedTopics, archivedRooms, archivedTopics, addAgent, updateAgent,
     removeAgent, sendMessage, loadOlder, interruptAgent, approveInteraction, clarifyInteraction,
     removeAgent, sendMessage, loadOlder, interruptAgent, approveInteraction, clarifyInteraction,
   }
   }
 })
 })

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

@@ -375,7 +375,7 @@ export function normalizeGroupMessage(value: unknown): GroupMessage {
     clientMessageId: string(pick(source, 'clientMessageId', 'client_message_id')) || null,
     clientMessageId: string(pick(source, 'clientMessageId', 'client_message_id')) || null,
     content: string(source.content), reasoning: string(source.reasoning), toolState: values(pick(source, 'toolState', 'tool_state')) as JsonValue[],
     content: string(source.content), reasoning: string(source.reasoning), toolState: values(pick(source, 'toolState', 'tool_state')) as JsonValue[],
     status: ['queued', 'streaming', 'completed', 'failed', 'interrupted'].includes(status) ? status : 'unknown',
     status: ['queued', 'streaming', 'completed', 'failed', 'interrupted'].includes(status) ? status : 'unknown',
-    error: string(source.error), createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
+    error: string(source.error), execution: source.execution ? record(source.execution) as GroupMessage['execution'] : null, createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
   }
   }
 }
 }
 
 
@@ -422,6 +422,7 @@ export function normalizeGroupTopic(value: unknown): GroupTopicSummary {
     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),
     archived: bool(source.archived),
+    pinned: bool(source.pinned),
   }
   }
 }
 }
 
 

+ 27 - 5
src/client/views/GroupsView.vue

@@ -50,15 +50,23 @@ const expandedRoomIds = ref(new Set<string>())
 const archivedOverlayOpen = ref(false)
 const archivedOverlayOpen = ref(false)
 const archivedRoomList = ref<GroupRoomSummary[]>([])
 const archivedRoomList = ref<GroupRoomSummary[]>([])
 const archivedTopicList = ref<GroupTopicSummary[]>([])
 const archivedTopicList = ref<GroupTopicSummary[]>([])
+const pinnedTopicsExpanded = ref(true)
 
 
 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))
+const pinnedTopics = computed(() => groups.pinnedTopics.filter(topic => !topic.archived).sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id)))
 function topicSidebarItemId(roomId: string, topicId: string): string { return `topic:${roomId}:${topicId}` }
 function topicSidebarItemId(roomId: string, topicId: string): string { return `topic:${roomId}:${topicId}` }
 function topicFromSidebarItemId(id: string): { roomId: string; topicId: string } | undefined {
 function topicFromSidebarItemId(id: string): { roomId: string; topicId: string } | undefined {
   const match = /^topic:([^:]+):([^:]+)$/.exec(id)
   const match = /^topic:([^:]+):([^:]+)$/.exec(id)
   return match ? { roomId: match[1]!, topicId: match[2]! } : undefined
   return match ? { roomId: match[1]!, topicId: match[2]! } : undefined
 }
 }
-const sidebarItems = computed<SidebarItem[]>(() => activeRooms.value.flatMap(room => {
+const sidebarItems = computed<SidebarItem[]>(() => {
+  const pinned: SidebarItem[] = pinnedTopics.value.length ? [{
+    id: 'pinned-topics', title: '话题置顶', subtitle: `${pinnedTopics.value.length} 个置顶话题`, section: '话题置顶', icon: 'branch', expandable: true, expanded: pinnedTopicsExpanded.value, showMore: false,
+  }, ...(!pinnedTopicsExpanded.value ? [] : pinnedTopics.value.map(topic => ({
+    id: topicSidebarItemId(topic.roomId, topic.id), title: topic.title, subtitle: activeRooms.value.find(room => room.id === topic.roomId)?.name || topic.preview, meta: '已置顶', section: '话题置顶', icon: 'branch' as const, nested: true, showMore: false, active: topic.id === groups.selectedTopicId,
+  })))] : []
+  return [...pinned, ...activeRooms.value.flatMap(room => {
   const roomItem = roomSidebarItem(room)
   const roomItem = roomSidebarItem(room)
   const expanded = groups.topicProtocol && expandedRoomIds.value.has(room.id)
   const expanded = groups.topicProtocol && expandedRoomIds.value.has(room.id)
   roomItem.expandable = groups.topicProtocol
   roomItem.expandable = groups.topicProtocol
@@ -89,8 +97,9 @@ const sidebarItems = computed<SidebarItem[]>(() => activeRooms.value.flatMap(roo
       active: true,
       active: true,
     })
     })
   }
   }
-  return items
-}))
+    return items
+  })]
+})
 const messages = computed(() => groups.messages.map(groupMessageToUi))
 const messages = computed(() => groups.messages.map(groupMessageToUi))
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
@@ -184,6 +193,7 @@ async function selectTopic(id: string) {
 }
 }
 
 
 async function selectSidebarItem(id: string) {
 async function selectSidebarItem(id: string) {
+  if (id === 'pinned-topics') return
   const topic = topicFromSidebarItemId(id)
   const topic = topicFromSidebarItemId(id)
   if (topic) {
   if (topic) {
     if (topic.roomId !== groups.selectedRoomId) {
     if (topic.roomId !== groups.selectedRoomId) {
@@ -206,6 +216,7 @@ function expandRoom(roomId: string) {
 }
 }
 
 
 async function toggleRoomTopics(roomId: string) {
 async function toggleRoomTopics(roomId: string) {
+  if (roomId === 'pinned-topics') { pinnedTopicsExpanded.value = !pinnedTopicsExpanded.value; return }
   if (!groups.topicProtocol) return
   if (!groups.topicProtocol) return
   if (expandedRoomIds.value.has(roomId)) {
   if (expandedRoomIds.value.has(roomId)) {
     const next = new Set(expandedRoomIds.value)
     const next = new Set(expandedRoomIds.value)
@@ -227,7 +238,7 @@ async function openRoomManager(id: string) {
 function openRoomActions(roomId: string, event: MouseEvent) {
 function openRoomActions(roomId: string, event: MouseEvent) {
   const topic = topicFromSidebarItemId(roomId)
   const topic = topicFromSidebarItemId(roomId)
   const width = 174
   const width = 174
-  const height = topic ? 46 : 80
+  const height = topic ? 80 : 80
   const inset = 8
   const inset = 8
   if (topic) {
   if (topic) {
     roomActionMenu.value = {
     roomActionMenu.value = {
@@ -386,6 +397,14 @@ async function archiveTopicFromAction() {
   await groups.archiveTopic(action.roomId, action.topicId)
   await groups.archiveTopic(action.roomId, action.topicId)
 }
 }
 
 
+async function toggleTopicPinnedFromAction() {
+  const action = roomActionMenu.value
+  closeRoomActions()
+  if (!action?.topicId) return
+  const topic = groups.topicsForRoom(action.roomId).find(item => item.id === action.topicId)
+  await groups.setTopicPinned(action.roomId, action.topicId, !topic?.pinned)
+}
+
 async function archiveRoomFromAction() {
 async function archiveRoomFromAction() {
   const action = roomActionMenu.value
   const action = roomActionMenu.value
   closeRoomActions()
   closeRoomActions()
@@ -594,7 +613,10 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
   <Teleport to="body">
   <Teleport to="body">
     <Transition name="group-menu">
     <Transition name="group-menu">
       <section v-if="roomActionMenu" class="group-room-actions" :style="roomActionMenuStyle" role="menu" aria-label="群聊房间操作" @contextmenu.prevent>
       <section v-if="roomActionMenu" class="group-room-actions" :style="roomActionMenuStyle" role="menu" aria-label="群聊房间操作" @contextmenu.prevent>
-        <button v-if="roomActionMenu.topicId" class="group-action-row danger" role="menuitem" type="button" @click="archiveTopicFromAction"><AppIcon name="archive" :size="14" />归档话题</button>
+        <template v-if="roomActionMenu.topicId">
+          <button class="group-action-row" role="menuitem" type="button" @click="toggleTopicPinnedFromAction"><AppIcon name="branch" :size="14" />{{ groups.topicsForRoom(roomActionMenu!.roomId).find(item => item.id === roomActionMenu!.topicId)?.pinned ? '取消置顶' : '置顶话题' }}</button>
+          <button class="group-action-row danger" role="menuitem" type="button" @click="archiveTopicFromAction"><AppIcon name="archive" :size="14" />归档话题</button>
+        </template>
         <template v-else>
         <template v-else>
           <button class="group-action-row" role="menuitem" type="button" @click="startTopicFromRoomAction"><AppIcon name="plus" :size="14" />新建话题</button>
           <button class="group-action-row" role="menuitem" type="button" @click="startTopicFromRoomAction"><AppIcon name="plus" :size="14" />新建话题</button>
           <button class="group-action-row danger" role="menuitem" type="button" @click="archiveRoomFromAction"><AppIcon name="archive" :size="14" />归档群聊</button>
           <button class="group-action-row danger" role="menuitem" type="button" @click="archiveRoomFromAction"><AppIcon name="archive" :size="14" />归档群聊</button>

+ 5 - 0
src/server/routes.ts

@@ -635,6 +635,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       search: searchFrom(ctx, ['limit', 'cursor', 'archived']),
       search: searchFrom(ctx, ['limit', 'cursor', 'archived']),
     })
     })
   })
   })
+  router.get('/api/app/groups/topics/pinned', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/topics/pinned', {
+      search: searchFrom(ctx, ['limit']),
+    })
+  })
   router.post('/api/app/groups/rooms', async (ctx) => {
   router.post('/api/app/groups/rooms', async (ctx) => {
     await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
     await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
       method: 'POST', requestBody: body(ctx),
       method: 'POST', requestBody: body(ctx),

+ 10 - 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 = 9
+export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 10
 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 {
@@ -297,6 +297,14 @@ export interface GroupMessage {
   toolState: JsonValue[]
   toolState: JsonValue[]
   status: GroupMessageStatus
   status: GroupMessageStatus
   error: string
   error: string
+  execution?: {
+    requestedModel?: string | null
+    requestedReasoningEffort?: string | null
+    requestedFastMode?: boolean | null
+    actualModel?: string | null
+    actualReasoningEffort?: string | null
+    actualFastMode?: boolean | null
+  } | null
   createdAt: number
   createdAt: number
   updatedAt: number
   updatedAt: number
 }
 }
@@ -368,6 +376,7 @@ export interface GroupTopicSummary {
   createdAt: number
   createdAt: number
   updatedAt: number
   updatedAt: number
   archived?: boolean
   archived?: boolean
+  pinned?: boolean
 }
 }
 
 
 export interface GroupRoomActivity {
 export interface GroupRoomActivity {