mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 13:54:10 +08:00
feat: 新增微信图片文字索引与问问微信图片检索能力
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user