fix: preview draft and queued image attachments directly
Make draft thumbnails and queued images open the shared image viewer without parsing. Keep queued attachment menus read-only and remove OCR source and settings entries while preserving explicit text extraction in drafts. Validation: 14 focused tests, typecheck and lint passed. Full suite: 5534 passed, 3 failures outside this change in conversation scrolling and frosted-glass descriptions. Release note: 草稿和队列附件中的图片现在支持点击放大查看,无需 OCR;队列只读菜单不再显示解析来源和解析设置。
This commit is contained in:
@@ -51,6 +51,10 @@
|
|||||||
菜单项包含附件名称以区分多个文件。普通图片的“提取图片文字”可作为就近次操作,不能和
|
菜单项包含附件名称以区分多个文件。普通图片的“提取图片文字”可作为就近次操作,不能和
|
||||||
菜单形成两套状态或重复执行。菜单复用 `AnchoredMenu` 的定位、键盘和焦点恢复。
|
菜单形成两套状态或重复执行。菜单复用 `AnchoredMenu` 的定位、键盘和焦点恢复。
|
||||||
|
|
||||||
|
草稿与队列中的图片缩略图可点击进入共享图片查看器,优先显示已保存的完整图片,只有
|
||||||
|
缩略图时使用缩略图预览;查看不触发 OCR。队列附件菜单只提供打开原文件,不读取 OCR
|
||||||
|
来源或显示解析设置;修改发送方式须先恢复到草稿。解析结果入口仅在已有结果时显示。
|
||||||
|
|
||||||
草稿、历史消息和队列预览中的“查看解析结果”“更多附件操作”使用共享 `icon-button`
|
草稿、历史消息和队列预览中的“查看解析结果”“更多附件操作”使用共享 `icon-button`
|
||||||
视觉,图标分别为文档查阅和省略号,点击区域为 `32px × 32px`,在独立操作行内排列。
|
视觉,图标分别为文档查阅和省略号,点击区域为 `32px × 32px`,在独立操作行内排列。
|
||||||
按钮提供含文件名的可访问名称、悬停提示及键盘焦点提示;草稿移除和导入取消使用同尺寸
|
按钮提供含文件名的可访问名称、悬停提示及键盘焦点提示;草稿移除和导入取消使用同尺寸
|
||||||
|
|||||||
@@ -6953,6 +6953,12 @@ describe("App", () => {
|
|||||||
await within(composer).findByText("需求说明.md"),
|
await within(composer).findByText("需求说明.md"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(within(composer).getByText("页面截图.png")).toBeInTheDocument();
|
expect(within(composer).getByText("页面截图.png")).toBeInTheDocument();
|
||||||
|
const draftImage = within(composer).getByRole("button", { name: "查看图片 页面截图.png" });
|
||||||
|
fireEvent.click(draftImage);
|
||||||
|
const draftViewer = await screen.findByRole("dialog", { name: "页面截图.png" });
|
||||||
|
expect(within(draftViewer).getByRole("img")).toHaveAttribute("src", imageAttachment.contentUrl);
|
||||||
|
fireEvent.click(within(draftViewer).getByRole("button", { name: "关闭图片查看器" }));
|
||||||
|
await waitFor(() => expect(draftImage).toHaveFocus());
|
||||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||||
target: { value: "分析这些附件" },
|
target: { value: "分析这些附件" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9656,12 +9656,19 @@ function App(): React.JSX.Element {
|
|||||||
title={attachment.preview}
|
title={attachment.preview}
|
||||||
>
|
>
|
||||||
{attachment.kind === "image" &&
|
{attachment.kind === "image" &&
|
||||||
attachment.thumbnailUrl ? (
|
(attachment.thumbnailUrl || attachment.contentUrl) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="message-image-button"
|
||||||
|
aria-label={t('chat.images.viewNamed', { title: attachment.name })}
|
||||||
|
onClick={(event) => openImageViewer({ src: attachment.contentUrl ?? attachment.thumbnailUrl!, title: attachment.name }, event.currentTarget)}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
alt=""
|
alt=""
|
||||||
className="context-chip__thumbnail"
|
className="context-chip__thumbnail"
|
||||||
src={attachment.thumbnailUrl}
|
src={attachment.thumbnailUrl ?? attachment.contentUrl}
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<FileText size={14} />
|
<FileText size={14} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -60,10 +60,11 @@ it('keeps attachment actions icon-only and named, and restores keyboard focus af
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps queued attachments read-only and exposes no action for legacy attachments without assets', async () => {
|
it('keeps queued attachments read-only and exposes no action for legacy attachments without assets', async () => {
|
||||||
const { rerender } = render(<AttachmentActions attachment={attachment} readOnly />)
|
const { rerender } = render(<AttachmentActions attachment={{ ...attachment, sendMode: 'text' }} conversationId="conversation" readOnly />)
|
||||||
fireEvent.click(screen.getByRole('button'))
|
fireEvent.click(screen.getByRole('button'))
|
||||||
await screen.findByText('解析来源:HTTP PaddleOCR-VL(远程服务)')
|
expect(screen.queryByText(/解析来源/)).not.toBeInTheDocument()
|
||||||
expect(screen.getAllByRole('menuitem')).toHaveLength(2)
|
expect(window.goodbuddy.documentParsing!.getSnapshot).not.toHaveBeenCalled()
|
||||||
|
expect(screen.getAllByRole('menuitem')).toHaveLength(1)
|
||||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||||
rerender(<AttachmentActions attachment={{ ...attachment, resourceId: undefined }} />)
|
rerender(<AttachmentActions attachment={{ ...attachment, resourceId: undefined }} />)
|
||||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||||
|
|||||||
@@ -31,24 +31,24 @@ export function AttachmentActions({ attachment, conversationId, onBusyChange, re
|
|||||||
return <>
|
return <>
|
||||||
<button type="button" className="icon-button attachment-action" title="更多附件操作" data-tooltip="更多附件操作" ref={anchor} aria-haspopup="menu" aria-expanded={menu} aria-controls={menu ? id : undefined}
|
<button type="button" className="icon-button attachment-action" title="更多附件操作" data-tooltip="更多附件操作" ref={anchor} aria-haspopup="menu" aria-expanded={menu} aria-controls={menu ? id : undefined}
|
||||||
aria-label={`更多附件操作:${attachment.name}`} disabled={Boolean(operation)} onClick={() => {
|
aria-label={`更多附件操作:${attachment.name}`} disabled={Boolean(operation)} onClick={() => {
|
||||||
if (!menu) {
|
if (!menu && !readOnly) {
|
||||||
setProvider('正在读取已保存的解析来源')
|
setProvider('正在读取已保存的解析来源')
|
||||||
void window.goodbuddy.documentParsing!.getSnapshot().then((snapshot) => setProvider(snapshot.settings.ocrProvider === 'paddleocr-vl' ? '解析来源:HTTP PaddleOCR-VL(远程服务)' : '解析来源:本地 OCR'), () => setProvider('来源读取失败;操作仍使用已保存的解析设置'))
|
void window.goodbuddy.documentParsing!.getSnapshot().then((snapshot) => setProvider(snapshot.settings.ocrProvider === 'paddleocr-vl' ? '解析来源:HTTP PaddleOCR-VL(远程服务)' : '解析来源:本地 OCR'), () => setProvider('来源读取失败;操作仍使用已保存的解析设置'))
|
||||||
}
|
}
|
||||||
setMenu((value) => !value)
|
setMenu((value) => !value)
|
||||||
}}><Ellipsis size={16} aria-hidden="true" /></button>
|
}}><Ellipsis size={16} aria-hidden="true" /></button>
|
||||||
{menu && <AnchoredMenu anchorRef={anchor} id={id} label={`附件操作:${attachment.name}`} onClose={() => setMenu(false)}>
|
{menu && <AnchoredMenu anchorRef={anchor} id={id} label={`附件操作:${attachment.name}`} onClose={() => setMenu(false)}>
|
||||||
<small>{provider}</small>
|
{!readOnly && <small>{provider}</small>}
|
||||||
<button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.openOriginal(attachment.resourceId!))}>打开原文件:{attachment.originalName ?? attachment.name}</button>
|
<button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.openOriginal(attachment.resourceId!))}>打开原文件:{attachment.originalName ?? attachment.name}</button>
|
||||||
{conversationId && <button type="button" role="menuitem" onClick={() => run((operationId) => window.goodbuddy.context.reparseDraft(conversationId, attachment.id, operationId))}>
|
{!readOnly && conversationId && <button type="button" role="menuitem" onClick={() => run((operationId) => window.goodbuddy.context.reparseDraft(conversationId, attachment.id, operationId))}>
|
||||||
{attachment.sendMode ? '提取图片文字' : '使用当前设置重新解析'}:{attachment.originalName ?? attachment.name}
|
{attachment.sendMode ? '提取图片文字' : '使用当前设置重新解析'}:{attachment.originalName ?? attachment.name}
|
||||||
</button>}
|
</button>}
|
||||||
{conversationId && attachment.sendMode === 'text' && <button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.sendOriginal(conversationId, attachment.id))}>改为发送原图:{attachment.originalName}</button>}
|
{!readOnly && conversationId && attachment.sendMode === 'text' && <button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.sendOriginal(conversationId, attachment.id))}>改为发送原图:{attachment.originalName}</button>}
|
||||||
{!readOnly && !conversationId && target?.activeId && <button type="button" role="menuitem" onClick={() => run(async (operationId) => {
|
{!readOnly && !conversationId && target?.activeId && <button type="button" role="menuitem" onClick={() => run(async (operationId) => {
|
||||||
await window.goodbuddy.context.copyToDraft(target.activeId!, attachment.resourceId!, operationId)
|
await window.goodbuddy.context.copyToDraft(target.activeId!, attachment.resourceId!, operationId)
|
||||||
target.notify('已按当前设置解析并添加到目标会话草稿')
|
target.notify('已按当前设置解析并添加到目标会话草稿')
|
||||||
})}>{attachment.sendMode ? '添加到当前草稿并提取文字' : '添加到当前草稿重新解析'}:{attachment.originalName ?? attachment.name}</button>}
|
})}>{attachment.sendMode ? '添加到当前草稿并提取文字' : '添加到当前草稿重新解析'}:{attachment.originalName ?? attachment.name}</button>}
|
||||||
<button type="button" role="menuitem" onClick={() => { setMenu(false); void window.goodbuddy.localInference.openSettings('document-parsing') }}>前往文档解析设置</button>
|
{!readOnly && <button type="button" role="menuitem" onClick={() => { setMenu(false); void window.goodbuddy.localInference.openSettings('document-parsing') }}>前往文档解析设置</button>}
|
||||||
</AnchoredMenu>}
|
</AnchoredMenu>}
|
||||||
{operation && <span role="status">正在处理附件<button type="button" className="secondary-button" onClick={() => void window.goodbuddy.context.cancelParsing(operation)}>取消解析</button></span>}
|
{operation && <span role="status">正在处理附件<button type="button" className="secondary-button" onClick={() => void window.goodbuddy.context.cancelParsing(operation)}>取消解析</button></span>}
|
||||||
{error && <span role="alert">{error}</span>}
|
{error && <span role="alert">{error}</span>}
|
||||||
|
|||||||
@@ -1,9 +1,38 @@
|
|||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, expect, it, vi } from 'vitest'
|
import { afterEach, expect, it, vi } from 'vitest'
|
||||||
import { QueuedAttachmentsDialog } from './QueuedAttachmentsDialog'
|
import { QueuedAttachmentsDialog } from './QueuedAttachmentsDialog'
|
||||||
|
import { DocumentConversationContext } from './DocumentConversationContext'
|
||||||
|
|
||||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||||
|
|
||||||
|
it.each([true, false])('previews queued images without parsing (full image available: %s)', async (fullImage) => {
|
||||||
|
const thumbnailUrl = 'data:image/png;base64,thumbnail'
|
||||||
|
const contentUrl = fullImage ? 'data:image/png;base64,full' : undefined
|
||||||
|
const openImage = vi.fn()
|
||||||
|
const getSnapshot = vi.fn()
|
||||||
|
const reparseDraft = vi.fn()
|
||||||
|
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
|
||||||
|
vi.stubGlobal('goodbuddy', {
|
||||||
|
conversationQueue: { getAttachments: vi.fn().mockResolvedValue([
|
||||||
|
{ id: 'image', resourceId: 'image', name: 'screenshot.png', kind: 'image', sendMode: 'image', thumbnailUrl, contentUrl }
|
||||||
|
]) },
|
||||||
|
documentParsing: { getSnapshot }, context: { reparseDraft }
|
||||||
|
})
|
||||||
|
render(<DocumentConversationContext.Provider value={{ openImage, create: vi.fn(), navigate: vi.fn(), notify: vi.fn() }}>
|
||||||
|
<QueuedAttachmentsDialog itemId="queued" onClose={vi.fn()} />
|
||||||
|
</DocumentConversationContext.Provider>)
|
||||||
|
expect(await screen.findByRole('img', { name: 'screenshot.png' })).toHaveAttribute('src', thumbnailUrl)
|
||||||
|
const trigger = screen.getByRole('button', { name: '查看图片 screenshot.png' })
|
||||||
|
fireEvent.click(trigger)
|
||||||
|
expect(openImage).toHaveBeenCalledWith(contentUrl ?? thumbnailUrl, 'screenshot.png', trigger)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '更多附件操作:screenshot.png' }))
|
||||||
|
expect(screen.getAllByRole('menuitem')).toHaveLength(1)
|
||||||
|
expect(screen.getByRole('menuitem')).toHaveTextContent('打开原文件')
|
||||||
|
expect(screen.queryByRole('button', { name: /查看解析结果/ })).not.toBeInTheDocument()
|
||||||
|
expect(getSnapshot).not.toHaveBeenCalled()
|
||||||
|
expect(reparseDraft).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('opens snapshot guidance without hiding empty state or closing the dialog on Escape', async () => {
|
it('opens snapshot guidance without hiding empty state or closing the dialog on Escape', async () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
vi.stubGlobal('goodbuddy', { conversationQueue: { getAttachments: vi.fn().mockResolvedValue([]) } })
|
vi.stubGlobal('goodbuddy', { conversationQueue: { getAttachments: vi.fn().mockResolvedValue([]) } })
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useContext, useEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import type { ContextAttachment } from '../../shared/contracts'
|
import type { ContextAttachment } from '../../shared/contracts'
|
||||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||||
import { AttachmentResultButton } from './AttachmentResultButton'
|
import { AttachmentResultButton } from './AttachmentResultButton'
|
||||||
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
|
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
|
||||||
import { InlineHelp } from './InlineHelp'
|
import { InlineHelp } from './InlineHelp'
|
||||||
|
import { DocumentConversationContext } from './DocumentConversationContext'
|
||||||
|
|
||||||
export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; onClose: () => void }): React.JSX.Element {
|
export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; onClose: () => void }): React.JSX.Element {
|
||||||
const close = useRef<HTMLButtonElement>(null)
|
const close = useRef<HTMLButtonElement>(null)
|
||||||
|
const conversationContext = useContext(DocumentConversationContext)
|
||||||
const [items, setItems] = useState<ContextAttachment[]>()
|
const [items, setItems] = useState<ContextAttachment[]>()
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
useEffect(() => activateModalFocus(() => close.current), [])
|
useEffect(() => activateModalFocus(() => close.current), [])
|
||||||
@@ -29,6 +31,10 @@ export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; o
|
|||||||
{items?.length === 0 && <p>此输入没有附件。</p>}
|
{items?.length === 0 && <p>此输入没有附件。</p>}
|
||||||
<div className="document-result-panel">{items?.map((item) => <article key={item.id}>
|
<div className="document-result-panel">{items?.map((item) => <article key={item.id}>
|
||||||
<h3>{item.name}</h3>
|
<h3>{item.name}</h3>
|
||||||
|
{item.kind === 'image' && (item.thumbnailUrl || item.contentUrl) && <div className="document-result-image"><button
|
||||||
|
type="button" className="message-image-button" aria-label={`查看图片 ${item.name}`}
|
||||||
|
onClick={(event) => conversationContext?.openImage(item.contentUrl ?? item.thumbnailUrl!, item.name, event.currentTarget)}
|
||||||
|
><img src={item.thumbnailUrl ?? item.contentUrl} alt={item.name} loading="lazy" /></button></div>}
|
||||||
<div className="attachment-metadata"><AttachmentStatus attachment={item} /></div>
|
<div className="attachment-metadata"><AttachmentStatus attachment={item} /></div>
|
||||||
<div className="attachment-actions">
|
<div className="attachment-actions">
|
||||||
{item.resultId && <AttachmentResultButton resultId={item.resultId} name={item.name} readOnly />}
|
{item.resultId && <AttachmentResultButton resultId={item.resultId} name={item.name} readOnly />}
|
||||||
|
|||||||
@@ -7752,11 +7752,17 @@ button > svg {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-chip > button:hover {
|
.context-chip > button:not(.message-image-button):hover {
|
||||||
background: var(--danger-subtle);
|
background: var(--danger-subtle);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-chip > .message-image-button {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
.attachment-metadata,
|
.attachment-metadata,
|
||||||
.attachment-actions {
|
.attachment-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user