Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions apps/electron/src/main/lib/chat-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { listChannels, resolveChannelRuntimeApiKey } from './channel-manager'
import { appendMessage, updateConversationMeta, getConversationMessages } from './conversation-manager'
import { readAttachmentAsBase64, isImageAttachment } from './attachment-service'
import { extractTextFromAttachment, isDocumentAttachment } from './document-parser'
import { estimatePromptTokens, retrieveDocumentContext } from './document-context'
import { getFetchFn } from './proxy-fetch'
import { getEffectiveProxyUrl } from './proxy-settings-service'
import { getEnabledTools } from './chat-tool-registry'
Expand Down Expand Up @@ -85,7 +86,8 @@ async function enrichMessageWithDocuments(
try {
const text = await extractTextFromAttachment(att.localPath)
if (text.trim()) {
parts.push(`\n<file name="${att.filename}">\n${text}\n</file>`)
const context = retrieveDocumentContext(text, messageText)
parts.push(`\n<file name="${att.filename}" mode="retrieved-chunks">\n${context}\n</file>`)
} else {
parts.push(`\n<file name="${att.filename}">\n[文件内容为空]\n</file>`)
}
Expand All @@ -102,7 +104,7 @@ async function enrichMessageWithDocuments(
/**
* 为历史消息列表注入文档附件文本
*
* 遍历历史消息,对包含文档附件的用户消息进行文本增强
* 历史附件只保留引用,禁止每一轮重新注入整篇文档
* 返回新的消息数组(不修改原始消息)。
*/
async function enrichHistoryWithDocuments(
Expand All @@ -115,8 +117,8 @@ async function enrichHistoryWithDocuments(
if (msg.role === 'user' && msg.attachments && msg.attachments.length > 0) {
const hasDocuments = msg.attachments.some((att) => isDocumentAttachment(att.mediaType))
if (hasDocuments) {
const enrichedContent = await enrichMessageWithDocuments(msg.content, msg.attachments)
enriched.push({ ...msg, content: enrichedContent })
const names = msg.attachments.filter((att) => isDocumentAttachment(att.mediaType)).map((att) => att.filename)
enriched.push({ ...msg, content: `${msg.content}\n[历史附件引用:${names.join('、')}]` })
continue
}
}
Expand Down Expand Up @@ -251,6 +253,19 @@ export async function sendMessage(
const filteredHistory = filterHistory(fullHistory, contextDividers, contextLength)
const enrichedHistory = await enrichHistoryWithDocuments(filteredHistory)
const enrichedUserMessage = await enrichMessageWithDocuments(userMessage, attachments)
const estimatedPromptTokens = estimatePromptTokens([
systemMessage ?? '',
...enrichedHistory.map((message) => message.content),
enrichedUserMessage,
])
// 留出模型回复和工具调用空间。超限必须由客户端明确提示,不能让 A2 静默截断。
if (estimatedPromptTokens > 180_000) {
webContents.send(CHAT_IPC_CHANNELS.STREAM_ERROR, {
conversationId,
error: `当前对话预计 ${estimatedPromptTokens.toLocaleString()} tokens,超过 Kiro 安全输入上限 180,000。请新建会话、缩小 PDF 范围或使用文档摘要。`,
})
return
}

// 6. 创建 AbortController
const controller = new AbortController()
Expand Down
15 changes: 15 additions & 0 deletions apps/electron/src/main/lib/document-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, test } from 'bun:test'
import { chunkDocument, DOCUMENT_CONTEXT_MAX_TOKENS, estimatePromptTokens, retrieveDocumentContext } from './document-context'

describe('文档上下文预算', () => {
test('长文档按重叠片段切分并只检索相关内容', () => {
const text = `${'普通内容 '.repeat(2000)}关键结论:财政政策有效。${'尾部 '.repeat(2000)}`
expect(chunkDocument(text).length).toBeGreaterThan(1)
expect(retrieveDocumentContext(text, '财政政策')).toContain('财政政策有效')
})

test('注入内容永远不超过六万 token 预算', () => {
const selected = retrieveDocumentContext('研究资料 '.repeat(200000), '研究资料')
expect(estimatePromptTokens([selected])).toBeLessThanOrEqual(DOCUMENT_CONTEXT_MAX_TOKENS)
})
})
54 changes: 54 additions & 0 deletions apps/electron/src/main/lib/document-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { createHash } from 'node:crypto'

const CHUNK_CHARS = 4_000
const CHUNK_OVERLAP_CHARS = 600
export const DOCUMENT_CONTEXT_MAX_TOKENS = 60_000
// 预留约 2% 给片段编号、XML 包装及 tokenizer 误差。
const DOCUMENT_CONTEXT_MAX_CHARS = Math.floor(DOCUMENT_CONTEXT_MAX_TOKENS * 4 * 0.98)

const chunkCache = new Map<string, string[]>()

function documentKey(text: string): string {
return createHash('sha256').update(text).digest('hex')
}

export function chunkDocument(text: string): string[] {
const key = documentKey(text)
const cached = chunkCache.get(key)
if (cached) return cached
const normalized = text.replace(/\r\n/g, '\n').trim()
const chunks: string[] = []
for (let start = 0; start < normalized.length; start += CHUNK_CHARS - CHUNK_OVERLAP_CHARS) {
chunks.push(normalized.slice(start, start + CHUNK_CHARS))
}
chunkCache.set(key, chunks)
return chunks
}

function queryTerms(query: string): Set<string> {
return new Set((query.toLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? []).slice(0, 200))
}

export function retrieveDocumentContext(text: string, query: string): string {
const terms = queryTerms(query)
const ranked = chunkDocument(text).map((chunk, index) => {
const lower = chunk.toLowerCase()
let score = 0
for (const term of terms) if (lower.includes(term)) score++
return { chunk, index, score }
}).sort((a, b) => b.score - a.score || a.index - b.index)

const selected: string[] = []
let chars = 0
for (const item of ranked) {
if (chars + item.chunk.length > DOCUMENT_CONTEXT_MAX_CHARS) continue
selected.push(`[片段 ${item.index + 1}]\n${item.chunk}`)
chars += item.chunk.length
if (chars >= DOCUMENT_CONTEXT_MAX_CHARS) break
}
return selected.join('\n\n')
}

export function estimatePromptTokens(parts: string[]): number {
return Math.ceil(parts.reduce((sum, value) => sum + value.length, 0) / 4)
}