|
|
@@ -0,0 +1,119 @@
|
|
|
+import {execFile} from 'node:child_process'
|
|
|
+import {existsSync,readFileSync} from 'node:fs'
|
|
|
+import {dirname,join,resolve} from 'node:path'
|
|
|
+import {homedir} from 'node:os'
|
|
|
+import {fileURLToPath} from 'node:url'
|
|
|
+import {promisify} from 'node:util'
|
|
|
+import {z} from 'zod'
|
|
|
+import {HttpError} from './errors.js'
|
|
|
+import {isLoopbackHost,type ServerConfig} from './config.js'
|
|
|
+import type {UpstreamServiceSession} from './localAuth.js'
|
|
|
+import type {HermesBridgeStatus,HermesBridgeProfileStatus,HermesBridgeInstallResult} from '../shared/hermesBridge.js'
|
|
|
+
|
|
|
+const execFileAsync=promisify(execFile)
|
|
|
+const profileName=z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/)
|
|
|
+const installInput=z.object({profile:profileName,enable:z.boolean().default(false)}).strict()
|
|
|
+interface LocalProfile {profile:string;exists:boolean;valid:boolean;installedVersion?:string;enabled?:boolean;disabled?:boolean;fingerprint?:string;filesCurrent?:boolean;message?:string}
|
|
|
+interface Options {home?:string;python?:string;assetsRoot?:string;isIdle?:()=>boolean;run?:(python:string,args:string[])=>Promise<string>}
|
|
|
+
|
|
|
+function assetRoot(){
|
|
|
+ const base=dirname(fileURLToPath(import.meta.url))
|
|
|
+ return [base,resolve(base,'../runner'),resolve(base,'../../')].find(root=>existsSync(join(root,'install-hermes-bridge.py'))||existsSync(join(root,'scripts/install-hermes-bridge.py')))
|
|
|
+}
|
|
|
+
|
|
|
+/** Admin-only installer for the configured local Hermes. No client-supplied paths or commands. */
|
|
|
+export class HermesBridgeManager {
|
|
|
+ readonly home:string
|
|
|
+ readonly python:string
|
|
|
+ readonly script:string
|
|
|
+ readonly bundled:boolean
|
|
|
+ readonly bundledVersion:string
|
|
|
+ readonly local:boolean
|
|
|
+ private installing?:string
|
|
|
+ private readonly run:(python:string,args:string[])=>Promise<string>
|
|
|
+ constructor(readonly config:ServerConfig,readonly session:Pick<UpstreamServiceSession,'request'>,readonly options:Options={}){
|
|
|
+ this.home=resolve(options.home??process.env.HERMES_HOME??join(homedir(),'.hermes'))
|
|
|
+ this.python=options.python??join(this.home,'hermes-agent','venv','bin','python')
|
|
|
+ const root=options.assetsRoot??assetRoot()??''
|
|
|
+ this.script=root?(existsSync(join(root,'install-hermes-bridge.py'))?join(root,'install-hermes-bridge.py'):join(root,'scripts/install-hermes-bridge.py')):''
|
|
|
+ this.bundled=!!root&&existsSync(this.script)&&(existsSync(join(root,'hermes-bots-bridge/plugin.yaml'))||existsSync(join(root,'integrations/hermes-bots-bridge/plugin.yaml')))
|
|
|
+ const manifest=root&&[join(root,'hermes-bots-bridge/plugin.yaml'),join(root,'integrations/hermes-bots-bridge/plugin.yaml')].find(existsSync)
|
|
|
+ this.bundledVersion=manifest?readFileSync(manifest,'utf8').match(/^version:\s*["']?([^\s"']+)/m)?.[1]??'未知':'不可用'
|
|
|
+ this.local=isLoopbackHost(config.upstream.hostname)&&config.localVmHost!=='runner'
|
|
|
+ this.run=options.run??(async(python,args)=>(await execFileAsync(python,args,{timeout:20000,maxBuffer:512*1024,env:{PATH:process.env.PATH,HOME:homedir(),PYTHONNOUSERSITE:'1',PYTHONDONTWRITEBYTECODE:'1'}})).stdout)
|
|
|
+ }
|
|
|
+ get idleForUpdate(){return !this.installing}
|
|
|
+ private get available(){return this.local&&this.bundled&&existsSync(this.python)&&existsSync(join(this.home,'config.yaml'))}
|
|
|
+ private async disk():Promise<LocalProfile[]>{
|
|
|
+ if(!this.available)return []
|
|
|
+ try{
|
|
|
+ const output=await this.run(this.python,[this.script,'--hermes-home',this.home,'--check'])
|
|
|
+ return output.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(item=>profileName.safeParse(item.profile).success).slice(0,64)
|
|
|
+ }catch{throw new HttpError(409,'无法检查本机工具桥,请确认 Hermes 的 Python 环境和 Profile 配置可用。','hermes_bridge_check_failed')}
|
|
|
+ }
|
|
|
+ async status():Promise<HermesBridgeStatus>{
|
|
|
+ const [disk,remote]=await Promise.allSettled([this.disk(),this.session.request('/api/profiles',{cache:'reload'})])
|
|
|
+ const localProfiles=disk.status==='fulfilled'?disk.value:[]
|
|
|
+ const connected=remote.status==='fulfilled'&&remote.value.status===200
|
|
|
+ const hostEntry=localProfiles.find(item=>item.profile==='default')
|
|
|
+ const hostEntryInstalled=hostEntry?.filesCurrent===true&&hostEntry.enabled===true&&!hostEntry.disabled
|
|
|
+ const names=new Set(localProfiles.map(item=>item.profile))
|
|
|
+ if(remote.status==='fulfilled'&&remote.value.status===200){
|
|
|
+ try{for(const item of JSON.parse(remote.value.body.toString()).profiles??[]){const name=typeof item==='string'?item:item.name;if(profileName.safeParse(name).success&&names.size<64)names.add(name)}}catch{}
|
|
|
+ }
|
|
|
+ if(!names.size)names.add('default')
|
|
|
+ const profiles:HermesBridgeProfileStatus[]=[]
|
|
|
+ const ordered=[...names].sort((a,b)=>a==='default'?-1:b==='default'?1:a.localeCompare(b))
|
|
|
+ for(let i=0;i<ordered.length;i+=4)profiles.push(...await Promise.all(ordered.slice(i,i+4).map(async profile=>{
|
|
|
+ const local=localProfiles.find(item=>item.profile===profile)
|
|
|
+ let live:any,liveStatus=0
|
|
|
+ if(connected)try{const response=await this.session.request('/api/plugins/yaoyao-bot-bridge/capabilities',{search:new URLSearchParams({profile}),cache:'reload'});liveStatus=response.status;if(response.status===200)live=JSON.parse(response.body.toString())}catch{}
|
|
|
+ const result:HermesBridgeProfileStatus={profile,state:'unavailable',message:'无法连接 Hermes 或读取工具桥状态',canInstall:this.available&&local?.valid===true&&!this.installing&&(this.options.isIdle?.()??true),
|
|
|
+ ...(local?.installedVersion?{installedVersion:local.installedVersion}:{}),...(typeof live?.plugin_version==='string'?{loadedVersion:live.plugin_version}:{})}
|
|
|
+ const ready=live?.ready===true&&live?.native_tools===true&&live?.in_process===true&&live?.computer_runtime_version===2
|
|
|
+ if(local){
|
|
|
+ if(!local.valid){result.message=local.message??'此 Profile 的配置不可用';return result}
|
|
|
+ if(!local.installedVersion){result.state='missing';result.message='尚未安装工具桥插件'}
|
|
|
+ else if(local.disabled||!local.enabled){result.state='disabled';result.message='工具桥插件已安装,但尚未启用'}
|
|
|
+ else if(!local.filesCurrent){result.state='outdated';result.message='本机插件与随附版本不同,需要更新或修复'}
|
|
|
+ else if(!connected||liveStatus===401||liveStatus===403){result.state='unavailable';result.message='插件文件已安装,但无法验证 Hermes 加载状态;请先检查上方连接和权限'}
|
|
|
+ else if(ready&&live?.plugin_fingerprint===local.fingerprint){result.state='ready';result.message='Hermes 已加载当前工具桥,可以使用'}
|
|
|
+ else if(live?.plugin_fingerprint===local.fingerprint&&!ready){result.state='disabled';result.message=typeof live.reason==='string'?live.reason.slice(0,300):'Hermes 尚未启用此 Profile 的工具桥,请检查工具集设置'}
|
|
|
+ else {result.state='restart-required';result.message='插件文件已就位;请在空闲时重启 Hermes 后重新检查'}
|
|
|
+ }else if(ready){result.state='ready';result.message='Hermes 工具桥已就绪'}
|
|
|
+ else if(live?.computer_runtime_version&&live.computer_runtime_version!==2){result.state='outdated';result.message='Hermes 已加载的工具桥版本需要更新'}
|
|
|
+ else if(typeof live?.reason==='string')result.message=live.reason.slice(0,300)
|
|
|
+ if(this.local&&profile!=='default'&&!hostEntryInstalled){result.canInstall=false;result.message+=';请先安装或更新默认 Profile 的工具桥入口'}
|
|
|
+ return result
|
|
|
+ })))
|
|
|
+ return {endpoint:this.config.upstream.origin,local:this.local,bundledVersion:this.bundledVersion,checkedAt:Date.now(),profiles,...(this.installing?{installing:this.installing}:{}),
|
|
|
+ ...(!this.local?{message:'当前 Hermes 由其他节点提供,请在 Hermes 所在节点安装或修复工具桥。'}:
|
|
|
+ !this.bundled?{message:'当前服务未包含工具桥安装包,请更新 Yaoyao。'}:
|
|
|
+ !this.available?{message:'当前服务环境未找到本机 Hermes 安装目录和 Python,无法直接安装。'}:
|
|
|
+ disk.status==='rejected'?{message:'本机插件检查失败,请核对 Hermes 环境。'}:
|
|
|
+ this.options.isIdle&&!this.options.isIdle()?{message:'当前仍有任务运行,请结束任务后安装。'}:{})}
|
|
|
+ }
|
|
|
+ async install(value:unknown):Promise<HermesBridgeInstallResult>{
|
|
|
+ const parsed=installInput.safeParse(value)
|
|
|
+ if(!parsed.success)throw new HttpError(400,'请选择有效的 Profile','invalid_bridge_install_request')
|
|
|
+ if(!this.available)throw new HttpError(409,'只能为当前服务可管理的本机 Hermes 安装工具桥。','hermes_bridge_install_unavailable')
|
|
|
+ if(this.installing)throw new HttpError(409,'工具桥正在安装,请等待完成。','hermes_bridge_install_busy')
|
|
|
+ if(this.options.isIdle&&!this.options.isIdle())throw new HttpError(409,'当前仍有任务运行,请结束任务后安装。','hermes_bridge_tasks_running')
|
|
|
+ this.installing=parsed.data.profile
|
|
|
+ let result:any
|
|
|
+ try{
|
|
|
+ const disk=await this.disk(),local=disk.find(item=>item.profile===parsed.data.profile)
|
|
|
+ if(!local?.valid)throw new HttpError(404,'本机没有此 Profile 的有效配置。','hermes_bridge_profile_missing')
|
|
|
+ const hostEntry=disk.find(item=>item.profile==='default')
|
|
|
+ if(parsed.data.profile!=='default'&&(!hostEntry?.filesCurrent||!hostEntry.enabled||hostEntry.disabled))
|
|
|
+ throw new HttpError(409,'请先安装或更新默认 Profile 的工具桥入口,再安装这个 Profile。','hermes_bridge_host_entry_required')
|
|
|
+ const output=await this.run(this.python,[this.script,'--hermes-home',this.home,'--profile',parsed.data.profile,...(parsed.data.enable?['--enable']:[])])
|
|
|
+ result=JSON.parse(output.trim())
|
|
|
+ if(result.profile!==parsed.data.profile||typeof result.backup!=='string')throw new Error('invalid installer result')
|
|
|
+ }catch(error){
|
|
|
+ if(error instanceof HttpError)throw error
|
|
|
+ throw new HttpError(409,'工具桥安装失败,请检查目录权限后重新检查状态。','hermes_bridge_install_failed')
|
|
|
+ }finally{this.installing=undefined}
|
|
|
+ return {profile:parsed.data.profile,backup:result.backup,message:result.disabled?'插件文件已更新,保留了禁用设置。':'工具桥已安装并启用。请在空闲时重启 Hermes,然后重新检查。',status:await this.status()}
|
|
|
+ }
|
|
|
+}
|