feat: 重构问问微信并完善知识库增量检索

统一问问微信与 Agent Hub 的查询链路
完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论
基于会话实现真正的增量追新与历史补齐
支持后台同步、取消恢复、重启续传以及同步期间继续查询
优化知识库跨会话检索、同步状态、进度展示和侧栏布局
This commit is contained in:
Wxw-Gu
2026-09-11 17:44:48 +08:00
parent 0c4932d740
commit ab3b3731b7
69 changed files with 8317 additions and 898 deletions
+46 -11
View File
@@ -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}
/>
+18 -8
View File
@@ -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,
+95 -16
View File
@@ -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 {