diff --git a/apps/admin-web/src/views/TrackingQuestionDetailView.vue b/apps/admin-web/src/views/TrackingQuestionDetailView.vue index bfb2e98..781afee 100644 --- a/apps/admin-web/src/views/TrackingQuestionDetailView.vue +++ b/apps/admin-web/src/views/TrackingQuestionDetailView.vue @@ -321,10 +321,22 @@ function createQwenSourceGroupPattern(): RegExp { } function createYuanbaoCitationPattern(): RegExp { - return /\[citation:\s*(\d+)\]/gi + // Match both formats so the same renderer works across the migration: + // - "[citation:N]" — legacy yuanbao SSE protocol. + // - "[N]" — new hunyuan_t1 protocol; the desktop adapter now lowers + // `
` placeholders to bare "[N]" and also + // normalises any residual "[citation:N]" SSE frames to bare "[N]" so + // yuanbao answers render with the same chip-style superscript as + // DeepSeek. + return /\[(?:citation:\s*)?(\d+)\]/gi } function createYuanbaoCitationStripPattern(): RegExp { + // The pattern's `replace` step covers any `[N]` we successfully matched. + // Strip leftover legacy `[citation:...]` fragments only — we must not + // strip bare "[N]" tokens here, because formatAnswerContent runs strip + // *after* replacing matched markers, so any remaining "[N]" is incidental + // text that should stay untouched. return /\[\s*citation\s*:[^\[\]]*?\]/gi } diff --git a/apps/desktop-client/src/main/adapters/yuanbao.test.ts b/apps/desktop-client/src/main/adapters/yuanbao.test.ts index 12300ad..365e630 100644 --- a/apps/desktop-client/src/main/adapters/yuanbao.test.ts +++ b/apps/desktop-client/src/main/adapters/yuanbao.test.ts @@ -10,6 +10,7 @@ const { extractQuestionText, findUnresolvedCitationIndexes, mergeText, + normalizeCitationMarkers, normalizeSourceUrl, parseYuanbaoCaptures, resolveCitationsFromMarkers, @@ -129,6 +130,124 @@ describe('yuanbao adapter helpers', () => { expect(findUnresolvedCitationIndexes('正文没有引用小图标', [])).toEqual([]) }) + it("recognises both '[N]' and '[citation:N]' inline markers", () => { + // The new yuanbao model lowers
placeholders to "[1][2][10]"; + // older SSE frames may still emit "[citation:N]". Both must extract the same indexes. + expect(extractCitationMarkerIndexes('合肥老牌选 [1][2] 性价比 [10]。')).toEqual([1, 2, 10]) + expect( + extractCitationMarkerIndexes('混合两种格式 [citation:3] 还有 [4]'), + ).toEqual([3, 4]) + + const sources = [ + { url: 'https://example.com/1', normalized_url: 'https://example.com/1' }, + { url: 'https://example.com/2', normalized_url: 'https://example.com/2' }, + ] + expect( + resolveCitationsFromMarkers('正文 [1] 然后 [2]', [[], sources]).map((item) => item.url), + ).toEqual(['https://example.com/1', 'https://example.com/2']) + }) + + it('rewrites legacy [citation:N] markers to bare [N] for SaaS rendering', () => { + expect(normalizeCitationMarkers('正文 [citation:1] 和 [citation:10]')).toBe( + '正文 [1] 和 [10]', + ) + // Already-normalised text passes through. + expect(normalizeCitationMarkers('已经是 [1][2] 格式')).toBe('已经是 [1][2] 格式') + // Null/empty pass through. + expect(normalizeCitationMarkers(null)).toBeNull() + expect(normalizeCitationMarkers('')).toBe('') + }) + + it("does not invent citations for fast-cache answers (no searchGuid frame, no [N] markers)", () => { + // hunyuan_t1 sometimes returns a cached / non-search answer ("已深度思考(用时1秒)" + // with no "找到了 N 篇相关资料" panel). The SSE then carries only `text` + // frames and no `searchGuid`. The adapter must still surface that answer to + // the SaaS pipeline — see the top-level query path: as long as `answer` is + // non-empty and there are no [N] markers, neither the empty-response nor + // the incomplete-citations branches fire, so we fall through to status=succeeded. + const sseFrames = [ + JSON.stringify({ type: 'text', msg: '在合肥,' }), + JSON.stringify({ type: 'text', msg: '全屋定制市场非常成熟,' }), + JSON.stringify({ type: 'text', msg: '没有绝对"最好"的商家。' }), + ] + .map((line) => `data: ${line}`) + .join('\n\n') + + const summary = parseYuanbaoCaptures([ + { + url: 'https://yuanbao.tencent.com/api/chat/cache-hit', + status: 200, + contentType: 'text/event-stream', + body: sseFrames, + }, + ]) + + expect(summary.answer).toBe('在合肥,全屋定制市场非常成熟,没有绝对"最好"的商家。') + expect(summary.citations).toEqual([]) + expect(summary.searchResults).toEqual([]) + // No marker → no unresolved indexes, so the adapter does not gate on citations. + expect(extractCitationMarkerIndexes(summary.answer)).toEqual([]) + expect(findUnresolvedCitationIndexes(summary.answer, summary.citations)).toEqual([]) + }) + + it("aligns citations[index-1] with answer markers when SSE places refs in searchGuid.docs (new hunyuan_t1 protocol)", () => { + // Captured against the live yuanbao endpoint: deep-search now ships every + // reference inside `searchGuid.docs[]` with a 1-based `index`, and the + // legacy `citations` field is always null in this frame. Whatever the + // adapter normalises to must preserve idx alignment so that the + // unresolved-citations check passes. + const sseFrames = [ + JSON.stringify({ + type: 'searchGuid', + title: '引用 3 篇资料作为参考', + docs: [ + { + index: 1, + title: '志邦家居官网', + url: 'https://www.zbom.com/about', + web_url: 'https://www.zbom.com/about', + web_site_name: '志邦家居', + }, + { + index: 2, + title: '合肥本地装修评测', + url: 'https://example.com/hefei-review', + web_site_name: '今日头条', + }, + { + index: 3, + title: '客来福官方介绍', + url: 'https://example.com/kelaifu', + web_site_name: '客来福', + }, + ], + citations: null, + }), + JSON.stringify({ + type: 'text', + msg: '志邦家居 [1] 与客来福 [3] 都是合肥本土头部 [2]。', + }), + ] + .map((line) => `data: ${line}`) + .join('\n\n') + + const summary = parseYuanbaoCaptures([ + { + url: 'https://yuanbao.tencent.com/api/chat/abc', + status: 200, + contentType: 'text/event-stream', + body: sseFrames, + }, + ]) + + expect(summary.citations.map((item) => item.url)).toEqual([ + 'https://www.zbom.com/about', + 'https://example.com/hefei-review', + 'https://example.com/kelaifu', + ]) + expect(findUnresolvedCitationIndexes(summary.answer, summary.citations)).toEqual([]) + }) + it('collects source candidates from deep search thinking fields', () => { const summary = parseYuanbaoCaptures([ { diff --git a/apps/desktop-client/src/main/adapters/yuanbao.ts b/apps/desktop-client/src/main/adapters/yuanbao.ts index eb26c09..4f16312 100644 --- a/apps/desktop-client/src/main/adapters/yuanbao.ts +++ b/apps/desktop-client/src/main/adapters/yuanbao.ts @@ -10,7 +10,14 @@ const YUANBAO_QUERY_TIMEOUT_MS = 120_000 const YUANBAO_CAPTURE_LIMIT = 96 const YUANBAO_CAPTURE_BODY_LIMIT = 1_200_000 const YUANBAO_CAPTURE_FLUSH_TIMEOUT_MS = 20_000 -const YUANBAO_CITATION_MARKER_PATTERN = /\[citation:\s*(\d+)\]/gi +// Match both formats so we tolerate either side of the migration without breaking: +// - "[citation:N]" — emitted by the legacy yuanbao SSE protocol and what older +// captures / fixtures still contain. +// - "[N]" — emitted by the new model (DOM places
+// placeholders inline; we now lower them to bare "[N]" markers, which is also +// the format DeepSeek answers carry through to the SaaS side). +const YUANBAO_CITATION_MARKER_PATTERN = /\[(?:citation:\s*)?(\d+)\]/gi +const YUANBAO_LEGACY_CITATION_MARKER_PATTERN = /\[citation:\s*(\d+)\]/gi const WINDOWS_1252_REVERSE_MAP = new Map([ [0x20ac, 0x80], [0x201a, 0x82], @@ -205,6 +212,16 @@ function extractCitationMarkerIndexes(value: string | null): number[] { return indexes } +// Normalise legacy "[citation:N]" markers (still emitted by some SSE frames) +// to "[N]" so the SaaS front-end can render the same superscript chip as +// DeepSeek answers — without that, mixed sources show two unrelated formats. +function normalizeCitationMarkers(value: string | null): string | null { + if (value == null) { + return value + } + return value.replace(YUANBAO_LEGACY_CITATION_MARKER_PATTERN, '[$1]') +} + function looksLikeNonAnswerNoise(value: string): boolean { const trimmed = value.trim() if (!trimmed) { @@ -1041,6 +1058,14 @@ function walkYuanbaoPayload(value: unknown, summary: YuanbaoSummary, depth = 0): ) break case 'searchGuid': + // The new hunyuan_t1 deep-search SSE places EVERY citable reference in + // `docs[]` (each entry carries a 1-based `index` that aligns with the + // answer's `[N]` markers), and the legacy `citations` field is now + // always `null`. Promote `docs` into both buckets — the citations + // bucket preserves array order so `citations[index - 1]` matches the + // marker, while the search-results bucket keeps the existing + // `dom_reference_links` semantics for downstream consumers. + collectSourceBucket(value.docs, summary.citations) collectSourceBucket(value.docs, summary.searchResults) collectSourceBucket(value.citations, summary.citations) break @@ -1518,8 +1543,18 @@ const yuanbaoQueryInPage = async (parameters: { const seen = new Set() const candidates: Array<{ element: HTMLElement; score: number }> = [] + // The new yuanbao toolbar renders the deep-think toggle as a plain
+ // with the business attribute `dt-button-id="deep_think"` (and parallel + // `dt-button-id` / `data-button-id` markers on other toolbar buttons), + // not a