Browse Source

WIP: 兼容群聊协议 v3

[gstack-context]
Decisions: Web 群聊显式支持协议 v2-v3,未知更高版本继续拒绝
Remaining: 完整测试、重启 8800 服务并做真实群聊验证
Tried: v3 端到端用例先失败后通过
Skill: /investigate
[/gstack-context]
samien 2 tháng trước cách đây
mục cha
commit
dfd8da42c4

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

@@ -1,5 +1,6 @@
 import { computed, ref, shallowRef, watch } from 'vue'
 import { defineStore } from 'pinia'
+import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
 import type {
   GroupAgent, GroupCapabilities, GroupMessage, GroupMessagePage, GroupRoomDetail, GroupRoomSummary, GroupSocketEnvelope, RealtimeConnectionState,
 } from '@shared/types'
@@ -134,9 +135,9 @@ export const useGroupsStore = defineStore('groups', () => {
       reconnectTimer = undefined
       try {
         const anchor = await getGroupCapabilities()
-        if (anchor.protocolVersion !== 2) {
+        if (!isSupportedGroupProtocolVersion(anchor.protocolVersion)) {
           availability.value = 'unsupported'
-          throw new Error(`群聊协议版本不兼容:需要 v2,服务器返回 v${anchor.protocolVersion || 'unknown'}`)
+          throw new Error(`群聊协议版本不兼容:支持 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL},服务器返回 v${anchor.protocolVersion || 'unknown'}`)
         }
         const loadedRooms = await loadRooms()
         if (expectedGeneration !== generation) return

+ 3 - 2
src/client/views/GroupsView.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
+import { SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
@@ -166,7 +167,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
 </script>
 
 <template>
-  <WorkspaceView sidebar-title="群聊" :sidebar-subtitle="groups.availability === 'available' ? `${filteredRooms.length} 个活跃房间` : '9119 群聊 v2'" :inspector-open="managerOpen && !!room" @close-inspector="managerOpen = false">
+  <WorkspaceView sidebar-title="群聊" :sidebar-subtitle="groups.availability === 'available' ? `${filteredRooms.length} 个活跃房间` : `9119 群聊 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL}`" :inspector-open="managerOpen && !!room" @close-inspector="managerOpen = false">
     <template #sidebar-action>
       <button class="sidebar-primary-action" type="button" :disabled="groups.availability !== 'available'" title="新建群聊" aria-label="新建群聊" @click="createOpen = true">
         <YaoYaoSidebarIcon name="add" />
@@ -189,7 +190,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
     </template>
 
     <div v-if="groups.availability === 'unsupported' || groups.availability === 'unavailable'" class="groups-unavailable">
-      <EmptyState icon="alert" :title="groups.availability === 'unsupported' ? '群聊协议版本不兼容' : '群聊服务暂不可用'" :description="groups.error || '请确认 9119 已安装 YaoYao 群聊 protocol v2 插件。'" 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 v-else class="groups-workspace">
       <MessageTimeline

+ 3 - 2
src/server/routes.ts

@@ -4,6 +4,7 @@ import { parse } from 'cookie'
 import { createReadStream, realpathSync, statSync } from 'node:fs'
 import { basename, resolve, sep } from 'node:path'
 import { lookup as mimeLookup } from 'mime-types'
+import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '../shared/types.js'
 import type { ServerConfig } from './config.js'
 import { isLoopbackUpstream } from './config.js'
 import { HttpError } from './errors.js'
@@ -315,8 +316,8 @@ async function issueLease(
       jar,
     ))
     const protocolVersion = Number(capabilities.protocolVersion ?? capabilities.protocol_version)
-    if (protocolVersion !== 2) {
-      throw new HttpError(409, 'Hermes group chat protocol v2 is required', 'unsupported_group_protocol')
+    if (!isSupportedGroupProtocolVersion(protocolVersion)) {
+      throw new HttpError(409, `Hermes group chat protocol ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL} is required`, 'unsupported_group_protocol')
     }
   }
   let credential: { name: 'ticket' | 'token'; value: string }

+ 10 - 0
src/shared/types.ts

@@ -240,6 +240,16 @@ export interface GroupLimits {
   maxAgentDisplayNameLength: number
 }
 
+export const MIN_SUPPORTED_GROUP_PROTOCOL_VERSION = 2
+export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 3
+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 {
+  return Number.isInteger(value)
+    && value >= MIN_SUPPORTED_GROUP_PROTOCOL_VERSION
+    && value <= MAX_SUPPORTED_GROUP_PROTOCOL_VERSION
+}
+
 export interface GroupCapabilities {
   protocolVersion: number
   journalEpoch: string

+ 1 - 1
tests/fixtures/fake-hermes.mjs

@@ -111,7 +111,7 @@ const server = createServer(async (request, response) => {
     return session ? json(response, 200, session) : json(response, 404, { detail: 'Not found' })
   }
   if (url.pathname === '/api/session-unread') return json(response, 200, { items: { 'session-demo': 0 } })
-  if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 2, journalEpoch: epoch, latestCursor: 0, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'agent.status'] })
+  if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 3, journalEpoch: epoch, latestCursor: 0, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'agent.status'] })
   if (url.pathname === '/api/plugins/yaoyao/v1/rooms') return json(response, 200, { items: [room], nextCursor: null })
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) return json(response, 200, { ...room, agents: [groupAgent], runs: [], pendingInteractions: [], latestCursor: 0 })
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) return json(response, 200, { items: groupMessages })