Procházet zdrojové kódy

fix: 保持会话链接的 Agent 归属

samien před 2 měsíci
rodič
revize
1668406471

+ 45 - 9
src/client/views/ChatView.vue

@@ -17,6 +17,7 @@ import { chatInteraction, chatMessagesToUi, sessionSidebarItem } from '@/compone
 import { consumeLibraryItemForComposer } from '@/components/workspace/pendingComposer'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
+import { getSession } from '@/api/sessions'
 
 const auth = useAuthStore()
 const chat = useChatStore()
@@ -74,19 +75,45 @@ const reasoningComposerOptions = computed<ComposerOption[]>(() => reasoningOptio
 })))
 const reasoningLabel = computed(() => reasoningOptions.find(option => option.id === (chat.reasoningEffort || ''))?.label || '默认')
 
+function routeProfile(): string {
+  const value = typeof route.query.profile === 'string' ? route.query.profile : ''
+  return auth.profiles.some(profile => profile.name === value) ? value : ''
+}
+
+function sessionLocation(id: string, profile: string) {
+  return { path: `/chat/${encodeURIComponent(id)}`, query: { profile } }
+}
+
+async function findLegacySessionProfile(sessionId: string): Promise<string> {
+  for (const profile of auth.profiles) {
+    try {
+      const session = await getSession(sessionId, profile.name)
+      if (session.id === sessionId) return profile.name
+    } catch { /* Try the next profile: 9119 returns 404 for a foreign session. */ }
+  }
+  return auth.activeProfile?.name || ''
+}
+
 async function refreshSessions() {
-  const profile = auth.activeProfile?.name
+  const requestedId = typeof route.params.sessionId === 'string' ? route.params.sessionId : ''
+  const profile = routeProfile() || (requestedId ? await findLegacySessionProfile(requestedId) : auth.activeProfile?.name || '')
+  if (profile && auth.activeProfile?.name !== profile) {
+    if (requestedId) await router.replace(sessionLocation(requestedId, profile))
+    auth.selectProfile(profile)
+    return
+  }
   // The session route is the authoritative navigation target. A secondary
   // unread-count request must never prevent its history from being restored.
   await chat.loadSessions(profile)
-  const requestedId = typeof route.params.sessionId === 'string' ? route.params.sessionId : ''
   if (requestedId && chat.activeSessionId !== requestedId) await chat.selectSession(requestedId, profile)
+  if (requestedId && profile && routeProfile() !== profile) await router.replace(sessionLocation(requestedId, profile))
   void chat.loadUnread(profile).catch(() => undefined)
 }
 
 async function chooseSession(id: string) {
   const session = chat.sessions.find(item => item.id === id)
-  await router.push(`/chat/${encodeURIComponent(id)}`)
+  await router.push(sessionLocation(id, session?.profile || auth.activeProfile?.name || 'default'))
+  if (session?.profile && auth.activeProfile?.name !== session.profile) auth.selectProfile(session.profile)
   await chat.selectSession(id, session?.profile)
   await chat.markRead(id, session?.profile)
   quoted.value = null
@@ -94,7 +121,7 @@ async function chooseSession(id: string) {
 
 async function createSession() {
   const id = chat.createSession(auth.activeProfile?.name)
-  await router.push(`/chat/${encodeURIComponent(id)}`)
+  await router.push(sessionLocation(id, auth.activeProfile?.name || 'default'))
 }
 
 async function send(payload: ComposerSubmit) {
@@ -153,7 +180,7 @@ async function deleteSession() {
 
 async function branch() {
   const id = await chat.branchSession()
-  if (id) await router.push(`/chat/${encodeURIComponent(id)}`)
+  if (id) await router.push(sessionLocation(id, chat.activeProfileName || auth.activeProfile?.name || 'default'))
 }
 
 async function revealSourceMessage() {
@@ -169,7 +196,8 @@ async function revealSourceMessage() {
 }
 
 onMounted(async () => {
-  await Promise.allSettled([refreshSessions(), chat.loadModels(auth.activeProfile?.name), chat.connect()])
+  await refreshSessions()
+  await Promise.allSettled([chat.loadModels(auth.activeProfile?.name), chat.connect()])
   await revealSourceMessage()
   const pendingFile = await consumeLibraryItemForComposer()
   if (pendingFile) composer.value?.attachFiles([pendingFile])
@@ -180,13 +208,21 @@ onBeforeUnmount(() => chat.disconnect())
 watch(() => auth.activeProfile?.name, async (profile, previous) => {
   if (!profile || profile === previous) return
   chat.switchProfile(profile)
-  await router.replace('/chat')
+  const requestedId = typeof route.params.sessionId === 'string' ? route.params.sessionId : ''
+  if (requestedId && routeProfile() === profile) {
+    chat.switchProfile(profile)
+    await Promise.allSettled([chat.loadSessions(profile), chat.loadModels(profile), chat.loadUnread(profile), chat.connect()])
+    await chat.selectSession(requestedId, profile)
+    return
+  }
+  await router.replace({ path: '/chat', query: { profile } })
   await Promise.allSettled([chat.loadSessions(profile), chat.loadModels(profile), chat.loadUnread(profile), chat.connect()])
 })
 
 watch(() => chat.activeSessionId, async id => {
-  if (!id || route.params.sessionId === id) return
-  await router.replace(`/chat/${encodeURIComponent(id)}`)
+  const profile = chat.activeProfileName || auth.activeProfile?.name || ''
+  if (!id || (route.params.sessionId === id && routeProfile() === profile)) return
+  await router.replace(sessionLocation(id, profile))
 })
 </script>
 

+ 8 - 0
tests/e2e/workspace.spec.ts

@@ -73,6 +73,14 @@ test('restores a routed conversation even when unread-count refresh fails', asyn
   await expect(page.getByText('已整理完成。下面是', { exact: false })).toBeVisible()
 })
 
+test('restores a legacy session link under its owning Agent profile', async ({ page }) => {
+  await page.goto('/chat/session-yaoer')
+  await expect(page.getByText('瑶儿历史消息', { exact: true })).toBeVisible()
+  await expect(page).toHaveURL(/\/chat\/session-yaoer\?profile=yaoer/)
+  await page.reload()
+  await expect(page.getByText('瑶儿历史消息', { exact: true })).toBeVisible()
+})
+
 test('renders historical assistant MEDIA as Markdown in chat and group chat', async ({ page }) => {
   await page.goto('/chat/session-demo')
   const image = page.locator('.markdown img[alt="夭夭 Logo"]')

+ 13 - 3
tests/fixtures/fake-hermes.mjs

@@ -37,6 +37,7 @@ const profiles = [
 const sessions = [
   { id: 'session-second', profile: 'yaoyao', source: 'web', title: '第二个会话', preview: '用于验证列表和切换', message_count: 2, tool_call_count: 0, started_at: now() - 7200, last_active_at: now() - 600 },
   { id: 'session-demo', profile: 'yaoyao', source: 'web', title: '夭夭 Web 验收会话', preview: '文件库与群聊已经就绪', message_count: 4, tool_call_count: 1, started_at: now() - 3600, last_active_at: now(), pinned: true, model: 'gpt-5.6', provider: 'openai' },
+  { id: 'session-yaoer', profile: 'yaoer', source: 'web', title: '瑶儿专属会话', message_count: 1, tool_call_count: 0, started_at: now() - 3500, last_active_at: now() - 20, model: 'gpt-5.6', provider: 'openai' },
   ...Array.from({ length: 101 }, (_, index) => ({ id: `session-page-${index + 1}`, profile: 'yaoyao', source: 'web', title: `分页会话 ${index + 1}`, message_count: 0, tool_call_count: 0, started_at: now() - 10_000 - index, last_active_at: now() - 10_000 - index })),
 ]
 const messages = [
@@ -80,10 +81,19 @@ const server = createServer(async (request, response) => {
   if (url.pathname === '/api/profiles/sessions' || url.pathname === '/api/sessions') {
     const offset = Math.max(0, Number(url.searchParams.get('offset') || 0))
     const limit = Math.max(1, Number(url.searchParams.get('limit') || 100))
-    return json(response, 200, { sessions: sessions.slice(offset, offset + limit), total: sessions.length, offset, limit })
+    const scoped = url.searchParams.get('profile') ? sessions.filter(item => item.profile === url.searchParams.get('profile')) : sessions
+    return json(response, 200, { sessions: scoped.slice(offset, offset + limit), total: scoped.length, offset, limit })
+  }
+  if (/^\/api\/sessions\/[^/]+\/messages$/.test(url.pathname)) {
+    const id = decodeURIComponent(url.pathname.split('/')[3])
+    const scopedMessages = id === 'session-yaoer' ? [{ id: 'yaoer-history', role: 'assistant', content: '瑶儿历史消息', timestamp: now() - 10 }] : messages
+    return json(response, 200, { messages: scopedMessages, pagination: { total: scopedMessages.length, returned: scopedMessages.length, limit: 150, hasMore: false } })
+  }
+  if (/^\/api\/sessions\/[^/]+$/.test(url.pathname)) {
+    const id = decodeURIComponent(url.pathname.split('/').at(-1))
+    const session = sessions.find(item => item.id === id && (!url.searchParams.get('profile') || item.profile === url.searchParams.get('profile')))
+    return session ? json(response, 200, session) : json(response, 404, { detail: 'Not found' })
   }
-  if (/^\/api\/sessions\/[^/]+\/messages$/.test(url.pathname)) return json(response, 200, { messages, pagination: { total: messages.length, returned: messages.length, limit: 150, hasMore: false } })
-  if (/^\/api\/sessions\/[^/]+$/.test(url.pathname)) return json(response, 200, sessions[0])
   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/rooms') return json(response, 200, { items: [room], nextCursor: null })