RunnerSettingsPanel.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. <script setup lang="ts">
  2. import { onMounted, ref } from 'vue'
  3. import { apiRequest } from '@/api/client'
  4. import { UNCONFIGURED_COMPUTER_IMAGE } from '@shared/runner'
  5. import type { RunnerConfiguration, RunnerRecord } from '@shared/runner'
  6. type Runner = Omit<RunnerRecord, 'tokenHash' | 'sourceOwner'> & {online: boolean}
  7. const props=defineProps<{computerSetup?:boolean}>()
  8. const bundleAvailable=ref(false),fixedDesktops=ref(false)
  9. const runners = ref<Runner[]>([]), sources = ref<Array<{id:string;name:string}>>([])
  10. const busy = ref(false), error = ref(''), notice = ref('')
  11. const name = ref(''), source = ref('local'), profiles = ref('default')
  12. const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119'), satellite = ref(false), serverHermes = ref('')
  13. const computerNetwork=ref<'none'|'public-proxy'>('public-proxy')
  14. const computers=ref(!!props.computerSetup),computerRuntime=ref<'docker'|'podman'>('docker'),computerImage=ref(''),computerPython=ref(''),hermesSource=ref(''),hermesHome=ref('')
  15. const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
  16. async function refresh() {
  17. const data = await apiRequest<{runners:Runner[];sources:Array<{id:string;name:string}>;bundleAvailable?:boolean;fixedDesktops?:boolean;hermesUpstream?:string}>('/api/app/admin/runners')
  18. runners.value = data.runners; sources.value = data.sources;bundleAvailable.value=!!data.bundleAvailable;fixedDesktops.value=!!data.fixedDesktops;serverHermes.value=data.hermesUpstream||''
  19. }
  20. async function action(work:()=>Promise<void>) {
  21. if(busy.value) return
  22. error.value = ''; notice.value = ''; busy.value = true
  23. try { await work() } catch(cause) { error.value = cause instanceof Error ? cause.message : '执行节点操作失败' }
  24. finally { busy.value = false }
  25. }
  26. async function register() {
  27. await action(async()=>{
  28. const hermesTarget = satellite.value ? serverHermes.value : hermesURL.value
  29. if(satellite.value && !hermesTarget)throw new Error('服务器尚未配置 Hermes 地址')
  30. const web = new URL(serverURL.value), local = new URL(hermesTarget)
  31. if(!['http:','https:'].includes(web.protocol)||web.username||web.password||web.search||web.hash||web.pathname!=='/')throw new Error('请填写不带路径的夭夭服务地址')
  32. if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程节点请使用 HTTPS,或勾选可信局域网 HTTP')
  33. if(satellite.value){
  34. if(['127.0.0.1','localhost','[::1]'].includes(local.hostname))throw new Error('服务器 Hermes 仍是本机回环地址,卫星连不上。请把上游配成卫星能访问的地址。')
  35. if(!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('服务器 Hermes 地址无效')
  36. }else if(!['127.0.0.1','[::1]'].includes(local.hostname)||!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('Hermes 地址必须指向 Runner 所在电脑的回环地址')
  37. const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
  38. if(computers.value&&((!!computerImage.value&&!/^sha256:[a-f0-9]{64}$/.test(computerImage.value))||[computerPython.value,hermesSource.value,hermesHome.value].some(path=>path&&!path.startsWith('/'))))throw new Error('自定义路径必须为执行电脑上的绝对路径;默认安装可留空。镜像 ID 可留空或填写完整 sha256 ID。')
  39. const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
  40. const lanHermes = satellite.value && local.protocol==='http:'
  41. configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),...(satellite.value?{satellite:true}:{}),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value||lanHermes?{allowInsecureLan:true}:{}),...(computers.value?{computers:{...(fixedDesktops.value?{managedBy:'compose' as const}:{}),network:fixedDesktops.value?'none':computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value||UNCONFIGURED_COMPUTER_IMAGE,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
  42. notice.value = satellite.value ? '节点已注册。请在卫星电脑上启动 Runner。会话连接服务器 Hermes,不要在卫星上启动本机 Hermes。' : '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
  43. await refresh()
  44. })
  45. }
  46. function download() {
  47. if(!configuration.value) return
  48. const url = URL.createObjectURL(new Blob([JSON.stringify(configuration.value,null,2)+'\n'],{type:'application/json'}))
  49. const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'runner.json'; anchor.click()
  50. setTimeout(()=>URL.revokeObjectURL(url),1000)
  51. }
  52. function disable(runner:Runner) {
  53. void action(async()=>{
  54. await apiRequest(`/api/app/admin/runners/${runner.id}`,{method:'DELETE',body:{}})
  55. if(configuration.value?.runnerId===runner.id)configuration.value=undefined
  56. await refresh(); notice.value = `已停用 ${runner.name}`
  57. })
  58. }
  59. onMounted(()=>{void action(refresh)})
  60. </script>
  61. <template>
  62. <section class="runner-panel" aria-label="执行节点设置">
  63. <p>在运行 Hermes 的电脑上启动 Runner,它会主动连接夭夭,接收 Bot 任务并提供本轮团队工具。注册后,该来源的 Bot 任务会交给 Runner;节点离线时无法启动新任务,恢复连接后可重试。</p>
  64. <p v-if="bundleAvailable"><a href="/api/app/admin/runners/bundle" download>下载配套执行节点程序</a>(包含桌面资源,需要 Node.js 24 或更高版本)。</p>
  65. <div class="runner-toolbar"><strong>已注册节点</strong><button type="button" :disabled="busy" @click="action(refresh)">刷新状态</button></div>
  66. <ul v-if="runners.length" class="runner-list">
  67. <li v-for="runner in runners" :key="runner.id">
  68. <div><strong>{{ runner.name }}</strong><span>{{ !runner.enabled ? '已停用' : runner.online ? '已连接' : '待连接' }}</span><small>{{ runner.allowedProfiles.join('、') }}</small></div>
  69. <button v-if="runner.enabled" type="button" :disabled="busy" :aria-label="`停用 ${runner.name}`" @click="disable(runner)">停用</button>
  70. </li>
  71. </ul>
  72. <p v-else>尚未注册执行节点。</p>
  73. <form v-if="!configuration" @submit.prevent="register">
  74. <label>节点名称<input v-model="name" required maxlength="100" autocomplete="off" placeholder="我的 Mac" :disabled="busy"></label>
  75. <label>接管的 Bot 来源<select v-model="source" :disabled="busy"><option v-for="item in sources" :key="item.id" :value="item.id">{{ item.name }}</option></select></label>
  76. <label>允许的 Profile<input v-model="profiles" required placeholder="default, writer" :disabled="busy"><small>填写 Hermes 的 Profile 名称,以逗号分隔;账号权限仍然生效。</small></label>
  77. <label class="runner-check"><input v-model="satellite" type="checkbox" :disabled="busy">卫星电脑(会话使用服务器 Hermes,不连接本机 9119)</label>
  78. <label>夭夭服务地址<input v-model="serverURL" type="url" required :disabled="busy"><small>必须能从 Runner 所在电脑访问。</small></label>
  79. <label v-if="!satellite">Runner 本机 Hermes 地址<input v-model="hermesURL" type="url" required :disabled="busy"></label>
  80. <p v-else>会话连接服务器 Hermes:{{ serverHermes || '尚未配置' }}</p>
  81. <label>允许导出的产物目录<textarea v-model="artifactRoots" rows="2" :disabled="busy" placeholder="每行一个绝对路径,留空时禁止导出文件"></textarea></label>
  82. <label class="runner-check"><input v-model="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
  83. <label class="runner-check"><input v-model="computers" type="checkbox" :disabled="busy">启用隔离电脑 Worker</label>
  84. <fieldset v-if="computers" class="runner-computer">
  85. <legend>执行电脑配置</legend><p v-if="fixedDesktops">桌面已由 Compose 创建。Runner 只转发执行和控制,不安装桌面镜像、不创建容器;联网方式由 Compose 指定。</p>
  86. <label v-if="!fixedDesktops">容器运行时<select v-model="computerRuntime"><option value="docker">Docker</option><option value="podman">Podman</option></select></label>
  87. <label v-if="!fixedDesktops">联网范围<select v-model="computerNetwork"><option value="public-proxy">公网网页(HTTP / HTTPS)</option><option value="none">不联网</option></select></label>
  88. <small v-if="!fixedDesktops">公网模式通过受控代理访问,不开放宿主和内网地址。</small>
  89. <label v-if="!fixedDesktops">兼容镜像 ID<input v-model.trim="computerImage" placeholder="可在本地虚拟机设置中准备"></label>
  90. <label>Hermes Python 路径<input v-model.trim="computerPython" placeholder="留空使用默认安装"></label>
  91. <label>Hermes 源码目录<input v-model.trim="hermesSource" placeholder="留空使用默认安装"></label>
  92. <label>Hermes 配置目录<input v-model.trim="hermesHome" placeholder="留空使用默认安装"></label>
  93. <small>留空时在执行电脑上查找 ~/.hermes/hermes-agent/venv/bin/python。自定义安装请填写绝对路径;镜像可在节点连接后于本页准备。</small>
  94. <small>这是外部执行节点的高级配置。本机虚拟机可直接在应用设置中准备,并从机器人聊天的电脑面板启用。</small>
  95. </fieldset>
  96. <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
  97. </form>
  98. <div v-else class="runner-setup">
  99. <button class="solid-button" type="button" @click="download">下载 Runner 配置</button>
  100. <p>{{ configuration.satellite ? '配置包含连接凭据,仅本次可下载。将文件放到卫星电脑,设置为仅自己可读。不要在卫星上启动 Hermes。' : '配置包含本节点的连接凭据,仅本次可下载。将文件放在 Hermes 所在电脑,设置为仅自己可读:' }}</p>
  101. <code v-if="bundleAvailable">mkdir -p yaoyao-runner<br>tar -xzf yaoyao-runner.tar.gz -C yaoyao-runner<br>chmod 600 runner.json<br>node yaoyao-runner/runner.mjs --config /完整路径/runner.json</code>
  102. <code v-else>chmod 600 runner.json<br>npm run runner -- --config /完整路径/runner.json</code>
  103. <small>{{bundleAvailable?'在运行 Hermes 和 Docker/Podman 的电脑上执行,节点连接后返回本地虚拟机设置准备桌面。':'在 yaoyao 项目中先运行 npm run build。'}}配置丢失后可停用节点并重新注册。</small>
  104. </div>
  105. <p v-if="error" class="runner-error" role="alert">{{ error }}</p>
  106. <p v-if="notice" role="status">{{ notice }}</p>
  107. </section>
  108. </template>
  109. <style scoped>
  110. .runner-panel { display: grid; gap: 14px; min-width: 0; font-size: 13px; }
  111. .runner-panel p { margin: 0; line-height: 1.6; color: var(--text-secondary); }
  112. .runner-toolbar,.runner-list li { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
  113. .runner-panel button { min-height: 36px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: var(--text-primary); cursor: pointer; }
  114. .runner-panel button:disabled { opacity: .5; cursor: default; }
  115. .runner-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
  116. .runner-list li { padding: 12px; border: 1px solid var(--line); border-radius: 10px; }
  117. .runner-list li div { display: grid; gap: 5px; overflow-wrap: anywhere; }
  118. .runner-list span,.runner-panel small { color: var(--text-secondary); font-size: 12px; line-height: 1.5; }
  119. .runner-panel form,.runner-panel label,.runner-setup { display: grid; gap: 8px; min-width: 0; }
  120. .runner-panel form { gap: 14px; }
  121. .runner-computer { display: grid; gap: 12px; border: 1px solid var(--line); border-radius: 8px; padding: 12px; min-width: 0; }
  122. .runner-panel input:not([type=checkbox]),.runner-panel select,.runner-panel textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: var(--text-primary); font: inherit; }
  123. .runner-panel .runner-check { display: flex; align-items: center; }
  124. .runner-panel code { overflow-wrap: anywhere; line-height: 1.7; }
  125. .runner-panel .runner-error { color: var(--danger); }
  126. .runner-panel :is(input,select,textarea,button):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
  127. </style>