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

增加桌面未读中心与安全 Dock 权威角标

Samien 1 месяц назад
Родитель
Сommit
a327a7f194

+ 6 - 2
desktop/main.mjs

@@ -1,3 +1,4 @@
+import { installUnreadBadge } from './unread-badge.mjs'
 import { app, BrowserWindow, ipcMain, Menu, Tray, nativeImage, shell, utilityProcess, powerMonitor, systemPreferences, dialog, safeStorage, net, session as electronSession } from 'electron'
 import { appendFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
 import { join, dirname } from 'node:path'
@@ -56,6 +57,8 @@ else {
     try { return remoteMode && remoteURL ? [new URL(remoteURL).origin] : manager?.state.url ? [new URL(manager.state.url).origin] : [] }
     catch { return [] }
   }
+  const unreadBadge=installUnreadBadge({ipcMain,app,owner:()=>window,origins:serviceOrigins,url:serviceURL,quitting:()=>closing||quitting,fetch:(url,options)=>electronSession.defaultSession.fetch(url,options)})
+  app.on('will-quit',()=>unreadBadge.stop())
   const closeComputerViewer = installComputerViewer({ owner: () => window, origin: () => serviceURL(),
     preload: join(desktopRoot, 'preload.cjs'), quitting: () => quitting })
   app.on('will-quit', closeComputerViewer)
@@ -169,6 +172,7 @@ else {
       catch { /* Invalid navigation is denied. */ }
       event.preventDefault(); safeExternal(url)
     }
+    window.webContents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame)unreadBadge.clear()})
     window.webContents.on('will-navigate', guardNavigation)
     window.webContents.on('will-redirect', guardNavigation)
     window.webContents.on('will-attach-webview', event => event.preventDefault())
@@ -235,7 +239,7 @@ else {
   function requestQuit(stopBackground = false) {
     if (closing || quitting) return
     const resumeLocal = serverModeActive && manager?.activationRequested === true
-    closing = true; clearInterval(timer)
+    closing = true; unreadBadge.clear(); clearInterval(timer)
     updater?.stopChecking?.(); updater?.cancel(); updateWindow?.close()
     window?.hide()
     // Native Cmd+Q can enter before-quit from Cocoa's termination callback.
@@ -276,7 +280,7 @@ else {
   async function prepareUpdateRestart() {
     if (closing || quitting) throw new Error('App 正在退出,请稍后重试')
     resumeLocalAfterUpdate = serverModeActive && manager?.activationRequested === true
-    closing = true; clearInterval(timer)
+    closing = true; unreadBadge.clear(); clearInterval(timer)
     await environmentHost?.close()
     await hostManager?.stop()
     await runnerManager?.stop()

+ 1 - 0
desktop/preload.cjs

@@ -1,5 +1,6 @@
 const { contextBridge, ipcRenderer } = require('electron')
 contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
+  unreadState: account => ipcRenderer.invoke('desktop:unread-state', account),
   status: () => ipcRenderer.invoke('desktop:status'),
   selectServer: mode => ipcRenderer.invoke('desktop:onboarding-select', mode),
   prepareServer: input => ipcRenderer.invoke('desktop:onboarding-prepare', input),

+ 35 - 0
desktop/unread-badge.mjs

@@ -0,0 +1,35 @@
+/** Fetch the authority in the existing browser session; renderer count input is never trusted. */
+export function installUnreadBadge({ ipcMain, app, owner, origins, url, quitting, fetch, interval = 3000 }) {
+ let account=null,generation=0,poll,pending=false
+ const badge=count=>{if(app.dock)app.dock.setBadge(count ? String(count) : '')}
+ const clear=()=>{account=null;generation++;pending=false;clearInterval(poll);poll=undefined;badge(0)}
+ const trusted=event=>{
+  try{return !quitting()&&event.sender===owner()?.webContents&&event.senderFrame===owner().webContents.mainFrame&&origins().includes(new URL(event.senderFrame.url).origin)}catch{return false}
+ }
+ async function refresh(){
+  if(!account||pending||quitting())return
+  const current=generation,identity=account,origin=new URL(url()).origin;pending=true
+  try{
+   const options={credentials:'include',redirect:'error',cache:'no-store'}
+   const auth=await fetch(`${origin}/api/app/bootstrap?csrfOnly=1`,options)
+   if(!auth.ok)throw new Error('authentication required')
+   const user=await auth.json()
+   // auth-disabled local identity is represented as null by csrfOnly.
+   if((user.userId??'local')!==identity){if(current===generation)clear();return}
+   const response=await fetch(`${origin}/api/app/unread`,options)
+   if([401,403].includes(response.status)){if(current===generation)clear();return}
+   if(!response.ok)return
+   const value=await response.json()
+   if(current===generation&&account===identity&&origins().includes(origin)&&Number.isSafeInteger(value.total)&&value.total>=0)badge(value.total)
+  }catch{ /* Offline preserves the last authenticated count. Navigation/logout clears immediately. */ }
+  finally{if(current===generation)pending=false}
+ }
+ ipcMain.handle('desktop:unread-state',async(event,input)=>{
+  if(!trusted(event))throw new Error('不允许此页面更新未读状态')
+  if(input!==null&&(typeof input!=='string'||!input.trim()||input.length>256))throw new Error('无效账号范围')
+  if(input===null){clear();return}
+  if(account!==input){clear();account=input;poll=setInterval(()=>void refresh(),interval);poll.unref?.()}
+  await refresh()
+ })
+ return { clear,refresh,stop(){clear();ipcMain.removeHandler('desktop:unread-state')} }
+}

+ 6 - 0
desktop/unread-badge.test.mjs

@@ -0,0 +1,6 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {installUnreadBadge} from './unread-badge.mjs'
+function fixture(){let handler,badges=[],total=12,user='one',quit=false;const frame={url:'https://fixture.local/chat'},contents={mainFrame:frame},win={webContents:contents};const controller=installUnreadBadge({ipcMain:{handle(_name,fn){handler=fn},removeHandler(){}},app:{dock:{setBadge:v=>badges.push(v)}},owner:()=>win,origins:()=>['https://fixture.local'],url:()=>frame.url,quitting:()=>quit,fetch:async url=>({ok:true,status:200,json:async()=>url.includes('bootstrap')?{userId:user}:{total}}),interval:100000});return {controller,badges,event:{sender:contents,senderFrame:frame},invoke:(e,v)=>handler(e,v),setTotal:v=>total=v,setUser:v=>user=v,setQuit:v=>quit=v}}
+test('authority continues while owner is hidden/closed-to-tray and clears logout scope',async()=>{const f=fixture();try{await f.invoke(f.event,'one');assert.equal(f.badges.at(-1),'12');f.setTotal(103);await f.controller.refresh();assert.equal(f.badges.at(-1),'103');await f.invoke(f.event,null);assert.equal(f.badges.at(-1),'');await f.invoke(f.event,'one');f.setUser('two');await f.controller.refresh();assert.equal(f.badges.at(-1),'');f.setTotal(0);await f.invoke(f.event,'two');assert.equal(f.badges.at(-1),'')}finally{f.controller.stop()}})
+test('rejects subframe, foreign webContents, origin and malformed input without weakening frame checks',async()=>{const f=fixture();try{for(const e of [{...f.event,sender:{}},{...f.event,senderFrame:{url:'https://fixture.local/chat'}},{...f.event,senderFrame:{url:'https://bad.local'}}])await assert.rejects(f.invoke(e,'one'));await assert.rejects(f.invoke(f.event,{count:3}));f.setQuit(true);await assert.rejects(f.invoke(f.event,'one'))}finally{f.controller.stop()}})

+ 6 - 0
src/client/App.vue

@@ -4,13 +4,19 @@ import { useRoute, useRouter } from 'vue-router'
 import LoadingScreen from '@/components/app/LoadingScreen.vue'
 import LoginView from '@/views/LoginView.vue'
 import PasswordChangeView from '@/views/PasswordChangeView.vue'
+import { useUnreadStore } from '@/stores/unread'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { useKanbanStore } from '@/stores/kanban'
 import { useThemeStore } from '@/stores/theme'
 import AgentIdentityFixture from '@/components/app/AgentIdentityFixture.vue'
 
+const unread=useUnreadStore()
 const auth = useAuthStore()
+watch(()=>auth.isAuthenticated ? `${auth.serverIdentity?.serverId ?? location.origin}:${auth.user?.id ?? ''}` : '',()=>{
+ if(auth.isAuthenticated&&auth.user)unread.start(auth.user.id)
+ else { unread.reset();void window.yaoyaoDesktop?.unreadState?.(null).catch(()=>{}) }
+},{immediate:true})
 const chat = useChatStore()
 const kanban = useKanbanStore()
 const theme = useThemeStore()

Разница между файлами не показана из-за своего большого размера
+ 21 - 0
src/client/components/app/UnreadPanel.vue


+ 10 - 1
src/client/components/app/WorkspaceShell.vue

@@ -1,4 +1,6 @@
 <script setup lang="ts">
+import UnreadPanel from './UnreadPanel.vue'
+import { useUnreadStore } from '@/stores/unread'
 import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import type { Profile } from '@shared/types'
@@ -99,6 +101,7 @@ const emit = defineEmits<{
   botSettingsChanged: []
 }>()
 
+const unread=useUnreadStore(),unreadOpen=ref(false)
 const route = useRoute()
 const router = useRouter()
 const mobileDrawerOpen = ref(false)
@@ -444,7 +447,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
 </script>
 
 <template>
-  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="settingsOpen || !!standalone || (applicationWorkspace && sidebarSearchOpen)">
+  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="unreadOpen || settingsOpen || !!standalone || (applicationWorkspace && sidebarSearchOpen)">
     <header class="mobile-header" :inert="mobileDrawerOpen">
       <button ref="mobileNavigationTrigger" class="icon-button" type="button" aria-label="打开导航" @click="openMobileDrawer">
         <AppIcon name="menu" :size="20" />
@@ -540,6 +543,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
         </div>
       </section>
 
+      <button class="unread-entry" type="button" :aria-label="`未读消息,${unread.total} 条`" title="未读消息" @click="unreadOpen=true"><AppIcon name="bell" :size="19" /><span v-if="!sidebarCollapsed || applicationWorkspace">未读消息</span><b v-if="unread.total">{{ unread.total>99?'99+':unread.total }}</b></button>
       <div class="sidebar-footer">
         <button v-if="applicationWorkspace" class="sidebar-tools-trigger" type="button" aria-haspopup="menu" :aria-expanded="toolsMenuOpen" @click="openToolsMenu"><YaoYaoSidebarIcon name="tools" :size="18" /><span>工具</span><AppIcon name="chevron-down" :size="15" /></button>
         <div class="sidebar-account-switcher">
@@ -748,6 +752,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
         </div>
       </div>
     </Teleport>
+  <UnreadPanel v-if="unreadOpen" :mode="applicationWorkspace?'bot':'chat'" @close="unreadOpen=false" />
   </div>
 </template>
 
@@ -996,3 +1001,7 @@ defineExpose({openLocalVm:showLocalVmSettings})
  .workspace-shell--conversation-open>.workspace-main{display:flex}
 }
 </style>
+
+<style scoped>
+.unread-entry{display:flex;align-items:center;gap:10px;margin:8px 12px;padding:10px 12px;border:0;border-radius:10px;background:transparent;color:inherit;text-align:left;cursor:pointer;min-height:44px}.unread-entry:hover{background:var(--surface-hover,rgba(128,128,128,.1))}.unread-entry span{flex:1}.unread-entry b{border-radius:20px;background:var(--accent,#796bcd);color:white;min-width:22px;padding:2px 6px;font-size:12px;white-space:nowrap}.desktop-sidebar--collapsed .unread-entry{position:relative;margin:8px;padding:10px}.desktop-sidebar--collapsed .unread-entry b{position:absolute;top:0;right:0;font-size:10px}@media(max-width:767px){.unread-entry{display:none}}
+</style>

+ 4 - 0
src/client/components/messages/MessageTimeline.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import {useVisibleMessages} from '@/utils/visibleMessages'
 import { useChatAppearance } from '@/stores/chatAppearance'
 import { bubbleVariables } from '@/utils/chatAppearance'
 import '@/styles/chat-bubbles.css'
@@ -73,6 +74,7 @@ const props = withDefaults(defineProps<{
 })
 
 const emit = defineEmits<{
+  visibleMessages: [ids:string[]]
   loadOlder: []
   quote: [message: UiMessage]
   branch: [message: UiMessage]
@@ -83,6 +85,7 @@ const emit = defineEmits<{
 }>()
 
 const scroller = ref<HTMLElement | null>(null)
+useVisibleMessages(scroller,ids=>emit('visibleMessages',ids))
 const pinnedToBottom = ref(true)
 const showJump = ref(false)
 const copiedMessageId = ref('')
@@ -134,6 +137,7 @@ function scrollToMessage(id: string): boolean {
   if (!root) return false
   const message = root.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
   if (!message) return false
+  pinnedToBottom.value=false
   message.scrollIntoView({ block: 'center', behavior: 'smooth' })
   message.classList.remove('message--revealed')
   requestAnimationFrame(() => message.classList.add('message--revealed'))

+ 3 - 0
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import {useVisibleMessages} from '@/utils/visibleMessages'
 import { useChatAppearance } from '@/stores/chatAppearance'
 import { bubbleVariables } from '@/utils/chatAppearance'
 import '@/styles/chat-bubbles.css'
@@ -91,6 +92,7 @@ const props = withDefaults(defineProps<{
 })
 
 const emit = defineEmits<{
+  visibleMessages: [ids:string[]]
   loadOlder: []
   quote: [message: UiMessage]
   branch: [message: UiMessage]
@@ -102,6 +104,7 @@ const emit = defineEmits<{
 }>()
 
 const scroller = ref<HTMLElement | null>(null)
+useVisibleMessages(scroller,ids=>emit('visibleMessages',ids))
 const pinnedToBottom = ref(true)
 const showJump = ref(false)
 const copiedMessageId = ref('')

+ 1 - 0
src/client/env.d.ts

@@ -11,6 +11,7 @@ interface ImportMeta {
 
 interface Window {
   yaoyaoDesktop?: {
+    unreadState?(accountId: string | null): Promise<void>
     openLogin?(): Promise<unknown>
     forgetLogin?(): Promise<unknown>
     openUpdates?(): Promise<void>

+ 38 - 0
src/client/stores/unread.ts

@@ -0,0 +1,38 @@
+import { ref, computed, reactive } from 'vue'
+import { apiRequest } from '@/api/client'
+import { emptyUnread, type UnreadConversation, type UnreadSnapshot } from '@shared/unread'
+const createUnreadStore=()=>{
+ const snapshot=ref<UnreadSnapshot>(emptyUnread()),error=ref(''),scope=ref('')
+ let epoch=0,revision=0,timer:ReturnType<typeof setInterval>|undefined,inflight:Promise<void>|undefined
+ function reset(account='') { epoch++;revision++;scope.value=account;snapshot.value=emptyUnread();error.value='';inflight=undefined;clearInterval(timer);timer=undefined }
+ async function refresh(){
+  if(!scope.value)return
+  if(inflight)return inflight
+  const generation=epoch,request=revision
+  const pending=apiRequest<UnreadSnapshot>('/api/app/unread').then(value=>{
+   if(generation!==epoch||request!==revision)return
+   snapshot.value=value;error.value=''
+   void window.yaoyaoDesktop?.unreadState?.(scope.value).catch(()=>{})
+  }).catch(e=>{if(generation===epoch)error.value=e instanceof Error?e.message:'未读同步失败'})
+  inflight=pending;try{await pending}finally{if(inflight===pending)inflight=undefined}
+ }
+ function start(account:string){reset(account);void window.yaoyaoDesktop?.unreadState?.(account).catch(()=>{});void refresh();timer=setInterval(()=>void refresh(),3000)}
+ async function read(items:UnreadConversation[]){
+  const generation=epoch,account=scope.value;revision++;inflight=undefined
+  await apiRequest<UnreadSnapshot>('/api/app/unread/read',{method:'POST',body:JSON.parse(JSON.stringify({items:items.map(({mode,id,profile,messages})=>({mode,id,profile,messages}))}))})
+  if(generation!==epoch||account!==scope.value)return
+  // Concurrent reads can finish out of order; fetch the latest authority.
+  revision++;await refresh()
+ }
+ async function visible(mode:'bot'|'chat',id:string,profile:string|undefined,ids:string[]){
+  if(!ids.length||!scope.value)return
+  const row=snapshot.value.conversations.find(c=>c.mode===mode&&c.id===id&&(mode==='bot'||c.profile===profile))
+  if(!row)return
+  const selected=new Set(ids),messages=row.messages.filter(m=>selected.has(m.id))
+  if(messages.length)await read([{...row,messages}])
+ }
+ const total=computed(()=>snapshot.value.total)
+ return reactive({snapshot,total,error,reset,start,refresh,read,visible})
+}
+let store:ReturnType<typeof createUnreadStore>|undefined
+export const useUnreadStore=()=>store??=createUnreadStore()

+ 19 - 0
src/client/utils/visibleMessages.ts

@@ -0,0 +1,19 @@
+import { onMounted,onBeforeUnmount,type Ref } from 'vue'
+export function useVisibleMessages(root:Ref<HTMLElement|null>,emit:(ids:string[])=>void){
+ let timer:ReturnType<typeof setInterval>|undefined,previous=new Set<string>()
+ const scan=()=>{
+  const el=root.value
+  if(!el||document.hidden||!document.hasFocus()){previous.clear();return}
+  const box=el.getBoundingClientRect(),current=new Set<string>()
+  for(const row of el.querySelectorAll<HTMLElement>('[data-message-id]')){
+   const r=row.getBoundingClientRect(),top=Math.max(r.top,box.top,0),bottom=Math.min(r.bottom,box.bottom,innerHeight),left=Math.max(r.left,box.left,0),right=Math.min(r.right,box.right,innerWidth)
+   if(bottom-top<Math.min(24,r.height)||right<=left)continue
+   const front=document.elementFromPoint((left+right)/2,(top+bottom)/2)
+   if(front&&row.contains(front)&&row.dataset.messageId)current.add(row.dataset.messageId)
+  }
+  const settled=[...current].filter(id=>previous.has(id));previous=current
+  if(settled.length)emit(settled)
+ }
+ onMounted(()=>{timer=setInterval(scan,350)})
+ onBeforeUnmount(()=>{clearInterval(timer);previous.clear()})
+}

+ 22 - 2
src/client/views/ChatView.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import {useUnreadStore} from '@/stores/unread'
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import AppIcon from '@/components/common/AppIcon.vue'
@@ -29,6 +30,7 @@ import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { getSession } from '@/api/sessions'
 
+const unread=useUnreadStore()
 const auth = useAuthStore()
 const chat = useChatStore()
 const route = useRoute()
@@ -67,7 +69,7 @@ const agentAvatars = computed(() => Object.fromEntries(auth.profiles.flatMap(pro
 )))
 const sidebarItems = computed(() => sessions.value.map(session => sessionSidebarItem(
   session,
-  chat.unreadCounts[session.id] ?? 0,
+  unread.snapshot.conversations.find(c=>c.mode==='chat'&&c.id===session.id&&c.profile===session.profile)?.count ?? 0,
   agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
 )))
 const messages = computed(() => chatMessagesToUi(chat.messages, profile => profile ? agentNames.value.get(profile) : undefined))
@@ -152,6 +154,23 @@ async function refreshSessions() {
   }
 }
 
+const readPending=new Set<string>()
+async function markVisible(ids:string[]){
+ if(preview.value||filePreview.value||outlineOpen.value||modelDialog.value||!chat.activeSessionId||typeof route.query.unread==='string')return
+ const session=chat.activeSessionId,profile=chat.activeProfileName,key=`${profile}:${session}:${ids.join(',')}`
+ if(readPending.has(key))return
+ readPending.add(key);try{await unread.visible('chat',session,profile,ids)}catch{}finally{readPending.delete(key)}
+}
+let unreadNavigation=0
+watch(()=>[route.params.id,route.query.unread,chat.messages.length],async()=>{
+ const id=route.query.unread;if(typeof id!=='string'||!chat.activeSessionId)return
+ const own=++unreadNavigation
+ for(let i=0;i<100&&own===unreadNavigation;i++){
+  await nextTick();if(timeline.value?.scrollToMessage(id)){const query={...route.query};delete query.unread;await router.replace({query});return}
+  if(!chat.hasMoreBefore)return
+  const before=chat.messages.length;await chat.loadOlder();if(chat.messages.length===before)return
+ }
+},{flush:'post'})
 async function chooseSession(id: string) {
   outlineOpen.value = false
   preview.value = null
@@ -161,7 +180,7 @@ async function chooseSession(id: string) {
   await ensureRouteProfile(profile)
     if (chat.activeProfileName !== profile) await chat.loadSessions(profile, 'chat')
   await chat.selectSession(id, profile)
-  await chat.markRead(id, profile).catch(() => undefined)
+
   quoted.value = null
 }
 
@@ -419,6 +438,7 @@ watch(() => chat.activeSessionId, async id => {
 
     <div class="chat-workspace">
       <MessageTimeline
+        @visible-messages="markVisible"
         ref="timeline"
         :messages="messages"
         :title="activeSession?.title || '新会话'"

+ 19 - 41
src/client/views/ConversationsView.vue

@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import { useUnreadStore } from '@/stores/unread'
 import type { ComputerBackend } from '@shared/managedBrowser'
 import { WORKSPACE_PATCH_CAPABILITY } from '@shared/workspaceMessagePatch'
 import { setApiCsrfToken } from '@/api/client'
@@ -376,8 +377,9 @@ async function load(id = selected.value, append = false, signal?: AbortSignal) {
     if (!append || atBottom) {
       await nextTick()
       if (!current()) return
-      if (!append) timeline.value?.scrollToBottom('auto')
-      void markRead().catch(e => { if (current()) error.value = e instanceof Error ? e.message : '同步已读失败' })
+      if (!append && typeof route.query.unread!=='string') timeline.value?.scrollToBottom('auto')
+      if(typeof route.query.unread==='string')void locateUnread(route.query.unread)
+
     }
   } catch (e) {
     if (current() && requestedTask && e instanceof ApiError && e.status === 404) {
@@ -396,44 +398,20 @@ async function load(id = selected.value, append = false, signal?: AbortSignal) {
     if (own === generation) loading.value = false
   }
 }
+async function locateUnread(id:string){
+ for(let i=0;i<100&&active.value?.id===selected.value;i++){
+  await nextTick();if(timeline.value?.scrollToMessage(id)){const query={...route.query};delete query.unread;await router.replace({query});return}
+  if(!older.value||loadingOlder.value)return
+  await loadOlder()
+ }
+}
+const unread=useUnreadStore()
 const pendingReads = new Set<string>()
-async function markRead() {
-  const c = active.value
-  const read = activeTask.value ?? c
-  if (!c || !read || (!workspaceHasUnread(read) && read.readSeq >= read.lastSeq)) return
-  const task = activeTask.value, seq = read.lastSeq, version = read.unreadVersion
-  const key = `${c.id}:${task?.id ?? ''}:${version}:${seq}`
-  if (pendingReads.has(key)) return
-  pendingReads.add(key)
-  const previous = { unread: read.unread, unreadCount: read.unreadCount, readSeq: read.readSeq }
-  const previousConversation = { unread: c.unread, unreadCount: c.unreadCount }
-  read.unread = false;read.unreadCount = 0;read.readSeq = seq
-  if (task) tasks.value = tasks.value.map(t => t.id === task.id ? { ...t, unread: false, unreadCount: 0, readSeq: seq } : t)
-  const publishUnread = () => {
-    c.unread = task ? tasks.value.some(workspaceHasUnread) : read.unread
-    c.unreadCount = c.unread ? 1 : 0
-    const row = conversations.value.find(row => row.id === c.id)
-    if (row && row.unreadVersion === c.unreadVersion) { row.unread = c.unread;row.unreadCount = c.unreadCount }
-  }
-  publishUnread()
-  try {
-    const result = await apiRequest<{conversation: Conversation}>(`/api/app/conversations/${c.id}/read${task ? `?taskId=${task.id}` : ''}`, {
-      method: 'PUT', body: { seq, ...(version === undefined ? {} : {unreadVersion:version}) },
-    })
-    const row = conversations.value.find(row => row.id === c.id)
-    if (row && result.conversation && (row.unreadVersion ?? 0) <= (result.conversation.unreadVersion ?? 0)) {
-      row.unread = result.conversation.unread;row.unreadCount = result.conversation.unreadCount;row.unreadVersion = result.conversation.unreadVersion
-    }
-  } catch (error) {
-    const row = conversations.value.find(row => row.id === c.id)
-    if (row && row.unreadVersion === c.unreadVersion) Object.assign(row, previousConversation)
-    if (active.value?.id === c.id && activeTask.value?.id === task?.id && (activeTask.value ?? active.value)?.unreadVersion === version) {
-      Object.assign(activeTask.value ?? active.value!, previous)
-      if (task) tasks.value = tasks.value.map(t => t.id === task.id ? { ...t, ...previous, unreadCount: previous.unreadCount ?? 0 } : t)
-      publishUnread()
-    }
-    throw error
-  } finally { pendingReads.delete(key) }
+async function markVisible(ids:string[]) {
+ const c=active.value;if(!c||loading.value||computerOpen.value||typeof route.query.unread==='string')return
+ const key=`${c.id}:${ids.join(',')}`;if(pendingReads.has(key))return
+ const complete=new Set(messages.value.filter(m=>m.role==='assistant'&&m.status==='complete'&&m.visible!==false).map(m=>m.id))
+ pendingReads.add(key);try{await unread.visible('bot',c.id,undefined,ids.filter(id=>complete.has(id)))}catch{}finally{pendingReads.delete(key)}
 }
 async function selectTask(event:Event){
   const select=event.currentTarget as HTMLSelectElement,value=select.value
@@ -503,7 +481,7 @@ function presentDetail(detail: WorkspaceDetail) {
   run.value = detail.run; interactions.value = detail.interactions
   context.value = detail.context; assignments.value = detail.assignments ?? []
   older.value = detail.hasOlder ?? false
-  if (atBottom) void markRead().catch(() => {})
+
 }
 function flushEvents() {
   eventFrame = undefined
@@ -1025,7 +1003,7 @@ onBeforeUnmount(() => {
     /></template>
     <div v-show="!twoDesktops" class="conversation-with-computer">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
-      <MessageTimeline ref="timeline" :identity="`${selected}:${activeTask?.id ?? ''}`" :loading-older="loadingOlder" :messages="uiMessages" :title="activeHeaderTitle"
+      <MessageTimeline @visible-messages="markVisible" ref="timeline" :identity="`${selected}:${activeTask?.id ?? ''}`" :loading-older="loadingOlder" :messages="uiMessages" :title="activeHeaderTitle"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
         :header-avatar-name="activeHeaderAvatarName" :header-avatar="activeHeaderAvatar" :header-avatar-kind="active?.kind === 'group' ? 'team' : 'agent'"
         :header-avatar-members="activeHeaderAvatarMembers" :header-avatar-state="activeHeaderAgent ? workspaceAvatarState(active, activeHeaderAgent.id) : 'idle'" :header-avatar-activity-key="active?.lastSeq"

+ 1 - 1
src/server/app.ts

@@ -250,7 +250,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
   // The application owns group events. No Dashboard plugin is queried.
   const workspace = new WorkspaceStore(config.home)
-  push.setUnreadCounter(owner => unreadSnapshot(workspace, chatCache!.store, owner).total)
+  push.setUnreadCounter(owner => unreadSnapshot(workspace, chatCache!.store, owner,(node,profile)=>auth.canUseSource(owner,node,profile)).total)
   const openVikingService = new OpenVikingService(workspace, openVikingConfiguration)
   const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession }, pairings.nodeID)
   workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)

+ 1 - 1
src/server/pushCoordinator.ts

@@ -1394,7 +1394,7 @@ export class PushCoordinator {
                 aps: {
                   alert: { title: item.title, body: item.body },
                   sound: 'default',
-                  badge: installation.badge,
+                  badge: this.unreadCounter ? this.unreadCounter(item.userId) : installation.badge,
                   ...(item.threadId ? { 'thread-id': item.threadId } : {}),
                 },
                 version: 1,

+ 2 - 2
tests/server/workspaceMessagePatch.test.ts

@@ -154,7 +154,7 @@ it('persists card-only messages and replays installation updates as full frames
   store.saveMessage('owner',message)
   store.close();store=new WorkspaceStore(home,{messagePatches:true})
   expect(store.require<WorkspaceMessage>('owner','message',message.id)).toMatchObject({visible:true,status:'complete',content:'',browserCard:{status:'closed',title:'测试网页'}})
-  expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(true)
+  expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(false)
 })
 
 it('persists nonfatal service warnings independently of reply text and keeps terminal warning-only replies visible',()=>{
@@ -165,5 +165,5 @@ it('persists nonfatal service warnings independently of reply text and keeps ter
   expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).preview).toBe('部分服务暂时不可用')
   message.status='complete';store.saveMessage('owner',message)
   expect(store.require<WorkspaceMessage>('owner','message',message.id)).toMatchObject({visible:true,content:'',status:'complete',serviceWarnings:message.serviceWarnings})
-  expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(true)
+  expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(false)
 })

Некоторые файлы не были показаны из-за большого количества измененных файлов