ソースを参照

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

samien 2 ヶ月 前
親
コミット
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}
 
 
+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(
     room_id: UUID, topic_id: UUID, request: UpdateTopicRequest
 ) -> dict[str, object]:
@@ -1298,6 +1305,7 @@ def _build_router() -> APIRouter:
     )
     built.add_api_route("/capabilities", capabilities, 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/{room_id}", get_room, methods=["GET"])
     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_MESSAGE_BYTES = 64 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024
@@ -178,16 +178,25 @@ class UpdateRoomRequest(GroupModel):
 
 class UpdateTopicRequest(GroupModel):
     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")
     @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()
         if not normalized:
             raise ValueError("title must not be blank")
         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):
     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)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
@@ -124,6 +124,7 @@ _SCHEMA_STATEMENTS = (
         created_at REAL NOT NULL,
         updated_at REAL NOT NULL,
         archived INTEGER NOT NULL DEFAULT 0,
+        pinned INTEGER NOT NULL DEFAULT 0,
         UNIQUE(room_id, id)
     )""",
     """CREATE TABLE IF NOT EXISTS group_agents (
@@ -235,7 +236,7 @@ _SCHEMA_STATEMENTS = (
         created_at REAL NOT NULL
     )""",
     "CREATE INDEX IF NOT EXISTS idx_group_rooms_ordering ON group_rooms(archived, updated_at DESC, id DESC)",
-    "CREATE INDEX IF NOT EXISTS idx_group_topics_room_ordering ON group_topics(room_id, 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_topic_seq ON group_messages(topic_id, seq)",
     "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"
         if raw_version == "8":
             self._migrate_v8_to_v9(connection)
+            raw_version = "9"
+        if raw_version == "9":
+            self._migrate_v9_to_v10(connection)
         self._validated_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'"
         )
 
+    @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
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
             "group_meta": {"key", "value"},
             "group_rooms": {"id", "name", "cwd", "max_reply_rounds", "created_at", "updated_at", "archived"},
-            "group_topics": {"id", "room_id", "title", "last_read_message_seq", "created_at", "updated_at", "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_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"},
@@ -1206,6 +1229,7 @@ class GroupStore:
             ("group_agent_topic_state", "last_context_message_seq"): ("INTEGER", 1, "0"),
             ("group_topics", "last_read_message_seq"): ("INTEGER", 1, "0"),
             ("group_topics", "archived"): ("INTEGER", 1, "0"),
+            ("group_topics", "pinned"): ("INTEGER", 1, "0"),
         }
         for table, required in expected_columns.items():
             rows = list(connection.execute(f"PRAGMA table_info({table})"))
@@ -1374,8 +1398,8 @@ class GroupStore:
                 "group_rooms(archived, updated_at DESC, id DESC)",
             ),
             "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": (
                 [("room_id", 0), ("seq", 0)],
@@ -1789,7 +1813,7 @@ class GroupStore:
         if cursor_values is not None:
             query += " AND ((topic.updated_at < ?) OR (topic.updated_at = ? AND topic.id < ?))"
             params.extend((cursor_values[0], cursor_values[0], cursor_values[1]))
-        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)
         with self.read_transaction() as connection:
             self._room_detail(connection, canonical_room_id, include_archived=True)
@@ -1804,6 +1828,20 @@ class GroupStore:
             )
         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]:
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
         with self.read_transaction() as connection:
@@ -1896,18 +1934,31 @@ class GroupStore:
     ) -> 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", "title"})
+        command = self._command(command, {"requestId", "title", "pinned"})
         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]:
             self._active_room(connection, canonical_room_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()
+            assignments.append("updated_at = ?")
+            values.extend((now, canonical_topic_id, canonical_room_id))
             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(
                 connection, canonical_room_id, canonical_topic_id
@@ -6479,6 +6530,7 @@ class GroupStore:
             "latestMessageSeq": int(row["latest_message_seq"]),
             "lastReadMessageSeq": int(row["last_read_message_seq"]),
             "archived": bool(row["archived"]),
+            "pinned": bool(row["pinned"]),
             "createdAt": row["created_at"],
             "updatedAt": row["updated_at"],
         }

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

@@ -37,7 +37,7 @@ def new_id() -> str:
 
 class GroupTopicsContractTests(unittest.TestCase):
     def test_protocol_and_router_advertise_topics(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 9)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 10)
         self.assertIn("topic.updated", PROTOCOL.EVENT_TYPES)
         self.assertIn("room.activity", PROTOCOL.EVENT_TYPES)
         request = PROTOCOL.SendMessageRequest.model_validate({
@@ -268,7 +268,7 @@ class GroupTopicsContractTests(unittest.TestCase):
             migrated = GroupStore(path)
             migrated.initialize()
 
-            self.assertEqual(migrated.schema_version(), 9)
+            self.assertEqual(migrated.schema_version(), 10)
             with migrated.connection() as connection:
                 topic = connection.execute(
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
@@ -454,6 +454,7 @@ class GroupTopicsContractTests(unittest.TestCase):
                     "latestMessageSeq",
                     "lastReadMessageSeq",
                     "archived",
+                    "pinned",
                     "createdAt",
                     "updatedAt",
                 },
@@ -1503,7 +1504,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 9)
+            self.assertEqual(store.schema_version(), 10)
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").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)}`, {
     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>
             <div v-if="message.role !== 'assistant' || showAssistantIdentity" class="message__meta">
               <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>
               <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: '发送失败', 'unknown-receipt': '回执未知' }[message.status] }}</span>
             </div>
@@ -387,7 +388,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
 .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__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--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; }

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

@@ -34,6 +34,7 @@ export type UiMessage = {
   profile?: string
   timelineKind?: 'delegation-complete' | 'system'
   timelineMetadata?: Record<string, unknown>
+  metadata?: string
 }
 
 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'
     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 {
     id: message.id,
     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',
     error: message.error,
     tools,
+    metadata,
   }
 }
 

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

@@ -7,9 +7,9 @@ import type {
 import {
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi, archiveGroupTopic as archiveTopicApi,
   clarifyGroupInteraction as clarifyApi, createGroupRoom as createApi, getGroupCapabilities,
-  getGroupMessages, getGroupRoom, getGroupRooms, getGroupTopics, interruptGroupAgent as interruptApi, markGroupTopicRead,
+  getGroupMessages, getGroupRoom, getGroupRooms, getGroupTopics, getPinnedGroupTopics, interruptGroupAgent as interruptApi, markGroupTopicRead,
   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'
 import { GroupEventSocket } from '@/api/realtime'
 import { ScopedCache } from '@/utils/cache'
@@ -42,6 +42,7 @@ export const useGroupsStore = defineStore('groups', () => {
   const protocol = shallowRef<GroupProtocolState>(initialProtocol())
   const selectedRoomId = ref<string>()
   const selectedTopicId = ref<string>()
+  const pinnedTopics = ref<GroupTopicSummary[]>([])
   const connectionState = ref<RealtimeConnectionState>('idle')
   const isLoading = 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> {
     await socket.connect({
       epoch: anchor.journalEpoch,
@@ -208,6 +213,8 @@ export const useGroupsStore = defineStore('groups', () => {
         }
         const loadedRooms = await loadRooms()
         if (expectedGeneration !== generation) return
+        await loadPinnedTopics()
+        if (expectedGeneration !== generation) return
         capabilities.value = anchor
         protocol.value = {
           epoch: anchor.journalEpoch,
@@ -476,6 +483,17 @@ export const useGroupsStore = defineStore('groups', () => {
     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> {
     const topic = await restoreTopicApi(roomId, topicId)
     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,
     messages, agents, pendingInteractions,
     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,
   }
 })

+ 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,
     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',
-    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')),
     updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
     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 archivedRoomList = ref<GroupRoomSummary[]>([])
 const archivedTopicList = ref<GroupTopicSummary[]>([])
+const pinnedTopicsExpanded = ref(true)
 
 const activeRooms = computed(() => groups.rooms.filter(room => !room.archived))
 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 topicFromSidebarItemId(id: string): { roomId: string; topicId: string } | undefined {
   const match = /^topic:([^:]+):([^:]+)$/.exec(id)
   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 expanded = groups.topicProtocol && expandedRoomIds.value.has(room.id)
   roomItem.expandable = groups.topicProtocol
@@ -89,8 +97,9 @@ const sidebarItems = computed<SidebarItem[]>(() => activeRooms.value.flatMap(roo
       active: true,
     })
   }
-  return items
-}))
+    return items
+  })]
+})
 const messages = computed(() => groups.messages.map(groupMessageToUi))
 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))
@@ -184,6 +193,7 @@ async function selectTopic(id: string) {
 }
 
 async function selectSidebarItem(id: string) {
+  if (id === 'pinned-topics') return
   const topic = topicFromSidebarItemId(id)
   if (topic) {
     if (topic.roomId !== groups.selectedRoomId) {
@@ -206,6 +216,7 @@ function expandRoom(roomId: string) {
 }
 
 async function toggleRoomTopics(roomId: string) {
+  if (roomId === 'pinned-topics') { pinnedTopicsExpanded.value = !pinnedTopicsExpanded.value; return }
   if (!groups.topicProtocol) return
   if (expandedRoomIds.value.has(roomId)) {
     const next = new Set(expandedRoomIds.value)
@@ -227,7 +238,7 @@ async function openRoomManager(id: string) {
 function openRoomActions(roomId: string, event: MouseEvent) {
   const topic = topicFromSidebarItemId(roomId)
   const width = 174
-  const height = topic ? 46 : 80
+  const height = topic ? 80 : 80
   const inset = 8
   if (topic) {
     roomActionMenu.value = {
@@ -386,6 +397,14 @@ async function archiveTopicFromAction() {
   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() {
   const action = roomActionMenu.value
   closeRoomActions()
@@ -594,7 +613,10 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
   <Teleport to="body">
     <Transition name="group-menu">
       <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>
           <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>

+ 5 - 0
src/server/routes.ts

@@ -635,6 +635,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       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) => {
     await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
       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 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 function isSupportedGroupProtocolVersion(value: number): boolean {
@@ -297,6 +297,14 @@ export interface GroupMessage {
   toolState: JsonValue[]
   status: GroupMessageStatus
   error: string
+  execution?: {
+    requestedModel?: string | null
+    requestedReasoningEffort?: string | null
+    requestedFastMode?: boolean | null
+    actualModel?: string | null
+    actualReasoningEffort?: string | null
+    actualFastMode?: boolean | null
+  } | null
   createdAt: number
   updatedAt: number
 }
@@ -368,6 +376,7 @@ export interface GroupTopicSummary {
   createdAt: number
   updatedAt: number
   archived?: boolean
+  pinned?: boolean
 }
 
 export interface GroupRoomActivity {