knowledge.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. // 资料库类型与资料集合(docs/design/21-knowledge-sources.md)
  2. import fs from 'node:fs'
  3. import http from 'node:http'
  4. import type { AddressInfo } from 'node:net'
  5. import path from 'node:path'
  6. import pino from 'pino'
  7. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  8. import type { ProfileInput } from '@agent-server/shared'
  9. import { createApp, type App } from '../src/app'
  10. import { knowledgeFilesDir } from '../src/config'
  11. import { describeKnowledge, linkOrigins } from '../src/knowledge/describe'
  12. let app: App
  13. let site: http.Server
  14. let siteOrigin: string
  15. beforeAll(async () => {
  16. app = createApp(pino({ level: 'silent' }), { providers: {} })
  17. site = http.createServer((req, res) => {
  18. if (req.url === '/missing') return res.writeHead(404).end()
  19. res.setHeader('content-type', 'text/html; charset=utf-8')
  20. res.end('<html><head><title> 产品 手册 </title></head><body>x</body></html>')
  21. })
  22. await new Promise<void>(r => site.listen(0, '127.0.0.1', r))
  23. siteOrigin = `http://127.0.0.1:${(site.address() as AddressInfo).port}`
  24. })
  25. afterAll(() => {
  26. site.close()
  27. })
  28. const profile = (over: Partial<ProfileInput> = {}): ProfileInput => ({
  29. name: '资料测试', description: '', provider: 'claude', model: 'haiku', systemPrompt: '', permissionMode: 'ask',
  30. allowedTools: [], disallowedTools: [], skills: 'all', fileAccess: { mode: 'restricted', read: [], write: [], deny: [] },
  31. knowledgeRootIds: [], isDefault: false, ...over,
  32. })
  33. describe('资料库类型', () => {
  34. it('托管目录:平台在 files/<id> 下创建目录,可以设为 Agent 可写;删除时连同文件一起删除', () => {
  35. const k = app.knowledge.create({ kind: 'managed', name: '产品资料', description: '产品手册', access: 'write' })
  36. expect(k.path).toBe(path.join(knowledgeFilesDir(), k.id))
  37. expect(fs.statSync(k.path).isDirectory()).toBe(true)
  38. expect(k).toMatchObject({ kind: 'managed', access: 'write', url: null, status: { state: 'ok' } })
  39. fs.writeFileSync(path.join(k.path, 'a.md'), 'x')
  40. expect(app.knowledge.update(k.id, { kind: 'managed', name: '产品资料', access: 'read' }).access).toBe('read')
  41. app.knowledge.delete(k.id)
  42. expect(fs.existsSync(k.path)).toBe(false)
  43. })
  44. it('网络链接:只接受 http/https,不能带用户名密码;没有目录、只读', () => {
  45. const k = app.knowledge.create({ kind: 'link', name: '内部文档', url: 'http://wiki.local/docs/', access: 'write' })
  46. expect(k).toMatchObject({ path: '', url: 'http://wiki.local/docs/', access: 'read', status: { state: 'ok' } })
  47. expect(() => app.knowledge.create({ kind: 'link', name: 'ftp', url: 'ftp://a.com/x' })).toThrow(/http/)
  48. expect(() => app.knowledge.create({ kind: 'link', name: '坏链接', url: 'not a url' })).toThrow(/格式不正确/)
  49. expect(() => app.knowledge.create({ kind: 'link', name: '带密码', url: 'http://u:p@a.com/' })).toThrow(/用户名或密码/)
  50. })
  51. it('名称不能重复;类型不能修改;本机目录与远程存储需要挂载服务', () => {
  52. const k = app.knowledge.list().find(x => x.name === '内部文档')!
  53. expect(() => app.knowledge.create({ kind: 'managed', name: '内部文档' })).toThrow(/已被使用/)
  54. expect(() => app.knowledge.update(k.id, { kind: 'managed', name: '内部文档' })).toThrow(/类型不能修改/)
  55. expect(() => app.knowledge.create({ kind: 'local', name: '本机', localPath: '/home/x/docs' })).toThrow(/挂载服务/)
  56. })
  57. it('测试访问:返回状态码、标题;失败时给出原因', async () => {
  58. expect(await app.knowledge.testLink(`${siteOrigin}/docs`)).toMatchObject({ ok: true, status: 200, contentType: 'text/html', title: '产品 手册' })
  59. expect(await app.knowledge.testLink(`${siteOrigin}/missing`)).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' })
  60. expect((await app.knowledge.testLink('http://127.0.0.1:1/')).ok).toBe(false)
  61. })
  62. it('提示词清单:标出类型、读写、链接的放行站点与不可用原因', () => {
  63. const roots = [
  64. ...app.knowledge.list(),
  65. { ...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 } },
  66. ]
  67. const text = describeKnowledge(roots)
  68. expect(text).toContain('- 内部文档(网络链接,只读):http://wiki.local/docs/')
  69. expect(text).toContain('同一站点(http://wiki.local)下的地址都可以访问')
  70. expect(text).toContain('- 移动硬盘(本机目录,只读,暂不可用:设备未连接):/srv/agent-server/knowledge/mnt/kb_x')
  71. expect(linkOrigins(roots)).toEqual(['http://wiki.local'])
  72. })
  73. })
  74. describe('资料集合', () => {
  75. it('Profile 生效的资料库 = 单独勾选的 ∪ 集合成员,去掉停用的;集合成员变化立即生效', () => {
  76. const a = app.knowledge.create({ kind: 'managed', name: '合同' })
  77. const b = app.knowledge.create({ kind: 'managed', name: '发票' })
  78. const c = app.knowledge.create({ kind: 'link', name: '报价单', url: 'https://erp.example.com/quotes' })
  79. const col = app.knowledge.createCollection({ name: '财务', description: '财务相关', rootIds: [a.id, b.id, a.id] })
  80. expect(col.rootIds).toEqual([a.id, b.id])
  81. expect(app.knowledge.get(a.id).collectionIds).toEqual([col.id])
  82. const p = app.profiles.create(profile({ knowledgeRootIds: [c.id], knowledgeCollectionIds: [col.id] }))
  83. expect(p.knowledgeCollectionIds).toEqual([col.id])
  84. const names = () => app.knowledge.effective(app.profiles.get(p.id)).map(k => k.name).sort()
  85. expect(names()).toEqual(['发票', '合同', '报价单'])
  86. app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: false })
  87. expect(names()).toEqual(['合同', '报价单'])
  88. app.knowledge.updateCollection(col.id, { name: '财务', rootIds: [a.id] })
  89. app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: true })
  90. expect(names()).toEqual(['合同', '报价单'])
  91. // 被 Profile 使用的集合不能删除;删除资料库时从集合中移除
  92. expect(app.knowledge.collection(col.id).usedBy).toEqual([{ id: p.id, name: '资料测试' }])
  93. expect(() => app.knowledge.deleteCollection(col.id)).toThrow(/正被 Profile 使用/)
  94. app.knowledge.delete(a.id)
  95. expect(app.knowledge.collection(col.id).rootIds).toEqual([])
  96. expect(names()).toEqual(['报价单'])
  97. })
  98. it('校验:名称不能重复,成员必须存在;Profile 只能引用已有的集合,恢复旧版本时忽略已删除的集合', () => {
  99. const col = app.knowledge.collections().find(x => x.name === '财务')!
  100. expect(() => app.knowledge.createCollection({ name: '财务', rootIds: [] })).toThrow(/已被使用/)
  101. expect(() => app.knowledge.createCollection({ name: '空', rootIds: ['kb_nope'] })).toThrow(/不存在/)
  102. expect(() => app.profiles.create(profile({ name: '坏引用', knowledgeCollectionIds: ['kbc_nope'] }))).toThrow(/资料集合不存在/)
  103. const temp = app.knowledge.createCollection({ name: '临时', rootIds: [] })
  104. const p = app.profiles.create(profile({ name: '版本测试', knowledgeCollectionIds: [temp.id, col.id] }))
  105. const v2 = app.profiles.update(p.id, profile({ name: '版本测试', knowledgeCollectionIds: [col.id] }))
  106. expect(app.profiles.versions(p.id)[0]!.changed).toEqual(['knowledgeCollectionIds'])
  107. app.knowledge.deleteCollection(temp.id)
  108. expect(app.profiles.restore(p.id, 1).knowledgeCollectionIds).toEqual([col.id])
  109. expect(v2.version).toBe(2)
  110. })
  111. })