fix: 问问微信恢复引用编号,日报修复成员头像与 hero 溢出

- 问问微信:Host 分配稳定 citationId 并注入模型上下文,正文 [E#] 与证据按钮同号
- 问问微信:回答返回前校验引用,幻觉编号移除并提示,不再出现不可信的引用按钮
- Agent Hub:收紧「最近会话」快捷路由,内容查询交回 Query Agent
This commit is contained in:
Wxw-Gu
2026-09-17 18:56:32 +08:00
parent 4b2395a8d2
commit b97e1f4bc6
23 changed files with 1635 additions and 228 deletions
+44 -38
View File
@@ -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,
+43 -15
View File
@@ -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')
+36 -1
View File
@@ -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))
}
+20 -3
View File
@@ -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 ''
})
+13 -8
View File
@@ -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 已指向它)。
+9 -1
View File
@@ -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(
+60
View File
@@ -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'
+16
View File
@@ -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
}