feat: 新增微信图片文字索引与问问微信图片检索能力

This commit is contained in:
电摇小子
2026-09-16 10:42:52 +08:00
parent 24399f1d70
commit b8f08d54c8
46 changed files with 6938 additions and 75 deletions
@@ -77,9 +77,27 @@ export function AISearchEvidencePanel({
{item.sourceKind === 'voice' && (
<span className="block text-[11px] font-semibold text-primary">语音转写</span>
)}
{item.derivedSource === 'image_ocr' && (
<span
className="mt-0.5 inline-block rounded-sm bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-primary"
data-testid="evidence-image-ocr-badge"
>
图片文字
</span>
)}
<span className="mt-[7px] block overflow-hidden text-[11px] leading-[17px] text-muted-foreground [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3]">
{messageText(item.message)}
</span>
{/* 命中解释:明确告诉用户"命中的是图里的这段文字",
避免被读成群友真的发过一条这样的文字消息。 */}
{item.derivedSource === 'image_ocr' && item.imageOcrText && (
<span
className="mt-1 block overflow-hidden text-[11px] leading-[17px] text-foreground [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3]"
data-testid="evidence-image-ocr-snippet"
>
“{item.imageOcrText}”
</span>
)}
<Button
variant="link"
size="sm"
@@ -43,6 +43,7 @@ import { ensureAiSearchDataConsent } from './services/aiSearchProviderConsent'
import { ExternalProviderConsentDialog } from './ExternalProviderConsentDialog'
import { AISearchComposer } from './AISearchComposer'
import { AISearchEvidencePanel } from './AISearchEvidencePanel'
import { ImageTextIndexCard } from './ImageTextIndexCard'
import {
forgetAskWechatConversation,
requestAskWechatQuery,
@@ -1394,6 +1395,9 @@ export function AISearchWorkspace({
<p>索引独立保存,不会删除或修改微信原始数据库。</p>
</details>
</section>
{/* 图片文字索引:与 Knowledge 卡片平级、但**独立的一维能力**。
文字消息索引完整不代表图片里的文字搜得到,所以两个入口必须并列可见。 */}
<ImageTextIndexCard dbReady={dbReady} onNotice={onNotice} />
</aside>
<main className="ai-search-main">
<div className="ai-search-main-scroll">
@@ -0,0 +1,427 @@
import { useEffect, useMemo, useState, type ReactElement } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '../ui'
import {
describeImageTextCoverage,
imageTextCoverageState,
imageTextProcessedPercent
} from '../../../../shared/image-text-index'
import { useImageTextIndexStatus } from './hooks/useImageTextIndexStatus'
type ImageTextIndexCardProps = {
/** 微信数据是否就绪。 */
dbReady: boolean
onNotice: (message: string) => void
}
/**
* 「图片文字索引」卡片。
*
* 与 Knowledge 卡片**平级并列**(同一组索引入口),但刻意是**独立的一维能力**:
* 文字消息索引完整不代表图片里的文字搜得到。
*
* 文案遵从严禁混淆的语义(§9):这里做的是「识别图片中文字」,不是
* 「本地识图模型 / 本地 Vision / AI OCR」,也不能暗示能理解场景或表情包。
*/
export function ImageTextIndexCard({ dbReady, onNotice }: ImageTextIndexCardProps): ReactElement {
const {
status,
count,
counting,
pending,
running,
paused,
established,
refreshCount,
start,
pause,
resume,
cancel,
resetFailures,
repair
} = useImageTextIndexStatus({ dbReady, onNotice })
const [confirming, setConfirming] = useState(false)
const [confirmCount, setConfirmCount] = useState<number | null>(null)
/**
* 处理时间范围(天)。
*
* 存在的意义是**可验证性**:几万张图片的全量回填没法拿来排查问题,
* 先跑"最近 1 天"这种小窗口才能证明链路真的通了。`0` = 全部历史。
*/
const [rangeDays, setRangeDays] = useState('0')
const sinceMs = useMemo(() => {
const days = Number(rangeDays)
return Number.isFinite(days) && days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : undefined
}, [rangeDays])
// 进页面 / 切换范围时统计一次(数字必须与当前窗口一致,否则确认弹窗会说谎)。
useEffect(() => {
if (!dbReady) return
void refreshCount(sinceMs)
}, [dbReady, sinceMs, refreshCount])
const coverage = status?.coverage ?? null
const progress = status?.progress ?? null
const coverageState = coverage ? imageTextCoverageState(coverage) : 'not_built'
/**
* 处理进度百分比。
*
* 刻意不在这里做 `Math.round(x * 100)` —— `45479 / 45707` 会被四舍五入成 `100`,
* 于是出现了"已建立 · 仅完成 100%"这种自相矛盾的显示。未完成时封顶 99.9%。
*/
const percent = coverage
? imageTextProcessedPercent(coverage.processed, coverage.totalImageMessages)
: 0
const systemicFailure = coverage?.systemicFailure === true
const visualState =
progress?.state === 'error' || coverageState === 'failed'
? 'error'
: running
? 'syncing'
: paused
? 'cancelled'
: !established
? 'unavailable'
: coverageState === 'complete'
? 'ready'
: 'building'
const detectedImages = count?.totalImageMessages ?? coverage?.totalImageMessages ?? null
const countFailed = count !== null && count.failedConversations > 0
/**
* 一个会话都没数成。
*
* 这时**绝不能显示 0** —— 那会让用户以为账号里没有图片,从而放弃建立索引。
* 数不出来和确实没有是两件事。
*/
const nothingCounted =
count !== null && count.scannedConversations === 0 && count.failedConversations > 0
const stateLabel = (() => {
if (progress?.state === 'error') return '建立失败'
if (running) return `建立中 · ${percent}%`
if (paused) return `已暂停 · ${percent}%`
if (!established) return '未建立'
// 「已建立」不能等于「全失败」:处理过但一条都没成功时必须叫异常。
if (coverageState === 'failed') return '图片文字索引异常'
if (coverageState === 'complete') return '已完成'
return `部分完成 · ${percent}%`
})()
/**
* 点「建立图片文字索引」:**先重新统计、再弹确认**。
*
* 确认弹窗里的数字必须新鲜——用户可能刚在微信里收了一批图片。
* 统计是纯 SQL COUNT,不解密任何图片,所以这一步够快。
*/
const requestStart = async (): Promise<void> => {
const fresh = await refreshCount(sinceMs)
setConfirmCount(fresh?.totalImageMessages ?? detectedImages)
setConfirming(true)
}
const confirmStart = async (): Promise<void> => {
setConfirming(false)
await start({ ...(sinceMs ? { sinceMs } : {}) })
}
return (
<>
<section
className={`ai-search-knowledge-card ${visualState}`}
aria-label="图片文字索引状态"
>
<div className="ai-search-knowledge-heading">
<div className="ai-search-knowledge-heading-text">
<span className="ai-search-knowledge-kicker">IMAGE TEXT INDEX</span>
<strong className="ai-search-knowledge-state" data-testid="image-text-index-state">
{stateLabel}
</strong>
</div>
<span className="ai-search-knowledge-dot" aria-hidden />
</div>
<p className="ai-search-knowledge-description">
让「问问微信」也能搜索微信图片中的文字(截图、报价图、公告截图等)。
识别在本机进行,原始图片无需发送给 AI Provider。
</p>
{/* 未建立:先告诉用户这个账号大概有多少图片,再让他决定要不要跑。 */}
{!established && !running && (
<>
<div className="ai-search-knowledge-rows">
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">检测到的图片消息</span>
<strong className="ai-search-knowledge-value" data-testid="image-text-index-count">
{counting
? '统计中…'
: nothingCounted
? '无法统计'
: detectedImages === null
? '—'
: detectedImages.toLocaleString()}
</strong>
</div>
</div>
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">处理范围</span>
<Select value={rangeDays} onValueChange={setRangeDays}>
<SelectTrigger data-testid="image-text-index-range" className="h-6 w-[86px] text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">全量</SelectItem>
<SelectItem value="1">近 1 天</SelectItem>
<SelectItem value="7">近 7 天</SelectItem>
<SelectItem value="30">近 30 天</SelectItem>
</SelectContent>
</Select>
</div>
{countFailed && (
<p className="ai-search-knowledge-error" data-testid="image-text-index-count-error">
{nothingCounted
? `无法统计本账号的图片消息(${count?.error || '读取消息表失败'})。这不代表账号里没有图片,可以点「重新统计」再试一次。`
: `有 ${count?.failedConversations.toLocaleString()} 个会话未能统计,上面的数字可能偏小。`}
</p>
)}
</>
)}
{/* 进度:只给真实数字,绝不显示 native handle / hash / HRESULT。 */}
{(running || paused) && progress && (
<div className="ai-search-knowledge-pass">
<div className="ai-search-knowledge-rows">
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">已处理</span>
<strong
className="ai-search-knowledge-value"
data-testid="image-text-index-progress"
>
{`${progress.processed.toLocaleString()} / ${progress.totalImageMessages.toLocaleString()}`}
</strong>
</div>
</div>
<div className="ai-search-sync-progress-track">
<span
style={{
width: `${Math.min(100, Math.max(0, progress.percent))}%`,
...(progress.totalImageMessages > 0
? {}
: { animation: 'ai-search-indeterminate 1.4s ease-in-out infinite' })
}}
/>
</div>
<p className="ai-search-knowledge-pass-line">
{`${progress.percent}% · 识别出文字 ${progress.indexed.toLocaleString()} · 没有文字 ${progress.empty.toLocaleString()} · 图片已清理 ${progress.missing.toLocaleString()} · 失败 ${progress.failed.toLocaleString()}`}
</p>
</div>
)}
{/* 已建立:给一份可核对的明细。 */}
{established && !running && !paused && coverage && (
<div className="ai-search-knowledge-rows">
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">已识别出文字</span>
<strong className="ai-search-knowledge-value">
{coverage.indexed.toLocaleString()}
</strong>
</div>
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">没有文字</span>
<strong className="ai-search-knowledge-value">{coverage.empty.toLocaleString()}</strong>
</div>
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">图片已清理</span>
<strong className="ai-search-knowledge-value">
{coverage.missing.toLocaleString()}
</strong>
</div>
{coverage.failed > 0 && (
<div className="ai-search-knowledge-row">
<span className="ai-search-knowledge-label">识别失败</span>
<strong className="ai-search-knowledge-value">
{coverage.failed.toLocaleString()}
</strong>
</div>
)}
<p className="ai-search-knowledge-pass-line">{describeImageTextCoverage(coverage)}</p>
</div>
)}
{progress?.state === 'error' && (
<p className="ai-search-knowledge-error">
{progress.lastError || '图片文字索引建立失败,可以稍后重试。'}
</p>
)}
{/* 「处理过但一条都没成功」= 索引异常,绝不能显示成"已建立"。 */}
{systemicFailure && (
<p
className="ai-search-knowledge-error"
data-testid="image-text-index-systemic-failure"
>
{`${(coverage?.failed ?? 0).toLocaleString()} 条处理失败,成功识别 0 条 —— 当前无法搜索图片中的文字。`}
</p>
)}
{paused && (
<p className="ai-search-knowledge-error">
已暂停。已经识别出的结果都保留了,点「继续」会从断点接着做,不会从第一张重新开始。
</p>
)}
{!dbReady && (
<p className="ai-search-knowledge-error">请先连接微信数据,然后再建立图片文字索引。</p>
)}
<div className="ai-search-knowledge-actions">
{!running && !paused && (
<Button
size="sm"
className="ai-search-knowledge-primary"
data-testid="image-text-index-start"
disabled={!dbReady || pending !== null || counting}
onClick={() => void requestStart()}
>
{established ? '更新图片文字索引' : '建立图片文字索引'}
</Button>
)}
{!running && !paused && countFailed && (
<Button
size="sm"
variant="outline"
className="ai-search-knowledge-cancel"
data-testid="image-text-index-recount"
disabled={pending !== null || counting}
onClick={() => void refreshCount()}
>
{counting ? '统计中…' : '重新统计'}
</Button>
)}
{/* 修好之后重跑:只重置失败记录,成功记录与其它数据一律不动。 */}
{!running && !paused && systemicFailure && (
<Button
size="sm"
variant="outline"
className="ai-search-knowledge-cancel"
data-testid="image-text-index-reset-failures"
disabled={pending !== null}
onClick={() => void resetFailures()}
>
{pending === 'reset' ? '处理中…' : '重试失败的图片'}
</Button>
)}
{/* 派生索引修复:只重建 Knowledge 里的图片搜索索引,**不重新识别任何图片**。
存在的意义就是"别为修一个索引问题重跑几万张图"。 */}
{!running && !paused && established && (
<Button
size="sm"
variant="outline"
className="ai-search-knowledge-cancel"
data-testid="image-text-index-repair"
disabled={pending !== null}
onClick={() => void repair()}
>
{pending === 'repair' ? '修复中…' : '修复图片搜索索引'}
</Button>
)}
{running && (
<>
<Button
size="sm"
variant="outline"
className="ai-search-knowledge-cancel"
data-testid="image-text-index-pause"
disabled={pending !== null}
onClick={() => void pause()}
>
{pending === 'pause' ? '暂停中…' : '暂停'}
</Button>
<Button
size="sm"
variant="outline"
className="ai-search-knowledge-cancel"
data-testid="image-text-index-cancel"
disabled={pending !== null}
onClick={() => void cancel()}
>
{pending === 'cancel' ? '取消中…' : '取消'}
</Button>
</>
)}
{paused && (
<>
<Button
size="sm"
className="ai-search-knowledge-primary"
data-testid="image-text-index-resume"
disabled={pending !== null}
onClick={() => void resume()}
>
{pending === 'resume' ? '继续中…' : '继续'}
</Button>
<Button
size="sm"
variant="outline"
className="ai-search-knowledge-cancel"
data-testid="image-text-index-cancel"
disabled={pending !== null}
onClick={() => void cancel()}
>
取消
</Button>
</>
)}
</div>
</section>
<AlertDialog open={confirming} onOpenChange={setConfirming}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>建立图片文字索引</AlertDialogTitle>
</AlertDialogHeader>
<div className="ai-search-knowledge-confirm">
<p>
当前账号检测到约{' '}
<strong>
{confirmCount === null ? '未知数量' : confirmCount.toLocaleString()} 条图片消息
</strong>
。
</p>
<p>
建立后,TraceMemo 会在本机读取这些图片中的文字,以后可以在「问问微信」里搜索截图、
报价图、公告截图等图片里的文字,并按结果回到对应的原始图片消息。
</p>
<p>识别过程:</p>
<ul>
<li>仅在本机进行识别,原始图片不会因为本地识别而自动上传</li>
<li>可能需要较长时间,可以暂停并稍后继续</li>
<li>图片已被微信清理或无法解密时会自动跳过</li>
<li>实际可识别的数量取决于本地图片文件是否仍然存在</li>
</ul>
<p>不会修改或删除微信原始图片与聊天记录。</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
data-testid="image-text-index-confirm"
onClick={() => void confirmStart()}
>
开始索引
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -52,6 +52,10 @@ export function mapAskWechatEvidence(items: AskWechatEvidenceItem[]): EvidenceIt
return {
evidenceId: `E${index + 1}`,
sourceKind: item.messageType as EvidenceItem['sourceKind'],
// 「靠图片里的文字命中」是来源语义,必须原样带到 UI;
// 但 authoritative source 仍然是原始图片消息(messageRef 已指向它)。
...(item.derivedSource ? { derivedSource: item.derivedSource } : {}),
...(item.imageOcrText ? { imageOcrText: item.imageOcrText } : {}),
contact: evidenceContact(item, anchor),
messageRef: item.messageRef,
message: {
@@ -0,0 +1,235 @@
import { useCallback, useEffect, useState } from 'react'
import type {
ImageTextIndexCountResult,
ImageTextIndexStartOptions,
ImageTextIndexStatus
} from '../../../../../shared/image-text-index'
type UseImageTextIndexStatusOptions = {
/** 微信数据是否已就绪。未就绪时既不统计也不允许建立索引。 */
dbReady: boolean
onNotice: (message: string) => void
}
export type ImageTextIndexAction = 'start' | 'pause' | 'resume' | 'cancel' | 'reset' | 'repair'
/**
* 「图片文字索引」的 renderer 侧状态。
*
* 三条不能省的语义:
* 1. **重启后进度是真的**:进度与覆盖度全部来自主进程的派生库快照,
* renderer 不自己累加、也不缓存百分比。应用重启后重新拉一次即可恢复真实进度。
* 2. **数量统计是显式动作**:COUNT(*) 要走一遍会话列表,不在每次渲染时触发;
* 只在「未建立」时拉一次、以及点击建立前重新拉一次(确认弹窗里的数字必须新鲜)。
* 3. **暂停 / 继续 / 取消都是待确认操作**:主进程返回 started/paused/cancelled
* 才提示成功;例如 `started: false` 表示已经有任务在跑,此时说"已开始"是假话。
*/
export function useImageTextIndexStatus({
dbReady,
onNotice
}: UseImageTextIndexStatusOptions): {
status: ImageTextIndexStatus | null
count: ImageTextIndexCountResult | null
counting: boolean
pending: ImageTextIndexAction | null
running: boolean
paused: boolean
established: boolean
refreshCount: (sinceMs?: number) => Promise<ImageTextIndexCountResult | null>
start: (options?: ImageTextIndexStartOptions) => Promise<void>
pause: () => Promise<void>
resume: () => Promise<void>
cancel: () => Promise<void>
resetFailures: () => Promise<void>
repair: () => Promise<void>
} {
const [status, setStatus] = useState<ImageTextIndexStatus | null>(null)
const [count, setCount] = useState<ImageTextIndexCountResult | null>(null)
const [counting, setCounting] = useState(false)
const [pending, setPending] = useState<ImageTextIndexAction | null>(null)
useEffect(() => {
// 这是一个**次要侧栏能力**:桥接缺失(旧 preload / 测试里手写的 window.api)
// 或推送异常,都不允许把整个「问问微信」拖垮。缺少桥接时按「未建立」降级即可。
const bridge = window.api as unknown as {
getImageTextIndexStatus?: () => Promise<ImageTextIndexStatus>
onImageTextIndexStatus?: (
callback: (status: ImageTextIndexStatus) => void
) => (() => void) | undefined
}
const loadStatus = bridge.getImageTextIndexStatus
const subscribe = bridge.onImageTextIndexStatus
if (typeof loadStatus !== 'function' || typeof subscribe !== 'function') return
let active = true
void loadStatus
.call(bridge)
.then((snapshot) => {
if (active) setStatus(snapshot)
})
.catch(() => undefined)
const unsubscribe = subscribe((snapshot) => {
if (active) setStatus(snapshot)
})
return () => {
active = false
if (typeof unsubscribe === 'function') unsubscribe()
}
}, [])
const refreshCount = useCallback(
async (sinceMs?: number): Promise<ImageTextIndexCountResult | null> => {
if (!dbReady) return null
setCounting(true)
try {
const result = await window.api.countImageMessages(sinceMs)
setCount(result)
return result
} catch (error) {
onNotice(error instanceof Error ? error.message : '统计图片消息数量失败')
return null
} finally {
setCounting(false)
}
},
[dbReady, onNotice]
)
const established = status?.coverage.established ?? false
void established
const start = useCallback(
async (options?: ImageTextIndexStartOptions): Promise<void> => {
if (!dbReady) {
onNotice('请先连接微信数据后再建立图片文字索引')
return
}
setPending('start')
try {
const result = await window.api.startImageTextIndex(options)
if (!result.started) {
onNotice('图片文字索引已经在进行中')
return
}
onNotice('已开始建立图片文字索引,可以继续使用软件')
} catch (error) {
onNotice(error instanceof Error ? error.message : '启动图片文字索引失败')
} finally {
setPending(null)
}
},
[dbReady, onNotice]
)
const pause = useCallback(async (): Promise<void> => {
setPending('pause')
try {
const result = await window.api.pauseImageTextIndex()
onNotice(result.paused ? '已暂停,已完成的识别结果会保留' : '当前没有正在进行的索引')
} catch (error) {
onNotice(error instanceof Error ? error.message : '暂停失败')
} finally {
setPending(null)
}
}, [onNotice])
const resume = useCallback(
async (options?: ImageTextIndexStartOptions): Promise<void> => {
setPending('resume')
try {
const result = await window.api.resumeImageTextIndex(options)
onNotice(result.started ? '已继续建立图片文字索引' : '索引已经在进行中')
} catch (error) {
onNotice(error instanceof Error ? error.message : '继续失败')
} finally {
setPending(null)
}
},
[onNotice]
)
const cancel = useCallback(async (): Promise<void> => {
setPending('cancel')
try {
const result = await window.api.cancelImageTextIndex()
if (!result.cancellable) {
onNotice('当前没有正在进行的索引')
return
}
if (!result.cancelled) {
onNotice('索引刚刚已经结束,无需取消')
return
}
onNotice('已取消,已识别的结果会保留,下次可从中断处继续')
} catch (error) {
onNotice(error instanceof Error ? error.message : '取消失败')
} finally {
setPending(null)
}
}, [onNotice])
/**
* 重置失败记录(代码修好后重跑)。
*
* 只说"已重置 N 条"是不够的 —— 必须同时讲清楚**成功记录没有被删**,
* 否则用户会以为刚才把已经跑好的结果也清掉了。
*/
const resetFailures = useCallback(async (): Promise<void> => {
setPending('reset')
try {
const result = await window.api.resetImageTextIndexFailures()
onNotice(
result.reset > 0
? `已把 ${result.reset.toLocaleString()} 条失败记录重置为待处理;已成功识别的记录保持不变。可以点「更新图片文字索引」重新处理这些图片`
: '没有需要重置的失败记录'
)
} catch (error) {
onNotice(error instanceof Error ? error.message : '重置失败记录失败')
} finally {
setPending(null)
}
}, [onNotice])
/**
* 派生索引修复:只重建 Knowledge 里的图片派生条目(L3),**不重新 OCR**(L1 不动)。
*
* 措辞必须讲清楚"没有重新识别":否则用户会以为又要等一小时,
* 从而不敢点这个按钮 —— 而这个按钮存在的全部意义就是"别重跑几万张图"。
*/
const repair = useCallback(async (): Promise<void> => {
setPending('repair')
try {
const result = await window.api.repairImageTextIndex()
if (result.skipped) {
onNotice('索引任务正在进行中,请等它结束后再修复搜索索引')
return
}
onNotice(
result.conversations > 0
? `已重建 ${result.conversations} 个会话的图片搜索索引;没有重新识别任何图片(已识别结果全部复用)`
: '没有需要重建的图片搜索索引'
)
} catch (error) {
onNotice(error instanceof Error ? error.message : '修复图片搜索索引失败')
} finally {
setPending(null)
}
}, [onNotice])
return {
status,
count,
counting,
pending,
running: status?.progress.state === 'running',
paused: status?.progress.state === 'paused',
established,
refreshCount,
start,
pause,
resume,
cancel,
resetFailures,
repair
}
}
@@ -24,6 +24,10 @@ export const mapPipelineEvidenceItem = (
return {
evidenceId: item.id,
sourceKind: item.sourceKind,
// 「靠图片里的文字命中」的来源语义与 OCR 片段同样要带到 UI,
// 否则 Legacy 检索路径下用户看不到「图片文字」标记(两条路径表现会不一致)。
...(item.derivedSource ? { derivedSource: item.derivedSource } : {}),
...(item.imageOcrText ? { imageOcrText: item.imageOcrText } : {}),
contact,
// 这条路径本来就同时知道真实会话 id 与消息 id,顺手补上稳定引用,
// 让 Legacy / ai-search 证据也能被精确定位(而不是只有 Query Agent 路径能跳准)。
@@ -34,6 +34,15 @@ export interface EvidenceItem {
/** Program-owned Final Evidence ID. Cached legacy records may omit it. */
evidenceId?: string
sourceKind?: KnowledgeMessageKind
/**
* 命中所依赖的派生来源。
*
* `image_ocr` = 这条结果靠**图片里的文字**命中,而不是群友真的发了一条文字消息。
* 有值时 Evidence 卡片显示轻量来源标记(「图片文字」)。
*/
derivedSource?: 'image_ocr'
/** 「从图片里读出来的文字」片段,只作命中解释。 */
imageOcrText?: string
contact: Contact
message: Message
/**
@@ -1,6 +1,16 @@
import { useCallback, useEffect, useState } from 'react'
import type { CacheSummary } from '../../../../../shared/cache'
import { Button } from '../../../components/ui'
import type { CacheSummary, CacheClearScope } from '../../../../../shared/cache'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button
} from '../../../components/ui'
const SEARCH_CACHE_KEYS = [
'wxe_ai_search_cache_v8',
@@ -25,9 +35,9 @@ export function CacheCleanupPage({
onNotice: (message: string) => void
}): React.ReactElement {
const [summary, setSummary] = useState<CacheSummary | null>(null)
const [busyScope, setBusyScope] = useState<
'bootstrap' | 'electron' | 'knowledge' | 'knowledge-directory' | 'all' | 'local' | null
>(null)
const [busyScope, setBusyScope] = useState<CacheClearScope | 'knowledge-directory' | 'local' | null>(null)
/** 需要二次确认的清理范围(目前只有图片文字索引)。 */
const [confirmingScope, setConfirmingScope] = useState<CacheClearScope | null>(null)
const refresh = useCallback(async (): Promise<void> => {
setSummary(await window.api.getCacheSummary())
@@ -44,7 +54,7 @@ export function CacheCleanupPage({
onNotice('已清理检索和导出本地缓存')
}
const clear = async (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise<void> => {
const clear = async (scope: CacheClearScope): Promise<void> => {
setBusyScope(scope)
try {
setSummary(await window.api.clearCache(scope))
@@ -54,9 +64,11 @@ export function CacheCleanupPage({
onNotice(
scope === 'knowledge'
? '已清理所有账号的本地知识库索引,需要时可在问问微信中重新建立'
: scope === 'all'
? '已清理全部可恢复缓存和检索记录'
: '缓存已清理'
: scope === 'image-text-index'
? '已清理图片文字索引,微信原始图片与聊天记录未受影响;需要时可在问问微信中重新建立'
: scope === 'all'
? '已清理全部可恢复缓存和检索记录'
: '缓存已清理'
)
} catch (error) {
onNotice(error instanceof Error ? error.message : '清理缓存失败')
@@ -65,6 +77,33 @@ export function CacheCleanupPage({
}
}
/**
* 清理图片文字索引。两步各司其职,不能省成一步:
*
* 1. `clearImageTextIndex()` —— 主进程先停任务、折叠 WAL、关连接、删三件套,
* 并**回验文件是否真的删掉**(Windows 上文件被占用时 rmSync 会静默失败)。
* 2. `clearCache('image-text-index')` —— 再扫掉整个派生目录(含其它账号的派生库),
* 并返回刷新后的占用摘要。
*
* 只要第 1 步回验失败,就必须如实报告,不能说"已清理"。
*/
const clearImageTextIndex = async (): Promise<void> => {
setBusyScope('image-text-index')
try {
const result = await window.api.clearImageTextIndex()
setSummary(await window.api.clearCache('image-text-index'))
onNotice(
result.removed
? '已清理图片文字索引;微信原始图片、聊天记录和普通文字知识库都未受影响。需要时可在「问问微信」里重新建立'
: '图片文字索引的数据文件仍被占用,没能完全删除。请重启 TraceMemo 后再试一次'
)
} catch (error) {
onNotice(error instanceof Error ? error.message : '清理图片文字索引失败')
} finally {
setBusyScope(null)
}
}
const openKnowledge = async (): Promise<void> => {
setBusyScope('knowledge-directory')
try {
@@ -134,9 +173,14 @@ export function CacheCleanupPage({
<Button
variant="outline"
size="sm"
data-testid={`cache-clear-${item.id}`}
disabled={busyScope !== null}
aria-busy={busyScope === item.id}
onClick={() => void clear(item.id)}
onClick={() =>
item.id === 'image-text-index'
? setConfirmingScope('image-text-index')
: void clear(item.id)
}
>
{busyScope === item.id ? '清理中...' : '清理'}
</Button>
@@ -169,6 +213,41 @@ export function CacheCleanupPage({
</div>
</div>
</div>
{/* 图片文字索引是「重新建立成本很高」的派生数据,必须二次确认并写清不可逆的范围。 */}
<AlertDialog
open={confirmingScope === 'image-text-index'}
onOpenChange={(open) => setConfirmingScope(open ? 'image-text-index' : null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理图片文字索引?</AlertDialogTitle>
<AlertDialogDescription>
将删除 TraceMemo 本地生成的图片 OCR 文本和对应搜索索引。
</AlertDialogDescription>
</AlertDialogHeader>
<div className="settings-confirm-detail">
<p>不会删除:</p>
<ul>
<li>微信原始图片</li>
<li>微信聊天记录</li>
<li>普通文字知识库</li>
<li>微信数据库</li>
</ul>
<p>清理后,「问问微信」将无法搜索图片中的文字;之后可以重新建立。</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
data-testid="cache-clear-image-text-index-confirm"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => void clearImageTextIndex()}
>
确认清理
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+27
View File
@@ -278,6 +278,33 @@
line-height: 15px;
}
/* 「建立图片文字索引」确认弹窗的正文:侧栏卡片用的 10px 在弹窗里太挤,
这里单独给一档更大的字号,并保持与卡片一致的次要文字色。 */
.ai-search-knowledge-confirm {
display: flex;
flex-direction: column;
gap: 8px;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 19px;
p {
margin: 0;
}
strong {
color: var(--wxex-text-primary);
}
ul {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 3px;
}
}
.ai-search-knowledge-error {
color: var(--wxex-warning);
}
@@ -79,6 +79,29 @@
}
}
/* 清理类确认弹窗的正文(「不会删除……」清单)。
侧栏卡片那种 10px 在弹窗里太小,这里单独给一档。 */
.settings-confirm-detail {
display: flex;
flex-direction: column;
gap: 8px;
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 19px;
p {
margin: 0;
}
ul {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 3px;
}
}
.voice-runtime-card dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));