knowledge.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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 { KnowledgeBrowseDto, KnowledgeLocalCheckDto, ProfileInput } from '@agent-server/shared'
  9. import { createApp, type App } from '../src/app'
  10. import { knowledgeFilesDir, knowledgeMountDir } from '../src/config'
  11. import { describeKnowledge, linkOrigins } from '../src/knowledge/describe'
  12. import { MounterRejected, MounterUnavailable, type MounterApi, type MountSpec, type MountStatusRow } from '../src/knowledge/mounter'
  13. /** 模拟挂载服务:/home/u 下在系统盘,/media/u/Data 下在外接盘(UUID aaaa) */
  14. class FakeMounter implements MounterApi {
  15. up = true
  16. synced: MountSpec[][] = []
  17. checks: string[] = []
  18. states = new Map<string, Omit<MountStatusRow, 'id'>>()
  19. private guard() {
  20. if (!this.up) throw new MounterUnavailable('挂载服务没有运行')
  21. }
  22. async list(p: string): Promise<KnowledgeBrowseDto> {
  23. this.guard()
  24. return { path: p, parent: '', entries: [{ name: 'docs', path: `${p}/docs`, selectable: true }] }
  25. }
  26. async check(p: string): Promise<KnowledgeLocalCheckDto> {
  27. this.guard()
  28. this.checks.push(p)
  29. 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' }
  30. if (p.startsWith('/home/u/')) return { path: p, uuid: null, relPath: null, systemDisk: true, mountPoint: '/', fsType: 'ext4', device: '/dev/dm-0' }
  31. throw new MounterRejected('只能选择 /home、/mnt、/media 下的目录', 'not_allowed')
  32. }
  33. private rows(): MountStatusRow[] {
  34. 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' }) }))
  35. }
  36. async sync(mounts: MountSpec[]) {
  37. this.guard()
  38. this.synced.push(mounts)
  39. return this.rows()
  40. }
  41. async status() {
  42. this.guard()
  43. return { version: 'test', mounts: this.rows() }
  44. }
  45. }
  46. const mounter = new FakeMounter()
  47. let app: App
  48. let site: http.Server
  49. let siteOrigin: string
  50. beforeAll(async () => {
  51. app = createApp(pino({ level: 'silent' }), { providers: {}, mounter })
  52. site = http.createServer((req, res) => {
  53. if (req.url === '/missing') return res.writeHead(404).end()
  54. res.setHeader('content-type', 'text/html; charset=utf-8')
  55. res.end('<html><head><title> 产品 手册 </title></head><body>x</body></html>')
  56. })
  57. await new Promise<void>(r => site.listen(0, '127.0.0.1', r))
  58. siteOrigin = `http://127.0.0.1:${(site.address() as AddressInfo).port}`
  59. })
  60. afterAll(() => {
  61. site.close()
  62. })
  63. const profile = (over: Partial<ProfileInput> = {}): ProfileInput => ({
  64. name: '资料测试', description: '', provider: 'claude', model: 'haiku', systemPrompt: '', permissionMode: 'ask',
  65. allowedTools: [], disallowedTools: [], skills: 'all', fileAccess: { mode: 'restricted', read: [], write: [], deny: [] },
  66. knowledgeRootIds: [], isDefault: false, ...over,
  67. })
  68. describe('资料库类型', () => {
  69. it('托管目录:平台在 files/<id> 下创建目录,可以设为 Agent 可写;删除时连同文件一起删除', async () => {
  70. const k = await app.knowledge.create({ kind: 'managed', name: '产品资料', description: '产品手册', access: 'write' })
  71. expect(k.path).toBe(path.join(knowledgeFilesDir(), k.id))
  72. expect(fs.statSync(k.path).isDirectory()).toBe(true)
  73. expect(k).toMatchObject({ kind: 'managed', access: 'write', url: null, status: { state: 'ok' } })
  74. fs.writeFileSync(path.join(k.path, 'a.md'), 'x')
  75. expect((await app.knowledge.update(k.id, { kind: 'managed', name: '产品资料', access: 'read' })).access).toBe('read')
  76. app.knowledge.delete(k.id)
  77. expect(fs.existsSync(k.path)).toBe(false)
  78. })
  79. it('网络链接:只接受 http/https,不能带用户名密码;没有目录、只读', async () => {
  80. const k = await app.knowledge.create({ kind: 'link', name: '内部文档', url: 'http://wiki.local/docs/', access: 'write' })
  81. expect(k).toMatchObject({ path: '', url: 'http://wiki.local/docs/', access: 'read', status: { state: 'ok' } })
  82. await expect(app.knowledge.create({ kind: 'link', name: 'ftp', url: 'ftp://a.com/x' })).rejects.toThrow(/http/)
  83. await expect(app.knowledge.create({ kind: 'link', name: '坏链接', url: 'not a url' })).rejects.toThrow(/格式不正确/)
  84. await expect(app.knowledge.create({ kind: 'link', name: '带密码', url: 'http://u:p@a.com/' })).rejects.toThrow(/用户名或密码/)
  85. })
  86. it('名称不能重复;类型不能修改;远程存储尚未提供', async () => {
  87. const k = app.knowledge.list().find(x => x.name === '内部文档')!
  88. await expect(app.knowledge.create({ kind: 'managed', name: '内部文档' })).rejects.toThrow(/已被使用/)
  89. await expect(app.knowledge.update(k.id, { kind: 'managed', name: '内部文档' })).rejects.toThrow(/类型不能修改/)
  90. await expect(app.knowledge.create({ kind: 'remote', name: '远程' })).rejects.toThrow(/尚未提供/)
  91. })
  92. it('测试访问:返回状态码、标题;失败时给出原因', async () => {
  93. expect(await app.knowledge.testLink(`${siteOrigin}/docs`)).toMatchObject({ ok: true, status: 200, contentType: 'text/html', title: '产品 手册' })
  94. expect(await app.knowledge.testLink(`${siteOrigin}/missing`)).toMatchObject({ ok: false, status: 404, error: 'HTTP 404' })
  95. expect((await app.knowledge.testLink('http://127.0.0.1:1/')).ok).toBe(false)
  96. })
  97. it('提示词清单:标出类型、读写、链接的放行站点与不可用原因', () => {
  98. const roots = [
  99. ...app.knowledge.list(),
  100. { ...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 } },
  101. ]
  102. const text = describeKnowledge(roots)
  103. expect(text).toContain('- 内部文档(网络链接,只读):http://wiki.local/docs/')
  104. expect(text).toContain('同一站点(http://wiki.local)下的地址都可以访问')
  105. expect(text).toContain('- 移动硬盘(本机目录,只读,暂不可用:设备未连接):/srv/agent-server/knowledge/mnt/kb_x')
  106. expect(linkOrigins(roots)).toEqual(['http://wiki.local'])
  107. })
  108. })
  109. describe('资料集合', () => {
  110. it('Profile 生效的资料库 = 单独勾选的 ∪ 集合成员,去掉停用的;集合成员变化立即生效', async () => {
  111. const a = await app.knowledge.create({ kind: 'managed', name: '合同' })
  112. const b = await app.knowledge.create({ kind: 'managed', name: '发票' })
  113. const c = await app.knowledge.create({ kind: 'link', name: '报价单', url: 'https://erp.example.com/quotes' })
  114. const col = app.knowledge.createCollection({ name: '财务', description: '财务相关', rootIds: [a.id, b.id, a.id] })
  115. expect(col.rootIds).toEqual([a.id, b.id])
  116. expect(app.knowledge.get(a.id).collectionIds).toEqual([col.id])
  117. const p = app.profiles.create(profile({ knowledgeRootIds: [c.id], knowledgeCollectionIds: [col.id] }))
  118. expect(p.knowledgeCollectionIds).toEqual([col.id])
  119. const names = () => app.knowledge.effective(app.profiles.get(p.id)).map(k => k.name).sort()
  120. expect(names()).toEqual(['发票', '合同', '报价单'])
  121. await app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: false })
  122. expect(names()).toEqual(['合同', '报价单'])
  123. app.knowledge.updateCollection(col.id, { name: '财务', rootIds: [a.id] })
  124. await app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: true })
  125. expect(names()).toEqual(['合同', '报价单'])
  126. // 被 Profile 使用的集合不能删除;删除资料库时从集合中移除
  127. expect(app.knowledge.collection(col.id).usedBy).toEqual([{ id: p.id, name: '资料测试' }])
  128. expect(() => app.knowledge.deleteCollection(col.id)).toThrow(/正被 Profile 使用/)
  129. app.knowledge.delete(a.id)
  130. expect(app.knowledge.collection(col.id).rootIds).toEqual([])
  131. expect(names()).toEqual(['报价单'])
  132. })
  133. it('校验:名称不能重复,成员必须存在;Profile 只能引用已有的集合,恢复旧版本时忽略已删除的集合', () => {
  134. const col = app.knowledge.collections().find(x => x.name === '财务')!
  135. expect(() => app.knowledge.createCollection({ name: '财务', rootIds: [] })).toThrow(/已被使用/)
  136. expect(() => app.knowledge.createCollection({ name: '空', rootIds: ['kb_nope'] })).toThrow(/不存在/)
  137. expect(() => app.profiles.create(profile({ name: '坏引用', knowledgeCollectionIds: ['kbc_nope'] }))).toThrow(/资料集合不存在/)
  138. const temp = app.knowledge.createCollection({ name: '临时', rootIds: [] })
  139. const p = app.profiles.create(profile({ name: '版本测试', knowledgeCollectionIds: [temp.id, col.id] }))
  140. const v2 = app.profiles.update(p.id, profile({ name: '版本测试', knowledgeCollectionIds: [col.id] }))
  141. expect(app.profiles.versions(p.id)[0]!.changed).toEqual(['knowledgeCollectionIds'])
  142. app.knowledge.deleteCollection(temp.id)
  143. expect(app.profiles.restore(p.id, 1).knowledgeCollectionIds).toEqual([col.id])
  144. expect(v2.version).toBe(2)
  145. })
  146. })
  147. describe('本机目录(挂载服务)', () => {
  148. const settle = () => new Promise(r => setTimeout(r, 20))
  149. it('新建时由挂载服务检查并识别设备;登记列表同步给挂载服务,状态来自挂载服务', async () => {
  150. const home = await app.knowledge.create({ kind: 'local', name: '项目文档', localPath: '/home/u/docs' })
  151. expect(home).toMatchObject({ kind: 'local', access: 'read', path: path.join(knowledgeMountDir(), home.id), local: { path: '/home/u/docs', uuid: null, relPath: null } })
  152. const disk = await app.knowledge.create({ kind: 'local', name: '移动硬盘资料', localPath: '/media/u/Data/资料', access: 'write' })
  153. expect(disk).toMatchObject({ access: 'read', local: { path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' } })
  154. await settle()
  155. expect(mounter.synced.at(-1)).toEqual(expect.arrayContaining([
  156. { id: home.id, kind: 'local', local: { path: '/home/u/docs', uuid: null, relPath: null } },
  157. { id: disk.id, kind: 'local', local: { path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' } },
  158. ]))
  159. expect(app.knowledge.get(home.id).status.state).toBe('ok')
  160. // 设备拔出:状态与原因来自挂载服务,提示词中标注暂不可用
  161. mounter.states.set(disk.id, { state: 'device_missing', message: '设备未连接(文件系统 UUID aaaa)', since: '2026-11-06T01:00:00Z' })
  162. await app.knowledge.refreshMounts()
  163. expect(app.knowledge.get(disk.id).status).toEqual({ state: 'device_missing', message: '设备未连接(文件系统 UUID aaaa)', since: '2026-11-06T01:00:00Z' })
  164. expect(describeKnowledge([app.knowledge.get(disk.id)])).toContain('(本机目录,只读,暂不可用:设备未连接(文件系统 UUID aaaa))')
  165. mounter.states.delete(disk.id)
  166. // 停用后不再挂载;删除后从登记列表中移除
  167. await app.knowledge.update(home.id, { kind: 'local', name: '项目文档', localPath: '/home/u/docs', enabled: false })
  168. await settle()
  169. expect(mounter.synced.at(-1)!.map(m => m.id)).not.toContain(home.id)
  170. app.knowledge.delete(home.id)
  171. await settle()
  172. expect(app.knowledge.list().some(k => k.id === home.id)).toBe(false)
  173. })
  174. it('挂载服务拒绝的目录不能登记;路径不变时修改名称不重新检查', async () => {
  175. await expect(app.knowledge.create({ kind: 'local', name: '系统目录', localPath: '/etc' })).rejects.toThrow(/只能选择/)
  176. await expect(app.knowledge.create({ kind: 'local', name: '空路径' })).rejects.toThrow(/请选择本机目录/)
  177. const k = app.knowledge.list().find(x => x.name === '移动硬盘资料')!
  178. mounter.checks = []
  179. await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', localPath: '/media/u/Data/资料' })
  180. expect(mounter.checks).toEqual([])
  181. expect(app.knowledge.get(k.id).local).toEqual({ path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' })
  182. })
  183. it('挂载服务不可用:本机目录状态为 mounter_down,不能新建;恢复后重新同步', async () => {
  184. mounter.up = false
  185. await app.knowledge.refreshMounts()
  186. const k = app.knowledge.list().find(x => x.name === '移动硬盘')!
  187. expect(k.status).toMatchObject({ state: 'mounter_down', message: '挂载服务没有运行' })
  188. expect(app.knowledge.mounterInfo()).toEqual({ available: false, version: null, error: '挂载服务没有运行' })
  189. await expect(app.knowledge.create({ kind: 'local', name: '新目录', localPath: '/home/u/new' })).rejects.toMatchObject({ status: 503 })
  190. await expect(app.knowledge.browse('/home')).rejects.toThrow(/agent-mounter/)
  191. // 挂载服务不可用时仍可修改名称、说明,也可以停用
  192. await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', description: '合同扫描件', localPath: '/media/u/Data/资料', enabled: false })
  193. // 恢复后:挂载服务的登记与平台不一致,重新同步
  194. mounter.up = true
  195. await app.knowledge.refreshMounts()
  196. expect(app.knowledge.mounterInfo()).toEqual({ available: true, version: 'test', error: null })
  197. await settle()
  198. expect(mounter.synced.at(-1)!.map(m => m.id)).not.toContain(k.id)
  199. await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', localPath: '/media/u/Data/资料', enabled: true })
  200. await settle()
  201. expect(mounter.synced.at(-1)!.map(m => m.id)).toContain(k.id)
  202. expect(app.knowledge.get(k.id).status.state).toBe('ok')
  203. })
  204. })