mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 05:27:48 +08:00
feat: 重构问问微信并完善知识库增量检索
统一问问微信与 Agent Hub 的查询链路 完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论 基于会话实现真正的增量追新与历史补齐 支持后台同步、取消恢复、重启续传以及同步期间继续查询 优化知识库跨会话检索、同步状态、进度展示和侧栏布局
This commit is contained in:
+46
-11
@@ -28,6 +28,8 @@ import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/Dat
|
||||
import { FirstUseWelcome } from './components/FirstUseWelcome'
|
||||
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
||||
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
|
||||
import type { EvidenceItem } from './components/search/searchTypes'
|
||||
import { decodeMessageRef } from '../../shared/local-query-api'
|
||||
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
|
||||
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key'
|
||||
import {
|
||||
@@ -249,6 +251,13 @@ function App(): React.ReactElement {
|
||||
const connectionOperationRef = React.useRef(0)
|
||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
||||
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
||||
/**
|
||||
* 精确跳转目标(规范化后的消息 id)。
|
||||
*
|
||||
* 与 `archiveJumpTime` 并存而不是替代:时间只能定位到"附近",秒级时间戳在群聊里
|
||||
* 经常对应多条消息。有 messageRef 时用 id 精确定位,没有时才退回按时间找。
|
||||
*/
|
||||
const [archiveJumpMessageId, setArchiveJumpMessageId] = useState<string | null>(null)
|
||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
||||
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
||||
@@ -1191,6 +1200,7 @@ function App(): React.ReactElement {
|
||||
|
||||
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
|
||||
setArchiveJumpTime(null)
|
||||
setArchiveJumpMessageId(null)
|
||||
setSelectedContact(contact)
|
||||
selectedContactMd5Ref.current = contact.md5
|
||||
currentGroupSnapshotRef.current = null
|
||||
@@ -1265,23 +1275,49 @@ function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenSearchEvidence = async (contact: Contact, createTime?: number): Promise<void> => {
|
||||
/**
|
||||
* 跳转到证据的原聊天。
|
||||
*
|
||||
* 只靠「真实会话 id + 秒级时间戳」定位不可靠:会话 id 若是展示层合成的 key 就选不中任何
|
||||
* 真实会话,而时间戳在同一秒有多条消息时会挑错。这里从 `messageRef` 还原真实会话 id →
|
||||
* 选中会话 → 用 `getMessagesAround` 按稳定消息 id 在有界窗口内精确锚定 → 高亮那一条。
|
||||
*
|
||||
* 窗口内找不到时**不静默失败**,明确提示"已打开对应会话,但暂时无法定位原消息"。
|
||||
*/
|
||||
const handleOpenSearchEvidence = async (evidence: EvidenceItem): Promise<void> => {
|
||||
const anchor = decodeMessageRef(evidence.messageRef)
|
||||
if (!anchor) {
|
||||
setReportNotice('这条证据缺少可定位的消息引用,已为你打开对应会话')
|
||||
setActivePage('archive')
|
||||
await handleSelectContact(evidence.contact)
|
||||
return
|
||||
}
|
||||
// 优先用联系人列表里的真实联系人(头像 / 备注等元数据完整),兜底用证据自带的最小信息。
|
||||
const contact = contacts.find((item) => item.md5 === anchor.conversationId) || evidence.contact
|
||||
setActivePage('archive')
|
||||
await handleSelectContact(contact)
|
||||
if (!createTime || selectedContactMd5Ref.current !== contact.md5) return
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
|
||||
const anchorSeconds = evidence.message.createTime || undefined
|
||||
try {
|
||||
const windowStart = Math.max(0, createTime - 12 * 3600)
|
||||
const windowEnd = createTime + 12 * 3600
|
||||
const nearbyMessages = await window.api.getMessages(contact.md5, windowStart, windowEnd)
|
||||
const around = await window.api.getMessagesAround(
|
||||
contact.md5,
|
||||
anchor.messageId,
|
||||
anchorSeconds
|
||||
)
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
const focusedMessages = sortMessagesChronologically(nearbyMessages)
|
||||
const focusedMessages = sortMessagesChronologically(around.messages)
|
||||
messageHistoryRef.current = focusedMessages
|
||||
setMessages(applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, focusedMessages)))
|
||||
setArchiveJumpTime(createTime)
|
||||
if (around.found) {
|
||||
setArchiveJumpMessageId(anchor.messageId)
|
||||
setArchiveJumpTime(anchorSeconds ?? null)
|
||||
} else {
|
||||
setReportNotice('已打开对应会话,但暂时无法定位原消息。')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Search] evidence context load failed:', error)
|
||||
setReportNotice('证据所在时间段加载失败,请在档案中手动查看')
|
||||
setReportNotice('已打开对应会话,但暂时无法定位原消息。')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1773,6 +1809,7 @@ function App(): React.ReactElement {
|
||||
onOpenPersonalWechatSettings={openWechatSendSettings}
|
||||
isAiLoading={reportGeneration.isGenerating}
|
||||
jumpToTime={archiveJumpTime}
|
||||
jumpToMessageId={archiveJumpMessageId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -2007,9 +2044,7 @@ function App(): React.ReactElement {
|
||||
dbReady={isDatabaseConnected}
|
||||
aiModelConfig={aiModelConfig}
|
||||
onSelectContact={(contact) => void handleSelectContact(contact)}
|
||||
onOpenEvidence={(contact, createTime) =>
|
||||
void handleOpenSearchEvidence(contact, createTime)
|
||||
}
|
||||
onOpenEvidence={(evidence) => void handleOpenSearchEvidence(evidence)}
|
||||
onOpenAISettings={openModelSettings}
|
||||
onNotice={setReportNotice}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,8 @@ interface ChatWindowProps {
|
||||
onOpenPersonalWechatSettings?: () => void
|
||||
isAiLoading?: boolean
|
||||
jumpToTime?: number | null
|
||||
/** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */
|
||||
jumpToMessageId?: string | null
|
||||
}
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
@@ -41,7 +43,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
onOpenTextToSpeechSettings,
|
||||
onOpenPersonalWechatSettings,
|
||||
isAiLoading = false,
|
||||
jumpToTime
|
||||
jumpToTime,
|
||||
jumpToMessageId
|
||||
}) => {
|
||||
const isGroupChat = Boolean(
|
||||
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
||||
@@ -76,24 +79,30 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
setIsAtLatest(true)
|
||||
}, [contact?.md5])
|
||||
|
||||
useEffect(() => {
|
||||
if (jumpToTime !== undefined && jumpToTime !== null) setIsAtLatest(false)
|
||||
}, [jumpToTime])
|
||||
// 一次跳转 = 有精确目标或有时间目标。用统一判据,避免"只带了 messageId 但没带时间"
|
||||
// 时自动滚到底把跳转结果顶掉(那会让用户看到"跳过去了但又被弹回最新")。
|
||||
const hasJumpTarget =
|
||||
(jumpToTime !== undefined && jumpToTime !== null) ||
|
||||
(jumpToMessageId !== undefined && jumpToMessageId !== null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
|
||||
if (hasJumpTarget) setIsAtLatest(false)
|
||||
}, [hasJumpTarget])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAtLatest || hasJumpTarget) return
|
||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [isAtLatest, jumpToTime, messages, scrollToBottom])
|
||||
}, [isAtLatest, hasJumpTarget, messages, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
|
||||
if (!isAtLatest || hasJumpTarget) return
|
||||
const content = messageListRef.current?.querySelector('.virtual-message-list')
|
||||
if (!content) return
|
||||
const observer = new ResizeObserver(() => scrollToBottom())
|
||||
observer.observe(content)
|
||||
return () => observer.disconnect()
|
||||
}, [contact?.md5, isAtLatest, jumpToTime, scrollToBottom])
|
||||
}, [contact?.md5, isAtLatest, hasJumpTarget, scrollToBottom])
|
||||
|
||||
const openImagePreview = (imageUrl: string): void => {
|
||||
setPreviewImage(imageUrl)
|
||||
@@ -175,6 +184,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
onReachTop={onLoadOlderMessages}
|
||||
onImageClick={openImagePreview}
|
||||
jumpToTime={jumpToTime}
|
||||
jumpToMessageId={jumpToMessageId}
|
||||
/>
|
||||
<ChatStatusBar
|
||||
count={filteredMessages.length}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Contact, Message } from '../../../../shared/types'
|
||||
import { MessageGroup } from './MessageGroup'
|
||||
import { buildMessageGroups } from './messageGrouping'
|
||||
import { resolveMessageJumpTarget } from './messageJump'
|
||||
|
||||
interface MessageListProps {
|
||||
contact: Contact
|
||||
@@ -18,6 +19,13 @@ interface MessageListProps {
|
||||
onReachTop?: () => Promise<void>
|
||||
onImageClick: (imageUrl: string) => void
|
||||
jumpToTime?: number | null
|
||||
/**
|
||||
* 精确跳转目标(规范化消息 id,即去掉 `local:` 前缀后的 WCDB 本地 id)。
|
||||
*
|
||||
* 优先于 `jumpToTime`:时间只能找到"附近的第一条",秒级时间戳在群聊里经常
|
||||
* 对应多条消息,于是会定位并高亮错一条。有 id 时必须按 id 找。
|
||||
*/
|
||||
jumpToMessageId?: string | null
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
@@ -33,7 +41,8 @@ export function MessageList({
|
||||
onScroll,
|
||||
onReachTop,
|
||||
onImageClick,
|
||||
jumpToTime
|
||||
jumpToTime,
|
||||
jumpToMessageId
|
||||
}: MessageListProps): React.ReactElement {
|
||||
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||
const groupsRef = React.useRef(groups)
|
||||
@@ -47,15 +56,10 @@ export function MessageList({
|
||||
overscan: 8
|
||||
})
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
const jumpTarget = React.useMemo(() => {
|
||||
if (jumpToTime === undefined || jumpToTime === null) return null
|
||||
const groupIndex = groups.findIndex((group) =>
|
||||
group.messages.some((message) => (message.createTime || 0) >= jumpToTime)
|
||||
)
|
||||
if (groupIndex < 0) return null
|
||||
const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime)
|
||||
return { groupIndex, messageId: message?.id }
|
||||
}, [groups, jumpToTime])
|
||||
const jumpTarget = React.useMemo(
|
||||
() => resolveMessageJumpTarget(groups, jumpToTime, jumpToMessageId),
|
||||
[groups, jumpToTime, jumpToMessageId]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!jumpTarget) return
|
||||
@@ -70,6 +74,7 @@ export function MessageList({
|
||||
const scrollElement = event.currentTarget
|
||||
if (
|
||||
(jumpToTime !== undefined && jumpToTime !== null) ||
|
||||
(jumpToMessageId !== undefined && jumpToMessageId !== null) ||
|
||||
scrollElement.scrollTop >= 48 ||
|
||||
loadingOlderRef.current ||
|
||||
isLoadingMessages ||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { MessageGroupModel } from './messageGrouping'
|
||||
|
||||
/**
|
||||
* 证据 → 归档侧的定位结果。
|
||||
*
|
||||
* `messageId` 刻意返回**归档侧真实出现的 id**(而不是传入的 id):
|
||||
* `MessageGroup` 的高亮是拿 `message.id` 直接比对的,返回归一化后的值会让
|
||||
* `local:` 前缀不同的情况静默失配 —— 表现为"跳过去了但没有高亮"。
|
||||
*/
|
||||
export interface MessageJumpTarget {
|
||||
groupIndex: number
|
||||
messageId?: string
|
||||
/** true = 按稳定身份精确命中;false = 退化到按时间戳找最近的一条。 */
|
||||
exact: boolean
|
||||
}
|
||||
|
||||
/** 两侧(证据侧 / 归档侧)都用同一套归一化,否则 `local:` 前缀会让匹配静默失败。 */
|
||||
export const normalizeJumpMessageId = (value: string | undefined | null): string =>
|
||||
String(value ?? '').replace(/^local:/, '')
|
||||
|
||||
/**
|
||||
* 解析跳转目标。
|
||||
*
|
||||
* 顺序(不能倒过来):
|
||||
* 1. **稳定身份优先**:证据带 messageRef 时,落在哪一条就是哪一条。
|
||||
* 这是唯一能正确处理"同一秒多条消息"的方式 —— 秒级时间戳只能找到"附近"。
|
||||
* 2. 退化为按时间找第一条 `createTime >= jumpToTime` 的消息(老证据 / 无引用路径)。
|
||||
* 找不到(目标已被删除 / 清理)→ null,调用方必须给出诚实文案。
|
||||
*/
|
||||
export const resolveMessageJumpTarget = (
|
||||
groups: MessageGroupModel[],
|
||||
jumpToTime: number | null | undefined,
|
||||
jumpToMessageId: string | null | undefined
|
||||
): MessageJumpTarget | null => {
|
||||
const wantedId = normalizeJumpMessageId(jumpToMessageId)
|
||||
if (wantedId) {
|
||||
for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
|
||||
const hit = groups[groupIndex].messages.find(
|
||||
(message) => normalizeJumpMessageId(message.id) === wantedId
|
||||
)
|
||||
if (hit) return { groupIndex, messageId: hit.id, exact: true }
|
||||
}
|
||||
}
|
||||
if (jumpToTime === undefined || jumpToTime === null) return null
|
||||
const groupIndex = groups.findIndex((group) =>
|
||||
group.messages.some((message) => (message.createTime || 0) >= jumpToTime)
|
||||
)
|
||||
if (groupIndex < 0) return null
|
||||
const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime)
|
||||
return { groupIndex, messageId: message?.id, exact: false }
|
||||
}
|
||||
@@ -139,13 +139,10 @@ export function AISearchComposer({
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
className="px-3 text-[11px]"
|
||||
disabled={knowledgeSyncing}
|
||||
title={knowledgeSyncing ? '知识库同步完成后才能开始分析' : undefined}
|
||||
>
|
||||
{knowledgeSyncing ? '同步中,暂不可分析' : '开始分析'}
|
||||
// 同步中**不允许**禁用提问。后台同步是可取消 / 可断点续传的;索引没追平时按
|
||||
// partial + freshness warning 如实作答(覆盖范围由主进程的 coverage/freshness 契约给出)。
|
||||
<Button type="submit" className="px-3 text-[11px]">
|
||||
开始分析
|
||||
<span aria-hidden className="text-base leading-3">
|
||||
→
|
||||
</span>
|
||||
@@ -153,7 +150,11 @@ export function AISearchComposer({
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] leading-[15px] text-muted-foreground">
|
||||
<span>Enter 发送 · Shift + Enter 换行</span>
|
||||
<span>
|
||||
{knowledgeSyncing
|
||||
? '知识库后台同步中 · 仍可提问,答案会标注覆盖范围'
|
||||
: 'Enter 发送 · Shift + Enter 换行'}
|
||||
</span>
|
||||
<span>AI 仅使用当前搜索所需的受控证据</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
import { aiSearchIntentLabel, aiSearchRangeStart } from '../../../../shared/ai-search'
|
||||
import type { AiSearchProgressEvent, AiSearchTimeRange } from '../../../../shared/ai-search'
|
||||
import type {
|
||||
AiSearchPipelineResult,
|
||||
AiSearchProgressEvent,
|
||||
AiSearchTimeRange
|
||||
} from '../../../../shared/ai-search'
|
||||
|
||||
import type {
|
||||
AISearchWorkspaceProps,
|
||||
@@ -15,14 +19,23 @@ import {
|
||||
contactLabel,
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
formatIndexDate,
|
||||
formatKnowledgeProcessed,
|
||||
formatMeasuredDuration,
|
||||
formatSearchTraceOverview,
|
||||
knowledgeIsStale,
|
||||
knowledgeStateLabel
|
||||
} from './searchFormatters'
|
||||
import { mapPipelineResultToRendererResult } from './searchMappers'
|
||||
import { createSearchResultResetState, resolveSearchResultViewTransition } from './searchState'
|
||||
import { useSearchHistory } from './hooks/useSearchHistory'
|
||||
import { useKnowledgeStatus } from './hooks/useKnowledgeStatus'
|
||||
import {
|
||||
QUERY_AGENT_PROGRESS_STEPS,
|
||||
queryAgentProgressLabel,
|
||||
queryAgentProgressStepIndex,
|
||||
useQueryAgentProgress
|
||||
} from './hooks/useQueryAgentProgress'
|
||||
import { useExternalProviderConsent } from './hooks/useExternalProviderConsent'
|
||||
import { EVIDENCE_PAGE_SIZE, useEvidenceCollection } from './hooks/useEvidenceCollection'
|
||||
import { useAiSearchRun } from './hooks/useAiSearchRun'
|
||||
@@ -30,7 +43,18 @@ import { ensureAiSearchDataConsent } from './services/aiSearchProviderConsent'
|
||||
import { ExternalProviderConsentDialog } from './ExternalProviderConsentDialog'
|
||||
import { AISearchComposer } from './AISearchComposer'
|
||||
import { AISearchEvidencePanel } from './AISearchEvidencePanel'
|
||||
import { Button } from '../ui'
|
||||
import {
|
||||
forgetAskWechatConversation,
|
||||
requestAskWechatQuery,
|
||||
resolveQueryAgentEnabled
|
||||
} from './queryAgentBridge'
|
||||
import {
|
||||
askWechatToolLabels,
|
||||
formatAskWechatStats,
|
||||
mapAskWechatEvidence
|
||||
} from './askWechatPresentation'
|
||||
import type { AskWechatScope, AskWechatStats } from '../../../../shared/query-agent'
|
||||
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui'
|
||||
|
||||
export function AISearchWorkspace({
|
||||
contacts,
|
||||
@@ -63,6 +87,12 @@ export function AISearchWorkspace({
|
||||
const [debugEntries, setDebugEntries] = useState<string[]>([])
|
||||
const [appLogPath, setAppLogPath] = useState('')
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null)
|
||||
/** Query Agent 主路径的当前请求 id(它不走 useAiSearchRun,需要自己的失效判断)。 */
|
||||
const askRequestRef = useRef('')
|
||||
/** Query Agent 是否为当前主路径:控制"搜索范围 / 时间范围"的显隐(主路径隐藏时间范围)。 */
|
||||
const [queryAgentEnabled, setQueryAgentEnabled] = useState(false)
|
||||
/** Query Agent 本次回答的真实统计(读取条数 / 证据条数 / 模型调用 / 耗时)。 */
|
||||
const [askStats, setAskStats] = useState<AskWechatStats | null>(null)
|
||||
const {
|
||||
evidence,
|
||||
setEvidence,
|
||||
@@ -128,8 +158,15 @@ export function AISearchWorkspace({
|
||||
syncStarting,
|
||||
knowledgeSyncing,
|
||||
knowledgeSyncingRef,
|
||||
startKnowledgeSync
|
||||
cancelRequested,
|
||||
startKnowledgeSync,
|
||||
cancelKnowledgeSync
|
||||
} = useKnowledgeStatus({ dbReady, onNotice })
|
||||
const {
|
||||
progress: qaProgress,
|
||||
begin: beginQueryAgentProgress,
|
||||
end: endQueryAgentProgress
|
||||
} = useQueryAgentProgress()
|
||||
const {
|
||||
externalProviderConsent,
|
||||
requestExternalProviderConsent,
|
||||
@@ -155,6 +192,7 @@ export function AISearchWorkspace({
|
||||
clearEvidenceCollection()
|
||||
setCachedAt(reset.cachedAt)
|
||||
setSearchTrace(reset.searchTrace)
|
||||
setAskStats(null)
|
||||
resetSearchRun()
|
||||
setSearchDetailsOpen(reset.searchDetailsOpen)
|
||||
}
|
||||
@@ -168,6 +206,17 @@ export function AISearchWorkspace({
|
||||
)
|
||||
}, [])
|
||||
|
||||
// 这个开关只影响"界面暴露哪些边界控件":Query Agent 主路径保留搜索范围、隐藏时间范围。
|
||||
React.useEffect(() => {
|
||||
let active = true
|
||||
void resolveQueryAgentEnabled().then((enabled) => {
|
||||
if (active) setQueryAgentEnabled(enabled)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const addDebugEntry = (message: string, details: Record<string, unknown> = {}): void => {
|
||||
const entry = `${new Date().toLocaleTimeString('zh-CN')} ${message} ${JSON.stringify(details)}`
|
||||
setDebugEntries((current) => [entry, ...current].slice(0, 80))
|
||||
@@ -184,21 +233,58 @@ export function AISearchWorkspace({
|
||||
const sourceLabel = {
|
||||
global: '所有聊天记录',
|
||||
groups: '群聊专属',
|
||||
contacts: '联系人专属',
|
||||
contacts: '单聊专属',
|
||||
conversation: contactLabel(activeContact)
|
||||
}[scope]
|
||||
|
||||
/**
|
||||
* UI 搜索范围 → Query Agent 的 conversationScope(WHERE TO SEARCH)。
|
||||
*
|
||||
* 这是确定性数据边界,与时间(WHEN,由问题的 temporalBasis 理解)完全正交;
|
||||
* 结果会由 Engine 结构性强制:越界 target 会被拒绝,模型无法自行扩大范围。
|
||||
*/
|
||||
const currentAskWechatScope = (): AskWechatScope | undefined => {
|
||||
if (scope === 'global') return { scope: { kind: 'all' }, label: '所有聊天记录' }
|
||||
if (scope === 'groups') return { scope: { kind: 'groups' }, label: '群聊专属' }
|
||||
if (scope === 'contacts') {
|
||||
const contact = allContacts.find(
|
||||
(item) => item.md5 === (scopeContactMd5 || selectedContact?.md5)
|
||||
)
|
||||
if (!contact) return undefined
|
||||
return {
|
||||
scope: { kind: 'contact', conversationId: contact.md5 },
|
||||
label: `单聊专属:${contactLabel(contact)}`
|
||||
}
|
||||
}
|
||||
if (!activeContact) return undefined
|
||||
return {
|
||||
scope: { kind: 'current', conversationId: activeContact.md5 },
|
||||
label: `当前会话:${contactLabel(activeContact)}`
|
||||
}
|
||||
}
|
||||
const currentSyncConversation = knowledgeStatus?.currentConversationId
|
||||
? contactLabel(
|
||||
allContacts.find((contact) => contact.md5 === knowledgeStatus.currentConversationId)
|
||||
)
|
||||
: ''
|
||||
/**
|
||||
* Knowledge 卡片的分区可见性。取值口径与改动前**完全一致**(只认 building / syncing),
|
||||
* 只是抽成具名变量,避免在 JSX 里重复三元又把 TypeScript 的收窄打断。
|
||||
*/
|
||||
const knowledgeIsRunning =
|
||||
knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing'
|
||||
const knowledgeMainLoopLagMs = knowledgeStatus?.pass?.mainLoopLagMs ?? 0
|
||||
const knowledgeCurrentConversationName =
|
||||
currentSyncConversation === '未选择会话' ? '正在切换会话' : currentSyncConversation
|
||||
const modelLabel = aiModelConfig.configured
|
||||
? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}`
|
||||
: '尚未配置 AI 模型'
|
||||
const cancelAnalysis = async (): Promise<void> => {
|
||||
clearExternalProviderConsent()
|
||||
const requestId = searchRunRequestId
|
||||
if (!requestId) return
|
||||
const askRequestId = askRequestRef.current
|
||||
if (!requestId && !askRequestId) return
|
||||
askRequestRef.current = ''
|
||||
setStage('idle')
|
||||
setAnalysisError('')
|
||||
setSearchDetailsOpen(false)
|
||||
@@ -220,10 +306,9 @@ export function AISearchWorkspace({
|
||||
): Promise<void> => {
|
||||
event?.preventDefault()
|
||||
if (stage === 'loading') return
|
||||
if (knowledgeSyncingRef.current) {
|
||||
onNotice('知识库正在同步,请等待同步完成后再开始分析')
|
||||
return
|
||||
}
|
||||
// 索引同步中**不允许**禁止查询:同步是后台的、可取消的、可断点续传的;
|
||||
// 索引没追平时按 partial + freshness warning 如实作答(覆盖范围由主进程的
|
||||
// coverage/freshness 契约给出),绝不把用户挡在门外。
|
||||
const {
|
||||
normalizedQuery,
|
||||
effectiveRange,
|
||||
@@ -251,6 +336,43 @@ export function AISearchWorkspace({
|
||||
setStage('insufficient')
|
||||
return
|
||||
}
|
||||
// Legacy AI Search 结果的统一落地(Query Agent 的 Legacy fallback 也走这里,保证展示路径只有一条)。
|
||||
const applyPipelineResult = (
|
||||
searchResult: AiSearchPipelineResult,
|
||||
appliedRange: SearchRange
|
||||
): void => {
|
||||
addDebugEntry('主进程搜索任务完成', {
|
||||
status: searchResult.status,
|
||||
candidateEvidenceCount: searchResult.candidateEvidenceCount,
|
||||
finalEvidenceCount: searchResult.evidence.length,
|
||||
elapsedMs: searchResult.elapsedMs,
|
||||
errorStage: searchResult.errorStage
|
||||
})
|
||||
const mappedResult = mapPipelineResultToRendererResult(searchResult, allContacts)
|
||||
setSearchTrace(mappedResult.searchTrace)
|
||||
setEvidenceResult(mappedResult.evidence, mappedResult.evidenceCollection)
|
||||
setSenderNames(mappedResult.senderNames)
|
||||
setMessageCount(mappedResult.messageCount)
|
||||
const viewTransition = resolveSearchResultViewTransition(searchResult, appliedRange)
|
||||
if (viewTransition.stage !== 'result') {
|
||||
setAnalysisError(viewTransition.analysisError)
|
||||
setStage(viewTransition.stage)
|
||||
return
|
||||
}
|
||||
if (!viewTransition.answer) throw new Error('搜索任务未返回回答')
|
||||
setResultQuery(normalizedQuery)
|
||||
setAnswer(viewTransition.answer)
|
||||
rememberQuery(normalizedQuery)
|
||||
persistSearchResult({
|
||||
key: cacheKey,
|
||||
answer: viewTransition.answer,
|
||||
evidence: mappedResult.evidence,
|
||||
evidenceCollection: mappedResult.evidenceCollection,
|
||||
senderNames: mappedResult.senderNames,
|
||||
messageCount: mappedResult.messageCount
|
||||
})
|
||||
setStage('result')
|
||||
}
|
||||
try {
|
||||
const cached = consumeCacheBypass() ? null : readCachedResult(cacheKey)
|
||||
if (cached) {
|
||||
@@ -280,12 +402,77 @@ export function AISearchWorkspace({
|
||||
onNotice('无法确认 AI 服务的数据发送授权,本次检索未执行')
|
||||
return
|
||||
}
|
||||
// 索引正在追新:降级成提示而不是阻断。用户问的是"最近谁聊过 X",
|
||||
// 拿一份明确标注覆盖范围的 partial 结果,永远好过一句"请等同步完成"。
|
||||
if (knowledgeSyncingRef.current) {
|
||||
onNotice('知识库正在同步,请等待同步完成后再开始分析')
|
||||
return
|
||||
onNotice('知识库正在后台同步,本次结果可能未覆盖最新消息')
|
||||
}
|
||||
setStage('loading')
|
||||
resetSearchResult()
|
||||
if (await resolveQueryAgentEnabled()) {
|
||||
// Query Agent 主路径:查询大脑换成 QueryAgentService(与微信 Agent Hub 同一实现)。
|
||||
// Legacy 只在 Runtime 不可恢复错误时由主进程回退,并以 engine='legacy' 返回 legacy 结果。
|
||||
setQueryAgentEnabled(true)
|
||||
askRequestRef.current = requestId
|
||||
// 订阅**本次** requestId 的真实进度;`finally` 里一定退订,
|
||||
// 否则下一次查询会被上一次的残留阶段污染成"假进度"。
|
||||
beginQueryAgentProgress(requestId)
|
||||
const askResult = await requestAskWechatQuery({
|
||||
requestId,
|
||||
text: normalizedQuery,
|
||||
// 搜索范围是 UI 决定的数据边界;时间不在 UI 上(写进问题,由 temporalBasis 理解)。
|
||||
scope: currentAskWechatScope(),
|
||||
legacy: {
|
||||
scope,
|
||||
range: effectiveRange,
|
||||
conversationId,
|
||||
timeRangeOverride: effectiveTimeRangeOverride
|
||||
}
|
||||
}).finally(() => endQueryAgentProgress())
|
||||
// 桥缺失(旧 preload / 测试环境)时不算 Query Agent 失败,直接走下面的 Legacy 路径。
|
||||
if (!askResult) {
|
||||
askRequestRef.current = ''
|
||||
} else if (askRequestRef.current !== requestId) {
|
||||
return
|
||||
} else {
|
||||
askRequestRef.current = ''
|
||||
if (askResult.engine === 'legacy') {
|
||||
applyPipelineResult(askResult.result, effectiveRange)
|
||||
return
|
||||
}
|
||||
if (askResult.status === 'answered') {
|
||||
const mappedEvidence = mapAskWechatEvidence(askResult.evidence)
|
||||
addDebugEntry('查询 Agent 完成', { ...askResult.diagnostics })
|
||||
setResultQuery(normalizedQuery)
|
||||
setAnswer(askResult.answer)
|
||||
// 真实证据直接来自 Runtime 收集的 Tool 结果,不从回答文本反解析。
|
||||
setEvidenceResult(mappedEvidence, mappedEvidence)
|
||||
setAskStats(askResult.stats)
|
||||
setMessageCount(0)
|
||||
rememberQuery(normalizedQuery)
|
||||
persistSearchResult({
|
||||
key: cacheKey,
|
||||
answer: askResult.answer,
|
||||
evidence: mappedEvidence,
|
||||
evidenceCollection: mappedEvidence,
|
||||
senderNames: {},
|
||||
messageCount: 0
|
||||
})
|
||||
setStage('result')
|
||||
return
|
||||
}
|
||||
// Provider 不可用 / Runtime 失败且无法回退:给出明确文案,不静默回退成另一次搜索。
|
||||
const failureMessage =
|
||||
askResult.status === 'provider_unavailable' || askResult.status === 'error'
|
||||
? askResult.message
|
||||
: '本次查询没有完成,请稍后再试。'
|
||||
setAskStats(null)
|
||||
addDebugEntry('查询失败', { ...askResult.diagnostics })
|
||||
setAnalysisError(failureMessage)
|
||||
setStage('insufficient')
|
||||
return
|
||||
}
|
||||
}
|
||||
const outcome = await startSearch({
|
||||
requestId,
|
||||
text: normalizedQuery,
|
||||
@@ -306,38 +493,7 @@ export function AISearchWorkspace({
|
||||
setStage('insufficient')
|
||||
return
|
||||
}
|
||||
const searchResult = outcome.result
|
||||
addDebugEntry('主进程搜索任务完成', {
|
||||
status: searchResult.status,
|
||||
candidateEvidenceCount: searchResult.candidateEvidenceCount,
|
||||
finalEvidenceCount: searchResult.evidence.length,
|
||||
elapsedMs: searchResult.elapsedMs,
|
||||
errorStage: searchResult.errorStage
|
||||
})
|
||||
const mappedResult = mapPipelineResultToRendererResult(searchResult, allContacts)
|
||||
setSearchTrace(mappedResult.searchTrace)
|
||||
setEvidenceResult(mappedResult.evidence, mappedResult.evidenceCollection)
|
||||
setSenderNames(mappedResult.senderNames)
|
||||
setMessageCount(mappedResult.messageCount)
|
||||
const viewTransition = resolveSearchResultViewTransition(searchResult, effectiveRange)
|
||||
if (viewTransition.stage !== 'result') {
|
||||
setAnalysisError(viewTransition.analysisError)
|
||||
setStage(viewTransition.stage)
|
||||
return
|
||||
}
|
||||
if (!viewTransition.answer) throw new Error('搜索任务未返回回答')
|
||||
setResultQuery(normalizedQuery)
|
||||
setAnswer(viewTransition.answer)
|
||||
rememberQuery(normalizedQuery)
|
||||
persistSearchResult({
|
||||
key: cacheKey,
|
||||
answer: viewTransition.answer,
|
||||
evidence: mappedResult.evidence,
|
||||
evidenceCollection: mappedResult.evidenceCollection,
|
||||
senderNames: mappedResult.senderNames,
|
||||
messageCount: mappedResult.messageCount
|
||||
})
|
||||
setStage('result')
|
||||
applyPipelineResult(outcome.result, effectiveRange)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '读取聊天记录失败'
|
||||
addDebugEntry('检索失败', { error: errorMessage })
|
||||
@@ -354,6 +510,9 @@ export function AISearchWorkspace({
|
||||
|
||||
const startNewQuestion = (): void => {
|
||||
clearCacheBypass()
|
||||
askRequestRef.current = ''
|
||||
// 新问题 = 新的对话上下文:清掉 Query Agent 的澄清记忆,避免和上一次追问串味。
|
||||
forgetAskWechatConversation()
|
||||
setQuery('')
|
||||
setResultQuery('')
|
||||
setStage('idle')
|
||||
@@ -413,6 +572,45 @@ export function AISearchWorkspace({
|
||||
: progress
|
||||
? '◉'
|
||||
: '○'
|
||||
// Query Agent 主路径:不产生 Legacy 的进度事件,也不固定说"从知识库检索"
|
||||
// (query_messages 直读微信数据库)。
|
||||
// 阶段与文案由**真实 Runtime 生命周期**驱动,不是静态 4 步:
|
||||
// 用户能看到"现在卡在哪一步、已经过去多久"。
|
||||
if (queryAgentEnabled) {
|
||||
const currentStep = queryAgentProgressStepIndex(qaProgress)
|
||||
const wideScope = scope === 'global' || scope === 'groups'
|
||||
return (
|
||||
<div className="ai-search-loading">
|
||||
<span className="ai-search-kicker">正在查询本地聊天记录</span>
|
||||
<h2>{queryAgentProgressLabel(qaProgress, wideScope)}</h2>
|
||||
<p>
|
||||
搜索范围:{sourceLabel}
|
||||
{qaProgress ? ` · 已用时 ${formatDuration(qaProgress.elapsedMs)}` : ''}
|
||||
</p>
|
||||
<div className="ai-search-pipeline" aria-label="本次查询过程">
|
||||
{QUERY_AGENT_PROGRESS_STEPS.map((step, index) => {
|
||||
const state = index < currentStep ? 'done' : index === currentStep ? 'active' : ''
|
||||
return (
|
||||
<section key={step.stage} className={`ai-search-pipeline-step ${state}`}>
|
||||
<span className="ai-search-pipeline-mark">
|
||||
{index < currentStep ? '✓' : index === currentStep ? '◉' : '○'}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{step.label}</strong>
|
||||
{/* 当前步的副提示:Tool 阶段说明"在搜什么范围",模型阶段说明在做什么。 */}
|
||||
{index === currentStep && (
|
||||
<span className="block text-[10px] text-muted-foreground">
|
||||
{queryAgentProgressLabel(qaProgress, wideScope)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="ai-search-loading">
|
||||
<span className="ai-search-kicker">本地检索进行中</span>
|
||||
@@ -674,27 +872,69 @@ export function AISearchWorkspace({
|
||||
<div>
|
||||
<span className="ai-search-kicker">✓ 已完成</span>
|
||||
<h2>{resultQuery || query}</h2>
|
||||
<p>
|
||||
知识库已收录 {messageCount.toLocaleString()} 条消息 →{' '}
|
||||
{cachedAt
|
||||
? `缓存中保留 ${evidenceCollection.length} 条 Evidence`
|
||||
: searchTrace?.retrievedEvidence !== undefined
|
||||
? `读取 ${searchTrace.retrievedEvidence} 条范围消息`
|
||||
: `读取 ${evidence.length} 条消息`}{' '}→ {evidence.length} 条 Evidence →
|
||||
已生成回答{cachedAt ? ' · 已使用缓存' : ''}
|
||||
</p>
|
||||
{searchTrace &&
|
||||
(() => {
|
||||
const overview = formatSearchTraceOverview(searchTrace)
|
||||
return (
|
||||
<div className="ai-search-trace" aria-label="本次检索追踪">
|
||||
<span>总耗时 {overview.totalDuration}</span>
|
||||
<span>本地检索 {overview.knowledgeDuration}</span>
|
||||
<span>AI {overview.aiDuration}</span>
|
||||
<span>上下文 {overview.contextEvidence}</span>
|
||||
{askStats ? (
|
||||
<>
|
||||
<p>已生成回答</p>
|
||||
<div className="ai-search-trace" aria-label="本次查询真实统计">
|
||||
{formatAskWechatStats(askStats).map((chip) => (
|
||||
<span key={chip}>{chip}</span>
|
||||
))}
|
||||
{askWechatToolLabels(askStats.tools).map((label) => (
|
||||
<span key={label}>能力:{label}</span>
|
||||
))}
|
||||
</div>
|
||||
{/* 耗时拆解:把总耗时还原成"AI 花了多少 / 本地查询花了多少"。
|
||||
普通 UI 只出现这三个用户能理解的名字,不出现 firstModelMs / toolTotalMs
|
||||
这类工程字段;逐次调用的细节只在 dev 模式展开,供排障用。 */}
|
||||
{askStats.timings && (
|
||||
<div className="ai-search-trace" aria-label="本次查询耗时拆解">
|
||||
<span data-testid="query-timing-total">
|
||||
总耗时 {formatDuration(askStats.timings.totalMs)}
|
||||
</span>
|
||||
<span data-testid="query-timing-model">
|
||||
AI {formatDuration(askStats.timings.modelMs)}
|
||||
</span>
|
||||
<span data-testid="query-timing-local">
|
||||
本地查询 {formatDuration(askStats.timings.localQueryMs)}
|
||||
</span>
|
||||
{import.meta.env.DEV && (
|
||||
<span data-testid="query-timing-dev-detail">
|
||||
dev 逐次调用:AI [
|
||||
{askStats.timings.modelDurationsMs?.map((v) => Math.round(v)).join(', ') ||
|
||||
'-'}
|
||||
] ms · 本地 [
|
||||
{askStats.timings.toolDurationsMs?.map((v) => Math.round(v)).join(', ') || '-'}
|
||||
] ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
知识库已收录 {messageCount.toLocaleString()} 条消息 →{' '}
|
||||
{cachedAt
|
||||
? `缓存中保留 ${evidenceCollection.length} 条 Evidence`
|
||||
: searchTrace?.retrievedEvidence !== undefined
|
||||
? `读取 ${searchTrace.retrievedEvidence} 条范围消息`
|
||||
: `读取 ${evidence.length} 条消息`}{' '}
|
||||
→ {evidence.length} 条 Evidence → 已生成回答{cachedAt ? ' · 已使用缓存' : ''}
|
||||
</p>
|
||||
{searchTrace &&
|
||||
(() => {
|
||||
const overview = formatSearchTraceOverview(searchTrace)
|
||||
return (
|
||||
<div className="ai-search-trace" aria-label="本次检索追踪">
|
||||
<span>总耗时 {overview.totalDuration}</span>
|
||||
<span>本地检索 {overview.knowledgeDuration}</span>
|
||||
<span>AI {overview.aiDuration}</span>
|
||||
<span>上下文 {overview.contextEvidence}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
{renderSearchDetails()}
|
||||
</div>
|
||||
<div className="ai-search-result-actions">
|
||||
@@ -902,89 +1142,169 @@ export function AISearchWorkspace({
|
||||
当前会话{activeContact ? ` · ${contactLabel(activeContact)}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
{/* 单聊专属需要一个明确的联系人(Legacy 下同样由档案选择决定,这里在 Query Agent 主路径提供显式选择) */}
|
||||
{queryAgentEnabled && scope === 'contacts' && (
|
||||
<Select
|
||||
value={scopeContactMd5 || selectedContact?.md5 || ''}
|
||||
onValueChange={(value) => setScopeContactMd5(value)}
|
||||
>
|
||||
<SelectTrigger aria-label="选择单聊联系人" className="mt-2 w-full">
|
||||
<SelectValue placeholder="选择联系人" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allContacts
|
||||
.filter((contact) => contact.type !== 'group')
|
||||
.map((contact) => (
|
||||
<SelectItem key={contact.md5} value={contact.md5}>
|
||||
{contactLabel(contact)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ai-search-filter-section ai-search-time-section">
|
||||
<span className="ai-search-field-label">时间范围</span>
|
||||
<div className="ai-search-time-menu">
|
||||
{(Object.keys(RANGE_LABELS) as SearchRange[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={range === item ? 'active' : ''}
|
||||
aria-pressed={range === item}
|
||||
onClick={() => {
|
||||
setRange(item)
|
||||
setTimeRangeOverride({
|
||||
startTime: aiSearchRangeStart(item),
|
||||
endTime: undefined,
|
||||
label: RANGE_LABELS[item],
|
||||
reason: '用户在界面选择的时间范围',
|
||||
source: 'user_selected'
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>{item === 'all' ? '▣' : item === 'today' ? '▤' : '◷'}</span>
|
||||
{item === 'all' ? '不限时间' : RANGE_LABELS[item]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{queryAgentEnabled ? (
|
||||
<section className="ai-search-filter-section">
|
||||
<span className="ai-search-field-label">时间</span>
|
||||
<p className="mt-1 text-[11px] leading-[17px] text-muted-foreground">
|
||||
时间直接写在问题里,例如「上个月 BOBO 发过什么文件?」「最近 7 天群里聊了什么?」
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="ai-search-filter-section ai-search-time-section">
|
||||
<span className="ai-search-field-label">时间范围</span>
|
||||
<div className="ai-search-time-menu">
|
||||
{(Object.keys(RANGE_LABELS) as SearchRange[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={range === item ? 'active' : ''}
|
||||
aria-pressed={range === item}
|
||||
onClick={() => {
|
||||
setRange(item)
|
||||
setTimeRangeOverride({
|
||||
startTime: aiSearchRangeStart(item),
|
||||
endTime: undefined,
|
||||
label: RANGE_LABELS[item],
|
||||
reason: '用户在界面选择的时间范围',
|
||||
source: 'user_selected'
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>{item === 'all' ? '▣' : item === 'today' ? '▤' : '◷'}</span>
|
||||
{item === 'all' ? '不限时间' : RANGE_LABELS[item]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section
|
||||
className={`ai-search-knowledge-card ${knowledgeStatus?.state || 'unavailable'}`}
|
||||
aria-label="知识库同步状态"
|
||||
>
|
||||
<div className="ai-search-knowledge-card-heading">
|
||||
<div>
|
||||
<span>KNOWLEDGE BASE</span>
|
||||
<strong>{knowledgeStateLabel(knowledgeStatus)}</strong>
|
||||
{/* 卡片自上而下固定五段:HEADER → CURRENT PASS → DATABASE STATUS → CURRENT → ACTION。
|
||||
每段的「标签 / 数值」行都用同一套栅格(label 可收缩、value 取自然宽且不折断),
|
||||
侧栏只有 ~145px 可用宽度,靠栅格而不是靠缩字号来避免挤成一团。 */}
|
||||
<div className="ai-search-knowledge-heading">
|
||||
<div className="ai-search-knowledge-heading-text">
|
||||
<span className="ai-search-knowledge-kicker">KNOWLEDGE BASE</span>
|
||||
{/* 折行规则在 CSS 里(word-break: keep-all):中文不在字与字之间断开,
|
||||
「 · 」两侧的空格仍是断点,所以折成两行时只会断在「可用 · 」之后。 */}
|
||||
<strong className="ai-search-knowledge-state" data-testid="knowledge-state-label">
|
||||
{knowledgeStateLabel(knowledgeStatus)}
|
||||
</strong>
|
||||
</div>
|
||||
<span className="ai-search-knowledge-dot" aria-hidden />
|
||||
</div>
|
||||
<p className="ai-search-knowledge-description">
|
||||
{knowledgeStatus?.state === 'unavailable'
|
||||
? '知识库不会自动建立,只有点击下方按钮后才会在后台同步。'
|
||||
: '后台增量同步不会影响原始微信聊天记录。'}
|
||||
: knowledgeStatus?.state === 'cancelled'
|
||||
? '同步已取消。已经建立的索引仍然可用,下次同步会从中断处继续,不会从头重扫。'
|
||||
: knowledgeIsStale(knowledgeStatus) && knowledgeStatus?.indexLatestAt
|
||||
? `索引还没追上最新聊天:跨会话搜索目前只覆盖到 ${formatIndexDate(knowledgeStatus.indexLatestAt)},之后的记录需要同步后才可检索。不影响你现在提问,但答案会标注覆盖范围。`
|
||||
: '后台增量同步不会影响原始微信聊天记录,也不会阻塞提问。'}
|
||||
</p>
|
||||
{(knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing') && (
|
||||
<div className="ai-search-sync-progress">
|
||||
<div className="ai-search-sync-progress-top">
|
||||
<span>
|
||||
已处理 {knowledgeStatus.processedMessages.toLocaleString()} 条
|
||||
{knowledgeStatus.totalMessages
|
||||
? ` / ${knowledgeStatus.totalMessages.toLocaleString()}`
|
||||
: ''}
|
||||
</span>
|
||||
<span>
|
||||
{knowledgeStatus.totalMessages
|
||||
? `${Math.min(100, Math.round((knowledgeStatus.processedMessages / knowledgeStatus.totalMessages) * 100))}%`
|
||||
: '统计中'}
|
||||
</span>
|
||||
{/* CURRENT PASS:只有这一遍真的在跑时才出现。 */}
|
||||
{knowledgeIsRunning && (
|
||||
<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">
|
||||
{knowledgeStatus.pass
|
||||
? `${knowledgeStatus.pass.processedConversations.toLocaleString()} / ${knowledgeStatus.pass.totalConversations.toLocaleString()}`
|
||||
: '准备中'}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-search-sync-progress-track">
|
||||
<span
|
||||
style={{
|
||||
width: knowledgeStatus.totalMessages
|
||||
? `${Math.min(100, (knowledgeStatus.processedMessages / knowledgeStatus.totalMessages) * 100)}%`
|
||||
: '35%'
|
||||
// 有真实分母时用真实比例;没有分母时不再假装 35% 的"假进度条",
|
||||
// 改成一条不确定态(UI 上用动画表示"在跑")。
|
||||
width: knowledgeStatus.pass?.totalConversations
|
||||
? `${Math.min(
|
||||
100,
|
||||
(knowledgeStatus.pass.processedConversations /
|
||||
knowledgeStatus.pass.totalConversations) *
|
||||
100
|
||||
)}%`
|
||||
: '100%',
|
||||
...(knowledgeStatus.pass?.totalConversations
|
||||
? {}
|
||||
: { animation: 'ai-search-indeterminate 1.4s ease-in-out infinite' })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 「已处理 X 条 / 统计中」是**无分母**的伪进度。这里改成两个真实数字:
|
||||
新增索引(真的写进去的)与已扫描(读了多少),口径写清楚。
|
||||
这一句太长,允许在「 · 」处折成两行(同上,交给 CSS 处理)。 */}
|
||||
<p className="ai-search-knowledge-pass-line" data-testid="knowledge-pass-progress">
|
||||
{formatKnowledgeProcessed(knowledgeStatus)}
|
||||
</p>
|
||||
{knowledgeStatus.pass && (
|
||||
<p className="ai-search-knowledge-pass-line" data-testid="knowledge-pass-scope">
|
||||
{knowledgeStatus.pass.phase === 'backfill'
|
||||
? `后台补齐历史:${(knowledgeStatus.pass.backfillCompletedConversations ?? 0).toLocaleString()} / ${(knowledgeStatus.pass.backfillConversations ?? 0).toLocaleString()} 个久未更新的会话`
|
||||
: knowledgeStatus.pass.phase === 'catchup'
|
||||
? `正在追最新消息:${(knowledgeStatus.pass.catchupConversations ?? 0).toLocaleString()} 个会话有新内容`
|
||||
: `正在建立索引:${(knowledgeStatus.pass.totalConversations ?? 0).toLocaleString()} 个会话`}
|
||||
</p>
|
||||
)}
|
||||
{knowledgeStatus.pass && knowledgeStatus.pass.skippedConversations > 0 && (
|
||||
<p className="ai-search-knowledge-pass-line">
|
||||
已跳过 {knowledgeStatus.pass.skippedConversations.toLocaleString()} 个没有新消息的会话
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="ai-search-knowledge-details">
|
||||
<div>
|
||||
<span>已索引消息</span>
|
||||
<strong>{(knowledgeStatus?.indexedMessageCount || 0).toLocaleString()}</strong>
|
||||
<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">
|
||||
{(knowledgeStatus?.indexedMessageCount || 0).toLocaleString()}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>知识片段</span>
|
||||
<strong>{(knowledgeStatus?.indexedChunkCount || 0).toLocaleString()}</strong>
|
||||
<div className="ai-search-knowledge-row">
|
||||
<span className="ai-search-knowledge-label">知识片段</span>
|
||||
<strong className="ai-search-knowledge-value">
|
||||
{(knowledgeStatus?.indexedChunkCount || 0).toLocaleString()}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>磁盘占用</span>
|
||||
<strong>
|
||||
{knowledgeStatus?.indexLatestAt ? (
|
||||
<div className="ai-search-knowledge-row">
|
||||
<span className="ai-search-knowledge-label">最新索引</span>
|
||||
<strong className="ai-search-knowledge-value">
|
||||
{formatIndexDate(knowledgeStatus.indexLatestAt)}
|
||||
</strong>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ai-search-knowledge-row">
|
||||
<span className="ai-search-knowledge-label">磁盘占用</span>
|
||||
<strong className="ai-search-knowledge-value">
|
||||
{formatBytes(
|
||||
(knowledgeStatus?.databaseBytes || 0) +
|
||||
(knowledgeStatus?.walBytes || 0) +
|
||||
@@ -992,41 +1312,70 @@ export function AISearchWorkspace({
|
||||
)}
|
||||
</strong>
|
||||
</div>
|
||||
{knowledgeStatus?.currentConversationId &&
|
||||
(knowledgeStatus.state === 'building' || knowledgeStatus.state === 'syncing') && (
|
||||
<div>
|
||||
<span>当前会话</span>
|
||||
<strong>
|
||||
{currentSyncConversation === '未选择会话'
|
||||
? '正在切换会话'
|
||||
: currentSyncConversation}
|
||||
</div>
|
||||
{(knowledgeStatus?.currentConversationId && knowledgeIsRunning) ||
|
||||
(knowledgeStatus?.pass && knowledgeStatus.pass.mainLoopLagMs > 0) ? (
|
||||
<div className="ai-search-knowledge-rows">
|
||||
{knowledgeStatus?.currentConversationId && knowledgeIsRunning && (
|
||||
<div className="ai-search-knowledge-row">
|
||||
<span className="ai-search-knowledge-label">当前会话</span>
|
||||
{/* 会话名可以很长(群名 / 备注),这里必须省略而不是撑破侧栏。 */}
|
||||
<strong
|
||||
className="ai-search-knowledge-value ai-search-knowledge-value--truncate"
|
||||
title={knowledgeCurrentConversationName}
|
||||
>
|
||||
{knowledgeCurrentConversationName}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{knowledgeMainLoopLagMs > 0 && (
|
||||
<div className="ai-search-knowledge-row">
|
||||
<span className="ai-search-knowledge-label">界面卡顿峰值</span>
|
||||
<strong className="ai-search-knowledge-value">
|
||||
{formatDuration(knowledgeMainLoopLagMs)}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{knowledgeStatus?.state === 'error' && (
|
||||
<p className="ai-search-knowledge-error">
|
||||
{knowledgeStatus.lastError || '同步异常,旧搜索仍可使用。'}
|
||||
{knowledgeStatus.lastError || '同步异常,已建立的索引仍可使用。'}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={
|
||||
syncStarting ||
|
||||
knowledgeStatus?.state === 'building' ||
|
||||
knowledgeStatus?.state === 'syncing'
|
||||
}
|
||||
onClick={() => void startKnowledgeSync()}
|
||||
>
|
||||
{syncStarting ||
|
||||
knowledgeStatus?.state === 'building' ||
|
||||
knowledgeStatus?.state === 'syncing'
|
||||
? '同步中…'
|
||||
: knowledgeStatus?.state === 'ready'
|
||||
? '同步最新记录'
|
||||
: '建立本地知识库'}
|
||||
</Button>
|
||||
{knowledgeStatus?.state === 'cancelled' && (
|
||||
<p className="ai-search-knowledge-error">
|
||||
上一遍同步被取消,已建立的索引仍然可用;下次同步会从断点继续。
|
||||
</p>
|
||||
)}
|
||||
<div className="ai-search-knowledge-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
className="ai-search-knowledge-primary"
|
||||
disabled={syncStarting || cancelRequested || knowledgeIsRunning}
|
||||
onClick={() => void startKnowledgeSync()}
|
||||
>
|
||||
{syncStarting
|
||||
? '启动中…'
|
||||
: knowledgeIsRunning
|
||||
? '同步中…'
|
||||
: knowledgeStatus?.indexedMessageCount
|
||||
? '同步最新记录'
|
||||
: '建立本地知识库'}
|
||||
</Button>
|
||||
{knowledgeIsRunning && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="ai-search-knowledge-cancel"
|
||||
data-testid="knowledge-cancel-sync"
|
||||
disabled={cancelRequested || knowledgeStatus?.pass?.cancellable === false}
|
||||
onClick={() => void cancelKnowledgeSync()}
|
||||
>
|
||||
{cancelRequested ? '正在取消…' : '取消同步'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<details className="ai-search-knowledge-more">
|
||||
<summary>同步详情</summary>
|
||||
<p>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { AskWechatEvidenceItem, AskWechatStats } from '../../../../shared/query-agent'
|
||||
import {
|
||||
decodeMessageRef,
|
||||
type CanonicalMessageIdentity
|
||||
} from '../../../../shared/local-query-api'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import { formatEvidenceTimestamp } from './searchFormatters'
|
||||
import type { EvidenceItem } from './searchTypes'
|
||||
|
||||
/**
|
||||
* Query Agent 的展示映射。
|
||||
*
|
||||
* - 证据来自 Runtime 收集的**真实** Tool 结果,不从 answer 文本反解析;
|
||||
* - 顶部统计使用真实执行数字(读取条数 / 证据条数 / 模型调用 / 耗时),
|
||||
* 不使用"知识库已收录"这类只有部分 Tool 才成立的文案。
|
||||
*/
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
query_messages: '精确读取',
|
||||
search_messages: '关键词检索',
|
||||
conversation_overview: '会话概览',
|
||||
message_context: '上下文'
|
||||
}
|
||||
|
||||
const SCOPE_LABELS: Record<string, string> = {
|
||||
all: '所有聊天记录',
|
||||
groups: '群聊专属',
|
||||
contact: '单聊专属',
|
||||
current: '当前会话'
|
||||
}
|
||||
|
||||
/**
|
||||
* 证据卡片的会话归属:群消息必须显示**群名**,而不是把所有群消息都归成"群聊"。
|
||||
*
|
||||
* `contact.md5` 必须是**真实会话 id**:合成 key(如 `query-agent:<群名>`)选不中任何会话,
|
||||
* 「跳转到原聊天」只能停在档案首页。真实 id 只能从 `messageRef` 里还原
|
||||
* (展示契约里刻意不含 md5 字段);`messageRef` 缺失或解析失败时退化成合成 key,
|
||||
* 并且调用方必须按"无法定位"处理。
|
||||
*/
|
||||
function evidenceContact(item: AskWechatEvidenceItem, anchor: CanonicalMessageIdentity | null): Contact {
|
||||
const name = item.conversationName?.trim() || '未命名会话'
|
||||
const type = item.conversationType === 'group' ? 'group' : 'user'
|
||||
if (anchor) {
|
||||
return { md5: anchor.conversationId, m_nsUsrName: anchor.conversationId, m_nsNickName: name, type }
|
||||
}
|
||||
return { md5: `query-agent:${item.conversationName || 'unknown'}`, m_nsUsrName: '', m_nsNickName: name, type }
|
||||
}
|
||||
|
||||
export function mapAskWechatEvidence(items: AskWechatEvidenceItem[]): EvidenceItem[] {
|
||||
return items.map((item, index) => {
|
||||
const anchor = decodeMessageRef(item.messageRef)
|
||||
return {
|
||||
evidenceId: `E${index + 1}`,
|
||||
sourceKind: item.messageType as EvidenceItem['sourceKind'],
|
||||
contact: evidenceContact(item, anchor),
|
||||
messageRef: item.messageRef,
|
||||
message: {
|
||||
// message 的 id 用**可读的消息 id**(不是 opaque ref):Archive 的高亮
|
||||
// 是按 `message.id === jumpTargetMessageId` 匹配的,两边必须是同一个值。
|
||||
// 解析不出身份时退化成 messageRef —— 它至少保证列表 key 唯一。
|
||||
id: anchor?.messageId || item.messageRef,
|
||||
from: 'user',
|
||||
type: '检索消息',
|
||||
datetime: formatEvidenceTimestamp(item.timestamp || 0),
|
||||
content: item.text || '',
|
||||
isSender: item.sender === '我',
|
||||
name: item.sender,
|
||||
createTime: Math.floor((item.timestamp || 0) / 1000)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部统计文案。
|
||||
* 按实际用到的 Tool 组合描述,例如:
|
||||
* - query_messages:`读取 20 条消息 · 使用 20 条证据`
|
||||
* - search/overview:`命中 12 条相关消息` / `覆盖 1200 条消息 · 使用 60 条证据`
|
||||
*/
|
||||
export function formatAskWechatStats(stats: AskWechatStats): string[] {
|
||||
const chips: string[] = []
|
||||
const scopeLabel = stats.scope
|
||||
? stats.scope.label || SCOPE_LABELS[stats.scope.kind] || '当前范围'
|
||||
: '当前范围'
|
||||
chips.push(`${scopeLabel}内查询`)
|
||||
const { reads, tools } = stats
|
||||
if (tools.includes('query_messages') && reads.messageCount > 0) {
|
||||
chips.push(`读取 ${reads.messageCount} 条消息`)
|
||||
}
|
||||
if (tools.includes('conversation_overview') && reads.overviewSourceCount > 0) {
|
||||
chips.push(`覆盖 ${reads.overviewSourceCount} 条消息`)
|
||||
}
|
||||
if (reads.evidenceCount > 0) {
|
||||
chips.push(`使用 ${reads.evidenceCount} 条证据`)
|
||||
} else if (reads.matchedCount > 0) {
|
||||
chips.push(`命中 ${reads.matchedCount} 条相关消息`)
|
||||
}
|
||||
chips.push(`${stats.modelCallCount} 次模型调用`)
|
||||
chips.push(formatMs(stats.totalMs))
|
||||
return chips
|
||||
}
|
||||
|
||||
export function askWechatToolLabels(tools: string[]): string[] {
|
||||
return Array.from(new Set(tools)).map((tool) => SOURCE_LABELS[tool] || tool)
|
||||
}
|
||||
|
||||
function formatMs(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 ms'
|
||||
return value < 1000 ? `${Math.round(value)} ms` : `${(value / 1000).toFixed(1)} s`
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { Contact } from '../../../../../shared/types'
|
||||
import type { EvidenceItem } from '../searchTypes'
|
||||
|
||||
export const EVIDENCE_PAGE_SIZE = 8
|
||||
|
||||
type UseEvidenceCollectionOptions = {
|
||||
onOpenEvidence: (contact: Contact, createTime?: number) => void
|
||||
/** 传整条证据:跳转需要它的稳定引用(messageRef),不只是会话与时间。 */
|
||||
onOpenEvidence: (evidence: EvidenceItem) => void
|
||||
}
|
||||
|
||||
export function useEvidenceCollection({ onOpenEvidence }: UseEvidenceCollectionOptions): {
|
||||
@@ -70,7 +70,7 @@ export function useEvidenceCollection({ onOpenEvidence }: UseEvidenceCollectionO
|
||||
const jumpToEvidence = (index: number): void => {
|
||||
const item = evidenceCollection[index]
|
||||
if (!item) return
|
||||
onOpenEvidence(item.contact, item.message.createTime)
|
||||
onOpenEvidence(item)
|
||||
}
|
||||
|
||||
const setEvidenceCardRef = (index: number, node: HTMLElement | null): void => {
|
||||
|
||||
@@ -11,10 +11,20 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
|
||||
syncStarting: boolean
|
||||
knowledgeSyncing: boolean
|
||||
knowledgeSyncingRef: React.MutableRefObject<boolean>
|
||||
cancelRequested: boolean
|
||||
startKnowledgeSync: () => Promise<void>
|
||||
cancelKnowledgeSync: () => Promise<void>
|
||||
} {
|
||||
const [knowledgeStatus, setKnowledgeStatus] = useState<KnowledgeRuntimeStatus | null>(null)
|
||||
const [syncStarting, setSyncStarting] = useState(false)
|
||||
/**
|
||||
* 本地「取消已发出、但还没落地」的状态。
|
||||
*
|
||||
* Worker 侧 abort 之后,当前会话还要安全收尾(事务提交 / 不残留 indexing),
|
||||
* 主进程的 `pass.cancellable=false` 会先到,`phase='cancelled'` 后到。
|
||||
* 这中间的窗口如果只靠 status 渲染,按钮会闪回"取消同步"。
|
||||
*/
|
||||
const [cancelRequested, setCancelRequested] = useState(false)
|
||||
const knowledgeSyncingRef = useRef(false)
|
||||
const knowledgeSyncing =
|
||||
syncStarting || knowledgeStatus?.state === 'building' || knowledgeStatus?.state === 'syncing'
|
||||
@@ -29,7 +39,11 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
|
||||
})
|
||||
.catch(() => undefined)
|
||||
const unsubscribe = window.api.onKnowledgeStatus((status) => {
|
||||
if (active) setKnowledgeStatus(status)
|
||||
if (!active) return
|
||||
setKnowledgeStatus(status)
|
||||
// 一遍 pass 真的结束了(不再可取消 / 已经有终态)→ 清掉本地的"正在取消"。
|
||||
const phase = status.pass?.phase
|
||||
if (phase === 'idle' || phase === 'cancelled' || phase === 'error') setCancelRequested(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
@@ -43,6 +57,7 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
|
||||
return
|
||||
}
|
||||
setSyncStarting(true)
|
||||
setCancelRequested(false)
|
||||
try {
|
||||
const status = await window.api.startKnowledgeIndex()
|
||||
setKnowledgeStatus(status)
|
||||
@@ -58,11 +73,44 @@ export function useKnowledgeStatus({ dbReady, onNotice }: UseKnowledgeStatusOpti
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消同步。
|
||||
*
|
||||
* 关键语义(不能简化成"点一下就当取消成功"):
|
||||
* - 只有真的中止到了任务,才提示"已取消";
|
||||
* - `cancelled: false` 表示请求时已经没有可取消的任务(例如刚好自己跑完了),
|
||||
* 这时候说"已取消"是假话;
|
||||
* - 已索引数据不会被清空,下次同步会从断点继续。
|
||||
*/
|
||||
const cancelKnowledgeSync = async (): Promise<void> => {
|
||||
if (cancelRequested) return
|
||||
setCancelRequested(true)
|
||||
try {
|
||||
const result = await window.api.cancelKnowledgeIndex()
|
||||
if (!result.cancellable) {
|
||||
setCancelRequested(false)
|
||||
onNotice('当前没有正在进行的同步')
|
||||
return
|
||||
}
|
||||
if (!result.cancelled) {
|
||||
setCancelRequested(false)
|
||||
onNotice('同步刚刚已经结束,无需取消')
|
||||
return
|
||||
}
|
||||
onNotice('已取消同步,已建立的部分会保留,下次可继续')
|
||||
} catch (error) {
|
||||
setCancelRequested(false)
|
||||
onNotice(error instanceof Error ? error.message : '取消同步失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
knowledgeStatus,
|
||||
syncStarting,
|
||||
knowledgeSyncing,
|
||||
knowledgeSyncingRef,
|
||||
startKnowledgeSync
|
||||
cancelRequested,
|
||||
startKnowledgeSync,
|
||||
cancelKnowledgeSync
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
QueryAgentProgressEvent,
|
||||
QueryAgentProgressStage
|
||||
} from '../../../../../shared/query-agent'
|
||||
|
||||
export interface QueryAgentProgressState {
|
||||
stage: QueryAgentProgressStage
|
||||
elapsedMs: number
|
||||
toolName?: string
|
||||
modelCallCount: number
|
||||
toolCallCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 「问问微信」的真实进度订阅。
|
||||
*
|
||||
* 设计约束:
|
||||
* - 只认自己那一次 requestId:用户连问两次时旧请求的进度不能串台;
|
||||
* - 事件是**真实生命周期边界**(understanding / searching / organizing_evidence /
|
||||
* generating_answer / completed),没有百分比、没有定时器伪进度;
|
||||
* - 不缓存历史阶段:每次 `begin(requestId)` 重置,避免上一次的阶段残留成"假进度"。
|
||||
*/
|
||||
export function useQueryAgentProgress(): {
|
||||
progress: QueryAgentProgressState | null
|
||||
begin: (requestId: string) => void
|
||||
end: () => void
|
||||
} {
|
||||
const [progress, setProgress] = useState<QueryAgentProgressState | null>(null)
|
||||
const activeRequestRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 桥缺失(旧 preload / 测试替身 / 尚未升级的宿主)时必须**静默降级**:
|
||||
// 进度只是增强信息,缺了它查询本身完全不受影响 —— 但抛异常会把整个工作区打挂。
|
||||
const subscribe = window.api?.onAskWechatProgress
|
||||
if (typeof subscribe !== 'function') return
|
||||
const unsubscribe = subscribe(
|
||||
(requestId: string, event: QueryAgentProgressEvent) => {
|
||||
if (activeRequestRef.current !== requestId) return
|
||||
setProgress({
|
||||
stage: event.stage,
|
||||
elapsedMs: event.elapsedMs,
|
||||
...(event.toolName ? { toolName: event.toolName } : {}),
|
||||
modelCallCount: event.modelCallCount,
|
||||
toolCallCount: event.toolCallCount
|
||||
})
|
||||
}
|
||||
)
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
return {
|
||||
progress,
|
||||
begin: (requestId: string) => {
|
||||
activeRequestRef.current = requestId
|
||||
setProgress(null)
|
||||
},
|
||||
end: () => {
|
||||
activeRequestRef.current = null
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 进度阶段 → 用户可读文案。内部工具名绝不外泄(不出现 toolName / SQL / FTS / 内部 id)。 */
|
||||
export function queryAgentProgressLabel(
|
||||
progress: QueryAgentProgressState | null,
|
||||
/**
|
||||
* 本次语料范围是不是"跨会话的大范围"(所有聊天记录 / 全部群聊)。
|
||||
*
|
||||
* 用它决定 Tool 阶段的副提示,而不是用工具调用次数猜:范围是 UI 自己定的边界,
|
||||
* 是**事实**;调用次数只是间接信号,容易把"重试一次"误报成"范围很大"。
|
||||
*/
|
||||
wideScope: boolean
|
||||
): string {
|
||||
if (!progress) return '正在准备查询'
|
||||
switch (progress.stage) {
|
||||
case 'understanding':
|
||||
return progress.modelCallCount <= 1 ? '正在理解你的问题' : '正在重新理解你的问题'
|
||||
case 'searching':
|
||||
// 跨会话检索明显比单会话慢一个量级,提前把预期说清楚,避免用户以为卡住了。
|
||||
return wideScope ? '正在搜索较大范围的聊天记录…' : '正在搜索聊天记录…'
|
||||
case 'organizing_evidence':
|
||||
return '正在整理找到的聊天记录'
|
||||
case 'generating_answer':
|
||||
return '正在生成回答'
|
||||
case 'completed':
|
||||
return '已完成'
|
||||
}
|
||||
}
|
||||
|
||||
/** 真实阶段顺序(与 Runtime 的生命周期一一对应),用于渲染进度列表。 */
|
||||
export const QUERY_AGENT_PROGRESS_STEPS: ReadonlyArray<{
|
||||
stage: QueryAgentProgressStage
|
||||
label: string
|
||||
}> = [
|
||||
{ stage: 'understanding', label: '理解问题' },
|
||||
{ stage: 'searching', label: '查找相关聊天' },
|
||||
{ stage: 'organizing_evidence', label: '整理证据' },
|
||||
{ stage: 'generating_answer', label: '生成回答' }
|
||||
]
|
||||
|
||||
/** 当前处于第几步(0-based);`completed` 视为全部完成。 */
|
||||
export function queryAgentProgressStepIndex(
|
||||
progress: QueryAgentProgressState | null
|
||||
): number {
|
||||
if (!progress || progress.stage === 'completed') return progress ? QUERY_AGENT_PROGRESS_STEPS.length : 0
|
||||
const index = QUERY_AGENT_PROGRESS_STEPS.findIndex((step) => step.stage === progress.stage)
|
||||
return index < 0 ? 0 : index
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
AskWechatConfig,
|
||||
AskWechatQueryRequest,
|
||||
AskWechatQueryResult
|
||||
} from '../../../../shared/query-agent'
|
||||
|
||||
/**
|
||||
* Renderer → Query Agent 的最小桥。
|
||||
*
|
||||
* Renderer 不直接碰 Query Agent 语义,只做「开关 + 转发 + 展示」;
|
||||
* preload 契约缺失时(旧 preload / 测试环境)安全回退到 Legacy ——
|
||||
* 这属于**能力缺失**的兜底,不是「查 0 条就回退」那种语义回退。
|
||||
*/
|
||||
interface AskWechatBridge {
|
||||
getAskWechatConfig?: () => Promise<AskWechatConfig>
|
||||
runAskWechatQuery?: (request: AskWechatQueryRequest) => Promise<AskWechatQueryResult>
|
||||
forgetAskWechatConversation?: () => Promise<void>
|
||||
}
|
||||
|
||||
const bridge = (): AskWechatBridge => window.api as unknown as AskWechatBridge
|
||||
|
||||
export async function resolveQueryAgentEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const config = await bridge().getAskWechatConfig?.()
|
||||
return config?.queryAgentEnabled === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 桥不可用时返回 null,调用方据此走 Legacy。 */
|
||||
export async function requestAskWechatQuery(
|
||||
request: AskWechatQueryRequest
|
||||
): Promise<AskWechatQueryResult | null> {
|
||||
const run = bridge().runAskWechatQuery
|
||||
if (!run) return null
|
||||
return run(request)
|
||||
}
|
||||
|
||||
/** 用户开始新问题时清掉澄清上下文(有界内存,不涉及持久化)。 */
|
||||
export function forgetAskWechatConversation(): void {
|
||||
try {
|
||||
void bridge().forgetAskWechatConversation?.()
|
||||
} catch {
|
||||
// 清理失败不影响主流程
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge'
|
||||
import { isKnowledgeFresh } from '../../../../shared/knowledge'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import type { SearchTrace } from './searchTypes'
|
||||
|
||||
@@ -18,15 +19,67 @@ export const formatMeasuredDuration = (milliseconds: number | undefined): string
|
||||
export const formatEvidenceTimestamp = (timestamp: number): string =>
|
||||
new Date(timestamp).toLocaleString('zh-CN', { hour12: false })
|
||||
|
||||
/** 只显示到日期,用于「索引更新至 8/26」这类如实口径。 */
|
||||
export const formatIndexDate = (timestamp: number): string =>
|
||||
new Date(timestamp).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
||||
|
||||
/**
|
||||
* 知识库状态文案。
|
||||
*
|
||||
* 两个**互相独立**的维度:
|
||||
* 1. 新鲜度:索引覆盖到源数据的哪个时刻 → `indexLatestAt` / `sourceLatestAt`
|
||||
* 2. 这一遍 pass 的进度 → `pass.phase` / `pass.cancellable`
|
||||
*
|
||||
* 约束:
|
||||
* - 只要索引还能查,文案必须是「可用 · …」;
|
||||
* - **绝不**在没有 pending gap 之前说「已同步」;
|
||||
* - 「正在追新」与「正在补齐历史」必须分开(前者只补新消息,后者在建库)。
|
||||
*/
|
||||
export const knowledgeStateLabel = (status: KnowledgeRuntimeStatus | null): string => {
|
||||
if (!status) return '读取中'
|
||||
return {
|
||||
unavailable: '未建立',
|
||||
building: '建立中',
|
||||
syncing: '增量同步',
|
||||
ready: '已同步',
|
||||
error: '异常'
|
||||
}[status.state]
|
||||
const phase = status.pass?.phase
|
||||
const usable = status.indexedMessageCount > 0 || status.indexedChunkCount > 0
|
||||
if (status.state === 'error' || phase === 'error') return usable ? '可用 · 更新失败' : '更新失败'
|
||||
if (status.state === 'cancelled' || phase === 'cancelled')
|
||||
return usable ? '可用 · 同步已取消' : '同步已取消'
|
||||
// 一个分片都没有:这不是"落后",是"还没建立",不该带"可用"前缀。
|
||||
// `unavailable` 必须在这里被吃掉:派生库不可查询时,残留的历史计数不能让它
|
||||
// 冒充「可用 · 已追至最新」(那是两句真话拼成的假话)。
|
||||
if (status.state === 'unavailable' || !usable) {
|
||||
return status.state === 'building' ? '正在建立' : '未建立'
|
||||
}
|
||||
if (status.state === 'building') return '可用 · 正在补齐历史'
|
||||
if (status.state === 'syncing') {
|
||||
// 已经有分片还在跑:`full`(首次建库)与 `backfill`(补历史缺口)都不算"追新",
|
||||
// 只有 `catchup`(读 delta 追最新)才是用户最关心、也最快的那个阶段。
|
||||
return phase === 'full' || phase === 'backfill' ? '可用 · 正在补齐历史' : '可用 · 正在追新'
|
||||
}
|
||||
if (isKnowledgeFresh(status) === true) return '可用 · 已追至最新'
|
||||
// ready 但落后、且当前没有 pass 在跑 = 有内容还没追到,但需要用户触发同步。
|
||||
return '可用 · 待追新'
|
||||
}
|
||||
|
||||
/** 索引落后于源数据时为 true,用于卡片描述与详情行。 */
|
||||
export const knowledgeIsStale = (status: KnowledgeRuntimeStatus | null): boolean =>
|
||||
Boolean(status) && status!.state === 'ready' && isKnowledgeFresh(status!) === false
|
||||
|
||||
/**
|
||||
* 「已处理」这类计数必须带分母或者换成诚实语义:只有分母未知的绝对值会把"这一遍扫描"
|
||||
* 说成"总量"。这里优先给分母;没有分母时退回**明确标注为单轮**的语义。
|
||||
*/
|
||||
export const formatKnowledgeProcessed = (status: KnowledgeRuntimeStatus): string => {
|
||||
if (status.pass) {
|
||||
const { indexedMessages, scannedMessages } = status.pass
|
||||
return `本轮新增索引 ${indexedMessages.toLocaleString()} 条 · 本轮已扫描 ${scannedMessages.toLocaleString()} 条`
|
||||
}
|
||||
if (status.totalMessages) {
|
||||
const percent = Math.min(
|
||||
100,
|
||||
Math.round((status.processedMessages / status.totalMessages) * 100)
|
||||
)
|
||||
return `${percent}%(${status.processedMessages.toLocaleString()} / ${status.totalMessages.toLocaleString()})`
|
||||
}
|
||||
return `本轮已处理 ${status.processedMessages.toLocaleString()} 条`
|
||||
}
|
||||
|
||||
export const contactLabel = (contact: Contact | null | undefined): string =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AiSearchFinalEvidence, AiSearchPipelineResult } from '../../../../shared/ai-search'
|
||||
import { encodeMessageRef } from '../../../../shared/local-query-api'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import { compactCacheItem } from './searchUtils'
|
||||
import type { AISearchCacheRecord, EvidenceItem, SearchTrace } from './searchTypes'
|
||||
@@ -24,6 +25,9 @@ export const mapPipelineEvidenceItem = (
|
||||
evidenceId: item.id,
|
||||
sourceKind: item.sourceKind,
|
||||
contact,
|
||||
// 这条路径本来就同时知道真实会话 id 与消息 id,顺手补上稳定引用,
|
||||
// 让 Legacy / ai-search 证据也能被精确定位(而不是只有 Query Agent 路径能跳准)。
|
||||
...(safeRef(item.conversationId, item.messageId) || {}),
|
||||
message: {
|
||||
id: item.messageId,
|
||||
from: item.senderId || 'user',
|
||||
@@ -38,6 +42,15 @@ export const mapPipelineEvidenceItem = (
|
||||
}
|
||||
}
|
||||
|
||||
/** 引用构造失败(缺 id)时返回 null,调用方退化到按时间定位 —— 不允许抛异常打断结果渲染。 */
|
||||
function safeRef(conversationId: string, messageId: string): { messageRef: string } | null {
|
||||
try {
|
||||
return { messageRef: encodeMessageRef(conversationId, messageId) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const mapPipelineEvidence = (
|
||||
items: AiSearchFinalEvidence[],
|
||||
contacts: Contact[]
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface EvidenceItem {
|
||||
sourceKind?: KnowledgeMessageKind
|
||||
contact: Contact
|
||||
message: Message
|
||||
/**
|
||||
* 稳定消息引用(opaque,可还原成 `{conversationId, messageId}`)。
|
||||
*
|
||||
* 只靠「会话 + 秒级时间戳」无法定位到**这一条**消息 —— 同一秒可能有多条,
|
||||
* 而且时间戳只能定位到"附近"。Archive 的跳转优先用它。
|
||||
* 老缓存记录 / Legacy 路径可能没有它,所以必须是可选的。
|
||||
*/
|
||||
messageRef?: string
|
||||
}
|
||||
|
||||
export interface AISearchCacheRecord {
|
||||
@@ -82,7 +90,13 @@ export interface AISearchWorkspaceProps {
|
||||
dbReady: boolean
|
||||
aiModelConfig: AIRuntimeModelConfig
|
||||
onSelectContact: (contact: Contact) => void
|
||||
onOpenEvidence: (contact: Contact, createTime?: number) => void
|
||||
/**
|
||||
* 跳转到证据的原聊天。
|
||||
*
|
||||
* 传整条 EvidenceItem 而不是 `(contact, createTime)`:后者丢掉了稳定身份(messageRef),
|
||||
* 跳转只能靠"会话 + 秒级时间戳"猜,而会话 id 若来自展示层合成的 key 则完全跳不过去。
|
||||
*/
|
||||
onOpenEvidence: (evidence: EvidenceItem) => void
|
||||
onOpenAISettings: () => void
|
||||
onNotice: (message: string) => void
|
||||
}
|
||||
|
||||
@@ -287,8 +287,11 @@ export const senderName = (
|
||||
return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员'
|
||||
}
|
||||
|
||||
export const compactCacheItem = ({ evidenceId, contact, message }: EvidenceItem): EvidenceItem => ({
|
||||
export const compactCacheItem = ({ evidenceId, contact, message, messageRef }: EvidenceItem): EvidenceItem => ({
|
||||
evidenceId,
|
||||
// 稳定引用必须一起进缓存:否则命中缓存后「跳转到原聊天」会退化成按时间戳猜
|
||||
// (缓存写入是最容易漏掉新字段的地方,这里显式列出而不是展开对象)。
|
||||
...(messageRef ? { messageRef } : {}),
|
||||
contact: {
|
||||
md5: contact.md5,
|
||||
m_nsUsrName: contact.m_nsUsrName,
|
||||
|
||||
@@ -211,6 +211,11 @@
|
||||
border: 1px solid hsl(var(--tm-border-subtle));
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-elevated);
|
||||
/* 侧栏最窄时卡片正文只有 ~145px。这里保持默认的 word-break / overflow-wrap:
|
||||
中文长句交给「行栅格 + 显式换行点」处理,不能在词中间乱断,也绝不允许撑破侧栏。 */
|
||||
min-width: 0;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-card.building,
|
||||
@@ -226,31 +231,42 @@
|
||||
border-color: color-mix(in srgb, var(--wxex-warning) 70%, var(--wxex-border));
|
||||
}
|
||||
|
||||
.ai-search-knowledge-card-heading,
|
||||
.ai-search-sync-progress-top,
|
||||
.ai-search-knowledge-details > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
/* HEADER:左列(品牌 + 状态)可收缩,右侧状态点取自然宽。 */
|
||||
.ai-search-knowledge-heading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
column-gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-card-heading > div {
|
||||
.ai-search-knowledge-heading-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-card-heading span:first-child {
|
||||
.ai-search-knowledge-kicker {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-card-heading strong {
|
||||
/* 状态文案允许折成两行,但只断在「 · 」之后。
|
||||
`keep-all` 禁止在中文的字与字之间断行,「 · 」两侧的空格仍然是断点,
|
||||
于是窄侧栏下只会变成「可用 · 」/「待追新」,不会碎成「可用 · 正在」/「补齐历史」。
|
||||
`break-word` 只作兜底:万一某一段本身超过可用宽度,允许溢出时在词内断开。 */
|
||||
.ai-search-knowledge-state {
|
||||
min-width: 0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-description,
|
||||
@@ -266,14 +282,22 @@
|
||||
color: var(--wxex-warning);
|
||||
}
|
||||
|
||||
.ai-search-sync-progress {
|
||||
/* CURRENT PASS */
|
||||
.ai-search-knowledge-pass {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-search-sync-progress-top {
|
||||
.ai-search-knowledge-pass-line {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
/* 与状态标题同一条折行规则:只在「 · 」之后断句。 */
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.ai-search-sync-progress-track {
|
||||
@@ -288,6 +312,7 @@
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--wxex-brand);
|
||||
transform-origin: left center;
|
||||
transition: width 250ms ease;
|
||||
}
|
||||
|
||||
@@ -306,21 +331,75 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-knowledge-details {
|
||||
/* 没有真实分母时进度条改为不确定态(JSX 内联 style 挂着这个动画名)。
|
||||
没有这段 keyframes 时那条 100% 宽的进度条是静止的,看起来像"已经跑完了"。 */
|
||||
@keyframes ai-search-indeterminate {
|
||||
0% {
|
||||
transform: translateX(-100%) scaleX(0.35);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(285%) scaleX(0.35);
|
||||
}
|
||||
}
|
||||
|
||||
/* DATABASE STATUS / CURRENT:一套行栅格。
|
||||
标签列 minmax(0, 1fr) 可收缩;数值列 minmax(0, auto) 取自然宽,
|
||||
空间不足时收缩的是数值列(配合 --truncate 省略),数字永远不被折断。 */
|
||||
.ai-search-knowledge-rows {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding-top: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-details span {
|
||||
.ai-search-knowledge-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, auto);
|
||||
align-items: baseline;
|
||||
column-gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-details strong {
|
||||
.ai-search-knowledge-value {
|
||||
min-width: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
line-height: 15px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-value--truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ACTION:主按钮吃满剩余宽度,取消按钮取自然宽且永不换行。 */
|
||||
.ai-search-knowledge-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-primary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-cancel {
|
||||
min-width: 68px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-more {
|
||||
|
||||
Reference in New Issue
Block a user