From b97e1f4bc661762fadbc03e571439d04c4743fe2 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Thu, 17 Sep 2026 18:56:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=97=AE=E9=97=AE=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=BC=95=E7=94=A8=E7=BC=96=E5=8F=B7=EF=BC=8C?= =?UTF-8?q?=E6=97=A5=E6=8A=A5=E4=BF=AE=E5=A4=8D=E6=88=90=E5=91=98=E5=A4=B4?= =?UTF-8?q?=E5=83=8F=E4=B8=8E=20hero=20=E6=BA=A2=E5=87=BA=20-=20=E9=97=AE?= =?UTF-8?q?=E9=97=AE=E5=BE=AE=E4=BF=A1=EF=BC=9AHost=20=E5=88=86=E9=85=8D?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=20citationId=20=E5=B9=B6=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8A=E4=B8=8B=E6=96=87=EF=BC=8C=E6=AD=A3?= =?UTF-8?q?=E6=96=87=20[E#]=20=E4=B8=8E=E8=AF=81=E6=8D=AE=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E5=90=8C=E5=8F=B7=20-=20=E9=97=AE=E9=97=AE=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=EF=BC=9A=E5=9B=9E=E7=AD=94=E8=BF=94=E5=9B=9E=E5=89=8D?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=BC=95=E7=94=A8=EF=BC=8C=E5=B9=BB=E8=A7=89?= =?UTF-8?q?=E7=BC=96=E5=8F=B7=E7=A7=BB=E9=99=A4=E5=B9=B6=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=87=BA=E7=8E=B0=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E4=BF=A1=E7=9A=84=E5=BC=95=E7=94=A8=E6=8C=89=E9=92=AE=20-=20Ag?= =?UTF-8?q?ent=20Hub=EF=BC=9A=E6=94=B6=E7=B4=A7=E3=80=8C=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E3=80=8D=E5=BF=AB=E6=8D=B7=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=EF=BC=8C=E5=86=85=E5=AE=B9=E6=9F=A5=E8=AF=A2=E4=BA=A4=E5=9B=9E?= =?UTF-8?q?=20Query=20Agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/mobile_daily_report_v1.html | 26 +- resources/mobile_daily_report_v2.html | 26 +- src/main/group-report-service.ts | 82 ++- .../services/agent-group-report-service.ts | 58 +- src/main/services/agent-hub-routing.ts | 37 +- src/main/services/ai-search-evidence.ts | 23 +- src/main/services/ask-wechat-service.ts | 21 +- src/main/services/query-agent-service.ts | 676 +++++++++++++++--- .../components/search/AISearchWorkspace.tsx | 42 +- .../search/askWechatPresentation.ts | 31 +- src/renderer/src/utils/group-report-facts.ts | 10 +- src/shared/group-report.ts | 60 ++ src/shared/query-agent.ts | 16 + .../component/ai-search-query-agent.test.tsx | 80 ++- .../component/evidence-source-labels.test.tsx | 4 + tests/typecheck-baseline.json | 3 +- tests/unit/agent-hub-routing.test.ts | 38 + tests/unit/ask-wechat-presentation.test.ts | 14 +- .../group-report-avatar-enrichment.test.ts | 120 ++++ .../group-report-member-hydration.test.ts | 138 ++++ tests/unit/query-agent-answer-rules.test.ts | 21 +- tests/unit/query-agent-citation.test.ts | 235 ++++++ .../unit/report-hero-avatar-geometry.test.ts | 102 +++ 23 files changed, 1635 insertions(+), 228 deletions(-) create mode 100644 tests/unit/group-report-avatar-enrichment.test.ts create mode 100644 tests/unit/group-report-member-hydration.test.ts create mode 100644 tests/unit/query-agent-citation.test.ts create mode 100644 tests/unit/report-hero-avatar-geometry.test.ts diff --git a/resources/mobile_daily_report_v1.html b/resources/mobile_daily_report_v1.html index aa34b40..71235fc 100644 --- a/resources/mobile_daily_report_v1.html +++ b/resources/mobile_daily_report_v1.html @@ -68,21 +68,29 @@ .overview { margin-top: 2px; } + /* + * hero 头像簇的几何必须由 contract 变量驱动,不能再写死容器尺寸。 + * + * 生产导出会额外注入 `report-template-fragment-contract.ts`,其中 + * `img.tm-avatar.tm-avatar--hero` 用 !important 把头像钉在 + * clamp(28px, var(--tm-avatar-hero-size, 40px), 56px)。 + * 旧版这里写死 58x58(单头像 28x28),两个权威打架:头像实际 40px, + * 2 列 x 40px + 3px gap = 83px 塞不进 58px 的盒子,于是头像向右向下溢出容器, + * 视觉上越过卡片内边距、压到卡片边缘之外。 + * + * 现在容器尺寸由内容决定(列宽/行高都取同一个变量):头像数 1..4 都不会溢出, + * 主题调整 --tm-avatar-hero-size 时容器与头像也不会分叉。 + */ .avatar-grid { - width: 58px; - height: 58px; display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(2, var(--tm-avatar-hero-size, 40px)); + grid-auto-rows: var(--tm-avatar-hero-size, 40px); gap: 3px; flex: 0 0 auto; } + /* 单头像时不保留空列,簇宽恰好等于一个头像。 */ .avatar-grid.avatar-count-1 { - width: 28px; - height: 28px; - grid-template-columns: 1fr; - } - .avatar-grid.avatar-count-2 { - height: 28px; + grid-template-columns: var(--tm-avatar-hero-size, 40px); } .avatar-grid.empty-section { display: none; diff --git a/resources/mobile_daily_report_v2.html b/resources/mobile_daily_report_v2.html index dc85de3..07bd3ae 100644 --- a/resources/mobile_daily_report_v2.html +++ b/resources/mobile_daily_report_v2.html @@ -61,21 +61,29 @@ font-size: 13px; line-height: 1.55; } + /* + * hero 头像簇的几何必须由 contract 变量驱动,不能再写死容器尺寸。 + * + * 生产导出会额外注入 `report-template-fragment-contract.ts`,其中 + * `img.tm-avatar.tm-avatar--hero` 用 !important 把头像钉在 + * clamp(28px, var(--tm-avatar-hero-size, 40px), 56px)。 + * 旧版这里写死 58x58(单头像 28x28),两个权威打架:头像实际 40px, + * 2 列 x 40px + 3px gap = 83px 塞不进 58px 的盒子,于是头像向右向下溢出容器, + * 视觉上越过卡片内边距、压到卡片边缘之外。 + * + * 现在容器尺寸由内容决定(列宽/行高都取同一个变量):头像数 1..4 都不会溢出, + * 主题调整 --tm-avatar-hero-size 时容器与头像也不会分叉。 + */ .avatar-grid { - width: 58px; - height: 58px; display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(2, var(--tm-avatar-hero-size, 40px)); + grid-auto-rows: var(--tm-avatar-hero-size, 40px); gap: 3px; flex: 0 0 auto; } + /* 单头像时不保留空列,簇宽恰好等于一个头像。 */ .avatar-grid.avatar-count-1 { - width: 28px; - height: 28px; - grid-template-columns: 1fr; - } - .avatar-grid.avatar-count-2 { - height: 28px; + grid-template-columns: var(--tm-avatar-hero-size, 40px); } .avatar-grid.empty-section { display: none; diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts index f908b40..9185028 100644 --- a/src/main/group-report-service.ts +++ b/src/main/group-report-service.ts @@ -13,6 +13,8 @@ import { GroupReportRenderSnapshotExportRequest, ReportHeat, ReportSectionMeta, + buildReportAvatarAliasIndex, + mergeReportAvatars, selectHeroParticipantNames } from '../shared/group-report' import { resolveMd5, getGroupSnapshot } from './services/chat-service' @@ -99,7 +101,10 @@ const fallbackAvatar = (name: string): RenderedAvatar => { const hue = hashName(name) % 360 const initial = escapeHtml(Array.from(name.trim())[0] || '?') const svg = `${initial}` - return { source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, fallback: true } + return { + source: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, + fallback: true + } } const imageMimeType = (contentType: string | null, source: string): string => { @@ -113,7 +118,8 @@ const imageMimeType = (contentType: string | null, source: string): string => { const embedAvatar = async (source: string | undefined, name: string): Promise => { if (!source) return fallbackAvatar(name) - if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return { source, fallback: false } + if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) + return { source, fallback: false } try { if (/^https?:\/\//i.test(source)) { @@ -126,12 +132,18 @@ const embedAvatar = async (source: string | undefined, name: string): Promise => { if (!metadata.talker) return @@ -162,22 +180,16 @@ const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise() - for (const member of snapshot.members) { - if (member.nickname && member.avatar && !index.has(member.nickname)) { - index.set(member.nickname, member.avatar) - } - } + // 每个成员的所有可用显示名都指向同一个头像 URL;先到先得,避免同名互相覆盖。 + // 只按 `member.nickname` 建索引会在"微信昵称 ≠ 群昵称"时全部对不上 —— 见 shared 里的注释。 + const index = buildReportAvatarAliasIndex(snapshot.members) metadata.avatars = metadata.avatars ?? {} - for (const [name, url] of index) { - if (metadata.avatars[name]) continue - metadata.avatars[name] = url - } + const filled = mergeReportAvatars(metadata.avatars, index) metadata.warnings = metadata.warnings ?? [] metadata.warnings.push( - `enriched ${index.size} member avatars from snapshot (${snapshot.memberCount} members)` + `enriched ${filled}/${index.size} member avatar aliases from snapshot (${snapshot.memberCount} members)` ) } @@ -295,9 +307,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise renderAvatar(name, 'hero', '', name)) - .join('') + const heroAvatars = heroNames.map((name) => renderAvatar(name, 'hero', '', name)).join('') const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section' const topicCards = report.topics @@ -656,7 +666,10 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise>['members'][number]>, + memberNameMode: ScheduledReportMemberNameMode +): Message[] { + const index = new Map( + members.map((member) => [ + member.wxid, + { name: resolveMemberName(member, memberNameMode), avatar: member.avatar } + ]) + ) + return messages.map((message) => { + const member = index.get(String(message.senderId || message.name || '')) + if (!member) return message + const shouldFillAvatar = !message.img && Boolean(member.avatar) + const resolvedName = member.name && !isInternalName(member.name) ? member.name : message.name + if (!shouldFillAvatar && resolvedName === message.name) return message + return { + ...message, + name: resolvedName, + ...(shouldFillAvatar ? { img: member.avatar } : {}) + } + }) +} + export interface AgentGroupReportRequest { group: string range?: SummaryDateRange | 'recent24h' @@ -101,22 +140,11 @@ export async function generateAgentGroupReport( const snapshot = getGroupSnapshot(contact.md5) if (snapshot) { - const members = new Map( - snapshot.members.map((member) => [ - member.wxid, - { - name: resolveMemberName(member, request.memberNameMode || 'groupNickname'), - avatar: member.avatar - } - ]) + messages = hydrateGroupMemberIdentity( + messages, + snapshot.members, + request.memberNameMode || 'groupNickname' ) - messages = messages.map((message) => { - if (!isInternalName(message.name)) return message - const member = members.get(String(message.senderId || message.name || '')) - return member?.name - ? { ...message, name: member.name, img: message.img || member.avatar } - : message - }) } const input = await buildGroupReportInput(messages, contact as Contact, true, 'full') diff --git a/src/main/services/agent-hub-routing.ts b/src/main/services/agent-hub-routing.ts index de8a544..7334100 100644 --- a/src/main/services/agent-hub-routing.ts +++ b/src/main/services/agent-hub-routing.ts @@ -87,15 +87,50 @@ export function matchGroupMemberChatIntent(text: string): GroupMemberChatIntent return null } +/** + * 「列出会话」的**形态标记**:用户在问"有哪些 / 和谁 / 列表 / 最近 N 条", + * 而不是在问"聊了什么内容"。 + */ +const RECENT_LIST_SHAPE = /(哪些|哪个|都有谁|都跟谁|和谁|跟谁|是谁|列表|名单|\d{1,2}(条|个|位))/ +/** 名单类问题必须落到会话 / 联系人这个对象上。 */ +const RECENT_LIST_TARGET = /(聊天|会话|联系人|好友|人|群|消息|窗口)/ +/** 「和谁 / 跟谁」问法本身就在问会话对象,不要求额外载体词。 */ +const RECENT_PEER_QUESTION = /(和谁|跟谁)/ +/** 内容探针:问的是消息里的内容 / 是否提到某事物 —— 必须交给 Query Agent。 */ +const RECENT_CONTENT_PROBE = + /(提到|提过|说过|说啥|说什么|说了什么|聊了啥|聊了什么|都聊什么|都说什么|什么话题|聊到|讨论|内容|讲了什么|哪条|哪一句|有没有|是否)/ + /** * "最近有哪些会话"类请求。 * * 这是**确定性能力**(列出会话),不是消息内容查询 —— Query Agent 无法表达, * 因此保留为不经过模型的无 LLM 快捷路径。 + * + * 判定必须**正向**:只有用户确实在要一份"会话 / 联系人名单"时才算 recent_list。 + * 早先的实现只要求「最近」+「消息|会话|聊天」同时出现,于是 + * 「最近群里聊的消息里有没有提到报价?」这类**内容查询**会被截走, + * 直接回一串会话名,用户永远得不到答案。 */ export function matchRecentChatIntent(text: string): number | null { const normalized = text.replace(/\s+/g, '') - if (!normalized.includes('最近') || !/(消息|会话|聊天)/.test(normalized)) return null + if (!normalized.includes('最近')) return null + + // 内容探针优先排除:问"有没有提到 X / 谁提过 X / 聊了什么"是在查消息内容,不是要名单。 + if (RECENT_CONTENT_PROBE.test(normalized)) return null + + /** + * 只有两种形态算"要最近会话列表": + * 1) 「和谁 / 跟谁」问法 —— 它本身就在问会话对象,不需要额外的载体词 + * (如「最近和谁聊过」); + * 2) 名单形态 + 会话载体 —— 如「最近有哪些聊天」「最近 5 个会话」「最近3条消息」。 + * + * 两种都不满足时交给 Query Agent:形状不像"要名单"的,就是在问内容。 + */ + const listLike = + RECENT_PEER_QUESTION.test(normalized) || + (RECENT_LIST_SHAPE.test(normalized) && RECENT_LIST_TARGET.test(normalized)) + if (!listLike) return null + const limit = Number(normalized.match(/\d{1,2}/)?.[0] || 5) return Math.max(1, Math.min(20, limit)) } diff --git a/src/main/services/ai-search-evidence.ts b/src/main/services/ai-search-evidence.ts index c1720d5..84494a2 100644 --- a/src/main/services/ai-search-evidence.ts +++ b/src/main/services/ai-search-evidence.ts @@ -249,16 +249,33 @@ function selectCoverage( } } +/** + * Citation sanitize 的允许集合。 + * + * 两个入口都只能引用「Host 侧已分配」的编号,但它们的形状不同: + * - Query Agent:`citationId` 字符串集合(`EvidenceCollector` 分配的 `E1`…`En`); + * - Legacy AI Search:Final Evidence 列表(取其中的 `id`)。 + * + * 故意不接受裸 `string`:那会被当成字符序列迭代,静默退化成单字符白名单。 + */ +export type CitationAllowList = + | ReadonlyArray> + | ReadonlySet + /** Do not expose citations that cannot resolve to program-owned Final Evidence. */ export function sanitizeAnswerCitations( answer: string, - evidence: Array> + allowed: CitationAllowList ): CitationValidationResult { - const allowed = new Set(evidence.map((item) => item.id)) + const allowedIds = new Set() + for (const entry of allowed) { + if (typeof entry === 'string') allowedIds.add(entry) + else if (entry && typeof entry.id === 'string') allowedIds.add(entry.id) + } const invalidCitationIds = new Set() const sanitized = answer.replace(/\[E(\d+)\]/g, (citation, number: string) => { const id = `E${number}` - if (allowed.has(id as AiSearchFinalEvidence['id'])) return citation + if (allowedIds.has(id)) return citation invalidCitationIds.add(id) return '' }) diff --git a/src/main/services/ask-wechat-service.ts b/src/main/services/ask-wechat-service.ts index f7909ce..957c8ba 100644 --- a/src/main/services/ask-wechat-service.ts +++ b/src/main/services/ask-wechat-service.ts @@ -136,7 +136,12 @@ export class AskWechatService { status: 'answered', answer, // 直接透传 Runtime 收集的真实证据:UI 不允许从 answer 文本反解析。 + // citationId 由 Runtime 分配,Adapter 不改写、不重编号。 evidence: (result.evidence || []) as AskWechatEvidenceItem[], + // Host 侧 citation 校验中被移除的非法编号(非空 = 模型引用过不存在的 E#)。 + ...(result.invalidCitationIds?.length + ? { invalidCitationIds: result.invalidCitationIds } + : {}), stats: buildAskWechatStats(result, request.scope, startedAt), diagnostics } @@ -168,7 +173,12 @@ export class AskWechatService { requestId: request.requestId, text: request.text }) - this.writeLog('warn', `Query Agent 失败后回退 Legacy(${this.options.entry})`, diagnostics, reason) + this.writeLog( + 'warn', + `Query Agent 失败后回退 Legacy(${this.options.entry})`, + diagnostics, + reason + ) return { engine: 'legacy', status: 'legacy', reason, result: legacyResult } } catch { this.writeLog('error', `Legacy fallback 也失败(${this.options.entry})`, diagnostics, reason) @@ -199,10 +209,7 @@ export class AskWechatService { ): QueryAgentDiagnostics { const traces = result.traces || [] // 图片 OCR 的两条结构化事实:不回读正文,只统计"取到了几条"与"当时覆盖度是多少"。 - const imageOcrTextCount = traces.reduce( - (sum, trace) => sum + (trace.imageOcrTextCount || 0), - 0 - ) + const imageOcrTextCount = traces.reduce((sum, trace) => sum + (trace.imageOcrTextCount || 0), 0) const coverageState = traces .map((trace) => trace.imageOcrCoverageState) .filter((value): value is string => typeof value === 'string') @@ -267,9 +274,7 @@ export function buildAskWechatStats( const totalMs = result.totalMs || Date.now() - startedAt // 真实拆解:模型总耗时直接来自每次模型调用的测量;本地查询 = 所有 Tool 的 durationMs 之和。 // 两者不互相推算(用 total - model 反推会把"框架开销"混进"本地查询",那是另一种谎)。 - const modelDurationsMs = (result.modelDurationsMs || []).filter((value) => - Number.isFinite(value) - ) + const modelDurationsMs = (result.modelDurationsMs || []).filter((value) => Number.isFinite(value)) const toolDurationsMs = (result.traces || []) .map((trace) => trace.durationMs) .filter((value) => Number.isFinite(value)) diff --git a/src/main/services/query-agent-service.ts b/src/main/services/query-agent-service.ts index ccebcd2..62d46c6 100644 --- a/src/main/services/query-agent-service.ts +++ b/src/main/services/query-agent-service.ts @@ -5,6 +5,9 @@ * entry point behaves consistently; they differ only by injected tool executor and adapter. */ import type { AIChatToolCall, AIChatToolDefinition } from './ai-provider-service' +// 引用清理与 Legacy AI Search 共用同一份实现(不复制第二套逻辑),避免两个引擎的 +// "哪些 [E#] 合法" 判断分叉。 +import { sanitizeAnswerCitations } from './ai-search-evidence' import { LOCAL_QUERY_TOOL_DEFINITIONS, type QueryCorpusScope, @@ -12,21 +15,28 @@ import { type QueryTemporalBasisKind } from '../../shared/local-query-api' // 进度事件定义在 shared(renderer 也要用),这里只是把它带进本文件作用域。 -import type { - QueryAgentProgressEvent, - QueryAgentProgressStage -} from '../../shared/query-agent' +import type { QueryAgentProgressEvent, QueryAgentProgressStage } from '../../shared/query-agent' const MAX_TOOL_CALLS = 5 -const FORBIDDEN_INPUT_KEYS = new Set(['apiKey', 'authorization', 'token', 'databasePath', 'sql', 'wxid', 'md5']) +const FORBIDDEN_INPUT_KEYS = new Set([ + 'apiKey', + 'authorization', + 'token', + 'databasePath', + 'sql', + 'wxid', + 'md5' +]) // 每个工具在“首次执行但结果为 0”之后允许的额外重试次数上限。 const ZERO_RESULT_RETRY_LIMIT = 1 // absolute 时间契约:LLM 只能给带时区的 ISO-8601 字符串,Host 负责换算成 Local Query API 的 epoch seconds。 -const ISO_ABSOLUTE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/ +const ISO_ABSOLUTE_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/ // sanity 窗口:聊天记录不可能早于 2000 年,也不允许查询明显属于未来的区间。 const ABSOLUTE_MIN_MS = Date.UTC(2000, 0, 1) const ABSOLUTE_MAX_FUTURE_MS = 366 * 24 * 60 * 60 * 1000 -const ISO_ABSOLUTE_HINT = '带时区偏移的 ISO-8601,例如 2026-08-01T00:00:00+08:00 或 2026-07-31T16:00:00Z' +const ISO_ABSOLUTE_HINT = + '带时区偏移的 ISO-8601,例如 2026-08-01T00:00:00+08:00 或 2026-07-31T16:00:00Z' interface ToolSchema { type?: string @@ -53,7 +63,12 @@ export interface ToolArgumentValidationError { } export interface QueryAgentProvider { - getRuntimeConfig(): { configured: boolean; providerName: string; model: string; modelName: string } + getRuntimeConfig(): { + configured: boolean + providerName: string + model: string + modelName: string + } chatWithTools( messages: Array>, tools: AIChatToolDefinition[] @@ -135,7 +150,11 @@ export interface QueryAgentModelCallDiagnostic { * 'provider_unavailable' = Provider 未配置;'provider_failure' = 模型请求本身失败 * (网络 / 上游 / 超时)。未分类的异常由 Adapter 归类为 runtime_error。 */ -export type QueryAgentErrorKind = 'invalid_question' | 'provider_unavailable' | 'provider_failure' | 'tool_limit' +export type QueryAgentErrorKind = + | 'invalid_question' + | 'provider_unavailable' + | 'provider_failure' + | 'tool_limit' /** * 多轮澄清所需的最小历史。**由 Adapter 提供**,Runtime 只负责按顺序放进 messages。 @@ -167,6 +186,12 @@ export interface QueryAgentResult { errorKind?: QueryAgentErrorKind /** 本次回答实际依据的证据(additive);按首次命中顺序去重。 */ evidence?: QueryAgentEvidenceItem[] + /** + * Host 侧 citation 校验中被移除的非法 `[E#]`(additive 诊断字段)。 + * + * 非空表示模型引用了未分配过的编号,已从 `answer` 中移除。 + */ + invalidCitationIds?: string[] } /** @@ -194,6 +219,17 @@ export type QueryAgentToolExecutor = ( * 只保留可展示字段;messageRef 仍是 opaque 引用。 */ export interface QueryAgentEvidenceItem { + /** + * Host 分配的稳定引用编号(`E1`、`E2`…)。 + * + * 在证据**首次命中**时分配,之后无论被多少 Tool 重复命中都不变; + * 同一个编号同时用于(a)Tool Result 里的模型可见上下文、(b)最终回答的校验白名单、 + * (c)UI 证据卡与底部引用按钮。三条路径必须同号,否则 inline citation 无意义。 + * + * 只有前 `MAX_EVIDENCE_ITEMS` 条(= 会真正出现在 UI 与模型上下文里的那些)才分配编号, + * 保证"模型可见集合 ⊆ UI 可见集合并同号"这一不变量。 + */ + citationId: string messageRef: string conversationName?: string conversationType?: 'user' | 'group' @@ -210,6 +246,8 @@ export interface QueryAgentEvidenceItem { /** 一次回答最多带出多少条证据(IPC 体积与 UI 噪声控制)。 */ const MAX_EVIDENCE_ITEMS = 40 +/** 尚未分配引用编号的证据条目;编号只在首次收录时由 `EvidenceCollector` 决定。 */ +type PendingEvidenceItem = Omit /** * 回答格式与单轮语义的硬规则。 @@ -222,6 +260,13 @@ export const ANSWER_RULES = ` - **不要用 Markdown 表格**承载多条命中结果 —— 结果栏很窄,表格列宽会错位、长字段换行后难读。改用编号列表:先给一句结论,再逐条列出(发送者 / 时间 / 会话 / 类型 / 内容),最后按需说明与范围。 - 逐条里的内容若来自本地派生(图片 OCR、语音转写),要写明它来自派生内容,不要说成群友发过的一条这样的文字消息。 +引用(citation): +- Tool Result 里每条消息都带 citationId 字段(形如 E1、E2)。**关键事实后面必须标出它来自哪一条**,写法是紧跟该事实加方括号编号,例如:张三提到周五团建[E3]。 +- 只能引用 **Tool Result 里真实出现过的 citationId**。没有 citationId 的消息**不可引用**。 +- **严禁**创建、猜测、改写编号:不要写没出现过的 [E9],不要把 E3 写成 E30,不要自己编号,也不要改用 [1]、(E3)、【E3】 等其他写法。 +- 无法对应到某条具体消息的概述、推断或范围说明**不加**编号;不要为了"看起来有依据"而给每句话都挂编号。 +- 同一事实由多条消息支持时,可以并列写多个编号,例如 [E2][E7]。 + 单轮语义(重要): - 当前是**单次检索回答**:一次提问、一次检索、一次回答。没有自动连续的多轮工具执行。 - **禁止**任何"下一步还能帮你继续"的邀约,包括但不限于:"如果你需要,我可以…""要不要我继续…""我还可以帮你进一步…""需要的话我再查…""我可以再帮你分析…"。除非该动作在**本轮已经真实执行过**。 @@ -300,7 +345,9 @@ function sanitizeInput(input: Record): Record function containsForbiddenKey(value: unknown): boolean { if (Array.isArray(value)) return value.some(containsForbiddenKey) if (!value || typeof value !== 'object') return false - return Object.entries(value as Record).some(([key, child]) => FORBIDDEN_INPUT_KEYS.has(key) || containsForbiddenKey(child)) + return Object.entries(value as Record).some( + ([key, child]) => FORBIDDEN_INPUT_KEYS.has(key) || containsForbiddenKey(child) + ) } function actualType(value: unknown): string { @@ -316,32 +363,78 @@ function schemaTypeMatches(value: unknown, type: string): boolean { return actualType(value) === type } -function validateSchema(value: unknown, schema: ToolSchema, field = '$'): ToolArgumentValidationError | undefined { +function validateSchema( + value: unknown, + schema: ToolSchema, + field = '$' +): ToolArgumentValidationError | undefined { if (schema.type && !schemaTypeMatches(value, schema.type)) { - return { status: 'invalid_tool_arguments', field, constraint: 'type', expected: schema.type, actual: actualType(value) } + return { + status: 'invalid_tool_arguments', + field, + constraint: 'type', + expected: schema.type, + actual: actualType(value) + } } if (schema.enum && !schema.enum.some((allowed) => Object.is(allowed, value))) { - return { status: 'invalid_tool_arguments', field, constraint: 'enum', expected: schema.enum, actual: value } + return { + status: 'invalid_tool_arguments', + field, + constraint: 'enum', + expected: schema.enum, + actual: value + } } if (typeof value === 'string') { if (schema.minLength !== undefined && value.length < schema.minLength) { - return { status: 'invalid_tool_arguments', field, constraint: 'minLength', expected: schema.minLength, actual: value.length } + return { + status: 'invalid_tool_arguments', + field, + constraint: 'minLength', + expected: schema.minLength, + actual: value.length + } } } if (typeof value === 'number') { if (schema.minimum !== undefined && value < schema.minimum) { - return { status: 'invalid_tool_arguments', field, constraint: 'minimum', expected: schema.minimum, actual: value } + return { + status: 'invalid_tool_arguments', + field, + constraint: 'minimum', + expected: schema.minimum, + actual: value + } } if (schema.maximum !== undefined && value > schema.maximum) { - return { status: 'invalid_tool_arguments', field, constraint: 'maximum', expected: schema.maximum, actual: value } + return { + status: 'invalid_tool_arguments', + field, + constraint: 'maximum', + expected: schema.maximum, + actual: value + } } } if (Array.isArray(value)) { if (schema.minItems !== undefined && value.length < schema.minItems) { - return { status: 'invalid_tool_arguments', field, constraint: 'minItems', expected: schema.minItems, actual: value.length } + return { + status: 'invalid_tool_arguments', + field, + constraint: 'minItems', + expected: schema.minItems, + actual: value.length + } } if (schema.maxItems !== undefined && value.length > schema.maxItems) { - return { status: 'invalid_tool_arguments', field, constraint: 'maxItems', expected: schema.maxItems, actual: value.length } + return { + status: 'invalid_tool_arguments', + field, + constraint: 'maxItems', + expected: schema.maxItems, + actual: value.length + } } if (schema.items) { for (let index = 0; index < value.length; index += 1) { @@ -354,20 +447,36 @@ function validateSchema(value: unknown, schema: ToolSchema, field = '$'): ToolAr const objectValue = value as Record for (const required of schema.required || []) { if (!(required in objectValue)) { - return { status: 'invalid_tool_arguments', field: field === '$' ? required : `${field}.${required}`, constraint: 'required', expected: true, actual: false } + return { + status: 'invalid_tool_arguments', + field: field === '$' ? required : `${field}.${required}`, + constraint: 'required', + expected: true, + actual: false + } } } const properties = schema.properties || {} if (schema.additionalProperties === false) { for (const key of Object.keys(objectValue)) { if (!(key in properties)) { - return { status: 'invalid_tool_arguments', field: field === '$' ? key : `${field}.${key}`, constraint: 'additionalProperties', expected: false, actual: true } + return { + status: 'invalid_tool_arguments', + field: field === '$' ? key : `${field}.${key}`, + constraint: 'additionalProperties', + expected: false, + actual: true + } } } } for (const [key, childSchema] of Object.entries(properties)) { if (key in objectValue) { - const error = validateSchema(objectValue[key], childSchema, field === '$' ? key : `${field}.${key}`) + const error = validateSchema( + objectValue[key], + childSchema, + field === '$' ? key : `${field}.${key}` + ) if (error) return error } } @@ -375,8 +484,21 @@ function validateSchema(value: unknown, schema: ToolSchema, field = '$'): ToolAr return undefined } -function argError(field: string, constraint: string, expected?: unknown, actual?: unknown, hint?: string): ToolArgumentValidationError { - return { status: 'invalid_tool_arguments', field, constraint, ...(expected === undefined ? {} : { expected }), ...(actual === undefined ? {} : { actual }), ...(hint ? { hint } : {}) } +function argError( + field: string, + constraint: string, + expected?: unknown, + actual?: unknown, + hint?: string +): ToolArgumentValidationError { + return { + status: 'invalid_tool_arguments', + field, + constraint, + ...(expected === undefined ? {} : { expected }), + ...(actual === undefined ? {} : { actual }), + ...(hint ? { hint } : {}) + } } /** @@ -392,11 +514,17 @@ function parseIsoInstant(value: string): number | undefined { const hour = Number(match[4]) const minute = Number(match[5]) const second = Number(match[6]) - if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return undefined + if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) + return undefined // 先在 UTC 语义下校验字面日期真实存在,再套用时区偏移,避免 2 月 31 日被静默进位。 const naiveMs = Date.UTC(year, month - 1, day, hour, minute, second) const naive = new Date(naiveMs) - if (naive.getUTCFullYear() !== year || naive.getUTCMonth() !== month - 1 || naive.getUTCDate() !== day) return undefined + if ( + naive.getUTCFullYear() !== year || + naive.getUTCMonth() !== month - 1 || + naive.getUTCDate() !== day + ) + return undefined const offset = match[7] if (offset === 'Z') return naiveMs const sign = offset.startsWith('-') ? -1 : 1 @@ -406,7 +534,10 @@ function parseIsoInstant(value: string): number | undefined { return naiveMs - sign * (offsetHour * 60 + offsetMinute) * 60000 } -function canonicalizeTimeRange(timeRange: Record, now: Date): { value?: Record; error?: ToolArgumentValidationError } { +function canonicalizeTimeRange( + timeRange: Record, + now: Date +): { value?: Record; error?: ToolArgumentValidationError } { const startTime = timeRange.startTime const endTime = timeRange.endTime if (timeRange.kind !== 'absolute') { @@ -415,19 +546,50 @@ function canonicalizeTimeRange(timeRange: Record, now: Date): { return { value: rest } } if (typeof startTime !== 'string' || typeof endTime !== 'string') { - return { error: argError('timeRange.startTime', 'required', ISO_ABSOLUTE_HINT, actualType(startTime ?? endTime), ISO_ABSOLUTE_HINT) } + return { + error: argError( + 'timeRange.startTime', + 'required', + ISO_ABSOLUTE_HINT, + actualType(startTime ?? endTime), + ISO_ABSOLUTE_HINT + ) + } } const startMs = parseIsoInstant(startTime) - if (startMs === undefined) return { error: argError('timeRange.startTime', 'format', ISO_ABSOLUTE_HINT, startTime, ISO_ABSOLUTE_HINT) } + if (startMs === undefined) + return { + error: argError( + 'timeRange.startTime', + 'format', + ISO_ABSOLUTE_HINT, + startTime, + ISO_ABSOLUTE_HINT + ) + } const endMs = parseIsoInstant(endTime) - if (endMs === undefined) return { error: argError('timeRange.endTime', 'format', ISO_ABSOLUTE_HINT, endTime, ISO_ABSOLUTE_HINT) } - if (endMs < startMs) return { error: argError('timeRange.endTime', 'range_order', 'endTime 不得早于 startTime', endTime) } + if (endMs === undefined) + return { + error: argError('timeRange.endTime', 'format', ISO_ABSOLUTE_HINT, endTime, ISO_ABSOLUTE_HINT) + } + if (endMs < startMs) + return { + error: argError('timeRange.endTime', 'range_order', 'endTime 不得早于 startTime', endTime) + } const latestMs = now.getTime() + ABSOLUTE_MAX_FUTURE_MS const sanityWindow = `2000-01-01 至 ${new Date(latestMs).toISOString()}` - if (startMs < ABSOLUTE_MIN_MS || startMs > latestMs) return { error: argError('timeRange.startTime', 'range_sanity', sanityWindow, startTime) } - if (endMs < ABSOLUTE_MIN_MS || endMs > latestMs) return { error: argError('timeRange.endTime', 'range_sanity', sanityWindow, endTime) } + if (startMs < ABSOLUTE_MIN_MS || startMs > latestMs) + return { error: argError('timeRange.startTime', 'range_sanity', sanityWindow, startTime) } + if (endMs < ABSOLUTE_MIN_MS || endMs > latestMs) + return { error: argError('timeRange.endTime', 'range_sanity', sanityWindow, endTime) } // 通过全部校验后才换算成 Local Query API 使用的 epoch seconds。 - return { value: { kind: 'absolute', startTime: Math.floor(startMs / 1000), endTime: Math.floor(endMs / 1000) } } + return { + value: { + kind: 'absolute', + startTime: Math.floor(startMs / 1000), + endTime: Math.floor(endMs / 1000) + } + } } /** canonical(已剥离、已校验)的 temporalBasis。 */ @@ -448,7 +610,11 @@ function canonicalizeToolInput( input: Record, now: Date, question: string -): { input?: Record; temporalBasis?: CanonicalTemporalBasis; error?: ToolArgumentValidationError } { +): { + input?: Record + temporalBasis?: CanonicalTemporalBasis + error?: ToolArgumentValidationError +} { const output: Record = { ...input } const rawBasis = output.temporalBasis delete output.temporalBasis @@ -458,21 +624,50 @@ function canonicalizeToolInput( const record = rawBasis as Record const kind = record.kind if (kind === 'constraint' || kind === 'recall_hint' || kind === 'none') { - const sourceText = typeof record.sourceText === 'string' ? record.sourceText.trim() : undefined + const sourceText = + typeof record.sourceText === 'string' ? record.sourceText.trim() : undefined if (kind === 'none') { if (sourceText) { - return { error: argError('temporalBasis.sourceText', 'forbidden_for_none', null, sourceText, 'kind=none 表示问题里没有任何时间表达,此时不要提供 sourceText。') } + return { + error: argError( + 'temporalBasis.sourceText', + 'forbidden_for_none', + null, + sourceText, + 'kind=none 表示问题里没有任何时间表达,此时不要提供 sourceText。' + ) + } } } else if (!sourceText) { - return { error: argError('temporalBasis.sourceText', 'required', '用户原问题中的时间原文片段', undefined, `kind=${kind} 时必须给出用户原问题中实际出现的时间片段。`) } + return { + error: argError( + 'temporalBasis.sourceText', + 'required', + '用户原问题中的时间原文片段', + undefined, + `kind=${kind} 时必须给出用户原问题中实际出现的时间片段。` + ) + } } else if (!question.includes(sourceText)) { - return { error: argError('temporalBasis.sourceText', 'source_not_in_question', question, sourceText, 'sourceText 必须是用户原问题中逐字出现的片段,不要改写、翻译或补全。') } + return { + error: argError( + 'temporalBasis.sourceText', + 'source_not_in_question', + question, + sourceText, + 'sourceText 必须是用户原问题中逐字出现的片段,不要改写、翻译或补全。' + ) + } } temporalBasis = { kind, ...(sourceText ? { sourceText } : {}) } } } - if (output.timeRange && typeof output.timeRange === 'object' && !Array.isArray(output.timeRange)) { + if ( + output.timeRange && + typeof output.timeRange === 'object' && + !Array.isArray(output.timeRange) + ) { const canonical = canonicalizeTimeRange(output.timeRange as Record, now) if (canonical.error) return { error: canonical.error } output.timeRange = canonical.value @@ -482,13 +677,24 @@ function canonicalizeToolInput( if (temporalBasis?.kind === 'none') { const kind = rangeKind(output) if (kind && kind !== 'all') { - return { error: argError('timeRange', 'temporal_basis_mismatch', 'timeRange.kind=all', output.timeRange, '用户没有给出任何时间表达(temporalBasis.kind=none)。请改用 timeRange.kind=all;如果需要 earliest/latest 这类边界,用 order 与 limit 表达。') } + return { + error: argError( + 'timeRange', + 'temporal_basis_mismatch', + 'timeRange.kind=all', + output.timeRange, + '用户没有给出任何时间表达(temporalBasis.kind=none)。请改用 timeRange.kind=all;如果需要 earliest/latest 这类边界,用 order 与 limit 表达。' + ) + } } } if (name === 'search_messages') { const raw = Array.isArray(output.queries) ? (output.queries as unknown[]) : [] - const probes = raw.filter((value): value is string => typeof value === 'string').map((value) => value.trim()).filter(Boolean) + const probes = raw + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter(Boolean) if (!probes.length) return { error: argError('queries', 'required', '至少一个非空检索项') } const [first, ...rest] = probes delete output.queries @@ -537,11 +743,20 @@ function lockConstraintTimeRange( if (name !== 'query_messages' || temporalBasis?.kind !== 'constraint') return undefined const signature = JSON.stringify(input.timeRange ?? null) if (!state.lockedConstraintRange) { - state.lockedConstraintRange = { signature, timeRange: (input.timeRange as Record) ?? {} } + state.lockedConstraintRange = { + signature, + timeRange: (input.timeRange as Record) ?? {} + } return undefined } if (state.lockedConstraintRange.signature === signature) return undefined - return argError('timeRange', 'constraint_time_range_immutable', state.lockedConstraintRange.timeRange, input.timeRange, '用户明确给出的时间范围不得改变;只能放宽 direction / messageTypes 等非时间条件。') + return argError( + 'timeRange', + 'constraint_time_range_immutable', + state.lockedConstraintRange.timeRange, + input.timeRange, + '用户明确给出的时间范围不得改变;只能放宽 direction / messageTypes 等非时间条件。' + ) } /** @@ -562,7 +777,8 @@ function shouldAutoBroaden( function normalizedTarget(input: Record): string { const target = input.target - const query = target && typeof target === 'object' ? (target as Record).query : undefined + const query = + target && typeof target === 'object' ? (target as Record).query : undefined return typeof query === 'string' ? query.trim().toLowerCase() : '' } @@ -572,30 +788,70 @@ function retrySignature(name: string, input: Record): string { const probes = [input.query, ...(Array.isArray(input.variants) ? input.variants : [])] .filter((value): value is string => typeof value === 'string') .map((value) => value.trim().toLowerCase()) - return JSON.stringify({ target: normalizedTarget(input), timeRange: input.timeRange ?? null, probes: Array.from(new Set(probes)).sort() }) + return JSON.stringify({ + target: normalizedTarget(input), + timeRange: input.timeRange ?? null, + probes: Array.from(new Set(probes)).sort() + }) } - const messageTypes = Array.isArray(input.messageTypes) ? [...(input.messageTypes as string[])].sort() : [] - return JSON.stringify({ target: normalizedTarget(input), timeRange: input.timeRange ?? null, direction: input.direction ?? null, messageTypes }) + const messageTypes = Array.isArray(input.messageTypes) + ? [...(input.messageTypes as string[])].sort() + : [] + return JSON.stringify({ + target: normalizedTarget(input), + timeRange: input.timeRange ?? null, + direction: input.direction ?? null, + messageTypes + }) } -function duplicateRetry(name: string, input: Record, state: ZeroResultRetryState): boolean { +function duplicateRetry( + name: string, + input: Record, + state: ZeroResultRetryState +): boolean { const signature = retrySignature(name, input) if (name === 'search_messages') return state.searchSignatures.includes(signature) if (name === 'query_messages') return state.querySignatures.includes(signature) return false } -function recordAttempt(name: string, input: Record, state: ZeroResultRetryState): void { - if (name === 'search_messages') { state.searchAttempts += 1; state.searchSignatures.push(retrySignature(name, input)) } - else if (name === 'query_messages') { state.queryAttempts += 1; state.querySignatures.push(retrySignature(name, input)) } +function recordAttempt( + name: string, + input: Record, + state: ZeroResultRetryState +): void { + if (name === 'search_messages') { + state.searchAttempts += 1 + state.searchSignatures.push(retrySignature(name, input)) + } else if (name === 'query_messages') { + state.queryAttempts += 1 + state.querySignatures.push(retrySignature(name, input)) + } } -function retryNote(name: string, result: QueryAgentToolResult, state: ZeroResultRetryState): string | undefined { - if (result.constraint === 'duplicate_retry') return '本次重试的条件与上一次完全相同,已被拒绝;请改用实质不同的条件,或直接基于现有结果作答。' +function retryNote( + name: string, + result: QueryAgentToolResult, + state: ZeroResultRetryState +): string | undefined { + if (result.constraint === 'duplicate_retry') + return '本次重试的条件与上一次完全相同,已被拒绝;请改用实质不同的条件,或直接基于现有结果作答。' if (result.status !== 'completed') return undefined const counts = resultCount(result) - if (name === 'search_messages' && !counts.evidenceCount && state.searchAttempts <= ZERO_RESULT_RETRY_LIMIT) return '本次检索没有任何 Evidence。允许再执行一次 search_messages,但每一项都必须与上一次实质不同;完全相同的检索会被拒绝。' - if (name === 'query_messages' && counts.resultCount === 0 && !result.fallbackLookup && state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT) return '本次精确查询返回 0 条。只允许放宽 direction 或 messageTypes 等非时间条件(改变时间范围会被拒绝)。**特别注意方向选反这种情况**:如果问题是“我给 X 发 / 我发给 X 的”,而本次用的是 from_target(对方发来),那是方向选反了 —— 直接改用 to_target 重查一次,这属于允许的实质不同重试。不要因为有 0 条就收尾,也不要问用户“是不是方向搞错了 / 要不要换个方向”,用户已经把说话人讲清楚了。' + if ( + name === 'search_messages' && + !counts.evidenceCount && + state.searchAttempts <= ZERO_RESULT_RETRY_LIMIT + ) + return '本次检索没有任何 Evidence。允许再执行一次 search_messages,但每一项都必须与上一次实质不同;完全相同的检索会被拒绝。' + if ( + name === 'query_messages' && + counts.resultCount === 0 && + !result.fallbackLookup && + state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT + ) + return '本次精确查询返回 0 条。只允许放宽 direction 或 messageTypes 等非时间条件(改变时间范围会被拒绝)。**特别注意方向选反这种情况**:如果问题是“我给 X 发 / 我发给 X 的”,而本次用的是 from_target(对方发来),那是方向选反了 —— 直接改用 to_target 重查一次,这属于允许的实质不同重试。不要因为有 0 条就收尾,也不要问用户“是不是方向搞错了 / 要不要换个方向”,用户已经把说话人讲清楚了。' return undefined } @@ -604,7 +860,11 @@ export function validateToolArguments( value: unknown, now: Date = new Date(), question = '' -): { input?: Record; temporalBasis?: CanonicalTemporalBasis; error?: ToolArgumentValidationError } { +): { + input?: Record + temporalBasis?: CanonicalTemporalBasis + error?: ToolArgumentValidationError +} { const definition = LOCAL_QUERY_TOOL_DEFINITIONS.find((tool) => tool.name === name) if (!definition) return { error: argError('$', 'tool', 'supported tool', name) } if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -618,19 +878,31 @@ export function validateToolArguments( return canonicalizeToolInput(name, value as Record, now, question) } -function resultCount(result: QueryAgentToolResult): { resultCount?: number; evidenceCount?: number; sourceMessageCount?: number } { +function resultCount(result: QueryAgentToolResult): { + resultCount?: number + evidenceCount?: number + sourceMessageCount?: number +} { return { resultCount: typeof result.returnedCount === 'number' ? result.returnedCount : undefined, - evidenceCount: typeof result.evidenceCount === 'number' ? result.evidenceCount : Array.isArray(result.evidence) ? result.evidence.length : undefined, + evidenceCount: + typeof result.evidenceCount === 'number' + ? result.evidenceCount + : Array.isArray(result.evidence) + ? result.evidence.length + : undefined, // 会话概览用它说明"覆盖了多少条源消息",UI 顶部统计需要真实数字。 - sourceMessageCount: typeof result.sourceMessageCount === 'number' ? result.sourceMessageCount : undefined + sourceMessageCount: + typeof result.sourceMessageCount === 'number' ? result.sourceMessageCount : undefined } } function messageRecordForModel(value: unknown): unknown { if (!value || typeof value !== 'object' || Array.isArray(value)) return value const record = value as Record - return record.messageType || !record.sourceKind ? record : { ...record, messageType: record.sourceKind } + return record.messageType || !record.sourceKind + ? record + : { ...record, messageType: record.sourceKind } } /** @@ -665,11 +937,43 @@ function imageOcrDiagnostics(result: QueryAgentToolResult): { } } -function toolResultForModel(name: string, result: QueryAgentToolResult, callsUsed: number, nextTools: AIChatToolDefinition[], note?: string): QueryAgentToolResult { const visible: QueryAgentToolResult = { ...result } - if (Array.isArray(result.messages)) visible.messages = result.messages.map(messageRecordForModel) +/** + * 按 messageRef 查 Host 已分配的引用编号。 + * 只有被 `EvidenceCollector` 收录(= 会同时出现在 UI 证据列表里)的消息才返回编号。 + */ +type CitationLookup = (messageRef: string) => string | undefined + +/** + * 把 `citationId` 注入模型可见的 Tool Result。 + * + * 这是 inline citation 的**必要条件**:模型只能引用它在上下文里见过的编号。 + * 未分配编号的消息(超过证据上限、未被 Collector 收录)不会被注入 —— 模型因此无法引用它们, + * Host 侧的 sanitize 也就不会放过任何"看起来像编号但从未分配过"的引用。 + */ +const withCitationId = (value: unknown, lookup?: CitationLookup): unknown => { + if (!lookup || !value || typeof value !== 'object' || Array.isArray(value)) return value + const record = value as Record + const messageRef = record.messageRef + if (typeof messageRef !== 'string' || !messageRef) return value + const citationId = lookup(messageRef) + return citationId ? { ...record, citationId } : value +} + +function toolResultForModel( + name: string, + result: QueryAgentToolResult, + callsUsed: number, + nextTools: AIChatToolDefinition[], + note?: string, + citations?: CitationLookup +): QueryAgentToolResult { + const visible: QueryAgentToolResult = { ...result } + const forModel = (value: unknown): unknown => + withCitationId(messageRecordForModel(value), citations) + if (Array.isArray(result.messages)) visible.messages = result.messages.map(forModel) if (Array.isArray(result.evidence)) { visible.evidence = result.evidence.map((item) => { - const record = messageRecordForModel(item) + const record = forModel(item) if (!record || typeof record !== 'object' || Array.isArray(record)) return record // `imageOcrText` 是给 Evidence UI 做"命中解释"的片段;它的内容已经在 `text` 里, // 再原样带一份进模型上下文是纯重复。模型侧保留 `derivedSource` 这个语义标记即可, @@ -679,14 +983,14 @@ function toolResultForModel(name: string, result: QueryAgentToolResult, callsUse return trimmed }) } - if (result.anchor) visible.anchor = messageRecordForModel(result.anchor) - if (Array.isArray(result.before)) visible.before = result.before.map(messageRecordForModel) - if (Array.isArray(result.after)) visible.after = result.after.map(messageRecordForModel) + if (result.anchor) visible.anchor = forModel(result.anchor) + if (Array.isArray(result.before)) visible.before = result.before.map(forModel) + if (Array.isArray(result.after)) visible.after = result.after.map(forModel) if (result.fallbackLookup && typeof result.fallbackLookup === 'object') { const fallback = result.fallbackLookup as Record visible.fallbackLookup = { ...fallback, - ...(Array.isArray(fallback.messages) ? { messages: fallback.messages.map(messageRecordForModel) } : {}) + ...(Array.isArray(fallback.messages) ? { messages: fallback.messages.map(forModel) } : {}) } } visible._agent = { @@ -703,12 +1007,18 @@ function toolResultForModel(name: string, result: QueryAgentToolResult, callsUse ? '本次结果有两个 scope:顶层是原查询,fallbackLookup 是系统自动扩大到全部历史后的结果。回答时必须分别说明这两个范围,不要让用户以为原问题就是按“全部历史”提出的。' : undefined, note - ].filter(Boolean).join(' ') + ] + .filter(Boolean) + .join(' ') } return visible } -function nextToolDefinitions(name: string, result: QueryAgentToolResult, state: ZeroResultRetryState): AIChatToolDefinition[] { +function nextToolDefinitions( + name: string, + result: QueryAgentToolResult, + state: ZeroResultRetryState +): AIChatToolDefinition[] { // 重复重试已被拒绝,不再开放工具,避免用有限的 tool budget 反复试同一条件。 if (result.constraint === 'duplicate_retry') return [] if (result.status === 'invalid_tool_arguments') return toolDefinition(name) @@ -767,7 +1077,9 @@ export interface QueryAgentRunOptions { function conversationScopeNote(input: QueryAgentConversationScope): string { const { scope } = input const label = input.label?.trim() - const header = label ? `当前搜索范围(由应用界面决定):${label}。` : '当前搜索范围由应用界面决定。' + const header = label + ? `当前搜索范围(由应用界面决定):${label}。` + : '当前搜索范围由应用界面决定。' const rules = [ '所有工具调用都会被强制限制在这个范围内;target 若不在范围内会被拒绝,被拒绝时请如实说明范围限制,不要试图绕过。', '范围之外还有别的会话,但你**看不到**它们,也不要在回答里声称它们的情况。' @@ -790,16 +1102,22 @@ function conversationScopeNote(input: QueryAgentConversationScope): string { * 从 Tool Result 中提取**真实**证据供 UI 展示 —— UI 不允许从回答文本里反解析证据。 * - 按 `messageRef` 去重(search 与 context 命中同一条消息只显示一次); * - 顺序 = 首次命中顺序;上限 MAX_EVIDENCE_ITEMS; + * - **首次命中时分配稳定 citationId**,重复命中保持原编号; * - 只保留展示字段,不携带 wxid / md5 / DB id / raw Tool JSON。 */ class EvidenceCollector { private readonly items = new Map() + private nextCitationNumber = 1 addFromToolResult(toolName: string, result: QueryAgentToolResult): void { - const target = result.target && typeof result.target === 'object' ? (result.target as Record) : undefined + const target = + result.target && typeof result.target === 'object' + ? (result.target as Record) + : undefined const defaults: { conversationName?: string; conversationType?: 'user' | 'group' } = { conversationName: typeof target?.displayName === 'string' ? target.displayName : undefined, - conversationType: target?.type === 'user' || target?.type === 'group' ? target.type : undefined + conversationType: + target?.type === 'user' || target?.type === 'group' ? target.type : undefined } const push = (value: unknown): void => { const item = this.normalize(value, toolName, defaults) @@ -810,21 +1128,42 @@ class EvidenceCollector { // message_context:只收 anchor(被补充语境的那条证据),前后文不是本次结论的依据。 if (result.anchor) push(result.anchor) // recall_hint 自动扩大的那次查询也是真实证据。 - const fallback = result.fallbackLookup && typeof result.fallbackLookup === 'object' ? (result.fallbackLookup as Record) : undefined + const fallback = + result.fallbackLookup && typeof result.fallbackLookup === 'object' + ? (result.fallbackLookup as Record) + : undefined if (Array.isArray(fallback?.messages)) fallback.messages.forEach(push) } list(): QueryAgentEvidenceItem[] { - return Array.from(this.items.values()).slice(0, MAX_EVIDENCE_ITEMS) + return Array.from(this.items.values()) } - private merge(item: QueryAgentEvidenceItem): void { + /** + * 已分配的合法引用编号 = Host 侧 sanitize 的白名单。 + * + * 与 UI 证据列表、模型可见上下文是**同一个集合**,所以 + * 「模型能引用什么」与「UI 能跳到哪一条」由构造保证一致。 + */ + citationIds(): string[] { + return Array.from(this.items.values(), (item) => item.citationId) + } + + /** Tool Result → citationId 查询(模型上下文注入用)。 */ + readonly citationIdFor = (messageRef: string): string | undefined => + this.items.get(messageRef)?.citationId + + private merge(item: PendingEvidenceItem): void { const existing = this.items.get(item.messageRef) if (!existing) { - this.items.set(item.messageRef, item) + // 超过上限的条目不收录:它们既不在 UI 列表里,也不会带 citationId 进模型上下文, + // 因此不可能被合法引用 —— 这正是"模型可见集合 ⊆ UI 可见集合"不变量的实现方式。 + if (this.items.size >= MAX_EVIDENCE_ITEMS) return + this.items.set(item.messageRef, { ...item, citationId: `E${this.nextCitationNumber}` }) + this.nextCitationNumber += 1 return } - // 同一消息被不同 Tool 命中:补齐缺失字段,保留首次的 source。 + // 同一消息被不同 Tool 命中:补齐缺失字段,保留首次的 source **与 citationId**。 const merged = existing as unknown as Record for (const key of Object.keys(item)) { if (key === 'source') continue @@ -837,13 +1176,15 @@ class EvidenceCollector { value: unknown, source: string, defaults: { conversationName?: string; conversationType?: 'user' | 'group' } - ): QueryAgentEvidenceItem | undefined { + ): PendingEvidenceItem | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined const record = value as Record const messageRef = typeof record.messageRef === 'string' ? record.messageRef : undefined if (!messageRef) return undefined const attachment = - record.attachment && typeof record.attachment === 'object' && !Array.isArray(record.attachment) + record.attachment && + typeof record.attachment === 'object' && + !Array.isArray(record.attachment) ? (record.attachment as Record) : undefined const attachmentView = attachment @@ -883,7 +1224,9 @@ class EvidenceCollector { ...(typeof record.imageOcrText === 'string' && record.imageOcrText ? { imageOcrText: record.imageOcrText } : {}), - ...(attachmentView && Object.keys(attachmentView).length ? { attachment: attachmentView } : {}), + ...(attachmentView && Object.keys(attachmentView).length + ? { attachment: attachmentView } + : {}), source } } @@ -935,12 +1278,37 @@ export class QueryAgentService { const trimmed = question.trim() const startedAt = Date.now() const runtime = this.provider.getRuntimeConfig() - const result: QueryAgentResult = { question: trimmed, provider: runtime.providerName, model: runtime.modelName || runtime.model, modelCallCount: 0, toolCallCount: 0, toolTotalMs: 0, totalMs: 0, traces: [], modelDurationsMs: [], modelDiagnostics: [] } - if (!trimmed) return { ...result, error: '请输入查询问题', errorKind: 'invalid_question', totalMs: Date.now() - startedAt } - if (!runtime.configured) return { ...result, error: '当前 AI Provider 尚未配置', errorKind: 'provider_unavailable', totalMs: Date.now() - startedAt } + const result: QueryAgentResult = { + question: trimmed, + provider: runtime.providerName, + model: runtime.modelName || runtime.model, + modelCallCount: 0, + toolCallCount: 0, + toolTotalMs: 0, + totalMs: 0, + traces: [], + modelDurationsMs: [], + modelDiagnostics: [] + } + if (!trimmed) + return { + ...result, + error: '请输入查询问题', + errorKind: 'invalid_question', + totalMs: Date.now() - startedAt + } + if (!runtime.configured) + return { + ...result, + error: '当前 AI Provider 尚未配置', + errorKind: 'provider_unavailable', + totalMs: Date.now() - startedAt + } const history = options.history || [] - const scopeNote = options.conversationScope ? conversationScopeNote(options.conversationScope) : undefined + const scopeNote = options.conversationScope + ? conversationScopeNote(options.conversationScope) + : undefined const messages: Array> = [ { role: 'system', content: SYSTEM_PROMPT }, // 范围说明是**上下文**,不是强制执行手段:真正的边界由 Engine 拒绝越界 target 来保证。 @@ -982,20 +1350,55 @@ export class QueryAgentService { ...(model.success ? {} : { error: model.error || '模型调用失败' }) }) if (firstModelAt === undefined) firstModelAt = Date.now() - if (!model.success) return { ...result, error: model.error || '模型调用失败', errorKind: 'provider_failure', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt } + if (!model.success) + return { + ...result, + error: model.error || '模型调用失败', + errorKind: 'provider_failure', + firstModelMs: firstModelAt - startedAt, + totalMs: Date.now() - startedAt + } const calls = model.toolCalls || [] if (calls.length === 0) { finalModelDuration = modelDuration - result.answer = model.data?.trim() || '模型未返回答案' + /** + * Host 侧 citation 校验:只保留能解析到本轮真实证据的 `[E#]`。 + * + * 必须在返回给 Adapter 之前完成 —— 否则一个幻觉编号会在 UI 里被渲染成 + * 可点击的引用按钮,把"有依据"这个产品承诺反过来变成误导。 + * 白名单就是 `EvidenceCollector` 分配过的编号集合(= 模型见过的那一批)。 + */ + const citationValidation = sanitizeAnswerCitations( + model.data?.trim() || '模型未返回答案', + evidence.citationIds() + ) + result.answer = citationValidation.answer + if (citationValidation.invalidCitationIds.length) { + result.invalidCitationIds = citationValidation.invalidCitationIds + } result.firstModelMs = firstModelAt - startedAt result.finalModelMs = finalModelDuration result.totalMs = Date.now() - startedAt return result } if (result.toolCallCount + calls.length > MAX_TOOL_CALLS) { - return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, errorKind: 'tool_limit', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt } + return { + ...result, + error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, + errorKind: 'tool_limit', + firstModelMs: firstModelAt - startedAt, + totalMs: Date.now() - startedAt + } } - messages.push({ role: 'assistant', content: model.data || '', tool_calls: calls.map((call) => ({ id: call.id, type: 'function', function: { name: call.name, arguments: call.arguments } })) }) + messages.push({ + role: 'assistant', + content: model.data || '', + tool_calls: calls.map((call) => ({ + id: call.id, + type: 'function', + function: { name: call.name, arguments: call.arguments } + })) + }) for (const call of calls) { const inputStartedAt = Date.now() let toolResult: QueryAgentToolResult | undefined @@ -1004,11 +1407,19 @@ export class QueryAgentService { let autoFallback: QueryAgentTraceItem['autoFallback'] try { if (!tools.some((tool) => tool.function.name === call.name)) { - toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'tool_availability', expected: tools.map((tool) => tool.function.name), actual: call.name } + toolResult = { + status: 'invalid_tool_arguments', + field: '$', + constraint: 'tool_availability', + expected: tools.map((tool) => tool.function.name), + actual: call.name + } } if (!toolResult) { let parsed: unknown - try { parsed = JSON.parse(call.arguments || '{}') } catch { + try { + parsed = JSON.parse(call.arguments || '{}') + } catch { toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'json' } } if (!toolResult) { @@ -1019,12 +1430,23 @@ export class QueryAgentService { traceInput = validated.input || {} temporalBasis = validated.temporalBasis // constraint 时间边界由 Host 结构性锁定:不依赖 prompt,也不静默改写用户问题。 - const constraintViolation = lockConstraintTimeRange(call.name, temporalBasis, traceInput, retry) + const constraintViolation = lockConstraintTimeRange( + call.name, + temporalBasis, + traceInput, + retry + ) if (constraintViolation) { toolResult = constraintViolation } else if (duplicateRetry(call.name, traceInput, retry)) { // 明确拒绝“换关键词重搜”里的 identical retry,让模型改用实质不同的条件。 - toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'duplicate_retry', expected: '与上一次实质不同的条件', actual: '与上一次完全相同的条件' } + toolResult = { + status: 'invalid_tool_arguments', + field: '$', + constraint: 'duplicate_retry', + expected: '与上一次实质不同的条件', + actual: '与上一次完全相同的条件' + } } else { recordAttempt(call.name, traceInput, retry) // Tool 真正开始执行 = "在搜索聊天记录"。跨会话范围会明显更慢, @@ -1037,14 +1459,25 @@ export class QueryAgentService { // 注意:自动补查必须沿用同一个语料边界,不能借它逃出当前搜索范围。 if (shouldAutoBroaden(call.name, temporalBasis, traceInput, primary)) { const fallbackStartedAt = Date.now() - const fallbackResult = await this.executeTool('query_messages', { ...traceInput, timeRange: { kind: 'all' } }, toolContext) + const fallbackResult = await this.executeTool( + 'query_messages', + { ...traceInput, timeRange: { kind: 'all' } }, + toolContext + ) const fallbackCounts = resultCount(fallbackResult) - autoFallback = { reason: AUTO_FALLBACK_REASON, timeRange: { kind: 'all' }, status: fallbackResult.status, durationMs: Date.now() - fallbackStartedAt, ...fallbackCounts } + autoFallback = { + reason: AUTO_FALLBACK_REASON, + timeRange: { kind: 'all' }, + status: fallbackResult.status, + durationMs: Date.now() - fallbackStartedAt, + ...fallbackCounts + } toolResult = { ...primary, fallbackLookup: { reason: AUTO_FALLBACK_REASON, - explanation: '你的 temporalBasis.kind=recall_hint 表示该时间范围只是你推断的回忆线索,并非用户给出的硬边界;首次查询为 0 条,系统已自动在全部历史中再查一次。请分别说明这两个范围的结果。', + explanation: + '你的 temporalBasis.kind=recall_hint 表示该时间范围只是你推断的回忆线索,并非用户给出的硬边界;首次查询为 0 条,系统已自动在全部历史中再查一次。请分别说明这两个范围的结果。', timeRange: { kind: 'all' }, status: fallbackResult.status, resolvedTimeRange: fallbackResult.resolvedTimeRange, @@ -1059,9 +1492,16 @@ export class QueryAgentService { } } } catch (error) { - if (!toolResult) toolResult = { status: 'invalid_request', error: error instanceof Error ? error.message : '工具调用失败' } + if (!toolResult) + toolResult = { + status: 'invalid_request', + error: error instanceof Error ? error.message : '工具调用失败' + } + } + const completedToolResult = toolResult || { + status: 'invalid_request', + error: '工具调用失败' } - const completedToolResult = toolResult || { status: 'invalid_request', error: '工具调用失败' } const durationMs = Date.now() - inputStartedAt result.toolCallCount += 1 counters.toolCallCount = result.toolCallCount @@ -1078,17 +1518,47 @@ export class QueryAgentService { rawTimings && typeof rawTimings === 'object' && !Array.isArray(rawTimings) ? (rawTimings as QuerySearchTimings) : undefined - result.traces.push({ toolName: call.name, input: sanitizeInput(traceInput), durationMs, status: completedToolResult.status, ...counts, ...imageOcrDiagnostics(completedToolResult), ...(temporalBasis ? { temporalBasis } : {}), ...(autoFallback ? { autoFallback } : {}), ...(searchTimings ? { searchTimings } : {}) }) - const nextTools = completedToolResult.constraint === 'tool_availability' - ? tools - : nextToolDefinitions(call.name, completedToolResult, retry) + result.traces.push({ + toolName: call.name, + input: sanitizeInput(traceInput), + durationMs, + status: completedToolResult.status, + ...counts, + ...imageOcrDiagnostics(completedToolResult), + ...(temporalBasis ? { temporalBasis } : {}), + ...(autoFallback ? { autoFallback } : {}), + ...(searchTimings ? { searchTimings } : {}) + }) + const nextTools = + completedToolResult.constraint === 'tool_availability' + ? tools + : nextToolDefinitions(call.name, completedToolResult, retry) const note = retryNote(call.name, completedToolResult, retry) - messages.push({ role: 'tool', tool_call_id: call.id, name: call.name, content: JSON.stringify(toolResultForModel(call.name, completedToolResult, result.toolCallCount, nextTools, note)) }) + // 注意顺序:证据必须先收集(上一行),模型可见的 Tool Result 才带得上 citationId。 + messages.push({ + role: 'tool', + tool_call_id: call.id, + name: call.name, + content: JSON.stringify( + toolResultForModel( + call.name, + completedToolResult, + result.toolCallCount, + nextTools, + note, + evidence.citationIdFor + ) + ) + }) tools = nextTools } } result.firstModelMs = firstModelAt ? firstModelAt - startedAt : undefined result.totalMs = Date.now() - startedAt - return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, errorKind: 'tool_limit' } + return { + ...result, + error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, + errorKind: 'tool_limit' + } } } diff --git a/src/renderer/src/components/search/AISearchWorkspace.tsx b/src/renderer/src/components/search/AISearchWorkspace.tsx index 9bdc0a4..a8aec07 100644 --- a/src/renderer/src/components/search/AISearchWorkspace.tsx +++ b/src/renderer/src/components/search/AISearchWorkspace.tsx @@ -94,6 +94,13 @@ export function AISearchWorkspace({ const [queryAgentEnabled, setQueryAgentEnabled] = useState(false) /** Query Agent 本次回答的真实统计(读取条数 / 证据条数 / 模型调用 / 耗时)。 */ const [askStats, setAskStats] = useState(null) + /** + * Query Agent 回答里被 Host 移除的非法 `[E#]`。 + * + * 有值 = 模型引用了不存在的编号;已从正文剥离,这里只做告知, + * 不让用户以为"每条断言都有出处"。 + */ + const [askInvalidCitationIds, setAskInvalidCitationIds] = useState([]) const { evidence, setEvidence, @@ -194,6 +201,7 @@ export function AISearchWorkspace({ setCachedAt(reset.cachedAt) setSearchTrace(reset.searchTrace) setAskStats(null) + setAskInvalidCitationIds([]) resetSearchRun() setSearchDetailsOpen(reset.searchDetailsOpen) } @@ -443,12 +451,17 @@ export function AISearchWorkspace({ } if (askResult.status === 'answered') { const mappedEvidence = mapAskWechatEvidence(askResult.evidence) - addDebugEntry('查询 Agent 完成', { ...askResult.diagnostics }) + addDebugEntry('查询 Agent 完成', { + ...askResult.diagnostics, + invalidCitationIds: askResult.invalidCitationIds + }) setResultQuery(normalizedQuery) setAnswer(askResult.answer) // 真实证据直接来自 Runtime 收集的 Tool 结果,不从回答文本反解析。 + // 证据卡的编号是 Host 分配的 citationId,与正文 [E#] 同号。 setEvidenceResult(mappedEvidence, mappedEvidence) setAskStats(askResult.stats) + setAskInvalidCitationIds(askResult.invalidCitationIds || []) setMessageCount(0) rememberQuery(normalizedQuery) persistSearchResult({ @@ -468,6 +481,7 @@ export function AISearchWorkspace({ ? askResult.message : '本次查询没有完成,请稍后再试。' setAskStats(null) + setAskInvalidCitationIds([]) addDebugEntry('查询失败', { ...askResult.diagnostics }) setAnalysisError(failureMessage) setStage('insufficient') @@ -883,6 +897,13 @@ export function AISearchWorkspace({ {askWechatToolLabels(askStats.tools).map((label) => ( 能力:{label} ))} + {/* 与 Legacy 同一措辞:Host 侧已把无法对应证据的引用从正文移除。 + 这里只做告知,不把它渲染成可点击的引用。 */} + {askInvalidCitationIds.length > 0 && ( + + 已移除无效引用:{askInvalidCitationIds.join('、')} + + )} {/* 耗时拆解:把总耗时还原成"AI 花了多少 / 本地查询花了多少"。 普通 UI 只出现这三个用户能理解的名字,不出现 firstModelMs / toolTotalMs @@ -904,7 +925,8 @@ export function AISearchWorkspace({ {askStats.timings.modelDurationsMs?.map((v) => Math.round(v)).join(', ') || '-'} ] ms · 本地 [ - {askStats.timings.toolDurationsMs?.map((v) => Math.round(v)).join(', ') || '-'} + {askStats.timings.toolDurationsMs?.map((v) => Math.round(v)).join(', ') || + '-'} ] ms )} @@ -985,9 +1007,16 @@ export function AISearchWorkspace({ {evidence.length > 0 && (
引用: - {evidence.map((_, index) => ( - ))}
@@ -1283,7 +1312,8 @@ export function AISearchWorkspace({ )} {knowledgeStatus.pass && knowledgeStatus.pass.skippedConversations > 0 && (

- 已跳过 {knowledgeStatus.pass.skippedConversations.toLocaleString()} 个没有新消息的会话 + 已跳过 {knowledgeStatus.pass.skippedConversations.toLocaleString()}{' '} + 个没有新消息的会话

)} diff --git a/src/renderer/src/components/search/askWechatPresentation.ts b/src/renderer/src/components/search/askWechatPresentation.ts index 70080b3..1d3f77a 100644 --- a/src/renderer/src/components/search/askWechatPresentation.ts +++ b/src/renderer/src/components/search/askWechatPresentation.ts @@ -1,8 +1,5 @@ import type { AskWechatEvidenceItem, AskWechatStats } from '../../../../shared/query-agent' -import { - decodeMessageRef, - type CanonicalMessageIdentity -} from '../../../../shared/local-query-api' +import { decodeMessageRef, type CanonicalMessageIdentity } from '../../../../shared/local-query-api' import type { Contact } from '../../../../shared/types' import { formatEvidenceTimestamp } from './searchFormatters' import type { EvidenceItem } from './searchTypes' @@ -37,20 +34,36 @@ const SCOPE_LABELS: Record = { * (展示契约里刻意不含 md5 字段);`messageRef` 缺失或解析失败时退化成合成 key, * 并且调用方必须按"无法定位"处理。 */ -function evidenceContact(item: AskWechatEvidenceItem, anchor: CanonicalMessageIdentity | null): Contact { +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: anchor.conversationId, + m_nsUsrName: anchor.conversationId, + m_nsNickName: name, + type + } + } + return { + md5: `query-agent:${item.conversationName || 'unknown'}`, + m_nsUsrName: '', + 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) => { + return items.map((item) => { const anchor = decodeMessageRef(item.messageRef) return { - evidenceId: `E${index + 1}`, + // 编号直接消费 Host 分配的 citationId —— 不再用数组下标自行合成。 + // 正文 inline citation、底部引用按钮、证据卡标题因此是同一条 evidence 的同一个编号; + // 下标一旦被过滤 / 分页 / 重排就会漂移,而且模型无从知道它(引用必然失效)。 + evidenceId: item.citationId, sourceKind: item.messageType as EvidenceItem['sourceKind'], // 「靠图片里的文字命中」是来源语义,必须原样带到 UI; // 但 authoritative source 仍然是原始图片消息(messageRef 已指向它)。 diff --git a/src/renderer/src/utils/group-report-facts.ts b/src/renderer/src/utils/group-report-facts.ts index 466bfce..efad33d 100644 --- a/src/renderer/src/utils/group-report-facts.ts +++ b/src/renderer/src/utils/group-report-facts.ts @@ -772,7 +772,15 @@ export const buildGroupReportFacts = async ( footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容默认只按类型与上下文参与日报。', heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name), avatars, - reportMode + reportMode, + /** + * 会话标识:导出层 `enrichAvatarsFromGroup` 靠它反查群成员快照补头像。 + * + * 必须用 `m_nsUsrName`(群 roomid,形如 `xxx@chatroom`)而不是群名 —— 群名是展示名, + * 可能重名或带表情符号;`resolveMd5` 对 roomid / md5 / wxid 都是精确匹配。 + * 此前这个字段从未被赋值,导致那条 enrich 分支实际是死代码,头像只能靠消息自带的 img。 + */ + ...(contact?.m_nsUsrName ? { talker: contact.m_nsUsrName } : {}) } const { media, voiceLeaderboard, warnings, imageInsightSummary } = await buildMediaSection( diff --git a/src/shared/group-report.ts b/src/shared/group-report.ts index c36769d..efef2a4 100644 --- a/src/shared/group-report.ts +++ b/src/shared/group-report.ts @@ -7,6 +7,66 @@ import type { ReportTemplateRef } from './report-template-package' export const selectHeroParticipantNames = (names: string[]): string[] => Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))).slice(0, 4) +/** + * 群成员快照里可以用来关联报告显示名的全部别名。 + * + * 报告里的显示名取决于 `memberNameMode`(默认是**群昵称**),而快照的 `nickname` + * 字段是 `wechatNickname || groupNickname || username`。一个成员同时有微信昵称与群昵称、 + * 且两者不同时,只按 `nickname` 建索引会**全部对不上** —— 头像 enrichment 会静默失效, + * 用户看到的就是首字 fallback。 + */ +export const REPORT_AVATAR_ALIAS_FIELDS = [ + 'nickname', + 'groupNickname', + 'wechatNickname', + 'remark', + 'wxid' +] as const + +export interface ReportAvatarMember { + wxid: string + nickname?: string + groupNickname?: string + wechatNickname?: string + remark?: string + avatar?: string +} + +/** + * 建立「显示名别名 → 头像 URL」索引:每个成员的所有可用显示名都指向同一头像。 + * 同名先到先得(P2 风险:群里两人同名)。 + */ +export const buildReportAvatarAliasIndex = ( + members: readonly ReportAvatarMember[] +): Map => { + const index = new Map() + for (const member of members) { + if (!member.avatar) continue + for (const field of REPORT_AVATAR_ALIAS_FIELDS) { + const name = String(member[field] || '').trim() + if (name && !index.has(name)) index.set(name, member.avatar) + } + } + return index +} + +/** + * 把别名索引合并进 `metadata.avatars`,返回实际补充的条数。 + * 调用方已经给出的有效头像一律保留,绝不被快照覆盖。 + */ +export const mergeReportAvatars = ( + avatars: Record, + index: ReadonlyMap +): number => { + let filled = 0 + for (const [name, url] of index) { + if (avatars[name]) continue + avatars[name] = url + filled += 1 + } + return filled +} + export type ReportSectionKey = | 'hero' | 'topics' diff --git a/src/shared/query-agent.ts b/src/shared/query-agent.ts index dae09dc..062d7bd 100644 --- a/src/shared/query-agent.ts +++ b/src/shared/query-agent.ts @@ -26,6 +26,15 @@ export interface AskWechatScope { /** 展示用证据:只含可读字段,不含 wxid / md5 / DB id / raw Tool JSON。 */ export interface AskWechatEvidenceItem { + /** + * Host 分配的稳定引用编号(`E1`、`E2`…)。 + * + * 由 Runtime 的 EvidenceCollector 在**模型调用之前**按首次命中顺序分配,并随 Tool Result + * 进入模型可见上下文 —— 因此正文里的 `[E#]` 与 UI 证据卡 / 底部引用按钮用的是同一个编号。 + * UI **不得**再用数组下标自行合成编号:那会在证据被过滤、分页或重排时漂移, + * 而且模型无从知道它(inline citation 会因此失效)。 + */ + citationId: string messageRef: string conversationName?: string conversationType?: 'user' | 'group' @@ -197,6 +206,13 @@ export type AskWechatQueryResult = answer: string /** 本次回答实际依据的证据(去重、限量);UI 不允许从 answer 反解析。 */ evidence: AskWechatEvidenceItem[] + /** + * Host 侧 citation 校验中被移除的非法编号(additive)。 + * + * 非空表示模型引用了不存在的 `[E#]`,已从 answer 中移除 —— UI 可据此提示 + * "已移除无法对应证据的引用",而不是把幻觉编号渲染成可点击的引用。 + */ + invalidCitationIds?: string[] stats: AskWechatStats diagnostics: QueryAgentDiagnostics } diff --git a/tests/component/ai-search-query-agent.test.tsx b/tests/component/ai-search-query-agent.test.tsx index 09d0d86..a1408fd 100644 --- a/tests/component/ai-search-query-agent.test.tsx +++ b/tests/component/ai-search-query-agent.test.tsx @@ -4,7 +4,12 @@ import userEvent from '@testing-library/user-event' import { AISearchWorkspace } from '../../src/renderer/src/components/search/AISearchWorkspace' import { aiSearchContact, aiSearchGroup, makeSearchResult } from './support/ai-search-fixtures' import { makeImageTextIndexApi } from './support/image-text-index-api' -import type { AskWechatQueryResult, AskWechatStats, QueryAgentProgressEvent } from '../../src/shared/query-agent' +import type { + AskWechatQueryResult, + AskWechatStats, + QueryAgentProgressEvent +} from '../../src/shared/query-agent' +import type { AISearchWorkspaceProps } from '../../src/renderer/src/components/search/searchTypes' type AnsweredResult = Extract @@ -65,7 +70,7 @@ const diagnostics = { outcome: 'answered' as const } -const makeProps = (): Record => ({ +const makeProps = (): AISearchWorkspaceProps => ({ contacts: [aiSearchContact, aiSearchGroup], selectedContact: aiSearchContact, dbReady: true, @@ -141,7 +146,7 @@ const answeredResult = (patch: Partial = {}): AnsweredResult => describe('AISearchWorkspace — Query Agent 主路径', () => { it('Query Agent 开启时用它回答,不再调用 Legacy 检索', async () => { - render() + render() await submitQuery('我和 BOBO 第一次聊了什么') expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy() @@ -155,7 +160,7 @@ describe('AISearchWorkspace — Query Agent 主路径', () => { api.runAskWechatQuery.mockResolvedValue( answeredResult({ answer: '当前可读取的完整范围里没有找到相关记录。' }) ) - render() + render() await submitQuery('BOBO 给我发过文件吗') expect(await screen.findByText('当前可读取的完整范围里没有找到相关记录。')).toBeTruthy() @@ -169,7 +174,7 @@ describe('AISearchWorkspace — Query Agent 主路径', () => { message: '当前 AI 查询服务暂时不可用,请稍后再试。', diagnostics: { ...diagnostics, outcome: 'provider_failure' } }) - render() + render() await submitQuery('BOBO 最近说过什么') expect(await screen.findByText('当前 AI 查询服务暂时不可用,请稍后再试。')).toBeTruthy() @@ -183,7 +188,7 @@ describe('AISearchWorkspace — Query Agent 主路径', () => { reason: 'runtime_error', result: makeSearchResult({ answer: 'Legacy 兜底答案' }) }) - render() + render() await submitQuery('BOBO 最近说过什么') expect(await screen.findByText('Legacy 兜底答案')).toBeTruthy() @@ -191,7 +196,7 @@ describe('AISearchWorkspace — Query Agent 主路径', () => { it('Query Agent 关闭时仍走 Legacy 检索(可回退)', async () => { api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: false }) - render() + render() await submitQuery('我和测试会话最近聊了什么') expect(await screen.findByText('测试搜索答案')).toBeTruthy() @@ -204,7 +209,7 @@ describe('AISearchWorkspace — Query Agent 主路径', () => { configurable: true, value: { ...api, getAskWechatConfig: undefined, runAskWechatQuery: undefined } }) - render() + render() await submitQuery('我和测试会话最近聊了什么') expect(await screen.findByText('测试搜索答案')).toBeTruthy() @@ -214,7 +219,7 @@ describe('AISearchWorkspace — Query Agent 主路径', () => { describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据', () => { it('Query Agent 开启时保留搜索范围、隐藏时间范围,并提示时间写在问题里', async () => { - render() + render() expect(await screen.findByText('搜索范围')).toBeTruthy() // 开关是异步解析的:等提示出现,说明 Query Agent 已生效 @@ -231,7 +236,7 @@ describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据 it('Query Agent 关闭时恢复 Legacy 的时间范围控件', async () => { api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: false }) - render() + render() expect(await screen.findByText('时间范围')).toBeTruthy() expect(screen.getByText('不限时间')).toBeTruthy() @@ -239,7 +244,7 @@ describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据 }) it('把界面选择的搜索范围传给 Query Agent', async () => { - render() + render() const user = userEvent.setup() await user.click(await screen.findByText('群聊专属')) await submitQuery('最近谁聊过健身') @@ -255,9 +260,11 @@ describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据 it('展示真实统计与真实证据,不再显示「知识库已收录 0」', async () => { api.runAskWechatQuery.mockResolvedValue( answeredResult({ - answer: '张三最近提过健身。', + // 正文带 Host 分配的引用编号:inline [E1] 应可点击并指向同一条证据。 + answer: '张三最近提过健身[E1]。', evidence: [ { + citationId: 'E1', messageRef: 'ref-1', conversationName: 'TraceMemo 交流群', conversationType: 'group', @@ -278,10 +285,19 @@ describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据 } }) ) - render() + render() await submitQuery('最近谁聊过健身') - expect(await screen.findByText('张三最近提过健身。')).toBeTruthy() + // 正文里 [E1] 被渲染成按钮,因此段落自身的直连文本节点是"张三最近提过健身。" + const paragraph = await screen.findByText('张三最近提过健身。') + // 段落完整文本 = 结论 + Host 分配的 [E1] + 句号:inline citation 真的在正文里,不是死文本 + expect(paragraph.textContent).toBe('张三最近提过健身[E1]。') + // 正文 inline 引用:Host 分配的 E1 是可点击引用,标题指向同一条证据 + const inlineCitation = screen.getByTitle('查看证据 E1') + expect(inlineCitation.textContent).toBe('[E1]') + // 底部引用行与正文 inline 引用必须是同一个编号(都来自 Host 的 citationId) + const bottomCitations = Array.from(document.querySelectorAll('[data-evidence-id]')) + expect(bottomCitations.map((node) => node.getAttribute('data-evidence-id'))).toEqual(['E1']) // 群消息证据必须能归属到具体群 + 成员 expect(screen.getAllByText(/TraceMemo 交流群/).length).toBeGreaterThan(0) // 证据卡片把"编号 · 发送者"渲染在同一行内,用正则匹配文本内容 @@ -292,6 +308,26 @@ describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据 expect(screen.getByText('使用 1 条证据')).toBeTruthy() expect(screen.queryByText(/知识库已收录/)).toBeNull() }) + + it('Host 移除的非法引用不会变成可点击的幻觉引用', async () => { + api.runAskWechatQuery.mockResolvedValue( + answeredResult({ + answer: '张三最近提过健身。', + evidence: [ + { citationId: 'E1', messageRef: 'ref-1', sender: '张三', source: 'search_messages' } + ], + invalidCitationIds: ['E9'] + }) + ) + render() + await submitQuery('最近谁聊过健身') + + expect(await screen.findByText('张三最近提过健身。')).toBeTruthy() + // E9 已被 Host 移除,UI 不应把它渲染成可点击引用 + expect(screen.queryByTitle('查看证据 E9')).toBeNull() + expect(screen.queryByText('[E9]')).toBeNull() + expect(screen.getByTestId('query-invalid-citations').textContent).toContain('E9') + }) }) describe('AISearchWorkspace — 知识库新鲜度状态', () => { @@ -301,7 +337,7 @@ describe('AISearchWorkspace — 知识库新鲜度状态', () => { indexLatestAt: new Date('2026-08-26T11:37:24+08:00').getTime(), sourceLatestAt: new Date('2026-09-11T11:57:24+08:00').getTime() }) - render() + render() // ready ≠ fresh:落后时必须说「可用 · 待追新」,并在描述里给出真实覆盖边界, // 绝不能用笼统的「已同步」把两件事混为一谈。 @@ -314,7 +350,7 @@ describe('AISearchWorkspace — 知识库新鲜度状态', () => { }) it('索引已追平源数据最新时才显示「可用 · 已追至最新」', async () => { - render() + render() expect(await screen.findByText('可用 · 已追至最新')).toBeTruthy() expect(screen.queryByText('已同步')).toBeNull() @@ -346,7 +382,7 @@ describe('AISearchWorkspace — 查询进度与耗时拆解', () => { release = resolve }) ) - render() + render() await userEvent.type(screen.getByRole('textbox'), '最近谁聊过健身') await userEvent.click(screen.getByRole('button', { name: /开始分析/ })) const requestId = api.runAskWechatQuery.mock.calls[0][0].requestId as string @@ -380,7 +416,7 @@ describe('AISearchWorkspace — 查询进度与耗时拆解', () => { release = resolve }) ) - render() + render() await userEvent.click(screen.getByRole('button', { name: /当前会话/ })) await userEvent.type(screen.getByRole('textbox'), '最近聊了什么') await userEvent.click(screen.getByRole('button', { name: /开始分析/ })) @@ -403,7 +439,7 @@ describe('AISearchWorkspace — 查询进度与耗时拆解', () => { release = resolve }) ) - render() + render() await submitQuery('最近谁聊过健身') await emitProgress('stale-request-id', { stage: 'generating_answer' }) @@ -432,7 +468,7 @@ describe('AISearchWorkspace — 查询进度与耗时拆解', () => { } }) ) - render() + render() await submitQuery('最近谁聊过健身') await screen.findByText('你们的第一次聊天是一条问候。') @@ -445,7 +481,7 @@ describe('AISearchWorkspace — 查询进度与耗时拆解', () => { }) it('does not render a timing breakdown when the runtime reported none', async () => { - render() + render() await submitQuery('最近谁聊过健身') await screen.findByText('你们的第一次聊天是一条问候。') @@ -455,7 +491,7 @@ describe('AISearchWorkspace — 查询进度与耗时拆解', () => { it('cancelling a query never touches the Knowledge sync abort scope', async () => { api.runAskWechatQuery.mockImplementation(() => new Promise(() => undefined)) - render() + render() await submitQuery('最近谁聊过健身') await userEvent.click(await screen.findByRole('button', { name: /取消分析/ })) diff --git a/tests/component/evidence-source-labels.test.tsx b/tests/component/evidence-source-labels.test.tsx index 923e6c7..f4dc282 100644 --- a/tests/component/evidence-source-labels.test.tsx +++ b/tests/component/evidence-source-labels.test.tsx @@ -26,6 +26,7 @@ const VOICE_REF = encodeMessageRef('fixture-conversation-a', '9002') const TEXT_REF = encodeMessageRef('fixture-conversation-a', '9003') const base = { + citationId: 'E1', conversationName: '测试群', conversationType: 'group' as const, sender: '用户A', @@ -35,6 +36,7 @@ const base = { /** 图片 OCR 命中。 */ const imageOcrEvidence: AskWechatEvidenceItem = { ...base, + citationId: 'E1', messageRef: IMAGE_REF, timestamp: Date.parse('2026-09-03T14:32:00+08:00'), messageType: 'image', @@ -46,6 +48,7 @@ const imageOcrEvidence: AskWechatEvidenceItem = { /** 语音转写命中。 */ const voiceTranscriptEvidence: AskWechatEvidenceItem = { ...base, + citationId: 'E2', messageRef: VOICE_REF, timestamp: Date.parse('2026-09-03T14:34:00+08:00'), messageType: 'voice', @@ -56,6 +59,7 @@ const voiceTranscriptEvidence: AskWechatEvidenceItem = { /** 普通文字消息(对照组)。 */ const plainEvidence: AskWechatEvidenceItem = { ...base, + citationId: 'E3', messageRef: TEXT_REF, timestamp: Date.parse('2026-09-03T14:30:00+08:00'), messageType: 'text', diff --git a/tests/typecheck-baseline.json b/tests/typecheck-baseline.json index 133965f..f38a42b 100644 --- a/tests/typecheck-baseline.json +++ b/tests/typecheck-baseline.json @@ -1,11 +1,10 @@ { "note": "测试文件类型检查基线:只允许下降,不允许上升。用 node scripts/typecheck-tests.cjs --update 下调。", - "total": 258, + "total": 240, "files": { "tests/component/ai-model-settings.test.tsx": 2, "tests/component/ai-search-cache-consent.test.tsx": 3, "tests/component/ai-search-knowledge-status-hook.test.tsx": 1, - "tests/component/ai-search-query-agent.test.tsx": 18, "tests/component/ai-search-run-hook.test.tsx": 1, "tests/component/ai-search-workspace-regression.test.tsx": 4, "tests/component/database-connection.test.tsx": 2, diff --git a/tests/unit/agent-hub-routing.test.ts b/tests/unit/agent-hub-routing.test.ts index 0a4e364..6c9b739 100644 --- a/tests/unit/agent-hub-routing.test.ts +++ b/tests/unit/agent-hub-routing.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { matchGroupReportIntent, + matchRecentChatIntent, queryAgentReplyText, QUERY_AGENT_UNAVAILABLE_TEXT, resolveInboundRoute @@ -64,6 +65,43 @@ describe('Agent Hub 入站路由 — Query / Action 分流', () => { }) }) +/** + * recent_list 的判定必须**正向**:只有用户确实在要"会话 / 联系人名单"时才算。 + * 早先只要求「最近」+「消息|会话|聊天」同时出现,于是内容查询被截走 —— 用户问 + * "最近…里有没有提到 X",得到的却是一串会话名。 + */ +describe('Agent Hub 入站路由 — recent_list 边界', () => { + const cases: Array<[string, string, string]> = [ + ['最近有哪些聊天', 'recent_list', '名单形态 + 聊天载体'], + ['最近有哪些会话', 'recent_list', '名单形态 + 会话载体'], + ['最近和谁聊过', 'recent_list', '「和谁」问法本身就在问会话对象'], + ['最近聊过哪些人', 'recent_list', '名单形态 + 人'], + ['最近 5 个会话', 'recent_list', '数量 + 载体'], + ['最近群里聊的消息里有没有提到报价', 'knowledge_query', '内容探针:有没有提到'], + ['最近聊天里谁提过健身', 'knowledge_query', '内容探针:谁提过'], + ['最近消息里有没有说过报价', 'knowledge_query', '内容探针:有没有说过'], + ['技术交流群今天主要聊了什么', 'knowledge_query', '没有「最近」,不是名单请求'], + ['生成技术交流群日报', 'report_action', '明确产物优先'], + ['微信里最近发生了什么', 'knowledge_query', '不是名单形态'], + ['总结一下 TraceMemo 交流群最近聊了什么', 'knowledge_query', '总结 ≠ 名单,且是内容探针'] + ] + + for (const [text, expected, why] of cases) { + it(`「${text}」→ ${expected}(${why})`, () => { + expect(resolveInboundRoute(text).kind).toBe(expected) + }) + } + + it('recent_list 只保留实际存在的会话数量上限语义', () => { + // 无数字时默认 5;有数字时取该数字,并夹在 1..20。 + expect(matchRecentChatIntent('最近和谁聊过')).toBe(5) + expect(matchRecentChatIntent('最近12个会话')).toBe(12) + expect(matchRecentChatIntent('最近99个会话')).toBe(20) + // 不是名单请求 → 不命中 + expect(matchRecentChatIntent('最近聊天里谁提过健身')).toBeNull() + }) +}) + describe('Agent Hub 回复文案映射', () => { const diagnostics = { entry: 'agent-hub' as const, diff --git a/tests/unit/ask-wechat-presentation.test.ts b/tests/unit/ask-wechat-presentation.test.ts index 5887a63..b0b80e3 100644 --- a/tests/unit/ask-wechat-presentation.test.ts +++ b/tests/unit/ask-wechat-presentation.test.ts @@ -20,6 +20,7 @@ describe('mapAskWechatEvidence', () => { it('群消息证据保留群名与成员,且不泄露 md5', () => { const [item] = mapAskWechatEvidence([ { + citationId: 'E1', messageRef: 'ref-1', conversationName: 'TraceMemo 交流群', conversationType: 'group', @@ -39,8 +40,19 @@ describe('mapAskWechatEvidence', () => { expect(item.contact.md5).not.toMatch(/^[0-9a-f]{32}$/) }) + it('证据编号直接消费 Host 的 citationId,不用数组下标合成', () => { + const mapped = mapAskWechatEvidence([ + { citationId: 'E3', messageRef: 'ref-3', source: 'search_messages' }, + { citationId: 'E7', messageRef: 'ref-7', source: 'search_messages' } + ]) + // 下标是 0/1;若还在自行编号就会得到 E1/E2 —— 那会让正文 [E3] 指向别的证据。 + expect(mapped.map((entry) => entry.evidenceId)).toEqual(['E3', 'E7']) + }) + it('缺少群名时给出可读回退,而不是把群消息归成"群聊"', () => { - const [item] = mapAskWechatEvidence([{ messageRef: 'ref-2', source: 'conversation_overview' }]) + const [item] = mapAskWechatEvidence([ + { citationId: 'E1', messageRef: 'ref-2', source: 'conversation_overview' } + ]) expect(item.contact.m_nsNickName).toBe('未命名会话') }) }) diff --git a/tests/unit/group-report-avatar-enrichment.test.ts b/tests/unit/group-report-avatar-enrichment.test.ts new file mode 100644 index 0000000..11771f7 --- /dev/null +++ b/tests/unit/group-report-avatar-enrichment.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { + buildReportAvatarAliasIndex, + mergeReportAvatars, + REPORT_AVATAR_ALIAS_FIELDS, + type ReportAvatarMember +} from '../../src/shared/group-report' + +/** + * 「日报头像 enrichment 死代码」的回归。 + * + * 两条独立缺陷: + * 1. `enrichAvatarsFromGroup` 的首行 `if (!metadata.talker) return` 恒真 —— 没有调用方写过 talker, + * 整段 enrichment 从未执行(由 `group-report-facts` 侧补 talker 修复)。 + * 2. 即使执行了,索引键只有 `member.nickname`(= `wechatNickname || groupNickname || wxid`), + * 而报告显示名默认是**群昵称**。成员同时有微信昵称与群昵称且不同时,索引全部对不上, + * 头像照样退化成首字 —— 这里锁死"多别名 + 不覆盖调用方头像"的语义。 + */ + +const AVATAR_A = 'http://wx.qlogo.cn/mmhead/a/0' +const AVATAR_B = 'http://wx.qlogo.cn/mmhead/b/0' + +const member = (patch: Partial & { wxid: string }): ReportAvatarMember => ({ + nickname: '', + groupNickname: '', + wechatNickname: '', + remark: '', + avatar: '', + ...patch +}) + +describe('buildReportAvatarAliasIndex — 显示名索引错位', () => { + it('微信昵称与群昵称不同时,两个名字都能取到头像', () => { + const index = buildReportAvatarAliasIndex([ + member({ + wxid: 'wxid_a', + // 快照的 nickname 是 wechatNickname 优先 —— 报告显示名却是群昵称 + nickname: '阿哲', + wechatNickname: '阿哲', + groupNickname: '阿哲(技术)', + avatar: AVATAR_A + }) + ]) + // 报告默认按群昵称显示 → 必须命中 + expect(index.get('阿哲(技术)')).toBe(AVATAR_A) + // 切到「微信昵称」模式时也要命中 + expect(index.get('阿哲')).toBe(AVATAR_A) + }) + + it('覆盖全部别名维度(含备注与 wxid)', () => { + const index = buildReportAvatarAliasIndex([ + member({ + wxid: 'wxid_b', + nickname: 'nick', + groupNickname: '群昵称', + wechatNickname: '微信昵称', + remark: '备注名', + avatar: AVATAR_B + }) + ]) + for (const alias of ['nick', '群昵称', '微信昵称', '备注名', 'wxid_b']) { + expect(index.get(alias), alias).toBe(AVATAR_B) + } + expect(REPORT_AVATAR_ALIAS_FIELDS).toHaveLength(5) + }) + + it('没有头像的成员不进索引 —— 避免用空值把别的成员头像覆盖掉', () => { + const index = buildReportAvatarAliasIndex([ + member({ wxid: 'wxid_c', nickname: '空空', groupNickname: '空空', avatar: '' }) + ]) + expect(index.size).toBe(0) + }) + + it('空字符串别名不进索引', () => { + const index = buildReportAvatarAliasIndex([ + member({ wxid: 'wxid_d', nickname: '', groupNickname: ' ', avatar: AVATAR_A }) + ]) + expect([...index.keys()]).toEqual(['wxid_d']) + }) + + it('同名先到先得,后到的成员不覆盖', () => { + const index = buildReportAvatarAliasIndex([ + member({ wxid: 'wxid_e1', groupNickname: '小明', avatar: AVATAR_A }), + member({ wxid: 'wxid_e2', groupNickname: '小明', avatar: AVATAR_B }) + ]) + expect(index.get('小明')).toBe(AVATAR_A) + }) +}) + +describe('mergeReportAvatars — 不覆盖调用方已给出的有效头像', () => { + it('已有头像保留,只补缺失的别名', () => { + const avatars: Record = { + 群昵称: 'http://from-caller/keep.jpg' + } + const index = buildReportAvatarAliasIndex([ + member({ + wxid: 'wxid_f', + nickname: '微信昵称', + groupNickname: '群昵称', + wechatNickname: '微信昵称', + avatar: AVATAR_A + }) + ]) + const filled = mergeReportAvatars(avatars, index) + + expect(avatars['群昵称']).toBe('http://from-caller/keep.jpg') + expect(avatars['微信昵称']).toBe(AVATAR_A) + expect(avatars['wxid_f']).toBe(AVATAR_A) + expect(filled).toBe(2) + }) + + it('空字符串视为缺失,允许补齐', () => { + const avatars: Record = { 群昵称: '' } + const index = buildReportAvatarAliasIndex([ + member({ wxid: 'wxid_g', groupNickname: '群昵称', avatar: AVATAR_B }) + ]) + mergeReportAvatars(avatars, index) + expect(avatars['群昵称']).toBe(AVATAR_B) + }) +}) diff --git a/tests/unit/group-report-member-hydration.test.ts b/tests/unit/group-report-member-hydration.test.ts new file mode 100644 index 0000000..c01ea1d --- /dev/null +++ b/tests/unit/group-report-member-hydration.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) + +import { hydrateGroupMemberIdentity } from '../../src/main/services/agent-group-report-service' +import type { Message } from '../../src/shared/types' + +/** + * 「Agent Hub 日报头像退化成首字」的回归。 + * + * 旧实现用 `isInternalName(message.name)` 做整体早退,而 `listMessages` 产出的 `name` + * 在真实群里通常是**已可读的昵称** → 整条记录被跳过 → `member.avatar` 永远补不上。 + * 这里锁死修正后的语义:头像只看 senderId 能不能命中真实群成员,与 name 是否可读无关。 + */ + +const AVATAR_ZHANG = 'http://wx.qlogo.cn/mmhead/zhang/0' +const AVATAR_LI = 'http://wx.qlogo.cn/mmhead/li/0' + +const members = [ + { + wxid: 'wxid_zhangsan', + nickname: '张三', + groupNickname: '张三(技术)', + wechatNickname: '张三', + remark: '', + avatar: AVATAR_ZHANG + }, + { + wxid: 'wxid_lisi', + nickname: '李四', + groupNickname: '李四', + wechatNickname: '李四', + remark: '', + avatar: AVATAR_LI + }, + { + wxid: 'wxid_noavatar', + nickname: '王五', + groupNickname: '王五', + wechatNickname: '王五', + remark: '', + avatar: '' + } +] + +const message = (patch: Partial): Message => + ({ + id: 'local-1', + from: 'user', + type: '普通文本', + datetime: '2026-09-17 10:00:00', + content: '正文', + isSender: false, + name: '', + createTime: 1_787_000_000, + ...patch + }) as Message + +describe('hydrateGroupMemberIdentity — Agent Hub 日报成员注入', () => { + it('message.name 已是正常中文昵称时,仍然补上 member.avatar', () => { + const [result] = hydrateGroupMemberIdentity( + [message({ senderId: 'wxid_zhangsan', name: '张三', img: '' })], + members, + 'groupNickname' + ) + // 这正是旧实现漏掉的那一条:name 可读 → 早退 → img 一直为空 → 导出层退化成首字头像 + expect(result.img).toBe(AVATAR_ZHANG) + }) + + it('message.name 仍是内部标识时,同时补显示名与头像', () => { + const [result] = hydrateGroupMemberIdentity( + [message({ senderId: 'wxid_lisi', name: 'wxid_lisi', img: '' })], + members, + 'groupNickname' + ) + expect(result.name).toBe('李四') + expect(result.img).toBe(AVATAR_LI) + }) + + it('显示名按 memberNameMode 解析,且只采用可读名(不降级成 wxid / 空串)', () => { + const [groupNickname] = hydrateGroupMemberIdentity( + [message({ senderId: 'wxid_zhangsan', name: '张三', img: '' })], + members, + 'groupNickname' + ) + expect(groupNickname.name).toBe('张三(技术)') + + const [wechatNickname] = hydrateGroupMemberIdentity( + [message({ senderId: 'wxid_zhangsan', name: '张三', img: '' })], + members, + 'wechatNickname' + ) + expect(wechatNickname.name).toBe('张三') + + // 快照只有 wxid 时解析结果是内部标识 → 保留原名,不写空、不退回 wxid + const [keepOriginal] = hydrateGroupMemberIdentity( + [message({ senderId: 'wxid_bare', name: '原始昵称', img: '' })], + [ + { + wxid: 'wxid_bare', + nickname: '', + groupNickname: '', + wechatNickname: '', + remark: '', + avatar: AVATAR_LI + } + ], + 'groupNickname' + ) + expect(keepOriginal.name).toBe('原始昵称') + expect(keepOriginal.img).toBe(AVATAR_LI) + }) + + it('消息自带有效 img 时保持优先,不被快照覆盖', () => { + const [result] = hydrateGroupMemberIdentity( + [message({ senderId: 'wxid_zhangsan', name: '张三', img: 'http://keep/me.jpg' })], + members, + 'groupNickname' + ) + expect(result.img).toBe('http://keep/me.jpg') + }) + + it('快照没有头像时不写入空 img', () => { + const [result] = hydrateGroupMemberIdentity( + [message({ senderId: 'wxid_noavatar', name: '王五', img: '' })], + members, + 'groupNickname' + ) + expect(result.img).toBe('') + expect(result.name).toBe('王五') + }) + + it('senderId 未命中群成员时整条消息原样返回', () => { + const original = message({ senderId: 'wxid_unknown', name: '陌生人', img: '' }) + const [result] = hydrateGroupMemberIdentity([original], members, 'groupNickname') + expect(result).toBe(original) + }) +}) diff --git a/tests/unit/query-agent-answer-rules.test.ts b/tests/unit/query-agent-answer-rules.test.ts index 4f30fd7..a53ccc3 100644 --- a/tests/unit/query-agent-answer-rules.test.ts +++ b/tests/unit/query-agent-answer-rules.test.ts @@ -23,7 +23,12 @@ describe('Query Agent 回答规则', () => { it('禁止续问邀约,并点名常见的错误句式', () => { expect(ANSWER_RULES).toContain('禁止') - for (const phrase of ['如果你需要,我可以', '要不要我继续', '我还可以帮你进一步', '需要的话我再查']) { + for (const phrase of [ + '如果你需要,我可以', + '要不要我继续', + '我还可以帮你进一步', + '需要的话我再查' + ]) { expect(ANSWER_RULES).toContain(phrase) } }) @@ -46,4 +51,18 @@ describe('Query Agent 回答规则', () => { it('没有同一性证据时不把"疑似同图"写成确定事实', () => { expect(ANSWER_RULES).toContain('内容高度相似') }) + + it('引用规则:关键事实要标 [E#],只能引用真实存在的编号', () => { + expect(ANSWER_RULES).toContain('[E3]') + expect(ANSWER_RULES).toContain('citationId') + // 必须明说"不得创建/改写"——只要求"请引用"是不够的,那会诱发幻觉编号 + expect(ANSWER_RULES).toContain('严禁') + expect(ANSWER_RULES).toContain('创建') + expect(ANSWER_RULES).toContain('改写') + // 其他括号写法必须被点名排除,否则模型会自创 [1] / (E3) + expect(ANSWER_RULES).toContain('[1]') + expect(ANSWER_RULES).toContain('(E3)') + // 概述/推断不加编号:避免"每句话都挂编号"的假依据 + expect(ANSWER_RULES).toContain('不加') + }) }) diff --git a/tests/unit/query-agent-citation.test.ts b/tests/unit/query-agent-citation.test.ts new file mode 100644 index 0000000..1554698 --- /dev/null +++ b/tests/unit/query-agent-citation.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from 'vitest' +import { + QueryAgentService, + type QueryAgentProvider, + type QueryAgentToolResult +} from '../../src/main/services/query-agent-service' +import type { AIChatToolCall } from '../../src/main/services/ai-provider-service' + +/** + * Query Agent 的稳定 Citation 契约。 + * + * 四个必须同时成立的条件(缺一,正文里的 `[E#]` 就会消失或变成幻觉引用): + * 1. Host 在**模型调用之前**给 evidence 分配稳定 `citationId`,同一 messageRef 只分配一次; + * 2. `citationId` 随 Tool Result 进入**模型可见**上下文(模型只能引用它见过的编号); + * 3. 模型可见编号集合 ⊆ UI 可见集合,且同号(UI 证据卡 / 底部引用按钮不另行编号); + * 4. 回答返回前经 Host 校验:非法 `[E#]` 移除并回报,绝不进 UI。 + */ + +function provider( + responses: Array>>, + configured = true +): QueryAgentProvider { + return { + getRuntimeConfig: () => ({ + configured, + providerName: 'Fixture Provider', + model: 'fixture-model', + modelName: 'Fixture Model' + }), + chatWithTools: vi.fn(async () => responses.shift() || { success: true, data: 'done' }) + } +} + +const queryMessagesCall = (id: string): AIChatToolCall => ({ + id, + name: 'query_messages', + arguments: JSON.stringify({ + target: { query: 'BOBO' }, + timeRange: { kind: 'all' }, + temporalBasis: { kind: 'none' }, + limit: 5 + }) +}) + +const searchCall = (id: string): AIChatToolCall => ({ + id, + name: 'search_messages', + arguments: JSON.stringify({ + target: { query: 'BOBO' }, + timeRange: { kind: 'all' }, + queries: ['topic'] + }) +}) + +const contextCall = (id: string, messageRef: string): AIChatToolCall => ({ + id, + name: 'message_context', + arguments: JSON.stringify({ messageRef, before: 1, after: 1 }) +}) + +/** 从第 N 次模型调用里取出 tool 消息内容。 */ +const toolMessageOf = (mocked: QueryAgentProvider, callIndex: number): Record => { + const messages = vi.mocked(mocked.chatWithTools).mock.calls[callIndex]?.[0] || [] + const toolMessage = messages.find((message) => message.role === 'tool') + expect(toolMessage, `第 ${callIndex + 1} 次模型调用应带 tool 消息`).toBeTruthy() + return JSON.parse(String(toolMessage?.content)) as Record +} + +describe('Query Agent Citation — Host 分配稳定编号', () => { + it('首次命中即分配 E1、E2…,顺序与 evidence 契约一致', async () => { + const execute = vi.fn( + async (): Promise => ({ + status: 'completed', + returnedCount: 2, + messages: [{ messageRef: 'ref-1' }, { messageRef: 'ref-2' }] + }) + ) + const service = new QueryAgentService( + provider([ + { success: true, toolCalls: [queryMessagesCall('call-1')] }, + { success: true, data: '读到了两条。' } + ]), + execute + ) + const result = await service.run('我和 BOBO 最开始聊了什么') + + expect(result.evidence?.map((item) => item.citationId)).toEqual(['E1', 'E2']) + expect(result.evidence?.map((item) => item.messageRef)).toEqual(['ref-1', 'ref-2']) + }) + + it('同一 messageRef 被第二个 Tool 再次命中时保持原 citationId,不重排、不新增', async () => { + const execute = vi.fn(async (name: string, input: Record) => { + if (name === 'search_messages') { + return { + status: 'completed', + evidenceCount: 2, + evidence: [{ messageRef: 'ref-1' }, { messageRef: 'ref-2' }] + } as QueryAgentToolResult + } + // message_context:anchor 就是被补语境的那条证据(ref-2 再次命中), + // before/after 不是本次结论的依据,不该进证据列表。 + return { + status: 'completed', + anchor: { messageRef: String(input.messageRef) }, + before: [{ messageRef: 'before-1' }], + after: [{ messageRef: 'after-1' }] + } as QueryAgentToolResult + }) + const configuredProvider = provider([ + { success: true, toolCalls: [searchCall('call-1')] }, + { success: true, toolCalls: [contextCall('call-2', 'ref-2')] }, + { success: true, data: '第二条更重要[E2]。' } + ]) + const result = await new QueryAgentService(configuredProvider, execute).run('查找相关记录') + + // ref-1 → E1,ref-2 → E2;ref-2 被 context 再次命中后编号不变 + expect(result.evidence?.map((item) => ({ ref: item.messageRef, id: item.citationId }))).toEqual( + [ + { ref: 'ref-1', id: 'E1' }, + { ref: 'ref-2', id: 'E2' } + ] + ) + // before/after 不进证据列表 + expect(result.evidence?.some((item) => String(item.messageRef).startsWith('before'))).toBe( + false + ) + }) + + it('citationId 随 Tool Result 进入模型可见上下文(否则模型无从引用)', async () => { + const execute = vi.fn( + async (): Promise => ({ + status: 'completed', + returnedCount: 2, + messages: [{ messageRef: 'ref-1' }, { messageRef: 'ref-2' }] + }) + ) + const configuredProvider = provider([ + { success: true, toolCalls: [queryMessagesCall('call-1')] }, + { success: true, data: '完成。' } + ]) + const result = await new QueryAgentService(configuredProvider, execute).run('第一条消息') + + const presented = toolMessageOf(configuredProvider, 1) + const presentedEvidence = presented.messages as Array> + expect(presentedEvidence.map((item) => item.citationId)).toEqual(['E1', 'E2']) + // 模型可见编号集合 == 契约编号集合(同号,不是另一套 index) + expect(presentedEvidence.map((item) => item.citationId)).toEqual( + result.evidence?.map((item) => item.citationId) + ) + }) + + it('超过证据上限的条目不分配编号,也不带 citationId 进模型上下文', async () => { + const limit = 40 + const execute = vi.fn( + async (): Promise => ({ + status: 'completed', + returnedCount: limit + 5, + messages: Array.from({ length: limit + 5 }, (_, index) => ({ + messageRef: `ref-${index + 1}` + })) + }) + ) + const configuredProvider = provider([ + { success: true, toolCalls: [queryMessagesCall('call-1')] }, + { success: true, data: '完成。' } + ]) + const result = await new QueryAgentService(configuredProvider, execute).run('第一条消息') + + expect(result.evidence).toHaveLength(limit) + expect(result.evidence?.at(-1)?.citationId).toBe(`E${limit}`) + // 第 41 条以后没有编号:模型上下文里也不该出现它们的 citationId + const presented = toolMessageOf(configuredProvider, 1).messages as Array< + Record + > + const cited = presented.filter((item) => typeof item.citationId === 'string') + expect(cited).toHaveLength(limit) + expect(presented.filter((item) => item.messageRef === 'ref-41')[0]?.citationId).toBeUndefined() + }) +}) + +describe('Query Agent Citation — Host 侧校验,幻觉编号不得进入 UI', () => { + it('合法的 [E#] 原样保留', async () => { + const execute = vi.fn( + async (): Promise => ({ + status: 'completed', + returnedCount: 1, + messages: [{ messageRef: 'ref-1' }] + }) + ) + const service = new QueryAgentService( + provider([ + { success: true, toolCalls: [queryMessagesCall('call-1')] }, + { success: true, data: '张三提到健身[E1]。' } + ]), + execute + ) + const result = await service.run('第一条消息') + + expect(result.answer).toBe('张三提到健身[E1]。') + expect(result.invalidCitationIds).toBeUndefined() + }) + + it('不存在的 [E#] 被移除并回报 invalidCitationIds', async () => { + const execute = vi.fn( + async (): Promise => ({ + status: 'completed', + returnedCount: 1, + messages: [{ messageRef: 'ref-1' }] + }) + ) + const service = new QueryAgentService( + provider([ + { success: true, toolCalls: [queryMessagesCall('call-1')] }, + { success: true, data: '张三提到健身[E1] ,另有来源[E9][E99]。' } + ]), + execute + ) + const result = await service.run('第一条消息') + + expect(result.answer).toContain('[E1]') + expect(result.answer).not.toMatch(/\[E(?:9|99)\]/) + expect(result.invalidCitationIds).toEqual(['E9', 'E99']) + }) + + it('本轮没有任何证据时,回答里的 [E#] 一律移除', async () => { + const configuredProvider = provider([{ success: true, data: '这个群今天聊了健身[E1]。' }]) + const result = await new QueryAgentService(configuredProvider, vi.fn()).run('闲聊') + + expect(result.answer).toBe('这个群今天聊了健身。') + expect(result.invalidCitationIds).toEqual(['E1']) + // 没有 Tool 就没有证据:白名单为空,所以任何编号都不成立。 + // (Runtime 不主动造空数组,Adapter 侧按 `|| []` 兜底,与既有契约一致。) + expect(result.evidence ?? []).toEqual([]) + }) +}) diff --git a/tests/unit/report-hero-avatar-geometry.test.ts b/tests/unit/report-hero-avatar-geometry.test.ts new file mode 100644 index 0000000..8de4fd5 --- /dev/null +++ b/tests/unit/report-hero-avatar-geometry.test.ts @@ -0,0 +1,102 @@ +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { describe, expect, it } from 'vitest' +import { REPORT_TEMPLATE_FRAGMENT_CONTRACT_CSS } from '../../src/shared/report-template-fragment-contract' + +/** + * hero 头像的几何只有一份权威。 + * + * 生产导出会注入 `report-template-fragment-contract.ts`,其中 + * `img.tm-avatar.tm-avatar--hero` 用 `!important` 把头像钉在 + * `clamp(28px, var(--tm-avatar-hero-size, 40px), 56px)`。 + * 如果模板再给容器写死一个尺寸(旧版 `.avatar-grid` 是 58x58、单头像 28x28), + * 两边就会打架:头像实际 40px,2 列 x 40px + 3px gap = 83px 塞不进 58px 的盒子, + * 头像向右向下溢出容器,视觉上越过卡片内边距 —— 这就是"日报头像超出范围"。 + * + * 这里断言的是**不变量**,不是措辞: + * 1. 容器尺寸必须来自 contract 的变量,不能写死; + * 2. 容器的最小可用宽度必须能容纳 2 列头像(由变量算出来,而不是靠猜); + * 3. contract 提供的变量在容器里被真正引用(改名/改值不会静默分叉)。 + */ + +const HERO_AVATAR_GAP_PX = 3 + +const readResource = (file: string): string => readFileSync(resolve('resources', file), 'utf8') + +/** 取出某个选择器的声明块(第一个匹配),返回声明文本。 */ +const ruleBody = (css: string, selector: string): string => { + const index = css.indexOf(selector) + if (index < 0) throw new Error(`selector not found: ${selector}`) + const open = css.indexOf('{', index) + const close = css.indexOf('}', open) + if (open < 0 || close < 0) throw new Error(`unterminated rule: ${selector}`) + return css.slice(open + 1, close) +} + +/** contract 里 hero 头像的目标尺寸与变量名。 */ +const contractHeroAvatar = (): { variable: string; fallbackPx: number } => { + const name = /--tm-avatar-hero-size:\s*([\d.]+)px/.exec(REPORT_TEMPLATE_FRAGMENT_CONTRACT_CSS) + expect(name, 'contract 必须声明 --tm-avatar-hero-size').not.toBeNull() + const forced = + /img\.tm-avatar\.tm-avatar--hero\s*\{[\s\S]*?var\((--tm-avatar-hero-size),\s*([\d.]+)px\)/.exec( + REPORT_TEMPLATE_FRAGMENT_CONTRACT_CSS + ) + expect(forced, 'contract 必须用变量 + px 兜底约束 hero 头像').not.toBeNull() + return { variable: forced![1], fallbackPx: Number(forced![2] || name![1]) } +} + +const LEGACY_TEMPLATES = ['mobile_daily_report_v1.html', 'mobile_daily_report_v2.html'] + +describe('日报 hero 头像几何 — 容器与 contract 不得分叉', () => { + it('contract 通过变量约束 hero 头像尺寸(含 px 兜底)', () => { + const { variable, fallbackPx } = contractHeroAvatar() + expect(variable).toBe('--tm-avatar-hero-size') + expect(fallbackPx).toBeGreaterThanOrEqual(28) + expect(fallbackPx).toBeLessThanOrEqual(56) + // 必须带 !important,否则模板的 width/height 会盖掉它,几何又会分裂 + const heroRule = /img\.tm-avatar\.tm-avatar--hero\s*\{[\s\S]*?\}/.exec( + REPORT_TEMPLATE_FRAGMENT_CONTRACT_CSS + )![0] + expect(heroRule).toContain('!important') + }) + + for (const file of LEGACY_TEMPLATES) { + describe(file, () => { + const css = readResource(file) + + it('avatar-grid 不再写死容器尺寸(写死就会小于头像本身)', () => { + const body = ruleBody(css, '.avatar-grid {') + expect(body, '容器写死 width 会让 40px 头像溢出').not.toMatch(/(^|\s)width\s*:/) + expect(body, '容器写死 height 会让第二行头像纵向溢出').not.toMatch(/(^|\s)height\s*:/) + }) + + it('列宽与行高都取 contract 的同一个变量', () => { + const { variable, fallbackPx } = contractHeroAvatar() + const body = ruleBody(css, '.avatar-grid {') + expect(body).toContain('grid-template-columns') + expect(body).toContain('grid-auto-rows') + // 变量名与兜底值都必须和 contract 一致 —— 否则主题化会分叉 + expect(body).toContain(`var(${variable}, ${fallbackPx}px)`) + }) + + it('单头像时列宽等于一个头像,不再用 28px 的窄盒子', () => { + const { variable, fallbackPx } = contractHeroAvatar() + const body = ruleBody(css, '.avatar-grid.avatar-count-1') + expect(body).toContain('grid-template-columns') + expect(body).toContain(`var(${variable}, ${fallbackPx}px)`) + expect(body).not.toMatch(/(^|\s)width\s*:/) + expect(body).not.toMatch(/(^|\s)height\s*:/) + }) + + it('按 contract 尺寸算出的两列簇宽不会被任何固定宽度切断', () => { + const { fallbackPx } = contractHeroAvatar() + const clusterWidth = fallbackPx * 2 + HERO_AVATAR_GAP_PX + const body = ruleBody(css, '.avatar-grid {') + const fixed = /(^|\s)width\s*:\s*(\d+(?:\.\d+)?)px/.exec(body) + // 既没有固定宽度,也不存在比簇宽更小的固定宽度 + if (fixed) expect(Number(fixed[2])).toBeGreaterThanOrEqual(clusterWidth) + expect(clusterWidth).toBeLessThanOrEqual(430 - 2 * 12 - 2 * 20 - 14) + }) + }) + } +})