// 资料库类型与资料集合(docs/design/21-knowledge-sources.md) import fs from 'node:fs' import http from 'node:http' import type { AddressInfo } from 'node:net' import path from 'node:path' import pino from 'pino' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { ProfileInput } from '@agent-server/shared' import { createApp, type App } from '../src/app' import { knowledgeFilesDir } from '../src/config' import { describeKnowledge, linkOrigins } from '../src/knowledge/describe' let app: App let site: http.Server let siteOrigin: string beforeAll(async () => { app = createApp(pino({ level: 'silent' }), { providers: {} }) site = http.createServer((req, res) => { if (req.url === '/missing') return res.writeHead(404).end() res.setHeader('content-type', 'text/html; charset=utf-8') res.end(' 产品 手册 x') }) await new Promise(r => site.listen(0, '127.0.0.1', r)) siteOrigin = `http://127.0.0.1:${(site.address() as AddressInfo).port}` }) afterAll(() => { site.close() }) const profile = (over: Partial = {}): ProfileInput => ({ name: '资料测试', description: '', provider: 'claude', model: 'haiku', systemPrompt: '', permissionMode: 'ask', allowedTools: [], disallowedTools: [], skills: 'all', fileAccess: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRootIds: [], isDefault: false, ...over, }) describe('资料库类型', () => { it('托管目录:平台在 files/ 下创建目录,可以设为 Agent 可写;删除时连同文件一起删除', () => { const k = app.knowledge.create({ kind: 'managed', name: '产品资料', description: '产品手册', access: 'write' }) expect(k.path).toBe(path.join(knowledgeFilesDir(), k.id)) expect(fs.statSync(k.path).isDirectory()).toBe(true) expect(k).toMatchObject({ kind: 'managed', access: 'write', url: null, status: { state: 'ok' } }) fs.writeFileSync(path.join(k.path, 'a.md'), 'x') expect(app.knowledge.update(k.id, { kind: 'managed', name: '产品资料', access: 'read' }).access).toBe('read') app.knowledge.delete(k.id) expect(fs.existsSync(k.path)).toBe(false) }) it('网络链接:只接受 http/https,不能带用户名密码;没有目录、只读', () => { const k = app.knowledge.create({ kind: 'link', name: '内部文档', url: 'http://wiki.local/docs/', access: 'write' }) expect(k).toMatchObject({ path: '', url: 'http://wiki.local/docs/', access: 'read', status: { state: 'ok' } }) expect(() => app.knowledge.create({ kind: 'link', name: 'ftp', url: 'ftp://a.com/x' })).toThrow(/http/) expect(() => app.knowledge.create({ kind: 'link', name: '坏链接', url: 'not a url' })).toThrow(/格式不正确/) expect(() => app.knowledge.create({ kind: 'link', name: '带密码', url: 'http://u:p@a.com/' })).toThrow(/用户名或密码/) }) it('名称不能重复;类型不能修改;本机目录与远程存储需要挂载服务', () => { const k = app.knowledge.list().find(x => x.name === '内部文档')! expect(() => app.knowledge.create({ kind: 'managed', name: '内部文档' })).toThrow(/已被使用/) expect(() => app.knowledge.update(k.id, { kind: 'managed', name: '内部文档' })).toThrow(/类型不能修改/) expect(() => app.knowledge.create({ kind: 'local', name: '本机', localPath: '/home/x/docs' })).toThrow(/挂载服务/) }) it('测试访问:返回状态码、标题;失败时给出原因', async () => { expect(await app.knowledge.testLink(`${siteOrigin}/docs`)).toMatchObject({ ok: true, status: 200, contentType: 'text/html', title: '产品 手册' }) expect(await app.knowledge.testLink(`${siteOrigin}/missing`)).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' }) expect((await app.knowledge.testLink('http://127.0.0.1:1/')).ok).toBe(false) }) it('提示词清单:标出类型、读写、链接的放行站点与不可用原因', () => { const roots = [ ...app.knowledge.list(), { ...app.knowledge.list()[0]!, id: 'kb_x', name: '移动硬盘', kind: 'local' as const, path: '/srv/agent-server/knowledge/mnt/kb_x', status: { state: 'device_missing' as const, message: '设备未连接', since: null } }, ] const text = describeKnowledge(roots) expect(text).toContain('- 内部文档(网络链接,只读):http://wiki.local/docs/') expect(text).toContain('同一站点(http://wiki.local)下的地址都可以访问') expect(text).toContain('- 移动硬盘(本机目录,只读,暂不可用:设备未连接):/srv/agent-server/knowledge/mnt/kb_x') expect(linkOrigins(roots)).toEqual(['http://wiki.local']) }) }) describe('资料集合', () => { it('Profile 生效的资料库 = 单独勾选的 ∪ 集合成员,去掉停用的;集合成员变化立即生效', () => { const a = app.knowledge.create({ kind: 'managed', name: '合同' }) const b = app.knowledge.create({ kind: 'managed', name: '发票' }) const c = app.knowledge.create({ kind: 'link', name: '报价单', url: 'https://erp.example.com/quotes' }) const col = app.knowledge.createCollection({ name: '财务', description: '财务相关', rootIds: [a.id, b.id, a.id] }) expect(col.rootIds).toEqual([a.id, b.id]) expect(app.knowledge.get(a.id).collectionIds).toEqual([col.id]) const p = app.profiles.create(profile({ knowledgeRootIds: [c.id], knowledgeCollectionIds: [col.id] })) expect(p.knowledgeCollectionIds).toEqual([col.id]) const names = () => app.knowledge.effective(app.profiles.get(p.id)).map(k => k.name).sort() expect(names()).toEqual(['发票', '合同', '报价单']) app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: false }) expect(names()).toEqual(['合同', '报价单']) app.knowledge.updateCollection(col.id, { name: '财务', rootIds: [a.id] }) app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: true }) expect(names()).toEqual(['合同', '报价单']) // 被 Profile 使用的集合不能删除;删除资料库时从集合中移除 expect(app.knowledge.collection(col.id).usedBy).toEqual([{ id: p.id, name: '资料测试' }]) expect(() => app.knowledge.deleteCollection(col.id)).toThrow(/正被 Profile 使用/) app.knowledge.delete(a.id) expect(app.knowledge.collection(col.id).rootIds).toEqual([]) expect(names()).toEqual(['报价单']) }) it('校验:名称不能重复,成员必须存在;Profile 只能引用已有的集合,恢复旧版本时忽略已删除的集合', () => { const col = app.knowledge.collections().find(x => x.name === '财务')! expect(() => app.knowledge.createCollection({ name: '财务', rootIds: [] })).toThrow(/已被使用/) expect(() => app.knowledge.createCollection({ name: '空', rootIds: ['kb_nope'] })).toThrow(/不存在/) expect(() => app.profiles.create(profile({ name: '坏引用', knowledgeCollectionIds: ['kbc_nope'] }))).toThrow(/资料集合不存在/) const temp = app.knowledge.createCollection({ name: '临时', rootIds: [] }) const p = app.profiles.create(profile({ name: '版本测试', knowledgeCollectionIds: [temp.id, col.id] })) const v2 = app.profiles.update(p.id, profile({ name: '版本测试', knowledgeCollectionIds: [col.id] })) expect(app.profiles.versions(p.id)[0]!.changed).toEqual(['knowledgeCollectionIds']) app.knowledge.deleteCollection(temp.id) expect(app.profiles.restore(p.id, 1).knowledgeCollectionIds).toEqual([col.id]) expect(v2.version).toBe(2) }) })