openVikingService.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import { OpenVikingClient, OpenVikingError } from '@openviking/sdk'
  2. import { createHash } from 'node:crypto'
  3. import { HttpError } from './errors.js'
  4. import { decryptOpenVikingSecret, encryptOpenVikingSecret, type OpenVikingConfiguration, type OpenVikingConfigurationManager } from './openVikingConfiguration.js'
  5. import type { WorkspaceStore } from './workspaceStore.js'
  6. import type { WorkspaceAgent } from '../shared/workspace.js'
  7. export interface OpenVikingUserBinding {
  8. provider: 'openviking'
  9. owner: string
  10. agentId: string
  11. userId: string
  12. status: 'active' | 'removed'
  13. encryptedUserKey: string
  14. updatedAt: number
  15. namespace?: string
  16. pendingRemoval?: boolean
  17. }
  18. export type OpenVikingClientFactory = (config: { baseUrl: string; apiKey: string; account?: string; user?: string }) => OpenVikingClient
  19. export const openVikingUserId = (agentId: string): string => `yy-${agentId.replaceAll('-', '')}`
  20. function mapError(error: unknown, operation: string): never {
  21. if (error instanceof HttpError) throw error
  22. const message = error instanceof Error ? error.message : String(error)
  23. throw new HttpError(502, `OpenViking ${operation}失败:${message}`, 'openviking_unavailable')
  24. }
  25. function apiKeyFromResult(value: Record<string, unknown>): string {
  26. const user = value.user && typeof value.user === 'object' ? value.user as Record<string, unknown> : undefined
  27. const key = value.user_key ?? value.userKey ?? value.apiKey ?? value.api_key ?? value.key ?? value.token ?? user?.user_key ?? user?.userKey ?? user?.apiKey ?? user?.api_key
  28. if (typeof key !== 'string' || !key) throw new Error('server did not return a user API key')
  29. return key
  30. }
  31. export class OpenVikingService {
  32. readonly #clients = new Map<string, OpenVikingClient>()
  33. readonly #adminClients = new Map<string, OpenVikingClient>()
  34. readonly #registrations = new Map<string, Promise<OpenVikingUserBinding>>()
  35. readonly #removals = new Map<string, Promise<void>>()
  36. onConfigurationChanged: () => void = () => {}
  37. #configuration: OpenVikingConfiguration | undefined
  38. readonly #clientFactory: OpenVikingClientFactory
  39. constructor(private readonly store: WorkspaceStore, private readonly configurationManager: OpenVikingConfigurationManager, clientFactory?: OpenVikingClientFactory) {
  40. this.#clientFactory = clientFactory ?? (config => new OpenVikingClient(config))
  41. this.configure(configurationManager.configuration())
  42. }
  43. get enabled(): boolean { return Boolean(this.#configuration) }
  44. get namespace(): string {
  45. const config = this.#configuration
  46. return config ? createHash('sha256').update(`${config.url.replace(/\/$/, '')}\0${config.accountId}`).digest('hex') : ''
  47. }
  48. configure(config: OpenVikingConfiguration | undefined): void {
  49. const signature = config ? `${config.url}\0${config.accountId}\0${config.adminKey}` : ''
  50. const changed=signature!==this.#signature
  51. if (changed) {
  52. this.#clients.clear()
  53. this.#adminClients.clear()
  54. }
  55. this.#configuration = config
  56. if(changed)this.onConfigurationChanged()
  57. }
  58. get #signature(): string { return this.#configuration ? `${this.#configuration.url}\0${this.#configuration.accountId}\0${this.#configuration.adminKey}` : '' }
  59. binding(owner: string, agentId: string): OpenVikingUserBinding | undefined {
  60. if (!this.enabled) return this.store.get<OpenVikingUserBinding>(owner, 'openviking-binding', agentId)
  61. const scoped = this.store.get<OpenVikingUserBinding>(owner, 'openviking-binding', `${this.namespace}:${agentId}`)
  62. if (scoped) return scoped
  63. const legacy = this.store.get<OpenVikingUserBinding>(owner, 'openviking-binding', agentId)
  64. return legacy && (!legacy.namespace || legacy.namespace === this.namespace) ? legacy : undefined
  65. }
  66. private saveBinding(binding: OpenVikingUserBinding): void {
  67. this.store.put(binding.owner, 'openviking-binding', `${binding.namespace}:${binding.agentId}`, binding)
  68. this.store.put(binding.owner, 'openviking-binding', binding.agentId, binding)
  69. }
  70. /** The pinned SDK lacks session-config PATCH; use the same scoped credentials. */
  71. async disableSessionAutoCommit(owner: string, agentId: string, sessionId: string): Promise<void> {
  72. const config = this.#configuration, binding = this.binding(owner, agentId)
  73. if (!config || !binding || binding.status !== 'active') throw new HttpError(409, 'OpenViking 用户未绑定', 'openviking_user_unbound')
  74. const response = await fetch(`${config.url.replace(/\/$/, '')}/api/v1/sessions/${encodeURIComponent(sessionId)}/config`, {
  75. method: 'PATCH', signal: AbortSignal.timeout(30_000),
  76. headers: { 'Content-Type': 'application/json', 'X-API-Key': decryptUserKey(this.store.home, binding.encryptedUserKey), 'X-OpenViking-Account': config.accountId, 'X-OpenViking-User': binding.userId },
  77. body: JSON.stringify({ auto_commit_policy: null }),
  78. })
  79. if (!response.ok) throw new HttpError(502, '无法关闭会话自动提交,请检查 OpenViking 版本', 'openviking_session_config_failed')
  80. }
  81. adminClient(): OpenVikingClient {
  82. const config = this.#configuration
  83. if (!config) throw new HttpError(409, 'OpenViking 记忆服务未启用', 'openviking_disabled')
  84. const key = `${config.url}\0${config.accountId}`
  85. let client = this.#adminClients.get(key)
  86. if (!client) {
  87. client = this.#clientFactory({ baseUrl: config.url, apiKey: config.adminKey, account: config.accountId })
  88. this.#adminClients.set(key, client)
  89. }
  90. return client
  91. }
  92. userClient(owner: string, agentId: string): OpenVikingClient {
  93. if(this.store.get(owner,'agent-deletion',agentId))throw new HttpError(410,'Bot 已删除,记忆用户不可使用','openviking_user_removed')
  94. const config = this.#configuration
  95. if (!config) throw new HttpError(409, 'OpenViking 记忆服务未启用', 'openviking_disabled')
  96. const binding = this.binding(owner, agentId)
  97. if (!binding || binding.status !== 'active') throw new HttpError(409, `Bot 尚未绑定 OpenViking user:${agentId}`, 'openviking_user_unbound')
  98. const cacheKey = `${config.url}\0${config.accountId}\0${binding.userId}\0${binding.encryptedUserKey}`
  99. let client = this.#clients.get(cacheKey)
  100. if (!client) {
  101. const key = decryptUserKey(this.store.home, binding.encryptedUserKey)
  102. client = this.#clientFactory({ baseUrl: config.url, apiKey: key, account: config.accountId, user: binding.userId })
  103. this.#clients.set(cacheKey, client)
  104. }
  105. return client
  106. }
  107. async ensureUser(owner: string, agent: Pick<WorkspaceAgent, 'id' | 'temporaryGoalId'>): Promise<OpenVikingUserBinding> {
  108. const key = `${this.namespace}:${owner}:${agent.id}`
  109. if(this.#removals.has(key)||this.store.get(owner,'agent-deletion',agent.id)||this.binding(owner,agent.id)?.status==='removed')
  110. throw new HttpError(410,'Bot 已删除或正在移除记忆用户,不能重新注册','openviking_user_removed')
  111. const existing = this.#registrations.get(key)
  112. if (existing) return existing
  113. const pending = this.registerUser(owner, agent)
  114. this.#registrations.set(key, pending)
  115. try { return await pending } finally { this.#registrations.delete(key) }
  116. }
  117. private async registerUser(owner: string, agent: Pick<WorkspaceAgent, 'id' | 'temporaryGoalId'>): Promise<OpenVikingUserBinding> {
  118. if (!this.#configuration) throw new HttpError(409, 'OpenViking 记忆服务未启用', 'openviking_disabled')
  119. if (agent.temporaryGoalId) throw new HttpError(403, '临时助手不创建 OpenViking user', 'helper_task_bound')
  120. const existing = this.binding(owner, agent.id)
  121. if (existing?.status === 'active') {
  122. if (!existing.namespace) { existing.namespace = this.namespace; this.saveBinding(existing) }
  123. return existing
  124. }
  125. const namespace = this.namespace
  126. const accountId = this.#configuration.accountId
  127. const admin = this.adminClient()
  128. const userId = openVikingUserId(agent.id)
  129. let result: Record<string, unknown>
  130. try {
  131. try {
  132. result = await admin.adminRegisterUser(accountId, userId, 'user', { seed: `yaoyao:${agent.id}` }) as Record<string, unknown>
  133. } catch (error) {
  134. if (!(error instanceof OpenVikingError) || !['EXISTS', 'USER_EXISTS', 'CONFLICT', 'ALREADY_EXISTS'].includes(error.code)) throw error
  135. result = await admin.adminRegenerateKey(accountId, userId, `yaoyao:${agent.id}`) as Record<string, unknown>
  136. }
  137. const binding: OpenVikingUserBinding = {
  138. provider: 'openviking', owner, agentId: agent.id, userId, status: 'active',
  139. encryptedUserKey: encryptUserKey(this.store.home, apiKeyFromResult(result)), updatedAt: Date.now(), namespace,
  140. }
  141. this.saveBinding(binding)
  142. return binding
  143. } catch (error) {
  144. mapError(error, 'user 注册')
  145. }
  146. }
  147. async removeUser(owner: string, agent: Pick<WorkspaceAgent, 'id' | 'temporaryGoalId'>): Promise<void> {
  148. const namespace=this.namespace,key=`${namespace}:${owner}:${agent.id}`
  149. const existing=this.#removals.get(key)
  150. if(existing)return existing
  151. const registrations=[...this.#registrations.entries()].filter(([registrationKey])=>registrationKey.endsWith(`:${owner}:${agent.id}`)).map(([,registration])=>registration)
  152. const config=this.#configuration,admin=config?this.adminClient():undefined
  153. const pending=Promise.resolve().then(async()=>{
  154. // Registration can already have reached the remote server. Finish it before
  155. // removal so its late receipt cannot recreate the deleted user's binding.
  156. await Promise.allSettled(registrations)
  157. // Keep the deletion attached to the configuration captured at admission.
  158. const legacy=this.store.get<OpenVikingUserBinding>(owner,'openviking-binding',agent.id)
  159. const binding=config
  160. ? this.store.get<OpenVikingUserBinding>(owner,'openviking-binding',`${namespace}:${agent.id}`)??(legacy&&(!legacy.namespace||legacy.namespace===namespace)?legacy:undefined)
  161. : legacy
  162. if(binding?.status==='removed'&&!binding.pendingRemoval)return
  163. const removed:OpenVikingUserBinding={...(binding??{provider:'openviking',owner,agentId:agent.id,userId:openVikingUserId(agent.id),encryptedUserKey:'',namespace}),status:'removed',pendingRemoval:true,updatedAt:Date.now()}
  164. this.saveBinding(removed)
  165. if(!config||!admin)return // Keep a durable pending removal while disabled.
  166. try{await admin.adminRemoveUser(config.accountId,removed.userId)}
  167. catch(error){if(!(error instanceof OpenVikingError)||!(error.statusCode===404||error.code==='NOT_FOUND'))mapError(error,'user 删除')}
  168. this.saveBinding({...removed,pendingRemoval:false,updatedAt:Date.now()})
  169. })
  170. this.#removals.set(key,pending)
  171. try{await pending}finally{this.#removals.delete(key)}
  172. }
  173. }
  174. function encryptUserKey(home: string, value: string): string {
  175. return encryptOpenVikingSecret(home, value)
  176. }
  177. function decryptUserKey(home: string, value: string): string {
  178. return decryptOpenVikingSecret(home, value)
  179. }