fix: harden speech and document interactions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { createFromBuffer, getSources, showOpenDialog } = vi.hoisted(() => ({
|
||||
@@ -175,6 +176,59 @@ describe('ContextManager', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts explicitly selected Office documents into bounded text context', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-context-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const filePath = join(directory, '需求说明.docx')
|
||||
await writeFile(
|
||||
filePath,
|
||||
Buffer.from(
|
||||
zipSync({
|
||||
'word/document.xml': strToU8(
|
||||
'<w:document><w:p><w:t>Word 需求正文</w:t></w:p></w:document>'
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
showOpenDialog.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [filePath]
|
||||
})
|
||||
const manager = new ContextManager()
|
||||
|
||||
const [attachment] = await manager.selectFiles({} as BrowserWindow)
|
||||
|
||||
expect(attachment).toMatchObject({
|
||||
name: '需求说明.docx',
|
||||
kind: 'text',
|
||||
preview: '[正文] Word 需求正文'
|
||||
})
|
||||
expect(showOpenDialog).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
filters: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'PDF 和 Office 文档',
|
||||
extensions: expect.arrayContaining([
|
||||
'docx',
|
||||
'pdf',
|
||||
'pptx',
|
||||
'xlsx'
|
||||
])
|
||||
})
|
||||
])
|
||||
})
|
||||
)
|
||||
const prompt = manager.enrichRequest({
|
||||
requestId: '1f6a37b6-e0a3-449f-8878-b10d353fbfb4',
|
||||
conversationId: 'conversation-1',
|
||||
prompt: '总结文档',
|
||||
contextIds: [attachment!.id]
|
||||
}).prompt
|
||||
expect(prompt).toContain('Word 需求正文')
|
||||
expect(prompt).toContain('"content":"[正文]\\nWord 需求正文"')
|
||||
})
|
||||
|
||||
it('keeps all five explicitly selected images', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-context-'))
|
||||
temporaryDirectories.push(directory)
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
AgentImage
|
||||
} from './agent/runtime'
|
||||
import { encodeBoundedJpeg } from './bounded-jpeg'
|
||||
import { parseDocument } from './knowledge/document-parser'
|
||||
|
||||
type StoredTextContext = ContextAttachment & {
|
||||
kind: 'text'
|
||||
@@ -35,6 +36,7 @@ type StoredImageContext = ContextAttachment & {
|
||||
type StoredContext = StoredTextContext | StoredImageContext
|
||||
|
||||
const maximumFileSize = 256 * 1024
|
||||
const maximumDocumentFileSize = 20 * 1024 * 1024
|
||||
const maximumContextBytes = 12 * 1024 * 1024
|
||||
const maximumContextCount = 16
|
||||
const maximumAttachmentsPerMessage = 8
|
||||
@@ -68,6 +70,36 @@ const supportedImageExtensions = new Set([
|
||||
'.png',
|
||||
'.webp'
|
||||
])
|
||||
const supportedDocumentExtensions = new Set([
|
||||
'.docx',
|
||||
'.pdf',
|
||||
'.pptx',
|
||||
'.xlsx'
|
||||
])
|
||||
|
||||
function truncateUtf8(value: string, maximumBytes: number): string {
|
||||
const buffer = Buffer.from(value)
|
||||
if (buffer.byteLength <= maximumBytes) {
|
||||
return value
|
||||
}
|
||||
const marker = '\n\n[文档内容过长,已截断]'
|
||||
const markerBytes = Buffer.byteLength(marker)
|
||||
return `${buffer
|
||||
.subarray(0, maximumBytes - markerBytes)
|
||||
.toString('utf8')
|
||||
.replace(/\uFFFD$/u, '')}${marker}`
|
||||
}
|
||||
|
||||
function formatParsedDocument(
|
||||
sections: Awaited<ReturnType<typeof parseDocument>>['sections']
|
||||
): string {
|
||||
return sections
|
||||
.map(
|
||||
(section) =>
|
||||
`[${section.locator}]\n${section.content}`
|
||||
)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export class ContextManager {
|
||||
private readonly contexts = new Map<string, StoredContext>()
|
||||
@@ -161,6 +193,12 @@ export class ContextManager {
|
||||
extensions: [...supportedImageExtensions].map((extension) =>
|
||||
extension.slice(1)
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'PDF 和 Office 文档',
|
||||
extensions: [...supportedDocumentExtensions].map((extension) =>
|
||||
extension.slice(1)
|
||||
)
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -178,7 +216,8 @@ export class ContextManager {
|
||||
const extension = extname(canonicalPath).toLowerCase()
|
||||
if (
|
||||
!supportedExtensions.has(extension) &&
|
||||
!supportedImageExtensions.has(extension)
|
||||
!supportedImageExtensions.has(extension) &&
|
||||
!supportedDocumentExtensions.has(extension)
|
||||
) {
|
||||
throw new Error(`不支持的文件类型:${extension || '未知'}`)
|
||||
}
|
||||
@@ -204,6 +243,33 @@ export class ContextManager {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (supportedDocumentExtensions.has(extension)) {
|
||||
try {
|
||||
const fileStat = await handle.stat()
|
||||
if (
|
||||
!fileStat.isFile() ||
|
||||
fileStat.size > maximumDocumentFileSize
|
||||
) {
|
||||
throw new Error('PDF 或 Office 文档必须小于 20MB 且不能是目录')
|
||||
}
|
||||
const parsed = await parseDocument(
|
||||
basename(canonicalPath),
|
||||
await handle.readFile()
|
||||
)
|
||||
attachments.push(
|
||||
this.storeText(
|
||||
basename(canonicalPath),
|
||||
truncateUtf8(
|
||||
formatParsedDocument(parsed.sections),
|
||||
maximumFileSize
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
continue
|
||||
}
|
||||
let content: string
|
||||
try {
|
||||
const fileStat = await handle.stat()
|
||||
|
||||
@@ -2,6 +2,33 @@ import { strToU8, zipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { chunkDocument, parseDocument } from './document-parser'
|
||||
|
||||
function createPdfFixture(text: string): Buffer {
|
||||
const stream = `BT /F1 18 Tf 50 100 Td (${text}) Tj ET`
|
||||
const objects = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`
|
||||
]
|
||||
let content = '%PDF-1.4\n'
|
||||
const offsets = [0]
|
||||
for (const [index, object] of objects.entries()) {
|
||||
offsets.push(Buffer.byteLength(content))
|
||||
content += `${index + 1} 0 obj\n${object}\nendobj\n`
|
||||
}
|
||||
const xrefOffset = Buffer.byteLength(content)
|
||||
content += `xref\n0 ${objects.length + 1}\n`
|
||||
content += '0000000000 65535 f \n'
|
||||
content += offsets
|
||||
.slice(1)
|
||||
.map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`)
|
||||
.join('')
|
||||
content += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n`
|
||||
content += `startxref\n${xrefOffset}\n%%EOF\n`
|
||||
return Buffer.from(content)
|
||||
}
|
||||
|
||||
describe('document parser', () => {
|
||||
it('parses text and creates overlapping bounded chunks', async () => {
|
||||
const parsed = await parseDocument(
|
||||
@@ -66,6 +93,21 @@ describe('document parser', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('extracts page text and locators from PDF files', async () => {
|
||||
const parsed = await parseDocument(
|
||||
'sample.pdf',
|
||||
createPdfFixture('PDF body text')
|
||||
)
|
||||
|
||||
expect(parsed.content).toContain('PDF body text')
|
||||
expect(parsed.sections).toEqual([
|
||||
{
|
||||
locator: '第 1 页',
|
||||
content: 'PDF body text'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects unsupported or oversized content', async () => {
|
||||
await expect(
|
||||
parseDocument('archive.zip', Buffer.from('not supported'))
|
||||
|
||||
Reference in New Issue
Block a user