Compare commits
2 Commits
af35301d9b
...
0f3a9a977e
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f3a9a977e | |||
| 1e6ed77453 |
@@ -321,10 +321,22 @@ function createQwenSourceGroupPattern(): RegExp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createYuanbaoCitationPattern(): 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
|
||||||
|
// `<div data-idx-list="1,2,10">` 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 {
|
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
|
return /\[\s*citation\s*:[^\[\]]*?\]/gi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { __kimiTestUtils } from './kimi'
|
|||||||
const {
|
const {
|
||||||
buildSourceItem,
|
buildSourceItem,
|
||||||
classifyKimiSources,
|
classifyKimiSources,
|
||||||
|
isKimiAnswerComplete,
|
||||||
|
isKimiPromotionalAnswerNoise,
|
||||||
mergeKimiSourceLinksIntoContentSnapshot,
|
mergeKimiSourceLinksIntoContentSnapshot,
|
||||||
normalizeUrl,
|
normalizeUrl,
|
||||||
} = __kimiTestUtils
|
} = __kimiTestUtils
|
||||||
@@ -25,6 +27,7 @@ function buildSnapshot(
|
|||||||
answerBlocks: ['答案'],
|
answerBlocks: ['答案'],
|
||||||
reasoningText: null,
|
reasoningText: null,
|
||||||
reasoningBlocks: [],
|
reasoningBlocks: [],
|
||||||
|
capacityNotice: null,
|
||||||
questionMatched: true,
|
questionMatched: true,
|
||||||
candidateAnswerCount: 1,
|
candidateAnswerCount: 1,
|
||||||
candidateReasoningCount: 0,
|
candidateReasoningCount: 0,
|
||||||
@@ -143,4 +146,25 @@ describe('kimi adapter helpers', () => {
|
|||||||
expect(merged.searchResultLinks).toHaveLength(1)
|
expect(merged.searchResultLinks).toHaveLength(1)
|
||||||
expect(merged.searchResultLinks[0]?.url).toBe('https://example.com/a')
|
expect(merged.searchResultLinks[0]?.url).toBe('https://example.com/a')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects Kimi K2.6 marketing copy as an answer', () => {
|
||||||
|
const promotionalText = [
|
||||||
|
'Kimi K2.6,已就位!',
|
||||||
|
'Coding、Agent 集群、Kimi Claw 同步升级',
|
||||||
|
'Agent 集群,升级了',
|
||||||
|
'多技能调用,多产物类型一次性交付',
|
||||||
|
'Kimi Claw 已就位!',
|
||||||
|
'一键部署 OpenClaw,Kimi 24小时为你干活',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
expect(isKimiPromotionalAnswerNoise(promotionalText)).toBe(true)
|
||||||
|
expect(
|
||||||
|
isKimiAnswerComplete(
|
||||||
|
buildSnapshot({
|
||||||
|
answerText: promotionalText,
|
||||||
|
answerBlocks: [promotionalText],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const KIMI_BOOTSTRAP_URL = 'https://www.kimi.com/'
|
|||||||
const KIMI_PAGE_READY_TIMEOUT_MS = 20_000
|
const KIMI_PAGE_READY_TIMEOUT_MS = 20_000
|
||||||
const KIMI_MODE_SWITCH_TIMEOUT_MS = 8_000
|
const KIMI_MODE_SWITCH_TIMEOUT_MS = 8_000
|
||||||
// kimi 现在算力不够需要更多时间来生成回答了,尤其是思考模式,所以把默认的查询超时从 90s 提高到 250s。
|
// kimi 现在算力不够需要更多时间来生成回答了,尤其是思考模式,所以把默认的查询超时从 90s 提高到 250s。
|
||||||
const KIMI_QUERY_TIMEOUT_MS = 250_000
|
const KIMI_QUERY_TIMEOUT_MS = 180_000
|
||||||
const KIMI_QUERY_POLL_INTERVAL_MS = 1_200
|
const KIMI_QUERY_POLL_INTERVAL_MS = 1_200
|
||||||
const KIMI_STABLE_POLLS_REQUIRED = 5
|
const KIMI_STABLE_POLLS_REQUIRED = 5
|
||||||
const KIMI_INCOMPLETE_ANSWER_GRACE_MS = 18_000
|
const KIMI_INCOMPLETE_ANSWER_GRACE_MS = 18_000
|
||||||
@@ -36,6 +36,17 @@ const KIMI_REDIRECT_QUERY_KEYS = [
|
|||||||
'jump_url',
|
'jump_url',
|
||||||
'to',
|
'to',
|
||||||
]
|
]
|
||||||
|
const KIMI_PROMOTIONAL_ANSWER_NOISE_PATTERNS = [
|
||||||
|
/Kimi\s*K2\.?6[,,]?\s*已就位/i,
|
||||||
|
/Coding[、,\s]+Agent\s*集群[、,\s]+Kimi\s*Claw/i,
|
||||||
|
/Agent\s*集群[,,]?\s*升级了/i,
|
||||||
|
/多技能调用[,,、\s]*多产物类型一次性交付/i,
|
||||||
|
/Kimi\s*Claw\s*已就位/i,
|
||||||
|
/OpenClaw/i,
|
||||||
|
/24\s*小时为你干活/i,
|
||||||
|
]
|
||||||
|
const KIMI_PROMOTIONAL_ANSWER_NOISE_PATTERN_SOURCES =
|
||||||
|
KIMI_PROMOTIONAL_ANSWER_NOISE_PATTERNS.map((pattern) => pattern.source)
|
||||||
|
|
||||||
type KimiDomLink = {
|
type KimiDomLink = {
|
||||||
url: string
|
url: string
|
||||||
@@ -57,6 +68,12 @@ type KimiPageSnapshot = {
|
|||||||
answerBlocks: string[]
|
answerBlocks: string[]
|
||||||
reasoningText: string | null
|
reasoningText: string | null
|
||||||
reasoningBlocks: string[]
|
reasoningBlocks: string[]
|
||||||
|
// Short capacity-error notice ("算力不够,请稍后再试" or similar) shown
|
||||||
|
// inside the assistant turn when Kimi's inference quota is exhausted.
|
||||||
|
// The candidate scorer filters these because they are too short to look
|
||||||
|
// like a real answer, so we surface them through a dedicated field and
|
||||||
|
// fall back to it in the top-level query path.
|
||||||
|
capacityNotice: string | null
|
||||||
questionMatched: boolean
|
questionMatched: boolean
|
||||||
candidateAnswerCount: number
|
candidateAnswerCount: number
|
||||||
candidateReasoningCount: number
|
candidateReasoningCount: number
|
||||||
@@ -872,6 +889,20 @@ function countMatches(input: string, pattern: RegExp): number {
|
|||||||
return input.match(new RegExp(pattern.source, flags))?.length ?? 0
|
return input.match(new RegExp(pattern.source, flags))?.length ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isKimiPromotionalAnswerNoise(text: string): boolean {
|
||||||
|
const normalized = normalizeText(text)?.replace(/\s+/g, ' ') ?? ''
|
||||||
|
if (!normalized) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedCount = KIMI_PROMOTIONAL_ANSWER_NOISE_PATTERNS.reduce(
|
||||||
|
(count, pattern) => count + (pattern.test(normalized) ? 1 : 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return matchedCount >= 2
|
||||||
|
}
|
||||||
|
|
||||||
function hasStructuredAnswerMarkers(text: string): boolean {
|
function hasStructuredAnswerMarkers(text: string): boolean {
|
||||||
return (
|
return (
|
||||||
/(^|\n)\s*(?:[-*+]\s+|\d+\.\s+|#{1,6}\s+)/m.test(text) ||
|
/(^|\n)\s*(?:[-*+]\s+|\d+\.\s+|#{1,6}\s+)/m.test(text) ||
|
||||||
@@ -940,6 +971,9 @@ function isKimiAnswerComplete(snapshot: KimiPageSnapshot): boolean {
|
|||||||
if (!answer) {
|
if (!answer) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (isKimiPromotionalAnswerNoise(answer)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (isKimiKeywordBlob(answer)) {
|
if (isKimiKeywordBlob(answer)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -973,7 +1007,12 @@ function shouldReturnStableKimiAnswer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const answer = normalizeOptionalString(snapshot.answerText)
|
const answer = normalizeOptionalString(snapshot.answerText)
|
||||||
if (!answer || isKimiKeywordBlob(answer) || firstChangedContentAt === null) {
|
if (
|
||||||
|
!answer ||
|
||||||
|
isKimiKeywordBlob(answer) ||
|
||||||
|
isKimiPromotionalAnswerNoise(answer) ||
|
||||||
|
firstChangedContentAt === null
|
||||||
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,11 +1035,18 @@ function hasKimiObservedContent(snapshot: KimiPageSnapshot | null): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const answer = normalizeOptionalString(snapshot.answerText)
|
||||||
|
const reasoning = normalizeOptionalString(snapshot.reasoningText)
|
||||||
|
const answerBlocks = snapshot.answerBlocks.filter((block) => !isKimiPromotionalAnswerNoise(block))
|
||||||
|
const reasoningBlocks = snapshot.reasoningBlocks.filter(
|
||||||
|
(block) => !isKimiPromotionalAnswerNoise(block),
|
||||||
|
)
|
||||||
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
normalizeOptionalString(snapshot.answerText) ||
|
(answer && !isKimiPromotionalAnswerNoise(answer)) ||
|
||||||
normalizeOptionalString(snapshot.reasoningText) ||
|
(reasoning && !isKimiPromotionalAnswerNoise(reasoning)) ||
|
||||||
snapshot.answerBlocks.length > 0 ||
|
answerBlocks.length > 0 ||
|
||||||
snapshot.reasoningBlocks.length > 0 ||
|
reasoningBlocks.length > 0 ||
|
||||||
snapshot.citationLinks.length > 0 ||
|
snapshot.citationLinks.length > 0 ||
|
||||||
snapshot.searchResultLinks.length > 0,
|
snapshot.searchResultLinks.length > 0,
|
||||||
)
|
)
|
||||||
@@ -1036,6 +1082,9 @@ function answerQualityScore(snapshot: KimiPageSnapshot | null): number {
|
|||||||
if (answer && isKimiKeywordBlob(answer)) {
|
if (answer && isKimiKeywordBlob(answer)) {
|
||||||
score -= 1_000
|
score -= 1_000
|
||||||
}
|
}
|
||||||
|
if (answer && isKimiPromotionalAnswerNoise(answer)) {
|
||||||
|
score -= 8_000
|
||||||
|
}
|
||||||
if (isKimiAnswerComplete(snapshot)) {
|
if (isKimiAnswerComplete(snapshot)) {
|
||||||
score += 1_500
|
score += 1_500
|
||||||
}
|
}
|
||||||
@@ -1468,7 +1517,7 @@ async function readKimiPageSnapshot(
|
|||||||
questionText: string,
|
questionText: string,
|
||||||
): Promise<KimiPageSnapshot> {
|
): Promise<KimiPageSnapshot> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
({ question, redirectQueryKeys }) => {
|
({ question, redirectQueryKeys, promotionalAnswerNoisePatternSources }) => {
|
||||||
type CandidateKind = 'answer' | 'reasoning'
|
type CandidateKind = 'answer' | 'reasoning'
|
||||||
type Candidate = {
|
type Candidate = {
|
||||||
element: HTMLElement
|
element: HTMLElement
|
||||||
@@ -1562,9 +1611,11 @@ async function readKimiPageSnapshot(
|
|||||||
? (normalizeInline(current.innerText)?.slice(0, 160) ?? '')
|
? (normalizeInline(current.innerText)?.slice(0, 160) ?? '')
|
||||||
: ''
|
: ''
|
||||||
if (
|
if (
|
||||||
/(side-console|console|drawer|modal|popover|popup|tooltip|floating|hover-card|dropdown|search-result|search-panel|reference-panel|ref-panel|citation-panel)/i.test(
|
/(side-console|console|drawer|modal|popover|popup|tooltip|floating|hover-card|dropdown|search-result|search-panel|reference-panel|ref-panel|citation-panel|dialog|alertdialog)/i.test(
|
||||||
descriptor,
|
descriptor,
|
||||||
) ||
|
) ||
|
||||||
|
current.tagName === 'DIALOG' ||
|
||||||
|
current.getAttribute('aria-modal') === 'true' ||
|
||||||
/^(引用来源|搜索网页)\s*\d*/.test(text)
|
/^(引用来源|搜索网页)\s*\d*/.test(text)
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
@@ -1636,6 +1687,9 @@ async function readKimiPageSnapshot(
|
|||||||
'登录后',
|
'登录后',
|
||||||
'有问题,免费聊',
|
'有问题,免费聊',
|
||||||
]
|
]
|
||||||
|
const promotionalAnswerNoisePatterns = promotionalAnswerNoisePatternSources.map(
|
||||||
|
(source) => new RegExp(source, 'i'),
|
||||||
|
)
|
||||||
const elementOrders = new Map<Element, number>()
|
const elementOrders = new Map<Element, number>()
|
||||||
let nextOrder = 1
|
let nextOrder = 1
|
||||||
const normalizedQuestion = normalizeBlock(question)
|
const normalizedQuestion = normalizeBlock(question)
|
||||||
@@ -2329,7 +2383,21 @@ async function readKimiPageSnapshot(
|
|||||||
return !partialNoiseSnippets.some((snippet) => line.includes(snippet))
|
return !partialNoiseSnippets.some((snippet) => line.includes(snippet))
|
||||||
})
|
})
|
||||||
|
|
||||||
return filtered.length > 0 ? filtered.join('\n') : null
|
if (!filtered.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = filtered.join('\n')
|
||||||
|
const normalizedText = text.replace(/\s+/g, ' ').trim()
|
||||||
|
const promotionalHitCount = promotionalAnswerNoisePatterns.reduce(
|
||||||
|
(count, pattern) => count + (pattern.test(normalizedText) ? 1 : 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if (promotionalHitCount >= 2) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
const findQuestionAnchor = (): HTMLElement | null => {
|
const findQuestionAnchor = (): HTMLElement | null => {
|
||||||
@@ -2468,6 +2536,22 @@ async function readKimiPageSnapshot(
|
|||||||
if (isAuxiliaryPanelElement(element)) {
|
if (isAuxiliaryPanelElement(element)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Skip wrapper candidates that span both the answer body and the
|
||||||
|
// chat composer (e.g. layout-level wrappers Kimi matches via the
|
||||||
|
// wide [class*="content"] fallback). When that happens, innerText
|
||||||
|
// pulls placeholder/skill text from the editor ("K2.6 思考",
|
||||||
|
// "Kimi K2.6 已就位") into the answer. Filter purely by HTML form
|
||||||
|
// semantics — <textarea>, non-hidden <input>, and contenteditable
|
||||||
|
// are standard signals for any chat composer, so this works for
|
||||||
|
// every platform/account without coupling to Kimi-specific class
|
||||||
|
// names that may be renamed in future redesigns.
|
||||||
|
if (
|
||||||
|
element.querySelector(
|
||||||
|
'textarea, input:not([type="hidden"]), [contenteditable="true"], [contenteditable=""]',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
seen.add(element)
|
seen.add(element)
|
||||||
const order = getElementOrder(element)
|
const order = getElementOrder(element)
|
||||||
|
|
||||||
@@ -2760,14 +2844,15 @@ async function readKimiPageSnapshot(
|
|||||||
if (visibleBusyTextExists) {
|
if (visibleBusyTextExists) {
|
||||||
busySignals.push('visible_busy_text')
|
busySignals.push('visible_busy_text')
|
||||||
}
|
}
|
||||||
const loadingIndicators = Array.from(
|
// Intentionally NOT scanning for [class*="loading"]/"stream"/"typing"
|
||||||
document.querySelectorAll(
|
// class names. Class-based loading detection is fragile across Kimi's
|
||||||
'[class*="loading"], [class*="typing"], [class*="spinner"], [class*="stream"]',
|
// UI redesigns (e.g. K2.6 ships always-on `text-stream` wrappers that
|
||||||
),
|
// never go away even after generation finishes, which forced
|
||||||
).filter((node) => isVisible(node))
|
// busy=true forever and produced spurious kimi_query_timeout errors).
|
||||||
if (loadingIndicators.length > 0) {
|
// The textual `busySignalsPattern` probe above (bodyText + visible
|
||||||
busySignals.push('loading_indicator')
|
// status/loading elements whose innerText matches "思考中/搜索中/
|
||||||
}
|
// 生成中…") is the true semantic signal — it stays valid no matter
|
||||||
|
// how Kimi renames its containers.
|
||||||
const sendButton = document.querySelector('.send-button-container')
|
const sendButton = document.querySelector('.send-button-container')
|
||||||
const sendDisabled =
|
const sendDisabled =
|
||||||
sendButton instanceof HTMLElement
|
sendButton instanceof HTMLElement
|
||||||
@@ -2781,6 +2866,24 @@ async function readKimiPageSnapshot(
|
|||||||
document.querySelector('.current-model')?.textContent,
|
document.querySelector('.current-model')?.textContent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Detect Kimi's capacity-exhausted notice ("算力不够 / 服务繁忙 /
|
||||||
|
// 请稍后再试" etc). The pattern matches phrases only — no class names —
|
||||||
|
// so it stays stable across UI redesigns.
|
||||||
|
const capacityLimitPattern =
|
||||||
|
/(算力不够|算力不足|服务繁忙|当前用户量较大|请稍后再试|请稍候再试|请求过多|系统繁忙|too\s*many\s*requests|service\s*(?:busy|unavailable)|capacity\s*(?:limit|exhaust))/i
|
||||||
|
const capacityNotice = (() => {
|
||||||
|
const assistantNodes = Array.from(
|
||||||
|
document.querySelectorAll('.chat-content-item-assistant, .segment-assistant'),
|
||||||
|
).filter((node) => node instanceof HTMLElement && isVisible(node)) as HTMLElement[]
|
||||||
|
if (!assistantNodes.length) return null
|
||||||
|
const lastNode = assistantNodes[assistantNodes.length - 1]
|
||||||
|
const text = normalizeBlock(lastNode.innerText)
|
||||||
|
// Capacity messages are short; if there's already a longer answer in
|
||||||
|
// this turn, don't fall back here — let the candidate scorer take it.
|
||||||
|
if (!text || text.length > 240) return null
|
||||||
|
return capacityLimitPattern.test(text) ? text : null
|
||||||
|
})()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
title: normalizeInline(document.title),
|
title: normalizeInline(document.title),
|
||||||
@@ -2794,6 +2897,7 @@ async function readKimiPageSnapshot(
|
|||||||
answerBlocks,
|
answerBlocks,
|
||||||
reasoningText,
|
reasoningText,
|
||||||
reasoningBlocks,
|
reasoningBlocks,
|
||||||
|
capacityNotice,
|
||||||
questionMatched: Boolean(questionAnchor),
|
questionMatched: Boolean(questionAnchor),
|
||||||
candidateAnswerCount: answerCandidates.length,
|
candidateAnswerCount: answerCandidates.length,
|
||||||
candidateReasoningCount: reasoningCandidates.length,
|
candidateReasoningCount: reasoningCandidates.length,
|
||||||
@@ -2819,6 +2923,7 @@ async function readKimiPageSnapshot(
|
|||||||
{
|
{
|
||||||
question: questionText,
|
question: questionText,
|
||||||
redirectQueryKeys: KIMI_REDIRECT_QUERY_KEYS,
|
redirectQueryKeys: KIMI_REDIRECT_QUERY_KEYS,
|
||||||
|
promotionalAnswerNoisePatternSources: KIMI_PROMOTIONAL_ANSWER_NOISE_PATTERN_SOURCES,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3033,7 +3138,17 @@ export const kimiAdapter: MonitorAdapter = {
|
|||||||
const searchResults = classifiedSources.searchResults
|
const searchResults = classifiedSources.searchResults
|
||||||
const inlineCitationCandidates = classifiedSources.inlineCitationCandidates
|
const inlineCitationCandidates = classifiedSources.inlineCitationCandidates
|
||||||
const panelCitations = classifiedSources.panelCitations
|
const panelCitations = classifiedSources.panelCitations
|
||||||
const answer = normalizeText(finalSnapshot.answerText)
|
// When Kimi exhausts inference capacity it shows a brief "算力不够 /
|
||||||
|
// 服务繁忙 / 请稍后再试" notice in place of an answer; the candidate
|
||||||
|
// scorer filters that text out for being too short. Fall back to the
|
||||||
|
// dedicated capacityNotice field so the SaaS pipeline still records the
|
||||||
|
// attempt with the user-visible message instead of an unknown row.
|
||||||
|
const capacityNotice = normalizeText(finalSnapshot.capacityNotice)
|
||||||
|
const rawAnswer = normalizeText(finalSnapshot.answerText)
|
||||||
|
const promotionalAnswerNoiseDetected = rawAnswer
|
||||||
|
? isKimiPromotionalAnswerNoise(rawAnswer)
|
||||||
|
: false
|
||||||
|
const answer = promotionalAnswerNoiseDetected ? capacityNotice : (rawAnswer ?? capacityNotice)
|
||||||
const reasoning = normalizeText(finalSnapshot.reasoningText)
|
const reasoning = normalizeText(finalSnapshot.reasoningText)
|
||||||
const answerComplete = isKimiAnswerComplete(finalSnapshot)
|
const answerComplete = isKimiAnswerComplete(finalSnapshot)
|
||||||
const keywordBlobDetected = answer ? isKimiKeywordBlob(answer) : false
|
const keywordBlobDetected = answer ? isKimiKeywordBlob(answer) : false
|
||||||
@@ -3094,11 +3209,14 @@ export const kimiAdapter: MonitorAdapter = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const capacityLimited = !rawAnswer && Boolean(capacityNotice)
|
||||||
context.reportProgress('kimi.parse_result')
|
context.reportProgress('kimi.parse_result')
|
||||||
return {
|
return {
|
||||||
status: 'succeeded',
|
status: 'succeeded',
|
||||||
summary: queryResult.ok
|
summary: queryResult.ok
|
||||||
? 'Kimi 监控任务执行成功。'
|
? capacityLimited
|
||||||
|
? 'Kimi 算力不足,已回采提示信息。'
|
||||||
|
: 'Kimi 监控任务执行成功。'
|
||||||
: 'Kimi 监控任务执行成功(超时前已收集到页面答案)。',
|
: 'Kimi 监控任务执行成功(超时前已收集到页面答案)。',
|
||||||
payload: {
|
payload: {
|
||||||
platform: 'kimi',
|
platform: 'kimi',
|
||||||
@@ -3123,6 +3241,9 @@ export const kimiAdapter: MonitorAdapter = {
|
|||||||
busy: finalSnapshot.busy,
|
busy: finalSnapshot.busy,
|
||||||
busy_signals: finalSnapshot.busySignals,
|
busy_signals: finalSnapshot.busySignals,
|
||||||
send_disabled: finalSnapshot.sendDisabled,
|
send_disabled: finalSnapshot.sendDisabled,
|
||||||
|
capacity_limited: capacityLimited,
|
||||||
|
capacity_notice: finalSnapshot.capacityNotice ?? null,
|
||||||
|
answer_promotional_noise_detected: promotionalAnswerNoiseDetected,
|
||||||
answer: answer ?? null,
|
answer: answer ?? null,
|
||||||
answer_complete: answerComplete,
|
answer_complete: answerComplete,
|
||||||
answer_keyword_blob_detected: keywordBlobDetected,
|
answer_keyword_blob_detected: keywordBlobDetected,
|
||||||
@@ -3166,6 +3287,8 @@ export const __kimiTestUtils = {
|
|||||||
buildSourceItem,
|
buildSourceItem,
|
||||||
classifyKimiSources,
|
classifyKimiSources,
|
||||||
dedupeSourceItems,
|
dedupeSourceItems,
|
||||||
|
isKimiAnswerComplete,
|
||||||
|
isKimiPromotionalAnswerNoise,
|
||||||
mergeKimiSourceLinksIntoContentSnapshot,
|
mergeKimiSourceLinksIntoContentSnapshot,
|
||||||
normalizeUrl,
|
normalizeUrl,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const {
|
|||||||
extractQuestionText,
|
extractQuestionText,
|
||||||
findUnresolvedCitationIndexes,
|
findUnresolvedCitationIndexes,
|
||||||
mergeText,
|
mergeText,
|
||||||
|
normalizeCitationMarkers,
|
||||||
normalizeSourceUrl,
|
normalizeSourceUrl,
|
||||||
parseYuanbaoCaptures,
|
parseYuanbaoCaptures,
|
||||||
resolveCitationsFromMarkers,
|
resolveCitationsFromMarkers,
|
||||||
@@ -129,6 +130,124 @@ describe('yuanbao adapter helpers', () => {
|
|||||||
expect(findUnresolvedCitationIndexes('正文没有引用小图标', [])).toEqual([])
|
expect(findUnresolvedCitationIndexes('正文没有引用小图标', [])).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("recognises both '[N]' and '[citation:N]' inline markers", () => {
|
||||||
|
// The new yuanbao model lowers <div data-idx-list="1,2,10"> 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', () => {
|
it('collects source candidates from deep search thinking fields', () => {
|
||||||
const summary = parseYuanbaoCaptures([
|
const summary = parseYuanbaoCaptures([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ const YUANBAO_QUERY_TIMEOUT_MS = 120_000
|
|||||||
const YUANBAO_CAPTURE_LIMIT = 96
|
const YUANBAO_CAPTURE_LIMIT = 96
|
||||||
const YUANBAO_CAPTURE_BODY_LIMIT = 1_200_000
|
const YUANBAO_CAPTURE_BODY_LIMIT = 1_200_000
|
||||||
const YUANBAO_CAPTURE_FLUSH_TIMEOUT_MS = 20_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 <div data-idx-list="N,M,..."/>
|
||||||
|
// 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<number, number>([
|
const WINDOWS_1252_REVERSE_MAP = new Map<number, number>([
|
||||||
[0x20ac, 0x80],
|
[0x20ac, 0x80],
|
||||||
[0x201a, 0x82],
|
[0x201a, 0x82],
|
||||||
@@ -205,6 +212,16 @@ function extractCitationMarkerIndexes(value: string | null): number[] {
|
|||||||
return indexes
|
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 {
|
function looksLikeNonAnswerNoise(value: string): boolean {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -1041,6 +1058,14 @@ function walkYuanbaoPayload(value: unknown, summary: YuanbaoSummary, depth = 0):
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
case 'searchGuid':
|
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.docs, summary.searchResults)
|
||||||
collectSourceBucket(value.citations, summary.citations)
|
collectSourceBucket(value.citations, summary.citations)
|
||||||
break
|
break
|
||||||
@@ -1518,8 +1543,18 @@ const yuanbaoQueryInPage = async (parameters: {
|
|||||||
const seen = new Set<HTMLElement>()
|
const seen = new Set<HTMLElement>()
|
||||||
const candidates: Array<{ element: HTMLElement; score: number }> = []
|
const candidates: Array<{ element: HTMLElement; score: number }> = []
|
||||||
|
|
||||||
|
// The new yuanbao toolbar renders the deep-think toggle as a plain <div>
|
||||||
|
// with the business attribute `dt-button-id="deep_think"` (and parallel
|
||||||
|
// `dt-button-id` / `data-button-id` markers on other toolbar buttons),
|
||||||
|
// not a <button>/[role=button]. Include those selectors so we still
|
||||||
|
// match the toggle by visible text. Business attributes are far more
|
||||||
|
// stable than CSS-module-hashed class names.
|
||||||
for (const scope of scopes) {
|
for (const scope of scopes) {
|
||||||
const nodes = Array.from(scope.querySelectorAll("button, [role='button'], label, [tabindex]"))
|
const nodes = Array.from(
|
||||||
|
scope.querySelectorAll(
|
||||||
|
"button, [role='button'], label, [tabindex], [dt-button-id], [data-button-id]",
|
||||||
|
),
|
||||||
|
)
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) {
|
if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) {
|
||||||
continue
|
continue
|
||||||
@@ -1912,7 +1947,7 @@ const yuanbaoQueryInPage = async (parameters: {
|
|||||||
.split(/[,,\s]+/)
|
.split(/[,,\s]+/)
|
||||||
.map((part) => Number.parseInt(part, 10))
|
.map((part) => Number.parseInt(part, 10))
|
||||||
.filter((index) => Number.isFinite(index) && index > 0)
|
.filter((index) => Number.isFinite(index) && index > 0)
|
||||||
.map((index) => `[citation:${index}]`)
|
.map((index) => `[${index}]`)
|
||||||
.join('')
|
.join('')
|
||||||
if (!markers) {
|
if (!markers) {
|
||||||
continue
|
continue
|
||||||
@@ -2359,7 +2394,7 @@ export const yuanbaoAdapter: MonitorAdapter = {
|
|||||||
dedupeSourceItems([...domCitations, ...parsed.citations, ...parsed.searchResults]),
|
dedupeSourceItems([...domCitations, ...parsed.citations, ...parsed.searchResults]),
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
const answer = answerWithMarkers
|
const answer = normalizeCitationMarkers(answerWithMarkers)
|
||||||
const citations =
|
const citations =
|
||||||
domCitations.length >= maxCitationMarkerIndex
|
domCitations.length >= maxCitationMarkerIndex
|
||||||
? appendUniqueSourceItems(domCitations, [...parsed.citations, ...markerCitations])
|
? appendUniqueSourceItems(domCitations, [...parsed.citations, ...markerCitations])
|
||||||
@@ -2485,6 +2520,7 @@ export const __yuanbaoTestUtils = {
|
|||||||
extractQuestionText,
|
extractQuestionText,
|
||||||
findUnresolvedCitationIndexes,
|
findUnresolvedCitationIndexes,
|
||||||
mergeText,
|
mergeText,
|
||||||
|
normalizeCitationMarkers,
|
||||||
normalizeSourceUrl,
|
normalizeSourceUrl,
|
||||||
parseYuanbaoCaptures,
|
parseYuanbaoCaptures,
|
||||||
resolveCitationsFromMarkers,
|
resolveCitationsFromMarkers,
|
||||||
|
|||||||
Reference in New Issue
Block a user