// 资料库类型与资料集合(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 { KnowledgeBrowseDto, KnowledgeLocalCheckDto, ProfileInput } from '@agent-server/shared' import { createApp, type App } from '../src/app' import { knowledgeFilesDir, knowledgeMountDir } from '../src/config' import { describeKnowledge, linkOrigins } from '../src/knowledge/describe' import { MounterRejected, MounterUnavailable, type MounterApi, type MountSpec, type MountStatusRow, type RemoteSpec } from '../src/knowledge/mounter' /** 模拟挂载服务:/home/u 下在系统盘,/media/u/Data 下在外接盘(UUID aaaa) */ class FakeMounter implements MounterApi { up = true synced: MountSpec[][] = [] checks: string[] = [] tests: RemoteSpec[] = [] states = new Map>() private guard() { if (!this.up) throw new MounterUnavailable('挂载服务没有运行') } async list(p: string): Promise { this.guard() return { path: p, parent: '', entries: [{ name: 'docs', path: `${p}/docs`, selectable: true }] } } async check(p: string): Promise { this.guard() this.checks.push(p) if (p.startsWith('/media/u/Data/')) return { path: p, uuid: 'aaaa', relPath: p.slice('/media/u/Data'.length), systemDisk: false, mountPoint: '/media/u/Data', fsType: 'ext4', device: '/dev/sda1' } if (p.startsWith('/home/u/')) return { path: p, uuid: null, relPath: null, systemDisk: true, mountPoint: '/', fsType: 'ext4', device: '/dev/dm-0' } throw new MounterRejected('只能选择 /home、/mnt、/media 下的目录', 'not_allowed') } async test(remote: RemoteSpec) { this.guard() this.tests.push(remote) return remote.secrets.secretAccessKey === 'sk-secret' ? { state: 'ok' as const, message: '', entries: ['产品手册/'] } : { state: 'auth_failed' as const, message: '认证失败或没有权限:SignatureDoesNotMatch', entries: null } } private rows(): MountStatusRow[] { return (this.synced.at(-1) ?? []).map(m => ({ id: m.id, ...(this.states.get(m.id) ?? { state: 'ok', message: null, since: '2026-11-06T00:00:00Z' }) })) } async sync(mounts: MountSpec[]) { this.guard() this.synced.push(mounts) return this.rows() } async status() { this.guard() return { version: 'test', mounts: this.rows() } } } const mounter = new FakeMounter() let app: App let site: http.Server let siteOrigin: string beforeAll(async () => { app = createApp(pino({ level: 'silent' }), { providers: {}, mounter }) 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 可写;删除时连同文件一起删除', async () => { const k = await 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((await 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,不能带用户名密码;没有目录、只读', async () => { const k = await 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' } }) await expect(app.knowledge.create({ kind: 'link', name: 'ftp', url: 'ftp://a.com/x' })).rejects.toThrow(/http/) await expect(app.knowledge.create({ kind: 'link', name: '坏链接', url: 'not a url' })).rejects.toThrow(/格式不正确/) await expect(app.knowledge.create({ kind: 'link', name: '带密码', url: 'http://u:p@a.com/' })).rejects.toThrow(/用户名或密码/) }) it('名称不能重复;类型不能修改;远程存储必须填写连接参数', async () => { const k = app.knowledge.list().find(x => x.name === '内部文档')! await expect(app.knowledge.create({ kind: 'managed', name: '内部文档' })).rejects.toThrow(/已被使用/) await expect(app.knowledge.update(k.id, { kind: 'managed', name: '内部文档' })).rejects.toThrow(/类型不能修改/) await expect(app.knowledge.create({ kind: 'remote', name: '远程' })).rejects.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 生效的资料库 = 单独勾选的 ∪ 集合成员,去掉停用的;集合成员变化立即生效', async () => { const a = await app.knowledge.create({ kind: 'managed', name: '合同' }) const b = await app.knowledge.create({ kind: 'managed', name: '发票' }) const c = await 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(['发票', '合同', '报价单']) await app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: false }) expect(names()).toEqual(['合同', '报价单']) app.knowledge.updateCollection(col.id, { name: '财务', rootIds: [a.id] }) await 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) }) }) describe('本机目录(挂载服务)', () => { const settle = () => new Promise(r => setTimeout(r, 20)) it('新建时由挂载服务检查并识别设备;登记列表同步给挂载服务,状态来自挂载服务', async () => { const home = await app.knowledge.create({ kind: 'local', name: '项目文档', localPath: '/home/u/docs' }) expect(home).toMatchObject({ kind: 'local', access: 'read', path: path.join(knowledgeMountDir(), home.id), local: { path: '/home/u/docs', uuid: null, relPath: null } }) const disk = await app.knowledge.create({ kind: 'local', name: '移动硬盘资料', localPath: '/media/u/Data/资料', access: 'write' }) expect(disk).toMatchObject({ access: 'read', local: { path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' } }) await settle() expect(mounter.synced.at(-1)).toEqual(expect.arrayContaining([ { id: home.id, kind: 'local', local: { path: '/home/u/docs', uuid: null, relPath: null } }, { id: disk.id, kind: 'local', local: { path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' } }, ])) expect(app.knowledge.get(home.id).status.state).toBe('ok') // 设备拔出:状态与原因来自挂载服务,提示词中标注暂不可用 mounter.states.set(disk.id, { state: 'device_missing', message: '设备未连接(文件系统 UUID aaaa)', since: '2026-11-06T01:00:00Z' }) await app.knowledge.refreshMounts() expect(app.knowledge.get(disk.id).status).toEqual({ state: 'device_missing', message: '设备未连接(文件系统 UUID aaaa)', since: '2026-11-06T01:00:00Z' }) expect(describeKnowledge([app.knowledge.get(disk.id)])).toContain('(本机目录,只读,暂不可用:设备未连接(文件系统 UUID aaaa))') mounter.states.delete(disk.id) // 停用后不再挂载;删除后从登记列表中移除 await app.knowledge.update(home.id, { kind: 'local', name: '项目文档', localPath: '/home/u/docs', enabled: false }) await settle() expect(mounter.synced.at(-1)!.map(m => m.id)).not.toContain(home.id) app.knowledge.delete(home.id) await settle() expect(app.knowledge.list().some(k => k.id === home.id)).toBe(false) }) it('挂载服务拒绝的目录不能登记;路径不变时修改名称不重新检查', async () => { await expect(app.knowledge.create({ kind: 'local', name: '系统目录', localPath: '/etc' })).rejects.toThrow(/只能选择/) await expect(app.knowledge.create({ kind: 'local', name: '空路径' })).rejects.toThrow(/请选择本机目录/) const k = app.knowledge.list().find(x => x.name === '移动硬盘资料')! mounter.checks = [] await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', localPath: '/media/u/Data/资料' }) expect(mounter.checks).toEqual([]) expect(app.knowledge.get(k.id).local).toEqual({ path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' }) }) it('挂载服务不可用:本机目录状态为 mounter_down,不能新建;恢复后重新同步', async () => { mounter.up = false await app.knowledge.refreshMounts() const k = app.knowledge.list().find(x => x.name === '移动硬盘')! expect(k.status).toMatchObject({ state: 'mounter_down', message: '挂载服务没有运行' }) expect(app.knowledge.mounterInfo()).toEqual({ available: false, version: null, error: '挂载服务没有运行' }) await expect(app.knowledge.create({ kind: 'local', name: '新目录', localPath: '/home/u/new' })).rejects.toMatchObject({ status: 503 }) await expect(app.knowledge.browse('/home')).rejects.toThrow(/agent-mounter/) // 挂载服务不可用时仍可修改名称、说明,也可以停用 await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', description: '合同扫描件', localPath: '/media/u/Data/资料', enabled: false }) // 恢复后:挂载服务的登记与平台不一致,重新同步 mounter.up = true await app.knowledge.refreshMounts() expect(app.knowledge.mounterInfo()).toEqual({ available: true, version: 'test', error: null }) await settle() expect(mounter.synced.at(-1)!.map(m => m.id)).not.toContain(k.id) await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', localPath: '/media/u/Data/资料', enabled: true }) await settle() expect(mounter.synced.at(-1)!.map(m => m.id)).toContain(k.id) expect(app.knowledge.get(k.id).status.state).toBe('ok') }) }) describe('远程存储', () => { const settle = () => new Promise(r => setTimeout(r, 20)) const s3 = (secrets: { name: string; value: string | null }[], params: Record = {}) => ({ type: 's3' as const, params: { provider: 'Minio', endpoint: 'http://127.0.0.1:9198', bucket: 'agentserver-kb', prefix: 'docs', unknown: 'x', ...params }, secrets, }) it('凭据只写不读:DTO 只显示是否已设置;留空沿用原值;随登记列表发给挂载服务', async () => { await expect(app.knowledge.create({ kind: 'remote', name: '对象存储', remote: s3([{ name: 'accessKeyId', value: 'agentserver' }]) })).rejects.toThrow(/Secret Key/) await expect(app.knowledge.create({ kind: 'remote', name: '对象存储', remote: s3([], { bucket: '' }) })).rejects.toThrow(/存储桶/) await expect(app.knowledge.create({ kind: 'remote', name: '对象存储', remote: s3([], { endpoint: 'http://u:p@x/' }) })).rejects.toThrow(/用户名或密码/) await expect(app.knowledge.create({ kind: 'remote', name: '对象存储', remote: s3([], { provider: 'Evil' }) })).rejects.toThrow(/服务商不正确/) const k = await app.knowledge.create({ kind: 'remote', name: '对象存储', access: 'write', remote: s3([{ name: 'accessKeyId', value: 'agentserver' }, { name: 'secretAccessKey', value: 'sk-secret' }]) }) expect(k).toMatchObject({ kind: 'remote', access: 'read', path: path.join(knowledgeMountDir(), k.id), remote: { type: 's3', params: { provider: 'Minio', endpoint: 'http://127.0.0.1:9198', bucket: 'agentserver-kb', prefix: 'docs' }, secrets: [{ name: 'accessKeyId', isSet: true }, { name: 'secretAccessKey', isSet: true }] }, }) expect(k.remote!.params).not.toHaveProperty('unknown') expect(JSON.stringify(app.knowledge.list())).not.toContain('sk-secret') await settle() expect(mounter.synced.at(-1)).toContainEqual({ id: k.id, kind: 'remote', remote: { type: 's3', params: k.remote!.params, secrets: { accessKeyId: 'agentserver', secretAccessKey: 'sk-secret' } } }) // 修改时凭据留空:沿用原值 await app.knowledge.update(k.id, { kind: 'remote', name: '对象存储', remote: s3([{ name: 'accessKeyId', value: null }, { name: 'secretAccessKey', value: '' }], { prefix: '' }) }) await settle() const spec = mounter.synced.at(-1)!.find(m => m.id === k.id)! expect(spec).toMatchObject({ remote: { secrets: { accessKeyId: 'agentserver', secretAccessKey: 'sk-secret' } } }) expect(spec.kind === 'remote' && spec.remote.params).not.toHaveProperty('prefix') // 提示词中标注远程资料 expect(describeKnowledge([app.knowledge.get(k.id)])).toContain('远程资料:先按文件名和目录结构查找') }) it('测试连接:新建时用填写的凭据;编辑时没有重新填写的凭据沿用原值', async () => { const k = app.knowledge.list().find(x => x.name === '对象存储')! expect(await app.knowledge.testRemote(s3([{ name: 'accessKeyId', value: 'a' }, { name: 'secretAccessKey', value: 'wrong' }]))).toEqual({ ok: false, state: 'auth_failed', message: '认证失败或没有权限:SignatureDoesNotMatch', entries: [] }) expect(await app.knowledge.testRemote(s3([]), k.id)).toEqual({ ok: true, state: 'ok', message: null, entries: ['产品手册/'] }) expect(mounter.tests.at(-1)!.secrets).toEqual({ accessKeyId: 'agentserver', secretAccessKey: 'sk-secret' }) // 换了存储类型:不沿用原来的凭据 await expect(app.knowledge.testRemote({ type: 'webdav', params: { url: 'http://dav.local/' }, secrets: [] }, k.id)).resolves.toMatchObject({ ok: false }) expect(mounter.tests.at(-1)!.secrets).toEqual({}) await expect(app.knowledge.testRemote({ type: 'webdav', params: {}, secrets: [] })).rejects.toThrow(/请填写地址/) }) it('停用后不再挂载', async () => { const k = app.knowledge.list().find(x => x.name === '对象存储')! await app.knowledge.update(k.id, { kind: 'remote', name: '对象存储', enabled: false, remote: s3([]) }) await settle() expect(mounter.synced.at(-1)!.map(m => m.id)).not.toContain(k.id) }) })