feat: 新增mac ocr转文字,增加到问一问微信 图片索引优化速度

- 图片文字索引性能与进度诚实化
- 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源
- 问问微信:回答规则禁止未真实执行的多轮承诺
- 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
This commit is contained in:
Wxw-Gu
2026-09-17 13:11:20 +08:00
parent b8f08d54c8
commit 1337bcb7de
53 changed files with 4985 additions and 624 deletions
@@ -0,0 +1,60 @@
/**
* 弹窗按钮必须自带主题样式。
*
* 这两处曾经把 Radix 的 primitive **原样导出**,渲染出来是浏览器默认的黑白方角
* 按钮 —— 跟主界面的主题色按钮完全脱节,用户会以为是两个不同的产品。
*
* 所以这里锁的是"类名里确实带了按钮变体",而不是某个具体颜色值。
* 规范见 `docs/development/ui-guidelines.md`。
*/
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter
} from '../../src/renderer/src/components/ui/alert-dialog'
function renderFooter(children: React.ReactNode): void {
render(
<AlertDialog open>
<AlertDialogContent>
<AlertDialogFooter>{children}</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
describe('弹窗按钮的主题样式', () => {
it('确认按钮走主题色实底', () => {
renderFooter(<AlertDialogAction>开始索引</AlertDialogAction>)
const action = screen.getByRole('button', { name: '开始索引' })
expect(action.className).toContain('bg-primary')
expect(action.className).toContain('text-primary-foreground')
})
it('取消按钮走次要描边,不抢主按钮的主题色', () => {
renderFooter(<AlertDialogCancel>取消</AlertDialogCancel>)
const cancel = screen.getByRole('button', { name: '取消' })
expect(cancel.className).toContain('border')
expect(cancel.className).not.toContain('bg-primary')
})
it('调用方传 className 能覆盖成危险动作', () => {
renderFooter(
<AlertDialogAction className="bg-destructive text-destructive-foreground">
删除
</AlertDialogAction>
)
const action = screen.getByRole('button', { name: '删除' })
expect(action.className).toContain('bg-destructive')
// 只断言"独立的 bg-primary 类不存在"—— `hover:bg-primary-hover` 含同样子串,
// 用裸字符串匹配会误伤。
expect(action.className).not.toMatch(/(^|\s)bg-primary(\s|$)/)
})
})
@@ -1,106 +0,0 @@
/**
* §1 / §17:Evidence 的「图片文字」来源语义。
*
* 三条不能退让的约束:
* 1. 来自图片 OCR 的命中,UI 必须有轻量来源标记(「图片文字」),
* 让用户知道这段内容来自图片,而不是群友真的发了一条文字消息;
* 2. authoritative source 仍然是**原始图片消息** —— messageRef 不变,跳转目标就是原图;
* 3. 引擎内部前缀(`图片文字:` / `OCR:` / `system-ocr`)绝不允许出现在用户可见文本里;
* 4. 普通文字消息的 Evidence 完全不受影响(不该凭空多出一个标记)。
*/
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { AISearchEvidencePanel } from '../../src/renderer/src/components/search/AISearchEvidencePanel'
import { mapAskWechatEvidence } from '../../src/renderer/src/components/search/askWechatPresentation'
import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes'
import type { AskWechatEvidenceItem } from '../../src/shared/query-agent'
import { encodeMessageRef } from '../../src/shared/local-query-api'
const OCR_TEXT = 'OpenAI ChatGPT Plus $20 Pro $200'
const IMAGE_REF = encodeMessageRef('md5-tech-group', '9001')
const TEXT_REF = encodeMessageRef('md5-tech-group', '9002')
/** Query Agent 交给渲染层的证据(图片 OCR 命中)。 */
const imageOcrEvidence: AskWechatEvidenceItem = {
messageRef: IMAGE_REF,
conversationName: '技术交流群',
conversationType: 'group',
sender: '张三',
timestamp: Date.parse('2026-09-03T14:32:00+08:00'),
messageType: 'image',
// 已经由 main 侧剥掉内部前缀的可读文本
text: OCR_TEXT,
derivedSource: 'image_ocr',
imageOcrText: OCR_TEXT,
source: 'search_messages'
}
/** 普通文字消息证据(对照组)。 */
const plainEvidence: AskWechatEvidenceItem = {
messageRef: TEXT_REF,
conversationName: '技术交流群',
conversationType: 'group',
sender: '张三',
timestamp: Date.parse('2026-09-03T14:30:00+08:00'),
messageType: 'text',
text: '今天正常讨论一下 API',
source: 'search_messages'
}
function renderPanel(evidence: EvidenceItem[]) {
const props: React.ComponentProps<typeof AISearchEvidencePanel> = {
evidence,
collectionCount: evidence.length,
selectedEvidence: 0,
evidenceFlash: { index: -1, nonce: 0 },
senderNames: {},
hasMoreEvidence: false,
onFocusEvidence: vi.fn(),
onJumpToEvidence: vi.fn(),
onLoadMoreEvidence: vi.fn(),
setEvidenceCardRef: vi.fn()
}
render(<AISearchEvidencePanel {...props} />)
return props
}
describe('图片文字 Evidence 的来源语义', () => {
it('映射层保留派生来源与 OCR 片段,且跳转目标仍是原始图片消息', () => {
const [mapped] = mapAskWechatEvidence([imageOcrEvidence])
expect(mapped.derivedSource).toBe('image_ocr')
expect(mapped.imageOcrText).toBe(OCR_TEXT)
// authoritative source = 原始图片消息:引用不变
expect(mapped.messageRef).toBe(IMAGE_REF)
expect(mapped.message.id).toBe('9001')
expect(mapped.contact.m_nsNickName).toBe('技术交流群')
expect(mapped.sourceKind).toBe('image')
})
it('图片 OCR 命中显示「图片文字」标记与命中解释,不泄露内部前缀', () => {
const evidence = mapAskWechatEvidence([imageOcrEvidence])
renderPanel(evidence)
const badge = screen.getByTestId('evidence-image-ocr-badge')
expect(badge).toBeVisible()
expect(badge.textContent).toBe('图片文字')
const snippet = screen.getByTestId('evidence-image-ocr-snippet')
expect(snippet.textContent).toContain(OCR_TEXT)
// 内部前缀绝不能出现在用户可见文本里
const panelText = document.body.textContent || ''
expect(panelText).not.toContain('图片文字:')
expect(panelText).not.toContain('OCR:')
expect(panelText).not.toContain('system-ocr')
})
it('普通文字消息的 Evidence 不受影响:没有来源标记,也没有 OCR 片段', () => {
const evidence = mapAskWechatEvidence([plainEvidence])
renderPanel(evidence)
expect(screen.queryByTestId('evidence-image-ocr-badge')).not.toBeInTheDocument()
expect(screen.queryByTestId('evidence-image-ocr-snippet')).not.toBeInTheDocument()
expect(screen.getByText('今天正常讨论一下 API')).toBeVisible()
})
})
@@ -0,0 +1,136 @@
/**
* Evidence 的来源语义与来源标签。
*
* 不能退让的约束:
* 1. **消息类型**与**派生来源**是两个正交维度,UI 必须分别标出来:
* 前者说"原始消息是什么"(文本 / 图片 / 语音…),
* 后者说"这条结果是靠什么命中的"(OCR命中 / 转写命中);
* 2. authoritative source 仍是原始消息 —— messageRef 不变,跳转目标就是它;
* 3. 派生命中内容必须自报来源(`OCR摘录:` / `转写摘录:`),
* 不能被读成群友真的发过这样一段文字;
* 4. 引擎内部前缀(`图片文字:` / `OCR:` / `system-ocr`)绝不出现在用户可见文本里;
* 5. 普通文字消息只标「文本消息」,不凭空多出来源标记。
*/
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { AISearchEvidencePanel } from '../../src/renderer/src/components/search/AISearchEvidencePanel'
import { mapAskWechatEvidence } from '../../src/renderer/src/components/search/askWechatPresentation'
import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes'
import type { AskWechatEvidenceItem } from '../../src/shared/query-agent'
import { encodeMessageRef } from '../../src/shared/local-query-api'
const OCR_TEXT = '今日特价 248 元'
const TRANSCRIPT_TEXT = '明天下午三点评审'
const IMAGE_REF = encodeMessageRef('fixture-conversation-a', '9001')
const VOICE_REF = encodeMessageRef('fixture-conversation-a', '9002')
const TEXT_REF = encodeMessageRef('fixture-conversation-a', '9003')
const base = {
conversationName: '测试群',
conversationType: 'group' as const,
sender: '用户A',
source: 'search_messages'
}
/** 图片 OCR 命中。 */
const imageOcrEvidence: AskWechatEvidenceItem = {
...base,
messageRef: IMAGE_REF,
timestamp: Date.parse('2026-09-03T14:32:00+08:00'),
messageType: 'image',
text: OCR_TEXT,
derivedSource: 'image_ocr',
imageOcrText: OCR_TEXT
}
/** 语音转写命中。 */
const voiceTranscriptEvidence: AskWechatEvidenceItem = {
...base,
messageRef: VOICE_REF,
timestamp: Date.parse('2026-09-03T14:34:00+08:00'),
messageType: 'voice',
text: TRANSCRIPT_TEXT,
derivedSource: 'voice_transcript'
}
/** 普通文字消息(对照组)。 */
const plainEvidence: AskWechatEvidenceItem = {
...base,
messageRef: TEXT_REF,
timestamp: Date.parse('2026-09-03T14:30:00+08:00'),
messageType: 'text',
text: '这是一条普通的文字消息'
}
function renderPanel(evidence: EvidenceItem[]): React.ComponentProps<typeof AISearchEvidencePanel> {
const props: React.ComponentProps<typeof AISearchEvidencePanel> = {
evidence,
collectionCount: evidence.length,
selectedEvidence: 0,
evidenceFlash: { index: -1, nonce: 0 },
senderNames: {},
hasMoreEvidence: false,
onFocusEvidence: vi.fn(),
onJumpToEvidence: vi.fn(),
onLoadMoreEvidence: vi.fn(),
setEvidenceCardRef: vi.fn()
}
render(<AISearchEvidencePanel {...props} />)
return props
}
describe('Evidence 的来源语义与来源标签', () => {
it('映射层保留消息类型与派生来源,且 authoritative source 不变', () => {
const [image] = mapAskWechatEvidence([imageOcrEvidence])
expect(image.sourceKind).toBe('image')
expect(image.derivedSource).toBe('image_ocr')
expect(image.imageOcrText).toBe(OCR_TEXT)
expect(image.messageRef).toBe(IMAGE_REF)
const [voice] = mapAskWechatEvidence([voiceTranscriptEvidence])
expect(voice.sourceKind).toBe('voice')
expect(voice.derivedSource).toBe('voice_transcript')
expect(voice.messageRef).toBe(VOICE_REF)
})
it('图片 OCR 命中:标「图片消息 + OCR命中」,片段写明是 OCR 摘录', () => {
renderPanel(mapAskWechatEvidence([imageOcrEvidence]))
expect(screen.getByTestId('evidence-badge-messageType').textContent).toBe('图片消息')
expect(screen.getByTestId('evidence-badge-derivedSource').textContent).toBe('OCR命中')
expect(screen.getByTestId('evidence-image-ocr-snippet').textContent).toContain(OCR_TEXT)
const panelText = document.body.textContent || ''
expect(panelText).toContain('OCR摘录:')
// 内部前缀绝不能出现在用户可见文本里
expect(panelText).not.toContain('图片文字:')
expect(panelText).not.toContain('OCR:')
expect(panelText).not.toContain('system-ocr')
// 不暴露工程字段名
expect(panelText).not.toContain('image_ocr')
expect(panelText).not.toContain('derivedSource')
})
it('语音转写命中:标「语音消息 + 转写命中」,片段写明是转写摘录', () => {
renderPanel(mapAskWechatEvidence([voiceTranscriptEvidence]))
expect(screen.getByTestId('evidence-badge-messageType').textContent).toBe('语音消息')
expect(screen.getByTestId('evidence-badge-derivedSource').textContent).toBe('转写命中')
const panelText = document.body.textContent || ''
expect(panelText).toContain('转写摘录:')
expect(panelText).not.toContain('voice_transcript')
})
it('普通文字消息只标「文本消息」,没有派生来源标记', () => {
renderPanel(mapAskWechatEvidence([plainEvidence]))
expect(screen.getByTestId('evidence-badge-messageType').textContent).toBe('文本消息')
expect(screen.queryByTestId('evidence-badge-derivedSource')).not.toBeInTheDocument()
expect(screen.queryByTestId('evidence-image-ocr-snippet')).not.toBeInTheDocument()
expect(screen.getByText('这是一条普通的文字消息')).toBeVisible()
const panelText = document.body.textContent || ''
expect(panelText).not.toContain('摘录:')
})
})
+85
View File
@@ -0,0 +1,85 @@
/**
* 图片密钥的「显示 / 隐藏」开关。
*
* 契约(产品要求):AES 密钥默认遮蔽,用户点一下眼睛能看见自己填的值。
* 关键边界:这只是**显示层**行为 —— 不许改动值、不许触发保存、不许影响校验。
*/
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ImageKeyConfiguration } from '../../src/renderer/src/features/settings/image-decryption/ImageKeyConfiguration'
import type { ImageDecryptionState } from '../../src/renderer/src/features/settings/image-decryption/types'
const AES_KEY = '0123456789abcdef'
function state(overrides: Partial<ImageDecryptionState> = {}): ImageDecryptionState {
return {
phase: 'idle',
config: null,
status: null,
contacts: [],
selectedUserMd5: '',
resourceRoot: '/tmp/fixture',
xorKey: '0x40',
aesKey: AES_KEY,
testResult: null,
autoPhase: 'idle',
autoProgress: '',
dirty: false,
...overrides
}
}
const aesInput = (): HTMLInputElement => {
const inputs = document.querySelectorAll<HTMLInputElement>('.image-key-secret > input')
if (!inputs.length) throw new Error('AES input missing')
return inputs[0]
}
describe('图片密钥显示开关', () => {
it('默认遮蔽,且按钮自称「显示图片密钥」', () => {
render(<ImageKeyConfiguration state={state()} disabled={false} onEdit={vi.fn()} />)
expect(aesInput().type).toBe('password')
expect(aesInput().value).toBe(AES_KEY)
const toggle = screen.getByTestId('image-key-reveal')
expect(toggle).toHaveAttribute('aria-pressed', 'false')
expect(toggle).toHaveAccessibleName('显示图片密钥')
})
it('点一下明文显示,再点一下回到遮蔽', async () => {
render(<ImageKeyConfiguration state={state()} disabled={false} onEdit={vi.fn()} />)
const toggle = screen.getByTestId('image-key-reveal')
await userEvent.click(toggle)
expect(aesInput().type).toBe('text')
expect(toggle).toHaveAttribute('aria-pressed', 'true')
expect(toggle).toHaveAccessibleName('隐藏图片密钥')
// 明文里能真的读到密钥本身。
expect(aesInput().value).toBe(AES_KEY)
await userEvent.click(toggle)
expect(aesInput().type).toBe('password')
expect(toggle).toHaveAttribute('aria-pressed', 'false')
})
it('只是显示层:切换不许改值、不许触发保存', async () => {
const onEdit = vi.fn()
render(<ImageKeyConfiguration state={state()} disabled={false} onEdit={onEdit} />)
await userEvent.click(screen.getByTestId('image-key-reveal'))
expect(onEdit).not.toHaveBeenCalled()
expect(aesInput().value).toBe(AES_KEY)
// XOR 字段不该被顺手改成密码框 —— 它本来就不是敏感值,一直是明文。
expect(document.querySelectorAll('input[type="password"]')).toHaveLength(0)
})
it('不可编辑时开关也跟着禁用,避免"能看不能改"的错觉', () => {
render(<ImageKeyConfiguration state={state()} disabled onEdit={vi.fn()} />)
expect(screen.getByTestId('image-key-reveal')).toBeDisabled()
expect(aesInput()).toBeDisabled()
})
})
+125 -10
View File
@@ -1,5 +1,5 @@
/**
* §3 / §4:「图片文字索引」卡片的用户可见行为。
* 「图片文字索引」卡片的用户可见行为。
*
* 这些断言对应的是产品需求里**写死的**交互契约,不是实现细节:
* - 未建立时先给出检测到的图片消息数量,而不是一个空洞的按钮;
@@ -7,7 +7,8 @@
* - 确认弹窗要写清本机执行、原图不会因识别而自动上传、可暂停、实际可识别数量取决于本地文件;
* - 进度只给真实数字(processed/total、识别出文字、没有文字、图片已清理、失败、百分比);
* - 暂停 / 继续 / 取消三个动作都在,且暂停后能继续;
* - 重启后进度来自主进程快照(这里用「首帧就是 paused 快照」模拟)。
* - 重启后进度来自主进程快照(这里用「首帧就是 paused 快照」模拟);
* - 操作区是**单列堆叠**:这张卡最多并列 3 个操作,横向排会撑破窄侧栏。
*/
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -85,6 +86,20 @@ const paused = status({
progress: { ...running.progress, state: 'paused', cancellable: false, paused: true }
})
/** 取消:进度全部保留,但状态是 cancelled 而不是 paused。 */
const cancelled = status({
...running,
progress: { ...running.progress, state: 'cancelled', cancellable: false, paused: false },
coverage: { ...running.coverage, established: true }
})
/** 已建立但未完成:这张状态下操作区最多并列 3 个按钮(更新 / 重新统计 / 修复)。 */
const establishedPartial = status({
...running,
progress: { ...running.progress, state: 'idle', cancellable: false },
coverage: { ...running.coverage, established: true }
})
const api = {
getImageTextIndexStatus: vi.fn(),
countImageMessages: vi.fn(),
@@ -174,6 +189,37 @@ describe('图片文字索引卡片', () => {
expect(api.cancelImageTextIndex).toHaveBeenCalledTimes(1)
})
it('运行中给出窗口速度与 ETA,而不是历史平均', async () => {
api.getImageTextIndexStatus.mockResolvedValue({
...running,
progress: {
...running.progress,
speedPerSec: 38,
etaMs: 2 * 60 * 60 * 1000 + 25 * 60 * 1000
}
})
await renderCard()
const rate = screen.getByTestId('image-text-index-rate')
expect(rate.textContent).toContain('约 38.0 张/秒')
expect(rate.textContent).toContain('2 小时 25 分')
// 用户界面不出现开发指标。
expect(rate.textContent).not.toMatch(/p50|p95|percentile/i)
})
it('速度样本不足时如实说「计算中」,不编数字', async () => {
// 默认的 running 夹具没有 speedPerSec / etaMs(主进程给 null 的情形)。
api.getImageTextIndexStatus.mockResolvedValue({
...running,
progress: { ...running.progress, speedPerSec: null, etaMs: null }
})
await renderCard()
const rate = screen.getByTestId('image-text-index-rate')
expect(rate.textContent).toContain('当前速度:计算中')
expect(rate.textContent).toContain('预计剩余:计算中')
})
it('暂停后可以继续,进度仍来自主进程快照', async () => {
api.getImageTextIndexStatus.mockResolvedValue(paused)
await renderCard()
@@ -183,6 +229,27 @@ describe('图片文字索引卡片', () => {
expect(api.resumeImageTextIndex).toHaveBeenCalledTimes(1)
})
/**
* 「取消」之后的入口曾经是缺失的:状态落回「部分完成」、按钮只剩「更新图片文字索引」,
* 用户既看不出自己中断过,也找不到继续的地方 —— 于是以为进度丢了。
* 取消和暂停一样保留 checkpoint,所以必须给同样的「继续」。
*/
it('取消之后仍然能继续:状态说「已取消」,「继续」入口还在', async () => {
api.getImageTextIndexStatus.mockResolvedValue(cancelled)
await renderCard()
expect(screen.getByTestId('image-text-index-state').textContent).toMatch(/^已取消 · /)
const resume = screen.getByTestId('image-text-index-resume')
expect(resume.textContent).toBe('继续')
// 「更新图片文字索引」和「继续」是同一件事,不能同时抢位。
expect(screen.queryByTestId('image-text-index-start')).toBeNull()
// 已经取消了,没有东西可再取消。
expect(screen.queryByTestId('image-text-index-cancel')).toBeNull()
await userEvent.click(resume)
expect(api.resumeImageTextIndex).toHaveBeenCalledTimes(1)
})
it('主进程推送真实进度后,卡片跟着更新(重启后恢复的进度同一条路径)', async () => {
await renderCard()
expect(screen.getByTestId('image-text-index-state').textContent).toBe('未建立')
@@ -194,7 +261,7 @@ describe('图片文字索引卡片', () => {
expect(screen.getByTestId('image-text-index-progress').textContent).toBe('3,842 / 12,483')
})
it('统计失败时显示「无法统计」而不是 0,并给出原因与重新统计入口', async () => {
it('统计失败时显示「无法统计」而不是 0,并给出原因', async () => {
api.countImageMessages.mockResolvedValue({
totalImageMessages: 0,
scannedConversations: 0,
@@ -213,7 +280,48 @@ describe('图片文字索引卡片', () => {
const error = screen.getByTestId('image-text-index-count-error')
expect(error.textContent).toContain('读取消息分片失败')
expect(error.textContent).toContain('不代表账号里没有图片')
expect(screen.getByTestId('image-text-index-recount')).toBeVisible()
// 「重新统计」入口已收掉:进度改用流水线真实走过的集合之后,
// 重算那个预估值不再影响任何东西,留着只会多一个看不懂的按钮。
// 断言按**用户可见文案**而不是已删除的 testid —— 对已移除 testid 断言
// 「不存在」是恒真的,删掉按钮之后它就再也测不出任何东西。
expect(screen.queryByText('重新统计')).not.toBeInTheDocument()
})
/**
* 操作区布局契约:主按钮与「更多」各占一行。
*
* 修复类操作(修复搜索索引 / 重试失败的图片)收进了「更多」菜单 ——
* 平铺出来时,用户看到的是几个都在说「索引」的按钮,只能靠猜哪个该点。
*
* jsdom 不做真实排版,所以这里锁的是**能推出该结果的结构**:
* 两个按钮是同一个操作容器的直接子元素,且该容器带单列堆叠修饰类
* (共享的栅格类 + `ai-search-image-index-actions`,后者把 grid 覆盖成 flex column)。
* 一旦有人在按钮外面套一层 wrapper、或去掉修饰类,这个测试就会失败。
*/
it('操作区只留主按钮与「更多」:同一容器的直接子元素,顺序为 更新 / 更多', async () => {
api.getImageTextIndexStatus.mockResolvedValue(establishedPartial)
api.countImageMessages.mockResolvedValue({
totalImageMessages: 12_483,
scannedConversations: 42,
failedConversations: 1,
typeColumn: 'local_type',
durationMs: 30
})
await renderCard()
const start = screen.getByTestId('image-text-index-start')
const more = screen.getByTestId('image-text-index-more')
expect(start.textContent).toBe('更新图片文字索引')
expect(more.textContent).toBe('更多')
const container = start.parentElement
expect(container).toBe(more.parentElement)
expect(container?.classList.contains('ai-search-knowledge-actions')).toBe(true)
expect(container?.classList.contains('ai-search-image-index-actions')).toBe(true)
// 直接子元素 == 独占一行;顺序断言同时锁住视觉顺序。
expect(Array.from(container?.children ?? [])).toEqual([start, more])
})
it('部分会话统计失败时给出真实数字并提示偏小', async () => {
@@ -244,7 +352,8 @@ describe('图片文字索引卡片', () => {
expect(screen.getByTestId('image-text-index-count').textContent).toBe('0')
expect(screen.queryByTestId('image-text-index-count-error')).not.toBeInTheDocument()
expect(screen.queryByTestId('image-text-index-recount')).not.toBeInTheDocument()
// 同上:按文案断言,避免对已删除的 testid 做恒真断言。
expect(screen.queryByText('重新统计')).not.toBeInTheDocument()
})
})
@@ -286,7 +395,7 @@ describe('图片文字索引卡片 — 修复图片搜索索引', () => {
}
} as Partial<ImageTextIndexStatus>)
it('已建立且空闲时提供修复入口,只在点击后调用主进程', async () => {
it('已建立且空闲时,修复入口收在「更多」里,点击后才调用主进程', async () => {
api.getImageTextIndexStatus.mockResolvedValue(established)
api.repairImageTextIndex.mockResolvedValue({
conversations: 12,
@@ -296,11 +405,15 @@ describe('图片文字索引卡片 — 修复图片搜索索引', () => {
})
const { onNotice } = await renderCard()
const button = screen.getByTestId('image-text-index-repair')
expect(button.textContent).toBe('修复图片搜索索引')
// 修复类操作不再平铺在操作区,必须先展开「更多」。
expect(screen.queryByTestId('image-text-index-repair')).not.toBeInTheDocument()
await userEvent.click(screen.getByTestId('image-text-index-more'))
const item = await screen.findByTestId('image-text-index-repair')
expect(item.textContent).toContain('修复搜索索引')
expect(api.repairImageTextIndex).not.toHaveBeenCalled()
await userEvent.click(button)
await userEvent.click(item)
expect(api.repairImageTextIndex).toHaveBeenCalledTimes(1)
// 提示语必须讲清楚"没有重新识别",否则用户会以为又要跑几万张图。
@@ -312,6 +425,7 @@ describe('图片文字索引卡片 — 修复图片搜索索引', () => {
api.getImageTextIndexStatus.mockResolvedValue(running)
await renderCard()
expect(screen.queryByTestId('image-text-index-more')).not.toBeInTheDocument()
expect(screen.queryByTestId('image-text-index-repair')).not.toBeInTheDocument()
})
@@ -325,7 +439,8 @@ describe('图片文字索引卡片 — 修复图片搜索索引', () => {
})
const { onNotice } = await renderCard()
await userEvent.click(screen.getByTestId('image-text-index-repair'))
await userEvent.click(screen.getByTestId('image-text-index-more'))
await userEvent.click(await screen.findByTestId('image-text-index-repair'))
expect(String(onNotice.mock.calls.at(-1)?.[0])).toContain('正在进行中')
})
@@ -0,0 +1,56 @@
/**
* Markdown 表格的降级渲染。
*
* 结果栏很窄,真表格在这里只会列宽错位、长字段换行难读。提示词已经禁止模型为
* 检索结果产表格,但**历史回答**与其它入口仍可能出现,所以渲染层必须保证它
* 不会横向炸出容器,且内容读得出来。
*
* 这里的做法是把它降级成逐行的键值列表(每格一个 span,靠 CSS flex-wrap 换行),
* 而不是渲染 `<table>` —— 没有 table 就不会有列宽挤压与横向溢出。
*/
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { renderMarkdown } from '../../src/renderer/src/components/search/searchMarkdown'
function renderValue(value: string): HTMLElement {
const { container } = render(<div>{renderMarkdown(value)}</div>)
return container
}
describe('Markdown 表格降级渲染', () => {
it('表格行渲染成逐格的键值单元,而不是 <table>', () => {
const container = renderValue('| 发送人 | 时间 | 图片中的文字 |')
expect(container.querySelector('table')).toBeNull()
const row = container.querySelector('.ai-search-markdown-table-row')
expect(row).not.toBeNull()
const cells = container.querySelectorAll('.ai-search-markdown-table-cell')
expect(cells).toHaveLength(3)
expect(cells[0].textContent).toBe('发送人')
expect(cells[2].textContent).toBe('图片中的文字')
})
it('分隔行(|---|---|)不产生内容', () => {
const container = renderValue('|---|---|')
expect(container.querySelectorAll('.ai-search-markdown-table-cell')).toHaveLength(0)
expect(container.querySelector('.ai-search-markdown-spacer')).not.toBeNull()
})
it('超长单元格文本原样保留,不截断也不丢字', () => {
const longText = '这是一段很长的识别文本'.repeat(12)
const container = renderValue(`| 用户A | ${longText} |`)
const cells = container.querySelectorAll('.ai-search-markdown-table-cell')
expect(cells).toHaveLength(2)
expect(cells[1].textContent).toBe(longText)
})
it('普通段落与列表不受影响', () => {
const container = renderValue('这是一段普通说明\n\n1. 第一条\n2. 第二条')
expect(container.querySelector('.ai-search-markdown-table-row')).toBeNull()
expect(screen.getByText('这是一段普通说明')).toBeVisible()
expect(container.querySelectorAll('.ai-search-markdown-list-item')).toHaveLength(2)
})
})