218 lines
9.3 KiB
TypeScript
218 lines
9.3 KiB
TypeScript
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
import crypto from 'node:crypto';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
let userDataDir = '';
|
|
|
|
vi.mock('@electron/utils/paths', () => ({
|
|
getDataDir: () => userDataDir,
|
|
}));
|
|
|
|
describe('knowledge routes', () => {
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
userDataDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-test-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(userDataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('copies supported text files into the app knowledge directory', async () => {
|
|
const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-'));
|
|
const sourcePath = join(sourceDir, 'faq.md');
|
|
await writeFile(sourcePath, '# FAQ\n\nhello', 'utf8');
|
|
|
|
const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge');
|
|
const result = await importKnowledgeFiles({
|
|
workspaceId: 'workspace/test',
|
|
filePaths: [sourcePath],
|
|
});
|
|
|
|
expect(result.rejected).toEqual([]);
|
|
expect(result.documents).toHaveLength(1);
|
|
expect(result.documents[0].name).toBe('faq.md');
|
|
expect(result.documents[0].mimeType).toBe('text/markdown');
|
|
expect(result.documents[0].storedPath).not.toBe(sourcePath);
|
|
await expect(stat(result.documents[0].storedPath)).resolves.toBeTruthy();
|
|
await expect(readFile(result.documents[0].storedPath, 'utf8')).resolves.toContain('hello');
|
|
|
|
const registryPath = join(userDataDir, 'yinian', 'knowledge', 'workspace_test', 'registry.json');
|
|
await expect(readFile(registryPath, 'utf8')).resolves.toContain('faq.md');
|
|
|
|
await rm(sourceDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('builds chat context from the local backup after the source file is deleted', async () => {
|
|
const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-'));
|
|
const sourcePath = join(sourceDir, 'policy.md');
|
|
await writeFile(sourcePath, '# Policy\n\nRefund within 7 days.', 'utf8');
|
|
|
|
const { importKnowledgeFiles, buildKnowledgeContext } = await import('@electron/api/routes/knowledge');
|
|
const imported = await importKnowledgeFiles({
|
|
workspaceId: 'workspace-context',
|
|
filePaths: [sourcePath],
|
|
});
|
|
await rm(sourceDir, { recursive: true, force: true });
|
|
|
|
const context = await buildKnowledgeContext({
|
|
workspaceId: 'workspace-context',
|
|
documentIds: [imported.documents[0].id],
|
|
});
|
|
|
|
expect(context.missing).toEqual([]);
|
|
expect(context.documents[0].name).toBe('policy.md');
|
|
expect(context.context).toContain('[知识库上下文]');
|
|
expect(context.context).toContain('Refund within 7 days.');
|
|
});
|
|
|
|
it('builds chat context from extracted docx text', async () => {
|
|
const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-'));
|
|
const sourcePath = join(sourceDir, 'handbook.docx');
|
|
await writeMinimalDocx(sourcePath, 'Use the local backup for answers.');
|
|
|
|
const { importKnowledgeFiles, buildKnowledgeContext } = await import('@electron/api/routes/knowledge');
|
|
const imported = await importKnowledgeFiles({
|
|
workspaceId: 'workspace-docx-context',
|
|
filePaths: [sourcePath],
|
|
});
|
|
|
|
const context = await buildKnowledgeContext({
|
|
workspaceId: 'workspace-docx-context',
|
|
documentIds: [imported.documents[0].id],
|
|
});
|
|
|
|
expect(context.context).toContain('handbook.docx');
|
|
expect(context.context).toContain('Use the local backup for answers.');
|
|
|
|
await rm(sourceDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('rejects unsupported binary-looking files', async () => {
|
|
const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-'));
|
|
const sourcePath = join(sourceDir, 'image.png');
|
|
await writeFile(sourcePath, 'not really an image', 'utf8');
|
|
|
|
const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge');
|
|
const result = await importKnowledgeFiles({
|
|
workspaceId: 'workspace-test',
|
|
filePaths: [sourcePath],
|
|
});
|
|
|
|
expect(result.documents).toEqual([]);
|
|
expect(result.rejected).toEqual([{ filePath: sourcePath, reason: '仅支持文本类知识文件' }]);
|
|
|
|
await rm(sourceDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('rejects missing files without changing the registry', async () => {
|
|
const missingPath = join(tmpdir(), `missing-${crypto.randomUUID()}.md`);
|
|
|
|
const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge');
|
|
const result = await importKnowledgeFiles({
|
|
workspaceId: 'workspace-missing',
|
|
filePaths: [missingPath],
|
|
});
|
|
|
|
expect(result.documents).toEqual([]);
|
|
expect(result.rejected).toEqual([{ filePath: missingPath, reason: '文件不存在或不可读取' }]);
|
|
|
|
const registryPath = join(userDataDir, 'yinian', 'knowledge', 'workspace-missing', 'registry.json');
|
|
await expect(readFile(registryPath, 'utf8')).rejects.toThrow();
|
|
});
|
|
|
|
it('keeps newer imports at the top of the local registry', async () => {
|
|
const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-'));
|
|
const oldPath = join(sourceDir, 'old.md');
|
|
const newPath = join(sourceDir, 'new.md');
|
|
await writeFile(oldPath, 'old knowledge', 'utf8');
|
|
await writeFile(newPath, 'new knowledge', 'utf8');
|
|
|
|
const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge');
|
|
await importKnowledgeFiles({ workspaceId: 'workspace-order', filePaths: [oldPath] });
|
|
await importKnowledgeFiles({ workspaceId: 'workspace-order', filePaths: [newPath] });
|
|
|
|
const registryPath = join(userDataDir, 'yinian', 'knowledge', 'workspace-order', 'registry.json');
|
|
const registry = JSON.parse(await readFile(registryPath, 'utf8')) as Array<{ name: string }>;
|
|
expect(registry.map((doc) => doc.name)).toEqual(['new.md', 'old.md']);
|
|
|
|
await rm(sourceDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('copies docx files and extracts searchable text', async () => {
|
|
const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-'));
|
|
const sourcePath = join(sourceDir, 'handbook.docx');
|
|
await writeMinimalDocx(sourcePath, 'Welcome to Zhinian Handbook');
|
|
|
|
const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge');
|
|
const result = await importKnowledgeFiles({
|
|
workspaceId: 'workspace-docx',
|
|
filePaths: [sourcePath],
|
|
});
|
|
|
|
expect(result.rejected).toEqual([]);
|
|
expect(result.documents).toHaveLength(1);
|
|
expect(result.documents[0].name).toBe('handbook.docx');
|
|
expect(result.documents[0].mimeType).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
expect(result.documents[0].storedPath).not.toBe(sourcePath);
|
|
expect(result.documents[0].textPath).toBeTruthy();
|
|
await expect(readFile(result.documents[0].textPath!, 'utf8')).resolves.toContain('Welcome to Zhinian Handbook');
|
|
|
|
await rm(sourceDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('rejects invalid docx files before adding them to the registry', async () => {
|
|
const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-'));
|
|
const sourcePath = join(sourceDir, 'broken.docx');
|
|
await writeFile(sourcePath, 'not a zip document', 'utf8');
|
|
|
|
const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge');
|
|
const result = await importKnowledgeFiles({
|
|
workspaceId: 'workspace-docx-broken',
|
|
filePaths: [sourcePath],
|
|
});
|
|
|
|
expect(result.documents).toEqual([]);
|
|
expect(result.rejected).toEqual([{ filePath: sourcePath, reason: 'Word 文档解析失败,请确认文件为 .docx 格式' }]);
|
|
|
|
const registryPath = join(userDataDir, 'yinian', 'knowledge', 'workspace-docx-broken', 'registry.json');
|
|
await expect(readFile(registryPath, 'utf8')).rejects.toThrow();
|
|
|
|
await rm(sourceDir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
async function writeMinimalDocx(filePath: string, text: string) {
|
|
const JSZip = (await import('jszip')).default;
|
|
const zip = new JSZip();
|
|
zip.file('[Content_Types].xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
<Default Extension="xml" ContentType="application/xml"/>
|
|
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
</Types>`);
|
|
zip.folder('_rels')?.file('.rels', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
</Relationships>`);
|
|
zip.folder('word')?.file('document.xml', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
<w:body>
|
|
<w:p><w:r><w:t>${escapeXml(text)}</w:t></w:r></w:p>
|
|
</w:body>
|
|
</w:document>`);
|
|
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
|
|
await writeFile(filePath, buffer);
|
|
}
|
|
|
|
function escapeXml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|