diff --git a/apps/desktop-client/src/main/adapters/kimi.test.ts b/apps/desktop-client/src/main/adapters/kimi.test.ts index cad198f..43019b5 100644 --- a/apps/desktop-client/src/main/adapters/kimi.test.ts +++ b/apps/desktop-client/src/main/adapters/kimi.test.ts @@ -5,6 +5,8 @@ import { __kimiTestUtils } from './kimi' const { buildSourceItem, classifyKimiSources, + isKimiAnswerComplete, + isKimiPromotionalAnswerNoise, mergeKimiSourceLinksIntoContentSnapshot, normalizeUrl, } = __kimiTestUtils @@ -25,6 +27,7 @@ function buildSnapshot( answerBlocks: ['答案'], reasoningText: null, reasoningBlocks: [], + capacityNotice: null, questionMatched: true, candidateAnswerCount: 1, candidateReasoningCount: 0, @@ -143,4 +146,25 @@ describe('kimi adapter helpers', () => { expect(merged.searchResultLinks).toHaveLength(1) 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) + }) }) diff --git a/apps/desktop-client/src/main/adapters/kimi.ts b/apps/desktop-client/src/main/adapters/kimi.ts index 52d7c6d..c6fc4a9 100644 --- a/apps/desktop-client/src/main/adapters/kimi.ts +++ b/apps/desktop-client/src/main/adapters/kimi.ts @@ -8,7 +8,7 @@ const KIMI_BOOTSTRAP_URL = 'https://www.kimi.com/' const KIMI_PAGE_READY_TIMEOUT_MS = 20_000 const KIMI_MODE_SWITCH_TIMEOUT_MS = 8_000 // 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_STABLE_POLLS_REQUIRED = 5 const KIMI_INCOMPLETE_ANSWER_GRACE_MS = 18_000 @@ -36,6 +36,17 @@ const KIMI_REDIRECT_QUERY_KEYS = [ 'jump_url', '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 = { url: string @@ -57,6 +68,12 @@ type KimiPageSnapshot = { answerBlocks: string[] reasoningText: string | null 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 candidateAnswerCount: number candidateReasoningCount: number @@ -872,6 +889,20 @@ function countMatches(input: string, pattern: RegExp): number { 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 { return ( /(^|\n)\s*(?:[-*+]\s+|\d+\.\s+|#{1,6}\s+)/m.test(text) || @@ -940,6 +971,9 @@ function isKimiAnswerComplete(snapshot: KimiPageSnapshot): boolean { if (!answer) { return false } + if (isKimiPromotionalAnswerNoise(answer)) { + return false + } if (isKimiKeywordBlob(answer)) { return false } @@ -973,7 +1007,12 @@ function shouldReturnStableKimiAnswer( } const answer = normalizeOptionalString(snapshot.answerText) - if (!answer || isKimiKeywordBlob(answer) || firstChangedContentAt === null) { + if ( + !answer || + isKimiKeywordBlob(answer) || + isKimiPromotionalAnswerNoise(answer) || + firstChangedContentAt === null + ) { return false } @@ -996,11 +1035,18 @@ function hasKimiObservedContent(snapshot: KimiPageSnapshot | null): boolean { 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( - normalizeOptionalString(snapshot.answerText) || - normalizeOptionalString(snapshot.reasoningText) || - snapshot.answerBlocks.length > 0 || - snapshot.reasoningBlocks.length > 0 || + (answer && !isKimiPromotionalAnswerNoise(answer)) || + (reasoning && !isKimiPromotionalAnswerNoise(reasoning)) || + answerBlocks.length > 0 || + reasoningBlocks.length > 0 || snapshot.citationLinks.length > 0 || snapshot.searchResultLinks.length > 0, ) @@ -1036,6 +1082,9 @@ function answerQualityScore(snapshot: KimiPageSnapshot | null): number { if (answer && isKimiKeywordBlob(answer)) { score -= 1_000 } + if (answer && isKimiPromotionalAnswerNoise(answer)) { + score -= 8_000 + } if (isKimiAnswerComplete(snapshot)) { score += 1_500 } @@ -1468,7 +1517,7 @@ async function readKimiPageSnapshot( questionText: string, ): Promise { return page.evaluate( - ({ question, redirectQueryKeys }) => { + ({ question, redirectQueryKeys, promotionalAnswerNoisePatternSources }) => { type CandidateKind = 'answer' | 'reasoning' type Candidate = { element: HTMLElement @@ -1562,9 +1611,11 @@ async function readKimiPageSnapshot( ? (normalizeInline(current.innerText)?.slice(0, 160) ?? '') : '' 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, ) || + current.tagName === 'DIALOG' || + current.getAttribute('aria-modal') === 'true' || /^(引用来源|搜索网页)\s*\d*/.test(text) ) { return true @@ -1636,6 +1687,9 @@ async function readKimiPageSnapshot( '登录后', '有问题,免费聊', ] + const promotionalAnswerNoisePatterns = promotionalAnswerNoisePatternSources.map( + (source) => new RegExp(source, 'i'), + ) const elementOrders = new Map() let nextOrder = 1 const normalizedQuestion = normalizeBlock(question) @@ -2329,7 +2383,21 @@ async function readKimiPageSnapshot( 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 => { @@ -2468,6 +2536,22 @@ async function readKimiPageSnapshot( if (isAuxiliaryPanelElement(element)) { 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 —