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

feat: 支持媒体浮层与系统事件折叠

samien 2 месяцев назад
Родитель
Сommit
a371f3ee2a

+ 27 - 3
src/client/components/messages/MarkdownContent.vue

@@ -121,6 +121,30 @@ function decorateFileLinks() {
   })
 }
 
+function decorateMediaPreviews() {
+  if (!props.fileCards || !root.value) return
+  root.value.querySelectorAll<HTMLImageElement>('img').forEach(image => {
+    if (image.dataset.mediaPreview) return
+    let url: URL
+    try { url = new URL(image.currentSrc || image.src, window.location.href) } catch { return }
+    if (url.origin !== window.location.origin) return
+    image.dataset.mediaPreview = 'true'
+    const name = decodeURIComponent(url.pathname.split('/').at(-1) || '图片')
+    image.setAttribute('role', 'button')
+    image.setAttribute('tabindex', '0')
+    image.setAttribute('aria-label', `预览图片 ${name}`)
+    const preview = (event: Event) => {
+      event.preventDefault()
+      event.stopPropagation()
+      emit('fileLink', name, `${url.pathname}${url.search}`)
+    }
+    image.addEventListener('click', preview)
+    image.addEventListener('keydown', event => {
+      if ((event as KeyboardEvent).key === 'Enter' || (event as KeyboardEvent).key === ' ') preview(event)
+    })
+  })
+}
+
 function onClick(event: MouseEvent) {
   const link = (event.target as HTMLElement).closest('a')
   if (!link) return
@@ -128,8 +152,8 @@ function onClick(event: MouseEvent) {
   if (/^(javascript|data|vbscript):/i.test(href)) event.preventDefault()
 }
 
-onMounted(() => { decorateCopyButtons(); decorateFileLinks() })
-onUpdated(() => { decorateCopyButtons(); decorateFileLinks() })
+onMounted(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPreviews() })
+onUpdated(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPreviews() })
 </script>
 
 <template>
@@ -159,7 +183,7 @@ onUpdated(() => { decorateCopyButtons(); decorateFileLinks() })
 .markdown :deep(th), .markdown :deep(td) { padding: 6px 10px; border: 1px solid var(--line); text-align: left; }
 .markdown :deep(th) { background: var(--surface-soft); color: var(--text-primary); font-weight: 640; }.markdown :deep(td) { color: var(--text-secondary); }
 .markdown :deep(hr) { margin: 16px 0; border: 0; border-top: 1px solid var(--line); }
-.markdown :deep(img) { display: block; width: auto; max-width: min(100%, 560px); max-height: 360px; margin: .7em 0; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); object-fit: contain; }
+.markdown :deep(img) { display: block; width: auto; max-width: min(100%, 560px); max-height: 360px; margin: .7em 0; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); object-fit: contain; }.markdown :deep(img[data-media-preview]) { cursor: zoom-in; }
 .markdown :deep(.file-link-card) { display: flex; width: min(390px, 100%); min-height: 52px; align-items: center; gap: 9px; margin: 8px 0; padding: 8px 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); box-shadow: 0 3px 10px rgba(0,0,0,.035); color: var(--text-primary); text-decoration: none; }.markdown :deep(.file-link-card)::before { display: grid; width: 27px; height: 27px; flex: 0 0 27px; place-items: center; border-radius: 7px; background: var(--surface-soft); color: var(--text-secondary); content: '▤'; font-size: 15px; }.markdown :deep(.file-link-card)::after { margin-left: auto; color: var(--text-muted); content: '预览'; font-size: 9px; }.markdown :deep(.file-link-card:hover) { border-color: var(--line-strong); background: var(--surface-soft); text-decoration: none; }
 .markdown :deep(.mention-highlight) { padding: .08em .34em; border-radius: 6px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--text-primary); font-weight: 600; }
 .markdown--streaming :deep(p:last-child)::after { content: ''; display: inline-block; width: 5px; height: 14px; margin-left: 3px; border-radius: 1px; background: currentColor; vertical-align: -2px; animation: caret 1s step-end infinite; }

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

@@ -74,6 +74,12 @@ function delegationSummary(metadata?: Record<string, unknown>): string {
   return `${total || completed + failed || 1} 个子任务 · ${completed} 已完成${failed ? ` · ${failed} 失败` : ''}${duration}`
 }
 
+function systemSummary(content: string): string {
+  const model = content.match(/(?:changed to|切换为)\s*([^\s,。\]]+?)(?=\s+via\b|\s*$|\])/i)?.[1]
+  if (model) return `模型已切换:${model}`
+  return '系统信息'
+}
+
 function onScroll() {
   const el = scroller.value
   if (!el) return
@@ -172,6 +178,12 @@ defineExpose({ scrollToMessage, scrollToBottom })
                 <MarkdownContent v-if="message.content" :content="message.content" />
               </details>
             </template>
+            <template v-else-if="message.timelineKind === 'system'">
+              <details class="system-event">
+                <summary><AppIcon name="settings" :size="13" /><span>{{ systemSummary(message.content) }}</span></summary>
+                <MarkdownContent v-if="message.content" :content="message.content" />
+              </details>
+            </template>
             <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>
@@ -267,6 +279,7 @@ defineExpose({ scrollToMessage, scrollToBottom })
 .message--user .message__meta { display: none; }
 .message--system { justify-content: center; }.message--system .message__avatar, .message--system .message__meta { display: none; }.message--system .message__body { max-width: 82%; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; color: var(--text-muted); text-align: center; }
 .delegation-event { min-width: 230px; max-width: 460px; text-align: left; }.delegation-event summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; }.delegation-event summary::-webkit-details-marker { display: none; }.delegation-event summary > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; }.delegation-event strong { color: var(--text-secondary); font-size: 11px; font-weight: 650; }.delegation-event small { color: var(--text-muted); font-size: 9px; }.delegation-event :deep(.markdown) { max-height: 260px; margin-top: 9px; overflow: auto; color: var(--text-secondary); font-size: 10px; }
+.system-event { max-width: 460px; text-align: left; }.system-event summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; color: var(--text-muted); font-size: 10px; }.system-event summary::-webkit-details-marker { display: none; }.system-event :deep(.markdown) { max-height: 220px; margin-top: 8px; overflow: auto; color: var(--text-secondary); font-size: 10px; }
 .message--tool-only { margin-block: 9px; }.message--tool-only .message__avatar, .message--tool-only .message__meta, .message--tool-only .message__actions { display: none; }.message--tool-only .message__body { max-width: 100%; }
 .message--assistant-anonymous .message__body { max-width: min(680px, 100%); }
 .message__reasoning { max-width: 420px; margin: 3px 0 10px; padding: 0 0 8px; border: 0; border-bottom: 1px dashed var(--line-strong); color: var(--text-secondary); font-size: 11px; }.message__reasoning summary { display: flex; align-items: center; gap: 5px; list-style: none; color: var(--text-muted); cursor: pointer; font-size: 9px; }.message__reasoning summary::-webkit-details-marker { display: none; }.message__reasoning summary::before { content: '›'; color: var(--text-muted); font-size: 14px; line-height: 1; transition: transform 120ms ease; }.message__reasoning[open] summary::before { transform: rotate(90deg); }.message__reasoning :deep(.markdown) { margin-top: 8px; }

+ 8 - 4
src/client/components/workspace/viewModels.ts

@@ -83,6 +83,10 @@ function timelineMetadata(value: unknown): Record<string, unknown> | undefined {
   return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
 }
 
+function isSystemTimelineMessage(message: ChatMessage): boolean {
+  return Boolean(message.displayKind) || /^\[system:/i.test(message.content.trim())
+}
+
 export function chatMessageToUi(message: ChatMessage, agentNameFor?: (profile?: string) => string | undefined): UiMessage {
   const attachments: UiMessageAttachment[] | undefined = message.attachments?.map(attachment => ({
     id: attachment.id,
@@ -132,11 +136,11 @@ export function chatMessagesToUi(messages: ChatMessage[], agentNameFor?: (profil
       if (message.toolCallId) toolOwners.set(message.toolCallId, owner)
       continue
     }
-    // Gateway system rows are transport/protocol detail rather than chat
-    // content. Keep the canvas focused on the user and participating agents.
-    if (message.role === 'system') continue
+    // Gateway system rows are transport/protocol detail unless they carry an
+    // explicit display marker or a human-readable [System: ...] notice.
+    if (message.role === 'system' && !isSystemTimelineMessage(message)) continue
     const ui = chatMessageToUi(message, agentNameFor)
-    if (message.displayKind) {
+    if (isSystemTimelineMessage(message)) {
       ui.role = 'system'
       ui.timelineKind = message.displayKind === 'async_delegation_complete' ? 'delegation-complete' : 'system'
       ui.timelineMetadata = timelineMetadata(message.displayMetadata)

+ 11 - 1
tests/e2e/workspace.spec.ts

@@ -68,7 +68,11 @@ test('restores a routed conversation even when unread-count refresh fails', asyn
 
 test('renders historical assistant MEDIA as Markdown in chat and group chat', async ({ page }) => {
   await page.goto('/chat/session-demo')
-  await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
+  const image = page.locator('.markdown img[alt="夭夭 Logo"]')
+  await expect(image).toBeVisible()
+  await image.click()
+  await expect(page.getByRole('dialog', { name: /预览 AppIcon-1024.png/ })).toBeVisible()
+  await page.getByRole('button', { name: '关闭预览' }).click()
 
   await page.getByRole('button', { name: '群聊' }).click()
   await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
@@ -90,6 +94,12 @@ test('renders subtask completion as a collapsed timeline event', async ({ page }
   await expect(page.locator('.delegation-event')).not.toHaveAttribute('open', '')
 })
 
+test('renders model changes as a collapsed system event', async ({ page }) => {
+  await page.goto('/chat/session-demo')
+  await expect(page.getByText('模型已切换:gpt-5.6-terra', { exact: true })).toBeVisible()
+  await expect(page.locator('.system-event')).not.toHaveAttribute('open', '')
+})
+
 test('opens a local message file link as a floating preview card', async ({ page }) => {
   await page.goto('/chat/session-demo')
   const card = page.getByRole('link', { name: '预览文件 方案草稿.md' })

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

@@ -49,6 +49,7 @@ const messages = [
   { id: 'message-legacy-media', role: 'assistant', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', timestamp: now() - 150 },
   { id: 'message-local-link', role: 'assistant', content: '[方案草稿.md](/Users/samien/Agents/方案草稿.md)', timestamp: now() - 145 },
   { id: 'message-delegation', role: 'user', content: '[ASYNC DELEGATION BATCH COMPLETE]\n后台子任务已经完成。', display_kind: 'async_delegation_complete', display_metadata: { task_count: 2, completed_count: 2, failed_count: 0, duration_seconds: 71 }, timestamp: now() - 140 },
+  { id: 'message-system', role: 'user', content: '[System: The active model for this chat has changed to gpt-5.6-terra via provider openai.]', timestamp: now() - 130 },
 ]
 const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }
 const groupMessage = { seq: 1, id: '44444444-4444-4444-8444-444444444444', roomId, senderKind: 'human', senderId: 'demo-user', senderName: '验收用户', content: '大家好,检查一下群聊输入框。', status: 'completed', createdAt: now() - 120, updatedAt: now() - 120 }