mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
fix: 问问微信恢复引用编号,日报修复成员头像与 hero 溢出
- 问问微信:Host 分配稳定 citationId 并注入模型上下文,正文 [E#] 与证据按钮同号 - 问问微信:回答返回前校验引用,幻觉编号移除并提示,不再出现不可信的引用按钮 - Agent Hub:收紧「最近会话」快捷路由,内容查询交回 Query Agent
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="18" fill="hsl(${hue} 45% 82%)"/><text x="48" y="58" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,PingFang SC,sans-serif" font-size="38" fill="hsl(${hue} 35% 28%)">${initial}</text></svg>`
|
||||
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<RenderedAvatar> => {
|
||||
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<Re
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const mime = imageMimeType(response.headers.get('content-type'), source)
|
||||
return { source: `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`, fallback: false }
|
||||
return {
|
||||
source: `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}`,
|
||||
fallback: false
|
||||
}
|
||||
}
|
||||
|
||||
const localPath = source.startsWith('file://') ? new URL(source) : source
|
||||
const buffer = await fs.readFile(localPath)
|
||||
return { source: `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`, fallback: false }
|
||||
return {
|
||||
source: `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}`,
|
||||
fallback: false
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[GroupReport] avatar fallback for ${name}:`, error)
|
||||
return fallbackAvatar(name)
|
||||
@@ -144,6 +156,12 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<Re
|
||||
* - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback
|
||||
* - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补
|
||||
* - 同名取首条(P2 风险:群里两人同名)
|
||||
*
|
||||
* **必须按多个别名建索引**:报告里的显示名取决于 `memberNameMode`
|
||||
* (默认 `groupNickname` = 群昵称),而快照的 `nickname` 字段是
|
||||
* `wechatNickname || groupNickname || username`(见 `normalizeGroupMembers`)。
|
||||
* 一个成员同时有微信昵称与群昵称且两者不同时,只按 `nickname` 建索引就会**全部对不上**,
|
||||
* 于是头像 enrichment 静默失效 —— 这正是原先只用单一索引键时的问题。
|
||||
*/
|
||||
const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<void> => {
|
||||
if (!metadata.talker) return
|
||||
@@ -162,22 +180,16 @@ const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise<vo
|
||||
return
|
||||
}
|
||||
|
||||
const index = new Map<string, string>()
|
||||
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<stri
|
||||
}
|
||||
|
||||
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
|
||||
const heroAvatars = heroNames
|
||||
.map((name) => 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<stri
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
||||
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
||||
return addReportCsp(injectReportTemplateFragmentContract(html), resolvedTemplate.source === 'builtin')
|
||||
return addReportCsp(
|
||||
injectReportTemplateFragmentContract(html),
|
||||
resolvedTemplate.source === 'builtin'
|
||||
)
|
||||
}
|
||||
|
||||
const renderReportSnapshotHtml = async (
|
||||
@@ -679,7 +692,10 @@ const renderReportSnapshotHtml = async (
|
||||
REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate)
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
return addReportCsp(html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''), resolvedTemplate.source === 'builtin')
|
||||
return addReportCsp(
|
||||
html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''),
|
||||
resolvedTemplate.source === 'builtin'
|
||||
)
|
||||
}
|
||||
|
||||
export const extractGroupReportRenderSnapshot = async (
|
||||
@@ -1021,15 +1037,10 @@ export const exportGroupReport = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(
|
||||
htmlPath,
|
||||
pngPath,
|
||||
request.templateId,
|
||||
{
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
}
|
||||
)
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
})
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
@@ -1074,15 +1085,10 @@ export const exportGroupReportSnapshot = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(
|
||||
htmlPath,
|
||||
pngPath,
|
||||
request.templateId,
|
||||
{
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
}
|
||||
)
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId, {
|
||||
...resolvedTemplate.definition,
|
||||
maxCaptureHeight: resolvedTemplate.captureMaxHeight
|
||||
})
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -23,6 +23,45 @@ import {
|
||||
|
||||
const aiProvider = new AIProviderService()
|
||||
|
||||
/**
|
||||
* 用真实群成员快照补全消息的显示名与头像。
|
||||
*
|
||||
* **头像与名称的门槛刻意不同**:
|
||||
*
|
||||
* 此前这里先用 `isInternalName(message.name)` 做整体早退 —— 只有当消息里的名字还是内部标识
|
||||
* (空 / `wxid_*` / `*@chatroom` / 18+ 位字母数字)时才继续。但 `listMessages` 产出的 `name`
|
||||
* 优先取 `senderNickname`,在真实群里通常是**已可读的昵称**,于是整条记录被跳过、
|
||||
* `member.avatar` 永远补不上,导出层只能退化成首字头像;软件内日报没有这个门槛,
|
||||
* 所以它能显示真实头像。
|
||||
*
|
||||
* 现在:**只要 senderId 命中真实群成员就允许补头像**;名称只在解析结果确实是可读名时才采用,
|
||||
* 避免把调用方已有的昵称降级成空串或内部标识。消息自带的 `img` 始终优先。
|
||||
*/
|
||||
export function hydrateGroupMemberIdentity(
|
||||
messages: Message[],
|
||||
members: ReadonlyArray<NonNullable<ReturnType<typeof getGroupSnapshot>>['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')
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<string | Pick<AiSearchFinalEvidence, 'id'>>
|
||||
| ReadonlySet<string>
|
||||
|
||||
/** Do not expose citations that cannot resolve to program-owned Final Evidence. */
|
||||
export function sanitizeAnswerCitations(
|
||||
answer: string,
|
||||
evidence: Array<Pick<AiSearchFinalEvidence, 'id'>>
|
||||
allowed: CitationAllowList
|
||||
): CitationValidationResult {
|
||||
const allowed = new Set(evidence.map((item) => item.id))
|
||||
const allowedIds = new Set<string>()
|
||||
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<string>()
|
||||
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 ''
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -94,6 +94,13 @@ export function AISearchWorkspace({
|
||||
const [queryAgentEnabled, setQueryAgentEnabled] = useState(false)
|
||||
/** Query Agent 本次回答的真实统计(读取条数 / 证据条数 / 模型调用 / 耗时)。 */
|
||||
const [askStats, setAskStats] = useState<AskWechatStats | null>(null)
|
||||
/**
|
||||
* Query Agent 回答里被 Host 移除的非法 `[E#]`。
|
||||
*
|
||||
* 有值 = 模型引用了不存在的编号;已从正文剥离,这里只做告知,
|
||||
* 不让用户以为"每条断言都有出处"。
|
||||
*/
|
||||
const [askInvalidCitationIds, setAskInvalidCitationIds] = useState<string[]>([])
|
||||
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) => (
|
||||
<span key={label}>能力:{label}</span>
|
||||
))}
|
||||
{/* 与 Legacy 同一措辞:Host 侧已把无法对应证据的引用从正文移除。
|
||||
这里只做告知,不把它渲染成可点击的引用。 */}
|
||||
{askInvalidCitationIds.length > 0 && (
|
||||
<span data-testid="query-invalid-citations">
|
||||
已移除无效引用:{askInvalidCitationIds.join('、')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 耗时拆解:把总耗时还原成"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
|
||||
</span>
|
||||
)}
|
||||
@@ -985,9 +1007,16 @@ export function AISearchWorkspace({
|
||||
{evidence.length > 0 && (
|
||||
<div className="ai-search-answer-evidence" aria-label="AI 引用证据">
|
||||
<span>引用:</span>
|
||||
{evidence.map((_, index) => (
|
||||
<button key={index} type="button" onClick={() => focusEvidence(index)}>
|
||||
E{index + 1}
|
||||
{evidence.map((item, index) => (
|
||||
// 标签取 Host 分配的 evidenceId(= citationId),与正文 inline citation 同号;
|
||||
// 只有 Legacy 缓存记录可能缺 evidenceId,才退回下标编号。
|
||||
<button
|
||||
key={item.evidenceId || index}
|
||||
type="button"
|
||||
data-evidence-id={item.evidenceId || `E${index + 1}`}
|
||||
onClick={() => focusEvidence(index)}
|
||||
>
|
||||
{item.evidenceId || `E${index + 1}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1283,7 +1312,8 @@ export function AISearchWorkspace({
|
||||
)}
|
||||
{knowledgeStatus.pass && knowledgeStatus.pass.skippedConversations > 0 && (
|
||||
<p className="ai-search-knowledge-pass-line">
|
||||
已跳过 {knowledgeStatus.pass.skippedConversations.toLocaleString()} 个没有新消息的会话
|
||||
已跳过 {knowledgeStatus.pass.skippedConversations.toLocaleString()}{' '}
|
||||
个没有新消息的会话
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
* (展示契约里刻意不含 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 已指向它)。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, string> => {
|
||||
const index = new Map<string, string>()
|
||||
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<string, string | undefined>,
|
||||
index: ReadonlyMap<string, string>
|
||||
): 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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<AskWechatQueryResult, { status: 'answered' }>
|
||||
|
||||
@@ -65,7 +70,7 @@ const diagnostics = {
|
||||
outcome: 'answered' as const
|
||||
}
|
||||
|
||||
const makeProps = (): Record<string, unknown> => ({
|
||||
const makeProps = (): AISearchWorkspaceProps => ({
|
||||
contacts: [aiSearchContact, aiSearchGroup],
|
||||
selectedContact: aiSearchContact,
|
||||
dbReady: true,
|
||||
@@ -141,7 +146,7 @@ const answeredResult = (patch: Partial<AnsweredResult> = {}): AnsweredResult =>
|
||||
|
||||
describe('AISearchWorkspace — Query Agent 主路径', () => {
|
||||
it('Query Agent 开启时用它回答,不再调用 Legacy 检索', async () => {
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
await submitQuery('我和 BOBO 第一次聊了什么')
|
||||
|
||||
expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy()
|
||||
@@ -155,7 +160,7 @@ describe('AISearchWorkspace — Query Agent 主路径', () => {
|
||||
api.runAskWechatQuery.mockResolvedValue(
|
||||
answeredResult({ answer: '当前可读取的完整范围里没有找到相关记录。' })
|
||||
)
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
await submitQuery('BOBO 给我发过文件吗')
|
||||
|
||||
expect(await screen.findByText('当前可读取的完整范围里没有找到相关记录。')).toBeTruthy()
|
||||
@@ -169,7 +174,7 @@ describe('AISearchWorkspace — Query Agent 主路径', () => {
|
||||
message: '当前 AI 查询服务暂时不可用,请稍后再试。',
|
||||
diagnostics: { ...diagnostics, outcome: 'provider_failure' }
|
||||
})
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
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(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
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(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
await submitQuery('我和测试会话最近聊了什么')
|
||||
|
||||
expect(await screen.findByText('测试搜索答案')).toBeTruthy()
|
||||
@@ -204,7 +209,7 @@ describe('AISearchWorkspace — Query Agent 主路径', () => {
|
||||
configurable: true,
|
||||
value: { ...api, getAskWechatConfig: undefined, runAskWechatQuery: undefined }
|
||||
})
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
await submitQuery('我和测试会话最近聊了什么')
|
||||
|
||||
expect(await screen.findByText('测试搜索答案')).toBeTruthy()
|
||||
@@ -214,7 +219,7 @@ describe('AISearchWorkspace — Query Agent 主路径', () => {
|
||||
|
||||
describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据', () => {
|
||||
it('Query Agent 开启时保留搜索范围、隐藏时间范围,并提示时间写在问题里', async () => {
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
|
||||
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(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
|
||||
expect(await screen.findByText('时间范围')).toBeTruthy()
|
||||
expect(screen.getByText('不限时间')).toBeTruthy()
|
||||
@@ -239,7 +244,7 @@ describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据
|
||||
})
|
||||
|
||||
it('把界面选择的搜索范围传给 Query Agent', async () => {
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
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(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
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(<AISearchWorkspace {...makeProps()} />)
|
||||
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(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
|
||||
// ready ≠ fresh:落后时必须说「可用 · 待追新」,并在描述里给出真实覆盖边界,
|
||||
// 绝不能用笼统的「已同步」把两件事混为一谈。
|
||||
@@ -314,7 +350,7 @@ describe('AISearchWorkspace — 知识库新鲜度状态', () => {
|
||||
})
|
||||
|
||||
it('索引已追平源数据最新时才显示「可用 · 已追至最新」', async () => {
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
|
||||
expect(await screen.findByText('可用 · 已追至最新')).toBeTruthy()
|
||||
expect(screen.queryByText('已同步')).toBeNull()
|
||||
@@ -346,7 +382,7 @@ describe('AISearchWorkspace — 查询进度与耗时拆解', () => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
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(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
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(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
await submitQuery('最近谁聊过健身')
|
||||
|
||||
await emitProgress('stale-request-id', { stage: 'generating_answer' })
|
||||
@@ -432,7 +468,7 @@ describe('AISearchWorkspace — 查询进度与耗时拆解', () => {
|
||||
}
|
||||
})
|
||||
)
|
||||
render(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
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(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
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(<AISearchWorkspace {...(makeProps() as never)} />)
|
||||
render(<AISearchWorkspace {...makeProps()} />)
|
||||
await submitQuery('最近谁聊过健身')
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /取消分析/ }))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('未命名会话')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ReportAvatarMember> & { 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<string, string | undefined> = {
|
||||
群昵称: '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<string, string | undefined> = { 群昵称: '' }
|
||||
const index = buildReportAvatarAliasIndex([
|
||||
member({ wxid: 'wxid_g', groupNickname: '群昵称', avatar: AVATAR_B })
|
||||
])
|
||||
mergeReportAvatars(avatars, index)
|
||||
expect(avatars['群昵称']).toBe(AVATAR_B)
|
||||
})
|
||||
})
|
||||
@@ -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>): 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)
|
||||
})
|
||||
})
|
||||
@@ -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('不加')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Awaited<ReturnType<QueryAgentProvider['chatWithTools']>>>,
|
||||
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<string, unknown> => {
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
describe('Query Agent Citation — Host 分配稳定编号', () => {
|
||||
it('首次命中即分配 E1、E2…,顺序与 evidence 契约一致', async () => {
|
||||
const execute = vi.fn(
|
||||
async (): Promise<QueryAgentToolResult> => ({
|
||||
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<string, unknown>) => {
|
||||
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<QueryAgentToolResult> => ({
|
||||
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<Record<string, unknown>>
|
||||
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<QueryAgentToolResult> => ({
|
||||
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<string, unknown>
|
||||
>
|
||||
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<QueryAgentToolResult> => ({
|
||||
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<QueryAgentToolResult> => ({
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user