mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 13:54:10 +08:00
feat: 新增mac ocr转文字,增加到问一问微信 图片索引优化速度
- 图片文字索引性能与进度诚实化 - 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源 - 问问微信:回答规则禁止未真实执行的多轮承诺 - 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { Button, EmptyState } from '../ui'
|
||||
import { derivedSnippetPrefix, evidenceSourceBadges } from './evidenceSourceLabels'
|
||||
import type { EvidenceItem } from './searchTypes'
|
||||
import { formatMessageTime, messageIdentity, messageText, senderName } from './searchUtils'
|
||||
|
||||
@@ -47,6 +48,7 @@ export function AISearchEvidencePanel({
|
||||
const flashing = evidenceFlash.index === index
|
||||
const evidenceLabel = item.evidenceId || `E${index + 1}`
|
||||
const evidenceSender = senderName(item.message, item.contact, senderNames)
|
||||
const badges = evidenceSourceBadges(item)
|
||||
return (
|
||||
<article
|
||||
key={`${messageIdentity(item.message)}-${index}-${flashing ? evidenceFlash.nonce : 0}`}
|
||||
@@ -74,18 +76,30 @@ export function AISearchEvidencePanel({
|
||||
<span className="mt-0.5 block text-[10px] leading-[15px] text-primary">
|
||||
{item.contact.m_nsNickName}
|
||||
</span>
|
||||
{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"
|
||||
>
|
||||
图片文字
|
||||
{/*
|
||||
来源标签:消息类型 + 派生来源。
|
||||
两者正交,最多两个;不做 tooltip,避免把右栏撑成说明文档。
|
||||
*/}
|
||||
{badges.length > 0 && (
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{badges.map((badge) => (
|
||||
<span
|
||||
key={badge.key}
|
||||
data-testid={`evidence-badge-${badge.key}`}
|
||||
className="rounded-sm bg-accent px-1.5 py-0.5 text-[10px] font-semibold leading-[14px] text-primary"
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</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]">
|
||||
{/* 派生命中内容必须自报来源,不能被读成群友真发过这段文字。 */}
|
||||
{derivedSnippetPrefix(item.derivedSource) && (
|
||||
<span className="font-semibold text-primary">
|
||||
{derivedSnippetPrefix(item.derivedSource)}
|
||||
</span>
|
||||
)}
|
||||
{messageText(item.message)}
|
||||
</span>
|
||||
{/* 命中解释:明确告诉用户"命中的是图里的这段文字",
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
@@ -33,7 +37,7 @@ type ImageTextIndexCardProps = {
|
||||
* 与 Knowledge 卡片**平级并列**(同一组索引入口),但刻意是**独立的一维能力**:
|
||||
* 文字消息索引完整不代表图片里的文字搜得到。
|
||||
*
|
||||
* 文案遵从严禁混淆的语义(§9):这里做的是「识别图片中文字」,不是
|
||||
* 文案遵从严禁混淆的语义:这里做的是「识别图片中文字」,不是
|
||||
* 「本地识图模型 / 本地 Vision / AI OCR」,也不能暗示能理解场景或表情包。
|
||||
*/
|
||||
export function ImageTextIndexCard({ dbReady, onNotice }: ImageTextIndexCardProps): ReactElement {
|
||||
@@ -79,8 +83,8 @@ export function ImageTextIndexCard({ dbReady, onNotice }: ImageTextIndexCardProp
|
||||
/**
|
||||
* 处理进度百分比。
|
||||
*
|
||||
* 刻意不在这里做 `Math.round(x * 100)` —— `45479 / 45707` 会被四舍五入成 `100`,
|
||||
* 于是出现了"已建立 · 仅完成 100%"这种自相矛盾的显示。未完成时封顶 99.9%。
|
||||
* 刻意不在这里做 `Math.round(x * 100)` —— 那会把 99.5% 显示成 100%,
|
||||
* 于是出现"已建立 · 仅完成 100%"这种自相矛盾的显示。未完成时封顶 99.9%。
|
||||
*/
|
||||
const percent = coverage
|
||||
? imageTextProcessedPercent(coverage.processed, coverage.totalImageMessages)
|
||||
@@ -110,10 +114,25 @@ export function ImageTextIndexCard({ dbReady, onNotice }: ImageTextIndexCardProp
|
||||
const nothingCounted =
|
||||
count !== null && count.scannedConversations === 0 && count.failedConversations > 0
|
||||
|
||||
/** 识别失败的图片数(派生库的真实统计),决定「更多」里有没有重试入口。 */
|
||||
const failureCount = coverage?.failed ?? 0
|
||||
|
||||
/**
|
||||
* 中断但**可续做**。
|
||||
*
|
||||
* `paused` 和 `cancelled` 都能靠 checkpoint 从断点接上(`startPass` 会跳过已完成会话、
|
||||
* 命中已有 artifact 不再重复 OCR),所以两者必须给**同一个**「继续」入口。
|
||||
* 只认 `paused` 的后果真实发生过:点过「取消」之后卡片只剩「更新图片文字索引」,
|
||||
* 状态还被显示成「部分完成 · 2.1%」—— 用户既看不出自己中断过,也找不到继续的地方。
|
||||
*/
|
||||
const interrupted = paused || progress?.state === 'cancelled'
|
||||
|
||||
const stateLabel = (() => {
|
||||
if (progress?.state === 'error') return '建立失败'
|
||||
if (running) return `建立中 · ${percent}%`
|
||||
if (paused) return `已暂停 · ${percent}%`
|
||||
// 取消 ≠ 部分完成:进度是保留的,但"被打断过"这件事必须说出来。
|
||||
if (progress?.state === 'cancelled') return `已取消 · ${percent}%`
|
||||
if (!established) return '未建立'
|
||||
// 「已建立」不能等于「全失败」:处理过但一条都没成功时必须叫异常。
|
||||
if (coverageState === 'failed') return '图片文字索引异常'
|
||||
@@ -227,6 +246,21 @@ export function ImageTextIndexCard({ dbReady, onNotice }: ImageTextIndexCardProp
|
||||
<p className="ai-search-knowledge-pass-line">
|
||||
{`${progress.percent}% · 识别出文字 ${progress.indexed.toLocaleString()} · 没有文字 ${progress.empty.toLocaleString()} · 图片已清理 ${progress.missing.toLocaleString()} · 失败 ${progress.failed.toLocaleString()}`}
|
||||
</p>
|
||||
{/*
|
||||
速度用最近窗口的实测值(Main 给的就是窗口速度,不是全程平均)。
|
||||
样本还不足时如实说"计算中",不要编一个数 —— 全量回填要跑几小时,
|
||||
一个假 ETA 比没有 ETA 更糟。
|
||||
*/}
|
||||
<p
|
||||
className="ai-search-knowledge-pass-line"
|
||||
data-testid="image-text-index-rate"
|
||||
>
|
||||
{`当前速度:${
|
||||
typeof progress.speedPerSec === 'number' && progress.speedPerSec > 0
|
||||
? `约 ${progress.speedPerSec.toFixed(1)} 张/秒`
|
||||
: '计算中'
|
||||
} · 预计剩余:${formatEta(progress.etaMs)}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -284,8 +318,9 @@ export function ImageTextIndexCard({ dbReady, onNotice }: ImageTextIndexCardProp
|
||||
<p className="ai-search-knowledge-error">请先连接微信数据,然后再建立图片文字索引。</p>
|
||||
)}
|
||||
|
||||
<div className="ai-search-knowledge-actions">
|
||||
{!running && !paused && (
|
||||
{/* 这张卡最多并列 3 个操作,横向排会撑破窄侧栏;修饰类把它改成单列堆叠。 */}
|
||||
<div className="ai-search-knowledge-actions ai-search-image-index-actions">
|
||||
{!running && !interrupted && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="ai-search-knowledge-primary"
|
||||
@@ -296,44 +331,50 @@ export function ImageTextIndexCard({ dbReady, onNotice }: ImageTextIndexCardProp
|
||||
{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 && (established || failureCount > 0) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="ai-search-knowledge-cancel"
|
||||
data-testid="image-text-index-more"
|
||||
disabled={pending !== null}
|
||||
>
|
||||
更多
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* 派生索引修复:只重建 Knowledge 里的图片搜索索引,**不重新识别任何图片**。
|
||||
存在的意义就是"别为修一个索引问题重跑几万张图"。 */}
|
||||
{established && (
|
||||
<DropdownMenuItem
|
||||
data-testid="image-text-index-repair"
|
||||
disabled={pending !== null}
|
||||
onSelect={() => void repair()}
|
||||
>
|
||||
图片内容搜不到?修复搜索索引
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 修好之后重跑:只重置失败记录,成功记录与其它数据一律不动。 */}
|
||||
{failureCount > 0 && (
|
||||
<DropdownMenuItem
|
||||
data-testid="image-text-index-reset-failures"
|
||||
disabled={pending !== null}
|
||||
onSelect={() => void resetFailures()}
|
||||
>
|
||||
{`重试识别失败的图片(${failureCount.toLocaleString()} 张)`}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{running && (
|
||||
<>
|
||||
@@ -359,28 +400,35 @@ export function ImageTextIndexCard({ dbReady, onNotice }: ImageTextIndexCardProp
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/*
|
||||
中断(暂停 / 取消)之后必须能找到「继续」。
|
||||
两种状态的 checkpoint 都是保留的,继续 = 从断点接上,
|
||||
所以这里刻意合并成一个入口 —— 否则「取消」过的索引会只剩
|
||||
「更新图片文字索引」,用户根本看不出还能接着做。
|
||||
*/}
|
||||
{interrupted && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="ai-search-knowledge-primary"
|
||||
data-testid="image-text-index-resume"
|
||||
disabled={pending !== null}
|
||||
onClick={() => void resume()}
|
||||
>
|
||||
{pending === 'resume' ? '继续中…' : '继续'}
|
||||
</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>
|
||||
</>
|
||||
<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>
|
||||
@@ -425,3 +473,18 @@ export function ImageTextIndexCard({ dbReady, onNotice }: ImageTextIndexCardProp
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 剩余时间文案。
|
||||
*
|
||||
* `null` = 分母不可信或速度样本还不足 —— 如实说"计算中"。
|
||||
* 刻意不显示 p50 / p95 这类开发指标:这是用户界面,不是性能面板。
|
||||
*/
|
||||
function formatEta(etaMs: number | null | undefined): string {
|
||||
if (typeof etaMs !== 'number' || !Number.isFinite(etaMs) || etaMs <= 0) return '计算中'
|
||||
const totalMinutes = Math.round(etaMs / 60_000)
|
||||
if (totalMinutes < 1) return '不到 1 分钟'
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
return hours > 0 ? `${hours} 小时 ${minutes} 分` : `${minutes} 分`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { KnowledgeDerivedSource, KnowledgeMessageKind } from '../../../../shared/knowledge'
|
||||
|
||||
/**
|
||||
* 证据卡的来源标签。
|
||||
*
|
||||
* 两个维度**正交**,必须分开表达,不能混成一个标签:
|
||||
* - 消息类型(`sourceKind`):原始消息本身是什么;
|
||||
* - 派生来源(`derivedSource`):这条结果是**靠什么命中**的(本地派生的 OCR / 转写)。
|
||||
*
|
||||
* 文案面向用户:不出现 `image_ocr` 这类工程词。每条证据最多两个标签,
|
||||
* 顺序固定为「消息类型 + 派生来源」。
|
||||
*/
|
||||
|
||||
/** 只列用户看得懂的类型;`other` 之类没有信息量的取值不给标签。 */
|
||||
const MESSAGE_TYPE_LABELS: Record<string, string> = {
|
||||
text: '文本消息',
|
||||
image: '图片消息',
|
||||
voice: '语音消息',
|
||||
video: '视频消息',
|
||||
file: '文件消息',
|
||||
link: '链接消息',
|
||||
sticker: '表情消息',
|
||||
system: '系统消息'
|
||||
}
|
||||
|
||||
const DERIVED_SOURCE_LABELS: Record<KnowledgeDerivedSource, string> = {
|
||||
image_ocr: 'OCR命中',
|
||||
voice_transcript: '转写命中'
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生命中内容的 snippet 前缀。
|
||||
*
|
||||
* 目的只有一个:**不能让派生文本看起来像原始聊天内容**。
|
||||
* 普通文本消息不加前缀,原样展示。
|
||||
*/
|
||||
const DERIVED_SNIPPET_PREFIXES: Record<KnowledgeDerivedSource, string> = {
|
||||
image_ocr: 'OCR摘录:',
|
||||
voice_transcript: '转写摘录:'
|
||||
}
|
||||
|
||||
export interface EvidenceSourceBadge {
|
||||
/** 稳定的 DOM key,不用下标 —— 标签顺序可能随数据变化。 */
|
||||
key: 'messageType' | 'derivedSource'
|
||||
label: string
|
||||
}
|
||||
|
||||
export function evidenceSourceBadges(item: {
|
||||
sourceKind?: KnowledgeMessageKind | string
|
||||
derivedSource?: KnowledgeDerivedSource
|
||||
}): EvidenceSourceBadge[] {
|
||||
const badges: EvidenceSourceBadge[] = []
|
||||
const messageLabel = item.sourceKind ? MESSAGE_TYPE_LABELS[item.sourceKind] : undefined
|
||||
if (messageLabel) badges.push({ key: 'messageType', label: messageLabel })
|
||||
const derivedLabel = item.derivedSource ? DERIVED_SOURCE_LABELS[item.derivedSource] : undefined
|
||||
if (derivedLabel) badges.push({ key: 'derivedSource', label: derivedLabel })
|
||||
return badges
|
||||
}
|
||||
|
||||
/** 派生内容的 snippet 前缀;普通文本消息返回空串(原样展示)。 */
|
||||
export function derivedSnippetPrefix(source?: KnowledgeDerivedSource): string {
|
||||
return source ? DERIVED_SNIPPET_PREFIXES[source] : ''
|
||||
}
|
||||
@@ -94,5 +94,33 @@ export const renderMarkdown = (value: string, options: MarkdownOptions = {}): Re
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/*
|
||||
* Markdown 表格行。
|
||||
*
|
||||
* 结果栏很窄,真表格在这里只会挤成一团(列宽错位、长字段换行难读)。提示词已经
|
||||
* 禁止模型为检索结果产表格,但历史回答与其它入口仍可能出现,所以这里把它降级成
|
||||
* **逐行的键值列表**:内容读得出来,且永远不会横向溢出容器。
|
||||
*/
|
||||
if (/^\s*\|.*\|\s*$/.test(line)) {
|
||||
const cells = line
|
||||
.trim()
|
||||
.replace(/^\||\|$/g, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
.filter((cell) => cell.length > 0)
|
||||
// `|---|---|` 这类分隔行没有信息,当作空行处理。
|
||||
if (!cells.length || cells.every((cell) => /^:?-{2,}:?$/.test(cell))) {
|
||||
return <div key={key} className="ai-search-markdown-spacer" />
|
||||
}
|
||||
return (
|
||||
<div key={key} className="ai-search-markdown-table-row">
|
||||
{cells.map((cell, cellIndex) => (
|
||||
<span key={`${key}-${cellIndex}`} className="ai-search-markdown-table-cell">
|
||||
{inlineMarkdown(cell, `${key}-${cellIndex}`, options)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <p key={key}>{inlineMarkdown(line, key, options)}</p>
|
||||
})
|
||||
|
||||
@@ -6,7 +6,11 @@ import type {
|
||||
AiSearchProgressEvent,
|
||||
AiSearchProgressStage
|
||||
} from '../../../../shared/ai-search'
|
||||
import type { KnowledgeMessageKind, KnowledgeVoiceCoverage } from '../../../../shared/knowledge'
|
||||
import type {
|
||||
KnowledgeDerivedSource,
|
||||
KnowledgeMessageKind,
|
||||
KnowledgeVoiceCoverage
|
||||
} from '../../../../shared/knowledge'
|
||||
import type { Contact, Message } from '../../../../shared/types'
|
||||
|
||||
export type SearchStage = 'idle' | 'loading' | 'result' | 'partial' | 'insufficient'
|
||||
@@ -37,10 +41,11 @@ export interface EvidenceItem {
|
||||
/**
|
||||
* 命中所依赖的派生来源。
|
||||
*
|
||||
* `image_ocr` = 这条结果靠**图片里的文字**命中,而不是群友真的发了一条文字消息。
|
||||
* 有值时 Evidence 卡片显示轻量来源标记(「图片文字」)。
|
||||
* 有值 = 这条结果靠**本地派生内容**命中,而不是原始消息本身的文字
|
||||
* (`image_ocr` = 图片里的文字,`voice_transcript` = 语音转写)。
|
||||
* authoritative source 始终是原始消息 —— 这里只用来多挂一个来源标记。
|
||||
*/
|
||||
derivedSource?: 'image_ocr'
|
||||
derivedSource?: KnowledgeDerivedSource
|
||||
/** 「从图片里读出来的文字」片段,只作命中解释。 */
|
||||
imageOcrText?: string
|
||||
contact: Contact
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
import { cn } from '../../lib/cn'
|
||||
import { buttonVariants } from './button'
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
const AlertDialogCancel = AlertDialogPrimitive.Cancel
|
||||
const AlertDialogAction = AlertDialogPrimitive.Action
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
@@ -70,6 +69,39 @@ const AlertDialogDescription = React.forwardRef<
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
|
||||
/**
|
||||
* 取消:次要动作,走 `outline`。
|
||||
*
|
||||
* 这两个组件必须**显式**挂上 `buttonVariants`。直接 `export const X = Primitive.X`
|
||||
* 会把 Radix 原始 primitive 原样抛出去,渲染成浏览器默认按钮(黑白方角),
|
||||
* 跟产品主题完全不搭 —— 这类"忘了挂样式"的 primitive 是默认样式的常见来源。
|
||||
*/
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
/**
|
||||
* 确认:主要动作,走 `default`(主题色)。
|
||||
*
|
||||
* 危险动作(删除、清空等)由调用方传 `className` 覆盖成 destructive ——
|
||||
* `cn` 走的是 tailwind-merge,同族类会被后者替换,不必在这里开新的分支。
|
||||
*/
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { SystemOcrCapability, SystemOcrResult } from '../../../../../shared/system-ocr'
|
||||
import type {
|
||||
SystemOcrCapability,
|
||||
SystemOcrEngine,
|
||||
SystemOcrResult
|
||||
} from '../../../../../shared/system-ocr'
|
||||
import { Button } from '../../../components/ui'
|
||||
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024
|
||||
@@ -18,12 +22,15 @@ interface LocalOcrState {
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地图片文字识别(Windows 系统 OCR)。
|
||||
* 本地图片文字识别(系统 OCR)。
|
||||
*
|
||||
* 这是**本地 Runtime**,不是 AI 图片理解:
|
||||
* - 只把图片里的文字读出来;不描述画面、人物、场景,也不做视觉推理;
|
||||
* - 原始图片不会因为这一步发给任何 AI Provider;
|
||||
* - 结果只是派生内容,不会写进本地知识库。
|
||||
*
|
||||
* 引擎由平台决定(Windows 系统 OCR / macOS 系统 OCR),UI 一律从 capability 派生文案,
|
||||
* 不硬编码平台名。
|
||||
*/
|
||||
export function LocalImageTextRecognition(): React.ReactElement {
|
||||
const [capability, setCapability] = useState<SystemOcrCapability | null>(null)
|
||||
@@ -120,13 +127,14 @@ export function LocalImageTextRecognition(): React.ReactElement {
|
||||
|
||||
const running = state.status === 'running'
|
||||
const result = state.result
|
||||
const engineLabel = systemOcrEngineLabel(capability?.engine)
|
||||
|
||||
return (
|
||||
<section className="settings-card local-ocr-test">
|
||||
<header>
|
||||
<div>
|
||||
<h2>本地图片文字识别</h2>
|
||||
<p>使用 Windows 系统 OCR 在本机读取图片中的文字,原始图片无需发送给 AI Provider。</p>
|
||||
<p>使用{engineLabel}在本机读取图片中的文字,原始图片无需发送给 AI Provider。</p>
|
||||
</div>
|
||||
<span className={`local-ocr-capability ${capability?.available ? 'supported' : ''}`}>
|
||||
{capability ? (capability.available ? '本机可用' : '本机不可用') : '检测中…'}
|
||||
@@ -138,7 +146,7 @@ export function LocalImageTextRecognition(): React.ReactElement {
|
||||
) : null}
|
||||
{capability?.available ? (
|
||||
<p className="local-ocr-runtime">
|
||||
引擎:Windows 系统 OCR
|
||||
引擎:{engineLabel}
|
||||
{capability.runtimeVersion ? ` · 组件 ${capability.runtimeVersion}` : ''}
|
||||
{capability.language ? ` · 语言 ${capability.language}` : ' · 语言跟随系统'}
|
||||
</p>
|
||||
@@ -183,7 +191,7 @@ export function LocalImageTextRecognition(): React.ReactElement {
|
||||
<dl>
|
||||
<div>
|
||||
<dt>引擎</dt>
|
||||
<dd>Windows 系统 OCR</dd>
|
||||
<dd>{systemOcrEngineLabel(result.engine)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>语言</dt>
|
||||
@@ -218,10 +226,22 @@ export function LocalImageTextRecognition(): React.ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
/** 引擎标识 → 展示名。UI 不硬编码平台,一律从 capability / result 派生。 */
|
||||
function systemOcrEngineLabel(engine: SystemOcrEngine | undefined): string {
|
||||
switch (engine) {
|
||||
case 'macos-system-ocr':
|
||||
return 'macOS 系统 OCR'
|
||||
case 'windows-system-ocr':
|
||||
return 'Windows 系统 OCR'
|
||||
default:
|
||||
return '系统 OCR'
|
||||
}
|
||||
}
|
||||
|
||||
function localOcrErrorMessage(result: SystemOcrResult): string {
|
||||
switch (result.errorCode) {
|
||||
case 'UNSUPPORTED_PLATFORM':
|
||||
return '本地图片文字识别目前仅支持 Windows。'
|
||||
return '本地图片文字识别目前支持 Windows 与 macOS。'
|
||||
case 'SYSTEM_OCR_UNAVAILABLE':
|
||||
return '本地文字识别组件不可用,请重新安装 TraceMemo。'
|
||||
case 'OCR_LANGUAGE_UNAVAILABLE':
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
import { useState } from 'react'
|
||||
import type { ImageDecryptionState } from './types'
|
||||
import { Input } from '../../../components/ui'
|
||||
import { Button, Input } from '../../../components/ui'
|
||||
|
||||
/**
|
||||
* 密钥显示切换图标。
|
||||
*
|
||||
* 项目里没有现成的眼睛图标(`LineIcon` 只有 database / shield 之类),
|
||||
* 这里就地画一个 16px 线框图标,避免为一处 UI 引入图标依赖。
|
||||
*/
|
||||
function EyeIcon({ crossed }: { crossed: boolean }): React.ReactElement {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
focusable="false"
|
||||
>
|
||||
<path d="M2.5 12S6 5.75 12 5.75 21.5 12 21.5 12 18 18.25 12 18.25 2.5 12 2.5 12Z" />
|
||||
<circle cx="12" cy="12" r="2.6" />
|
||||
{crossed ? <path d="M4.5 4.5l15 15" /> : null}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ImageKeyConfiguration({
|
||||
state,
|
||||
@@ -10,6 +38,13 @@ export function ImageKeyConfiguration({
|
||||
disabled: boolean
|
||||
onEdit: (field: 'xorKey' | 'aesKey', value: string) => void
|
||||
}): React.ReactElement {
|
||||
/**
|
||||
* 只控制**本机的显示方式**,不影响任何存储、校验或解密行为:
|
||||
* 密钥仍然以 password 语义渲染(浏览器/密码管理器照旧),
|
||||
* 切换只是把 input 的 type 换成 text,让用户能核对自己填的 16 位密钥。
|
||||
*/
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
|
||||
return (
|
||||
<section className="settings-card image-key-editor">
|
||||
<div className="image-key-grid">
|
||||
@@ -23,14 +58,29 @@ export function ImageKeyConfiguration({
|
||||
</label>
|
||||
<label>
|
||||
<span>AES Key</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={state.aesKey}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
placeholder="输入 16 位图片密钥"
|
||||
onChange={(event) => onEdit('aesKey', event.target.value)}
|
||||
/>
|
||||
<div className="image-key-secret">
|
||||
<Input
|
||||
type={revealed ? 'text' : 'password'}
|
||||
value={state.aesKey}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
placeholder="输入 16 位图片密钥"
|
||||
onChange={(event) => onEdit('aesKey', event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="image-key-secret-toggle"
|
||||
data-testid="image-key-reveal"
|
||||
aria-label={revealed ? '隐藏图片密钥' : '显示图片密钥'}
|
||||
aria-pressed={revealed}
|
||||
title={revealed ? '隐藏图片密钥' : '显示图片密钥'}
|
||||
disabled={disabled}
|
||||
onClick={() => setRevealed((current) => !current)}
|
||||
>
|
||||
<EyeIcon crossed={revealed} />
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<p>修改后请先选择会话完成图片解析测试,再确认保存。</p>
|
||||
|
||||
@@ -420,6 +420,34 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 图片文字索引卡片的操作区:每个按钮独占一行。
|
||||
*
|
||||
* 这张卡最多并列 3 个操作(更新图片文字索引 / 更多 / 继续或取消),
|
||||
* 而上面那套两列栅格是按「1 主 + 1 次」设计的:`auto` 列不可收缩,
|
||||
* 窄侧栏下第 2、3 个按钮会把卡片撑出横向溢出。
|
||||
* 改成纵向堆叠后按钮宽度只跟随容器,结构上不可能溢出。
|
||||
*/
|
||||
.ai-search-knowledge-actions.ai-search-image-index-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 宽度严格跟随容器:`min-width: 0` 覆盖按钮自身的 68px 下限
|
||||
* (单列下那个下限已经没有意义,反而会阻止收缩)。
|
||||
* 标签最长 8 个汉字,最窄侧栏(190px)下仍有余量,所以保持单行不换行。
|
||||
*/
|
||||
.ai-search-knowledge-actions.ai-search-image-index-actions > * {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-primary {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1082,6 +1110,32 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/*
|
||||
* Markdown 表格的降级渲染。
|
||||
*
|
||||
* 结果栏很窄,真表格塞进来只会列宽错位、长字段换行难读。渲染层把表格行转成
|
||||
* 逐行的键值列表(见 searchMarkdown.tsx),这里只保证两件事:
|
||||
* 读得出来,且**永远不会横向溢出容器**。所以用 flex + wrap,不用 table。
|
||||
*/
|
||||
.ai-search-markdown-table-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 10px;
|
||||
margin: 3px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-search-markdown-table-cell {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
|
||||
.ai-search-markdown-table-cell:first-child {
|
||||
color: var(--wxex-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.ai-search-header-actions {
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -944,6 +944,25 @@
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
/*
|
||||
* AES 密钥字段 +「显示/隐藏」开关。
|
||||
*
|
||||
* 用 flex 让按钮做**同级的兄弟节点**,而不是浮在输入框上:
|
||||
* 这样不需要绝对定位、不需要给输入框留 padding,值很长时也不会被图标压住。
|
||||
*/
|
||||
.image-key-secret {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.image-key-secret > input {
|
||||
/* Input 自身是 w-full(width: 100%),flex 里必须放开 min-width 才能收缩。 */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.image-key-secret-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.image-key-editor > p {
|
||||
margin: 0;
|
||||
color: #66706b;
|
||||
|
||||
Reference in New Issue
Block a user