Преглед изворни кода

将 Bot 模式未读改为持久化标记并加快会话进入

Samien пре 1 месец
родитељ
комит
f43a05ca75

+ 3 - 1
src/client/components/app/ResourceSidebar.vue

@@ -165,7 +165,8 @@ defineExpose({ focusSearch })
               </span>
               <span v-if="!singleLine && row.item.subtitle" class="sidebar-item__row sidebar-item__row--secondary">
                 <span>{{ row.item.subtitle }}</span>
-                <b v-if="row.item.unread">{{ row.item.unread > 99 ? '99+' : row.item.unread }}</b>
+                <i v-if="row.item.unreadDot" class="sidebar-unread-dot" role="img" aria-label="未读" />
+                <b v-else-if="row.item.unread">{{ row.item.unread > 99 ? '99+' : row.item.unread }}</b>
               </span>
             </span>
             <button v-if="row.item.showMore !== false" class="sidebar-item__more" type="button" aria-label="更多操作" @click.stop="emit('more', row.item.id, $event)">
@@ -228,6 +229,7 @@ defineExpose({ focusSearch })
 .sidebar-item__row small { flex: 0 0 auto; color: var(--text-muted); font-size: 9.5px; font-variant-numeric: tabular-nums; }
 .sidebar-item__row--secondary { color: var(--text-muted); font-size: 10.5px; line-height: 13px; }
 .sidebar-item__row b { display: grid; place-items: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--accent); color: var(--text-on-solid); font-size: 9px; }
+.sidebar-unread-dot { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--accent); }
 .sidebar-item__more { display: none; position: absolute; right: 5px; top: 7px; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 7px; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; box-shadow: 0 1px 5px rgba(0,0,0,.07); }
 .sidebar-item:hover .sidebar-item__more, .sidebar-item:focus-within .sidebar-item__more { display: grid; }
 .sidebar-load-more { display: block; width: calc(100% - 14px); min-height: 32px; margin: 8px 7px 2px; border: 0; border-radius: 8px; background: var(--surface-soft); color: var(--text-secondary); cursor: pointer; font: 11px var(--font-ui); }

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

@@ -6,6 +6,7 @@ export type SidebarItemBase = {
   section?: string
   active?: boolean
   unread?: number
+  unreadDot?: boolean
   pinned?: boolean
   nested?: boolean
   topic?: boolean

+ 3 - 2
src/client/components/workspace/viewModels.ts

@@ -1,4 +1,5 @@
 import { visibleMessageText, messageReasoningText } from '@shared/messageFiles'
+import { workspaceHasUnread } from '@shared/workspace'
 import { toolResultFailed } from '@shared/chatTools'
 import { serverFilePath, serverFileUrl } from '@shared/serverFiles'
 import type {
@@ -421,7 +422,7 @@ export function workspaceAvatarState(conversation: import('@shared/workspace').W
   const signal = conversation?.avatarSignals?.[agentId]
   if (signal && Date.now() - signal.at < 2000) return signal.state
   if (conversation?.activeRunStatus === 'queued' && conversation.memberIds[0] === agentId) return 'loading'
-  if (!conversation?.activeRunId || conversation.activeAgentId !== agentId) return conversation?.previewAgentId === agentId && (conversation.unreadCount ?? Math.max(0, conversation.lastSeq - conversation.readSeq)) > 0 ? 'notifying' : 'idle'
+  if (!conversation?.activeRunId || conversation.activeAgentId !== agentId) return conversation?.previewAgentId === agentId && workspaceHasUnread(conversation) ? 'notifying' : 'idle'
   return conversation.activeRunStatus === 'waiting' || conversation.activeRunStatus === 'uncertain' ? 'waiting' : 'working'
 }
 
@@ -451,6 +452,6 @@ export function workspaceConversationItem(c: import('@shared/workspace').Workspa
     meta: formatConversationTime(c.lastMessageAt ?? c.createdAt),
     avatarKind: c.kind === 'direct' ? 'agent' : 'team',
     avatarState: activity ?? workspaceAvatarState(c, c.memberIds[0] || ''), avatarActivityKey: c.lastSeq,
-    unread: c.unreadCount ?? Math.max(0, c.lastSeq - c.readSeq), status: activity || c.activeRunId ? 'working' : undefined,
+    unreadDot: workspaceHasUnread(c), status: activity || c.activeRunId ? 'working' : undefined,
   }
 }

+ 52 - 10
src/client/views/ConversationsView.vue

@@ -12,6 +12,7 @@ import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
 import TaskPlan from '@/components/workspace/TaskPlan.vue'
 import type { AgentAssignment } from '@shared/agentTasks'
 import type { WorkspaceTask } from '@shared/workspace'
+import { workspaceHasUnread } from '@shared/workspace'
 import type { WorkspaceLifecycleAction, WorkspaceLifecyclePreview } from '@shared/workspaceLifecycle'
 import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
 import type { SidebarItem } from '@/components/app/types'
@@ -239,7 +240,12 @@ async function refresh() {
   ])
   if (disposed) return
   agents.value = a.agents
-  conversations.value = c.conversations
+  const localConversations = new Map(conversations.value.map(row => [row.id, row]))
+  conversations.value = c.conversations.map(conversation => {
+    const local = localConversations.get(conversation.id)
+    return local && local.unreadVersion === conversation.unreadVersion && [...pendingReads].some(key => key.startsWith(`${conversation.id}:`))
+      ? { ...conversation, unread: local.unread, unreadCount: local.unreadCount } : conversation
+  })
   if (!cursor) cursor = c.cursor
 }
 async function load(id = selected.value, append = false) {
@@ -255,8 +261,15 @@ async function load(id = selected.value, append = false) {
   }
   const own = ++generation
   const previousComposer = composerKey.value
+  const previousFiles = composer.value?.filesSnapshot() ?? []
+  const previousQuote = quoted.value
   const requestedTask = selectedTask.value || (active.value?.id === id ? activeTask.value?.id : undefined)
   if (!append) loading.value = true
+  if (active.value?.id !== id) {
+    active.value = conversations.value.find(c => c.id === id)
+    messages.value = [];run.value = null;interactions.value = [];context.value = null
+    activeTask.value = null;tasks.value = [];assignments.value = [];quoted.value = null;older.value = false
+  }
   try {
     const r = await apiRequest<{
       conversation: Conversation
@@ -283,8 +296,8 @@ async function load(id = selected.value, append = false) {
     interactions.value = r.interactions
     context.value = r.context
     if (previousComposer !== composerKey.value) {
-      taskFiles.set(previousComposer,composer.value?.filesSnapshot() ?? [])
-      taskQuotes.set(previousComposer,quoted.value)
+      taskFiles.set(previousComposer,previousFiles)
+      taskQuotes.set(previousComposer,previousQuote)
       if (activeTask.value && !requestedTask) {
         const legacy = `hermes-yaoyao:composer:group:${auth.user?.id}:${active.value.id}`
         const current = `hermes-yaoyao:composer:group:${composerKey.value}`
@@ -302,7 +315,7 @@ async function load(id = selected.value, append = false) {
     if (!append || atBottom) {
       await nextTick()
       if (!append) timeline.value?.scrollToBottom('auto')
-      await markRead()
+      void markRead().catch(e => { if (own === generation) error.value = e instanceof Error ? e.message : '同步已读失败' })
     }
   } catch (e) {
     if (own === generation && requestedTask && e instanceof ApiError && e.status === 404) {
@@ -320,15 +333,44 @@ async function load(id = selected.value, append = false) {
     if (own === generation) loading.value = false
   }
 }
+const pendingReads = new Set<string>()
 async function markRead() {
   const c = active.value
   const read = activeTask.value ?? c
-  if (!c || !read || read.readSeq >= read.lastSeq) return
-  await apiRequest(`/api/app/conversations/${c.id}/read${activeTask.value ? `?taskId=${activeTask.value.id}` : ''}`, {
-    method: 'PUT',
-    body: { seq: read.lastSeq },
-  })
-  read.readSeq = read.lastSeq
+  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 selectTask(event:Event){
   const select=event.currentTarget as HTMLSelectElement,value=select.value

+ 7 - 10
src/server/workspaceRoutes.ts

@@ -223,8 +223,10 @@ export function workspaceRouter(
     ctx.body = { task: store.updateTask(owner(ctx), ctx.params.id, ctx.params.taskId, body(ctx)) }
   })
   router.put('/api/app/conversations/:id/tasks/:taskId/read', (ctx) => {
-    const seq = parse(z.object({ seq: z.number().int().nonnegative() }).strict(), body(ctx)).seq
-    ctx.body = { task: store.markTaskRead(owner(ctx), ctx.params.id, ctx.params.taskId, seq) }
+    const { seq, unreadVersion } = parse(z.object({ seq: z.number().int().nonnegative(), unreadVersion: z.number().int().nonnegative().optional() }).strict(), body(ctx))
+    const user = owner(ctx)
+    ctx.body = { task: store.markTaskRead(user, ctx.params.id, ctx.params.taskId, seq, unreadVersion),
+      conversation: store.conversationSummary(user, store.require(user, 'conversation', ctx.params.id)) }
   })
   router.delete('/api/app/conversations/:id/tasks/:taskId', async (ctx) => {
     const user = owner(ctx)
@@ -301,18 +303,13 @@ export function workspaceRouter(
   router.put('/api/app/conversations/:id/read', (ctx) => {
     const user = owner(ctx),
       c = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id)
-    const seq = parse(z.object({ seq: z.number().int().nonnegative() }).strict(), body(ctx)).seq
+    const { seq, unreadVersion } = parse(z.object({ seq: z.number().int().nonnegative(), unreadVersion: z.number().int().nonnegative().optional() }).strict(), body(ctx))
     if (c.kind === 'group') {
       const taskId = typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
         task = store.resolveTask(user, c.id, taskId)!
-      store.markTaskRead(user, c.id, task.id, seq)
+      store.markTaskRead(user, c.id, task.id, seq, unreadVersion)
     }
-    c.readSeq = Math.max(c.readSeq, Math.min(seq, c.lastSeq))
-    store.atomic(() => {
-      store.put(user, 'conversation', c.id, c)
-      store.event(user, 'conversation.changed', c, c.id)
-    })
-    ctx.body = { conversation: c }
+    ctx.body = { conversation: store.markConversationRead(user, c.id, seq, unreadVersion) }
   })
   router.post('/api/app/runs/:id/stop', async (ctx) => {
     await runtime.stop(owner(ctx), ctx.params.id)

+ 74 - 12
src/server/workspaceStore.ts

@@ -136,6 +136,9 @@ export class WorkspaceStore {
       CREATE INDEX IF NOT EXISTS workspace_turn_status ON workspace_entities(owner, json_extract(data,'$.status'), json_extract(data,'$.planned')) WHERE kind='turn';
       CREATE INDEX IF NOT EXISTS workspace_turn_conversation ON workspace_entities(owner, json_extract(data,'$.conversationId')) WHERE kind='turn';
       CREATE INDEX IF NOT EXISTS workspace_message_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.seq')) WHERE kind='message';
+      CREATE INDEX IF NOT EXISTS workspace_message_conversation ON workspace_entities(owner, json_extract(data,'$.conversationId'), json_extract(data,'$.seq')) WHERE kind='message';
+      CREATE INDEX IF NOT EXISTS workspace_message_hidden ON workspace_entities(owner, json_extract(data,'$.conversationId'), json_extract(data,'$.conversationTaskId'), json_extract(data,'$.seq')) WHERE kind='message' AND json_extract(data,'$.visible')=0;
+      CREATE INDEX IF NOT EXISTS workspace_task_unread ON workspace_entities(owner, json_extract(data,'$.conversationId'), json_extract(data,'$.unread')) WHERE kind='conversation-task';
       CREATE INDEX IF NOT EXISTS workspace_run_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='run';
       CREATE INDEX IF NOT EXISTS workspace_turn_task ON workspace_entities(owner, json_extract(data,'$.conversationTaskId'), json_extract(data,'$.status')) WHERE kind='turn';`)
     this.db.exec('CREATE TABLE IF NOT EXISTS server_identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1), server_id TEXT NOT NULL, name TEXT NOT NULL, revision INTEGER NOT NULL)')
@@ -185,6 +188,30 @@ export class WorkspaceStore {
       }
       this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
     })
+    this.atomic(() => {
+      const migration = 'workspace-unread-flag-v1'
+      if (this.db.prepare('SELECT id FROM workspace_migrations WHERE id=?').get(migration)) return
+      for (const owner of this.owners()) {
+        for (const task of this.list<Task>(owner, 'conversation-task')) {
+          task.unread ??= task.unreadCount > 0
+          task.unreadVersion ??= task.unread ? 1 : 0
+          task.unreadCount = task.unread ? 1 : 0
+          this.put(owner, 'conversation-task', task.id, task)
+        }
+        for (const conversation of this.list<Conversation>(owner, 'conversation')) {
+          conversation.unread ??= conversation.kind === 'group'
+            ? this.hasUnreadTask(owner, conversation.id)
+            : !!this.db.prepare("SELECT 1 FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.seq')>? AND coalesce(json_extract(data,'$.visible'),1)!=0 LIMIT 1").get(owner, conversation.id, conversation.readSeq)
+          conversation.unreadVersion ??= conversation.unread ? 1 : 0
+          conversation.unreadCount = conversation.unread ? 1 : 0
+          this.put(owner, 'conversation', conversation.id, conversation)
+        }
+      }
+      this.db.prepare('INSERT INTO workspace_migrations VALUES(?)').run(migration)
+    })
+  }
+  private hasUnreadTask(owner: string, conversationId: string): boolean {
+    return !!this.db.prepare("SELECT 1 FROM workspace_entities WHERE owner=? AND kind='conversation-task' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.unread')=1 LIMIT 1").get(owner, conversationId)
   }
   get<T>(owner: string, kind: string, id: string): T | undefined {
     const row = this.db
@@ -338,6 +365,8 @@ export class WorkspaceStore {
         archived: false,
         pinned: false,
         readSeq: 0,
+        unread: false,
+        unreadVersion: 0,
         lastSeq: 0,
         preview: '',
         createdAt: now,
@@ -514,6 +543,8 @@ export class WorkspaceStore {
       archived: false,
       pinned: false,
       readSeq: 0,
+      unread: false,
+      unreadVersion: 0,
       lastSeq: 0,
       preview: '',
       createdAt: now,
@@ -580,7 +611,7 @@ export class WorkspaceStore {
         messageCount: visibleMessages.length,
         readSeq,
         lastSeq,
-        unreadCount: visibleMessages.filter(message => message.seq > readSeq).length,
+        unreadCount: visibleMessages.some(message => message.seq > readSeq) ? 1 : 0,
         lastMessageAt: lastMessage.createdAt,
         createdAt: firstMessage.createdAt,
         updatedAt: lastMessage.createdAt,
@@ -672,6 +703,8 @@ export class WorkspaceStore {
         messageCount: 0,
         readSeq: 0,
         lastSeq: 0,
+        unread: false,
+        unreadVersion: 0,
         unreadCount: 0,
         createdAt: now,
         updatedAt: now,
@@ -707,17 +740,33 @@ export class WorkspaceStore {
       return next
     })
   }
-  markTaskRead(owner: string, conversationId: string, taskId: string, seq: number): Task {
+  markTaskRead(owner: string, conversationId: string, taskId: string, seq: number, unreadVersion?: number): Task {
     return this.atomic(() => {
       const task = this.requireTask(owner, conversationId, taskId)
       task.readSeq = Math.max(task.readSeq, Math.min(seq, task.lastSeq))
-      task.unreadCount = this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, false, task.id)
-        .filter(message => message.seq > task.readSeq).length
+      if (unreadVersion === undefined ? seq >= task.lastSeq : unreadVersion === (task.unreadVersion ?? 0)) task.unread = false
+      task.unreadCount = task.unread ? 1 : 0
       this.put(owner, 'conversation-task', task.id, task)
+      const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
+      conversation.unread = this.hasUnreadTask(owner, conversationId)
+      conversation.unreadCount = conversation.unread ? 1 : 0
+      this.put(owner, 'conversation', conversationId, conversation)
       this.event(owner, 'task.changed', task, conversationId)
+      this.event(owner, 'conversation.changed', conversation, conversationId)
       return task
     })
   }
+  markConversationRead(owner: string, conversationId: string, seq: number, unreadVersion?: number): Conversation {
+    return this.atomic(() => {
+      const conversation = this.require<Conversation>(owner, 'conversation', conversationId)
+      conversation.readSeq = Math.max(conversation.readSeq, Math.min(seq, conversation.lastSeq))
+      if (conversation.kind === 'direct' && (unreadVersion === undefined ? seq >= conversation.lastSeq : unreadVersion === (conversation.unreadVersion ?? 0))) conversation.unread = false
+      conversation.unreadCount = conversation.unread ? 1 : 0
+      this.put(owner, 'conversation', conversationId, conversation)
+      this.event(owner, 'conversation.changed', conversation, conversationId)
+      return this.conversationSummary(owner, conversation)
+    })
+  }
   deleteTask(owner: string, conversationId: string, taskId: string): void {
     this.atomic(() => {
       const task = this.requireTask(owner, conversationId, taskId)
@@ -742,6 +791,8 @@ export class WorkspaceStore {
       conversation.previewAgentId = last?.role === 'assistant' ? last.agentId : undefined
       conversation.lastMessageAt = last?.createdAt
       conversation.updatedAt = Date.now()
+      conversation.unread = this.hasUnreadTask(owner, conversationId)
+      conversation.unreadCount = conversation.unread ? 1 : 0
       this.projectConversationRuns(owner, conversation)
       this.put(owner, 'conversation', conversationId, conversation)
       this.event(owner, 'conversation.changed', conversation, conversationId)
@@ -831,6 +882,15 @@ export class WorkspaceStore {
       if (previous?.visible === false && message.visible !== false) message.seq = 0
       if (!message.seq) message.seq = c.lastSeq + 1
       c.lastSeq = Math.max(c.lastSeq, message.seq)
+      const needsAttention = (value: Message | undefined) => !!value && value.role !== 'user' && value.visible !== false
+        && ['complete', 'failed', 'interrupted'].includes(value.status)
+        && !!(value.content.trim() || value.error || this.messageForDisplay(owner, value).attachments.length)
+      if (needsAttention(message) && !needsAttention(previous)) {
+        c.unread = true
+        c.unreadVersion = (c.unreadVersion ?? 0) + 1
+        if (task) { task.unread = true; task.unreadVersion = (task.unreadVersion ?? 0) + 1 }
+      }
+      c.unreadCount = c.unread ? 1 : 0
       if (message.visible !== false && (message.content.trim() || message.attachments.length)) {
         c.updatedAt = Date.now()
         c.lastMessageAt = Math.max(c.lastMessageAt ?? 0, message.createdAt)
@@ -845,8 +905,7 @@ export class WorkspaceStore {
         if (becameVisible) task.messageCount += 1
         if (becameHidden) task.messageCount = Math.max(0, task.messageCount - 1)
         task.lastSeq = Math.max(task.lastSeq, message.seq)
-        if (becameVisible && message.seq > task.readSeq) task.unreadCount += 1
-        if (becameHidden && message.seq > task.readSeq) task.unreadCount = Math.max(0, task.unreadCount - 1)
+        task.unreadCount = task.unread ? 1 : 0
         if (message.visible !== false && (message.content.trim() || message.attachments.length)) {
           if (message.role === 'user' && task.titleSource === 'automatic' && task.messageCount <= 1) {
             const source = notificationPlainText(message.content, { maximum: 48, fallback: message.attachments[0]?.name ?? '' }).trim()
@@ -866,13 +925,12 @@ export class WorkspaceStore {
     return { ...agent, avatar: agent.avatar || encodeAgentAvatar(defaultAgentIdentity(agent.id, agent.name)) }
   }
   conversationSummary(owner: string, conversation: Conversation): Conversation {
-    const member = conversation.kind === 'direct' ? this.get<Agent>(owner, 'agent', conversation.memberIds[0] ?? '') : undefined,
-      unreadCount = conversation.kind === 'group'
-        ? this.tasks(owner, conversation.id).reduce((count, task) => count + task.unreadCount, 0)
-        : this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER).filter(message => message.seq > conversation.readSeq).length
+    const member = conversation.kind === 'direct' ? this.get<Agent>(owner, 'agent', conversation.memberIds[0] ?? '') : undefined
     return {
       ...conversation,
-      unreadCount,
+      unread: conversation.unread ?? false,
+      unreadVersion: conversation.unreadVersion ?? 0,
+      unreadCount: conversation.unread ? 1 : 0,
       ...(member ? { avatar: this.agentSummary(member).avatar } : {}),
       lastMessageAt: conversation.lastMessageAt
         ?? (conversation.lastSeq > 0 ? this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 1).at(-1)?.createdAt : undefined)
@@ -880,7 +938,11 @@ export class WorkspaceStore {
     }
   }
   hiddenMessageIds(owner: string, conversationId: string, conversationTaskId?: string): string[] {
-    return this.messages(owner, conversationId, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true, conversationTaskId).filter(m => m.visible === false).map(m => m.id)
+    this.require(owner, 'conversation', conversationId)
+    if (conversationTaskId) this.requireTask(owner, conversationId, conversationTaskId)
+    const taskClause = conversationTaskId ? "AND json_extract(data,'$.conversationTaskId')=?" : ''
+    return this.db.prepare(`SELECT id FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.visible')=0 ${taskClause} ORDER BY json_extract(data,'$.seq') ASC`)
+      .all(...(conversationTaskId ? [owner, conversationId, conversationTaskId] : [owner, conversationId])).map(row => String(row.id))
   }
   saveRun(owner: string, run: Run): void {
     this.atomic(() => {

+ 10 - 0
src/shared/workspace.ts

@@ -56,6 +56,10 @@ export interface WorkspaceConversation {
   activeAgentStates?: Record<string, 'running' | 'waiting' | 'uncertain' | 'queued'>
   avatarSignals?: Record<string, { id: string; state: 'success' | 'failure'; at: number }>
   queuedMessageCount?: number
+  unread?: boolean
+  /** Changes when new attention arrives, so an older read cannot clear it. */
+  unreadVersion?: number
+  /** Compatibility signal for older clients: always 0 or 1. */
   unreadCount?: number
   createdAt: number
   updatedAt: number
@@ -68,6 +72,9 @@ export interface WorkspaceTask {
   messageCount: number
   readSeq: number
   lastSeq: number
+  unread?: boolean
+  unreadVersion?: number
+  /** Compatibility signal for older clients: always 0 or 1. */
   unreadCount: number
   activeRunId?: string
   activeRunStatus?: WorkspaceRun['status']
@@ -76,6 +83,9 @@ export interface WorkspaceTask {
   updatedAt: number
   goal?: import('./agentTasks.js').AgentGoal
 }
+export function workspaceHasUnread(value: { unread?: boolean; unreadCount?: number; lastSeq: number; readSeq: number }): boolean {
+  return value.unread ?? (value.unreadCount !== undefined ? value.unreadCount > 0 : value.lastSeq > value.readSeq)
+}
 export interface WorkspaceFile {
   id: string
   name: string

+ 1 - 1
tests/client/conversationSettings.test.ts

@@ -35,7 +35,7 @@ it.each(['direct', 'group'] as const)('opens and saves the menu target %s while
   await router.push('/conversations/current'); await router.isReady()
   wrapper = shallowMount(ConversationsView, { attachTo: document.body, global: {
     provide: { [matchedRouteKey as symbol]: shallowRef(router.currentRoute.value.matched[0]) }, plugins: [router],
-    stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, Teleport: false },
+    stubs: { WorkspaceShell: { template: '<div><slot name="sidebar" /><slot /></div>' }, ComposerShell: { template: '<div />', methods: { filesSnapshot: () => [], attachFiles: async () => {} } }, Teleport: false },
   } })
   await flushPromises()
   wrapper.findComponent({ name: 'ConversationList' }).vm.$emit('settings', 'target')

+ 12 - 0
tests/client/workspacePresentation.test.ts

@@ -23,6 +23,18 @@ import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import TeamAvatar from '@/components/common/TeamAvatar.vue'
 import type { WorkspaceAgent, WorkspaceConversation } from '../../src/shared/workspace'
 
+it('renders Bot unread as a dot and treats explicit false as authoritative over legacy counts', async () => {
+  const conversation: WorkspaceConversation = { id: 'dot', kind: 'direct', name: '未读测试', avatar: '', memberIds: [], instructions: '', administratorId: '', mode: 'host', autoReplyIds: [], maxReplyRounds: 1, archived: false, pinned: false, readSeq: 0, lastSeq: 999, unreadCount: 999, unread: true, preview: '完成', createdAt: 1, updatedAt: 1 }
+  const wrapper = mount(ConversationList, { props: { conversations: [conversation], agents: [] } })
+  expect(wrapper.find('[aria-label="未读"]').exists()).toBe(true)
+  expect(wrapper.find('.sidebar-item__row b').exists()).toBe(false)
+  await wrapper.setProps({ conversations: [{ ...conversation, unread: false }] })
+  expect(wrapper.find('[aria-label="未读"]').exists()).toBe(false)
+  await wrapper.setProps({ conversations: [{ ...conversation, unread: undefined }] })
+  expect(wrapper.find('[aria-label="未读"]').exists()).toBe(true)
+  wrapper.unmount()
+})
+
 it('composes real member avatars and refreshes them without changing group membership', async () => {
   const agents: WorkspaceAgent[] = ['first','second'].map((id,index) => ({id,name:id,avatar:`yaoyao-mascot:v1:${index ? 'square' : 'circle'}:377fe6:friendly`,instructions:'',nodeId:'local',profile:'default',archived:false,revision:1,createdAt:1,updatedAt:1}))
   const c: WorkspaceConversation = {id:'g',kind:'group',name:'群聊',avatar:'data:image/png;base64,AA==',memberIds:['second','first'],instructions:'',administratorId:'first',mode:'host',autoReplyIds:[],maxReplyRounds:1,archived:false,pinned:false,readSeq:0,lastSeq:0,preview:'',createdAt:Date.now(),updatedAt:Date.now(),lastMessageAt:Date.now()}

+ 76 - 0
tests/e2e/workspace-chat.spec.ts

@@ -23,6 +23,82 @@ async function createAgent(page: Page, name: string) {
   await expect(page.getByRole('heading', { name, exact: true })).toBeVisible()
 }
 
+test('Bot unread dots sync across viewers and neither detail nor read requests delay navigation', async ({ page, context }, testInfo) => {
+  await login(page)
+  const capabilities = await (await page.request.get('/api/realtime/capabilities')).json()
+  const headers = { 'X-CSRF-Token': capabilities.csrfToken, Origin: new URL(page.url()).origin }
+  const create = async (name: string) => {
+    const response = await page.request.post('/api/app/agents', { headers, data: { name, profile: 'default' } })
+    expect(response.ok(), await response.text()).toBe(true)
+    return (await response.json()).agent
+  }
+  const first = await create('红点测试甲'), second = await create('红点测试乙')
+  const conversations = (await (await page.request.get('/api/app/conversations')).json()).conversations
+  const direct = conversations.find((c: any) => c.kind === 'direct' && c.memberIds[0] === first.id)
+  const other = conversations.find((c: any) => c.kind === 'direct' && c.memberIds[0] === second.id)
+  const groupResponse = await page.request.post('/api/app/conversations', { headers, data: { name: '红点测试群', memberIds: [first.id, second.id], administratorId: first.id } })
+  expect(groupResponse.ok(), await groupResponse.text()).toBe(true)
+  const group = (await groupResponse.json()).conversation
+  await page.goto(`/conversations/${other.id}`)
+  const viewer = await context.newPage()
+  await viewer.goto(`/conversations/${other.id}`)
+  try {
+    for (const target of [direct, group]) {
+      const sent = await page.request.post(`/api/app/conversations/${target.id}/messages`, { headers, data: { requestId: crypto.randomUUID(), content: '给出测试结果' } })
+      expect(sent.ok(), await sent.text()).toBe(true)
+      const row = page.locator(`.desktop-sidebar [data-sidebar-id="${target.id}"]`)
+      const viewerRow = viewer.locator(`.desktop-sidebar [data-sidebar-id="${target.id}"]`)
+      await expect(row.getByRole('img', { name: '未读', exact: true })).toBeVisible()
+      await expect(viewerRow.getByRole('img', { name: '未读', exact: true })).toBeVisible()
+      await expect(row.locator('b')).toHaveCount(0)
+      await page.screenshot({ path: testInfo.outputPath(`unread-${target.kind}-dot.png`) })
+      let releaseDetail!: () => void, releaseRead!: () => void
+      const detailGate = new Promise<void>(resolve => { releaseDetail = resolve })
+      const readGate = new Promise<void>(resolve => { releaseRead = resolve })
+      const detailPattern = new RegExp(`/api/app/conversations/${target.id}(?:\\?.*)?$`)
+      const readPattern = new RegExp(`/api/app/conversations/${target.id}/read(?:\\?.*)?$`)
+      await page.route(detailPattern, async route => { await detailGate; await route.continue() })
+      await page.route(readPattern, async route => { await readGate; await route.continue() })
+      try {
+        const detailStarted = page.waitForRequest(request => detailPattern.test(request.url()))
+        await row.click(); await detailStarted
+        await expect(page.getByRole('heading', { name: target.name, exact: true })).toBeVisible()
+        releaseDetail()
+        await expect(page.getByRole('textbox', { name: '输入消息,Enter 发送,Shift + Enter 换行' })).toBeEnabled()
+        await expect(row.getByRole('img', { name: '未读', exact: true })).toHaveCount(0)
+        releaseRead()
+        await expect(viewerRow.getByRole('img', { name: '未读', exact: true })).toHaveCount(0)
+      } finally {
+        releaseDetail(); releaseRead()
+        await page.unroute(detailPattern); await page.unroute(readPattern)
+      }
+      await page.locator(`.desktop-sidebar [data-sidebar-id="${other.id}"]`).click()
+    }
+    await expect(page.getByRole('textbox', { name: '输入消息,Enter 发送,Shift + Enter 换行' })).toBeEnabled()
+    await page.screenshot({ path: testInfo.outputPath('unread-desktop.png') })
+    const sent = await page.request.post(`/api/app/conversations/${group.id}/messages`, { headers, data: { requestId: crypto.randomUUID(), content: '再次确认结果' } })
+    expect(sent.ok(), await sent.text()).toBe(true)
+    await page.setViewportSize({ width: 375, height: 812 })
+    await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce' })
+    await page.goto('/conversations')
+    await expect(page.getByRole('option').filter({ hasText: group.name }).getByRole('img', { name: '未读', exact: true })).toBeVisible()
+    await page.screenshot({ path: testInfo.outputPath('unread-mobile.png') })
+  } finally {
+    await viewer.close()
+    // This suite shares its fixture account; remove only the records created above.
+    const archived = await page.request.patch(`/api/app/conversations/${group.id}`, { headers, data: { archived: true } })
+    expect(archived.ok(), await archived.text()).toBe(true)
+    const removed = await page.request.delete(`/api/app/conversations/${group.id}`, { headers })
+    expect(removed.ok(), await removed.text()).toBe(true)
+    for (const agent of [first, second]) {
+      const archived = await page.request.patch(`/api/app/agents/${agent.id}`, { headers, data: { archived: true } })
+      expect(archived.ok(), await archived.text()).toBe(true)
+      const removed = await page.request.delete(`/api/app/agents/${agent.id}`, { headers })
+      expect(removed.ok(), await removed.text()).toBe(true)
+    }
+  }
+})
+
 test('an open Web chat list receives cross-client sessions and real title events without reload', async ({
   page,
   context,

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

@@ -369,7 +369,7 @@ describe('Web-owned workspace', () => {
     expect(store.get<any>(owner, 'context', firstTask.id)).toMatchObject({ conversationTaskId: firstTask.id, percent: 40 })
     expect(store.get<any>(owner, 'context', secondTask.id)).toMatchObject({ conversationTaskId: secondTask.id, percent: 40 })
     const firstSummary = store.require<any>(owner, 'conversation-task', firstTask.id)
-    expect(firstSummary).toMatchObject({ title: '第一项工作', titleSource: 'automatic', messageCount: 2, unreadCount: 2 })
+    expect(firstSummary).toMatchObject({ title: '第一项工作', titleSource: 'automatic', messageCount: 2, unread: true, unreadCount: 1 })
     expect(store.require<any>(owner, 'conversation-task', secondTask.id)).toMatchObject({ title: '显式标题', titleSource: 'user', messageCount: 2 })
     expect(store.markTaskRead(owner, conversation.id, firstTask.id, firstSummary.lastSeq).unreadCount).toBe(0)
     store.deleteTask(owner, conversation.id, firstTask.id)
@@ -1112,7 +1112,7 @@ it('continues free discussion automatically and hides irrelevant automatic repli
   await finished(root.id)
   expect(requests.filter(r=>r.method==='prompt.submit'&&speaker(r.params)===b.name)).toHaveLength(2)
   expect(store.messages(owner,g.id).map(m=>m.content)).toEqual(['一个问题','我来回答'])
-  expect(store.conversationSummary(owner,store.require(owner,'conversation',g.id)).unreadCount).toBe(2)
+  expect(store.conversationSummary(owner,store.require(owner,'conversation',g.id))).toMatchObject({unread:true, unreadCount:1})
   expect(store.hiddenMessageIds(owner,g.id)).toHaveLength(2)
 })
 

+ 27 - 0
tests/server/workspaceRoutes.test.ts

@@ -95,6 +95,33 @@ afterEach(() => {
   rmSync(home, { recursive: true, force: true })
 })
 describe('application workspace HTTP contract', () => {
+  it.each(['direct', 'group'] as const)('uses versioned unread acknowledgments for %s without recounting history', async kind => {
+    const store = runtime.workspace
+    const first = store.createAgent('first', { name: '未读甲', profile: 'default' })
+    const second = store.createAgent('first', { name: '未读乙', profile: 'default' })
+    const conversation = kind === 'group'
+      ? store.createGroup('first', { name: '未读群', memberIds: [first.id, second.id], administratorId: first.id })
+      : store.list<any>('first', 'conversation').find(c => c.memberIds[0] === first.id)
+    const task = kind === 'group' ? store.tasks('first', conversation.id)[0] : undefined
+    const message: import('../../src/shared/workspace').WorkspaceMessage = { id: randomUUID(), conversationId: conversation.id, conversationTaskId: task?.id,
+      seq: 0, role: 'assistant', content: '进行中', reasoning: '', status: 'streaming', attachments: [], tools: [], createdAt: Date.now() }
+    store.saveMessage('first', message)
+    const snapshot = (await req('get', `/api/app/conversations/${conversation.id}`).expect(200)).body
+    const read = snapshot.task ?? snapshot.conversation
+    expect(read.unread).toBe(false)
+    message.status = 'complete'; message.content = '已完成'; store.saveMessage('first', message)
+    const path = `/api/app/conversations/${conversation.id}${task ? `/tasks/${task.id}` : ''}/read`
+    const display = vi.spyOn(store, 'messageForDisplay')
+    const stale = (await req('put', path).send({ seq: read.lastSeq, unreadVersion: read.unreadVersion }).expect(200)).body
+    expect(stale.conversation.unread).toBe(true)
+    const current = stale.task ?? stale.conversation
+    const acknowledged = (await req('put', path).send({ seq: current.lastSeq, unreadVersion: current.unreadVersion }).expect(200)).body
+    expect(acknowledged.conversation).toMatchObject({ unread: false, unreadCount: 0 })
+    expect(display).not.toHaveBeenCalled()
+    await req('put', path, 'foreign').send({ seq: current.lastSeq, unreadVersion: current.unreadVersion }).expect(404)
+    await req('put', path).send({ seq: current.lastSeq, unreadVersion: -1 }).expect(400)
+    display.mockRestore()
+  })
   it.each(['archive', 'delete'] as const)('%s removes a confirmed ordinary member from all groups atomically', async action => {
     const store = runtime.workspace
     const lead = store.createAgent('first', { name: '管理者', profile: 'default' })

+ 110 - 0
tests/server/workspaceUnread.test.ts

@@ -0,0 +1,110 @@
+// @vitest-environment node
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { WorkspaceStore } from '../../src/server/workspaceStore'
+import type { WorkspaceConversation, WorkspaceMessage, WorkspaceTask } from '../../src/shared/workspace'
+
+let home: string, store: WorkspaceStore, direct: WorkspaceConversation, group: WorkspaceConversation
+const owner = 'owner'
+const current = (id: string) => store.require<WorkspaceConversation>(owner, 'conversation', id)
+const task = (id: string) => store.require<WorkspaceTask>(owner, 'conversation-task', id)
+function message(conversationId = direct.id, patch: Partial<WorkspaceMessage> = {}): WorkspaceMessage {
+  return { id: randomUUID(), conversationId, seq: 0, role: 'assistant', content: '完成', reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: 1, ...patch }
+}
+beforeEach(() => {
+  home = mkdtempSync(join(tmpdir(), 'yaoyao-unread-'))
+  store = new WorkspaceStore(home)
+  const first = store.createAgent(owner, { name: '甲', profile: 'default' })
+  const second = store.createAgent(owner, { name: '乙', profile: 'default' })
+  direct = store.list<WorkspaceConversation>(owner, 'conversation').find(c => c.memberIds[0] === first.id)!
+  group = store.createGroup(owner, { name: '测试群', memberIds: [first.id, second.id], administratorId: first.id })
+})
+afterEach(() => { vi.restoreAllMocks(); store.close(); rmSync(home, { recursive: true, force: true }) })
+
+it('stores one unread flag for any number of completed replies and persists read state', () => {
+  for (let i = 0; i < 12; i++) store.saveMessage(owner, message())
+  expect(current(direct.id)).toMatchObject({ unread: true, unreadCount: 1 })
+  const display = vi.spyOn(store, 'messageForDisplay')
+  expect(store.conversationSummary(owner, current(direct.id)).unread).toBe(true)
+  store.markConversationRead(owner, direct.id, current(direct.id).lastSeq, current(direct.id).unreadVersion)
+  expect(display).not.toHaveBeenCalled()
+  expect(current(direct.id)).toMatchObject({ unread: false, unreadCount: 0 })
+  store.close(); store = new WorkspaceStore(home)
+  expect(current(direct.id).unread).toBe(false)
+})
+
+it('ignores user, streaming, hidden and empty tool output; completion at the same message sequence is new attention', () => {
+  for (const patch of [{ role: 'user' as const }, { status: 'streaming' as const }, { visible: false }, { content: '', reasoning: '思考', tools: [{ name: 'read' }] }]) store.saveMessage(owner, message(direct.id, patch))
+  const streamed = message(direct.id, { status: 'streaming' })
+  store.saveMessage(owner, streamed)
+  expect(current(direct.id).unread).toBe(false)
+  const snapshot = current(direct.id)
+  store.markConversationRead(owner, direct.id, snapshot.lastSeq, snapshot.unreadVersion)
+  streamed.status = 'complete'; store.saveMessage(owner, streamed)
+  expect(current(direct.id)).toMatchObject({ lastSeq: snapshot.lastSeq, unread: true })
+  store.markConversationRead(owner, direct.id, snapshot.lastSeq, snapshot.unreadVersion)
+  expect(current(direct.id).unread).toBe(true)
+  store.markConversationRead(owner, direct.id, streamed.seq, current(direct.id).unreadVersion)
+  store.saveMessage(owner, streamed)
+  expect(current(direct.id).unread).toBe(false)
+})
+
+it('keeps group attention until every unread task is read, without parsing messages', () => {
+  const first = store.tasks(owner, group.id)[0]!
+  const second = store.createTask(owner, group.id, {})
+  store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
+  store.saveMessage(owner, message(group.id, { conversationTaskId: second.id }))
+  const display = vi.spyOn(store, 'messageForDisplay')
+  store.markTaskRead(owner, group.id, first.id, task(first.id).lastSeq, task(first.id).unreadVersion)
+  expect(task(first.id).unread).toBe(false)
+  expect(current(group.id).unread).toBe(true)
+  store.markTaskRead(owner, group.id, second.id, task(second.id).lastSeq, task(second.id).unreadVersion)
+  expect(current(group.id).unread).toBe(false)
+  expect(display).not.toHaveBeenCalled()
+  expect(current(direct.id).unread).toBe(false)
+})
+
+it('does not let a stale task read clear another completion and recomputes flags after deletion', () => {
+  const first = store.tasks(owner, group.id)[0]!
+  store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
+  const snapshot = task(first.id)
+  store.saveMessage(owner, message(group.id, { conversationTaskId: first.id, status: 'failed', error: '执行失败' }))
+  store.markTaskRead(owner, group.id, first.id, snapshot.lastSeq, snapshot.unreadVersion)
+  expect(task(first.id).unread).toBe(true)
+  expect(() => store.markTaskRead('another-owner', group.id, first.id, 100, 1)).toThrow()
+  expect(() => store.markTaskRead(owner, direct.id, first.id, 100, 1)).toThrow()
+  store.deleteTask(owner, group.id, first.id)
+  expect(current(group.id).unread).toBe(false)
+})
+
+it('migrates legacy counts once without loading message bodies and does not resurrect read flags', () => {
+  const first = store.tasks(owner, group.id)[0]!
+  store.saveMessage(owner, message())
+  store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
+  store.db.exec("UPDATE workspace_entities SET data=json_remove(data,'$.unread','$.unreadVersion') WHERE kind IN ('conversation','conversation-task')")
+  store.db.prepare('DELETE FROM workspace_migrations WHERE id=?').run('workspace-unread-flag-v1')
+  store.close(); store = new WorkspaceStore(home)
+  expect(current(direct.id)).toMatchObject({ unread: true, unreadVersion: 1, unreadCount: 1 })
+  expect(current(group.id).unread).toBe(true)
+  expect(task(first.id).unread).toBe(true)
+  store.markConversationRead(owner, direct.id, current(direct.id).lastSeq, 1)
+  store.close(); store = new WorkspaceStore(home)
+  expect(current(direct.id).unread).toBe(false)
+})
+
+it('reads only hidden IDs while preserving owner and task boundaries', () => {
+  const first = store.tasks(owner, group.id)[0]!, second = store.createTask(owner, group.id, {})
+  const hidden = message(group.id, { conversationTaskId: first.id, visible: false })
+  store.saveMessage(owner, hidden)
+  store.saveMessage(owner, message(group.id, { conversationTaskId: second.id, visible: false }))
+  store.saveMessage(owner, message(group.id, { conversationTaskId: first.id }))
+  const display = vi.spyOn(store, 'messageForDisplay')
+  expect(store.hiddenMessageIds(owner, group.id, first.id)).toEqual([hidden.id])
+  expect(display).not.toHaveBeenCalled()
+  expect(() => store.hiddenMessageIds('another-owner', group.id)).toThrow()
+  const plan = store.db.prepare("EXPLAIN QUERY PLAN SELECT id FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.visible')=0 AND json_extract(data,'$.conversationTaskId')=?").all(owner, group.id, first.id)
+  expect(plan.some(row => String(row.detail).includes('workspace_message_hidden'))).toBe(true)
+})