workspace-server.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. /** Isolated, deterministic standard-Hermes fixture. It exposes no plugin APIs. */
  2. import { createServer } from 'node:http'
  3. import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join, resolve } from 'node:path'
  6. import { randomUUID, randomBytes, scryptSync } from 'node:crypto'
  7. import { WebSocketServer } from 'ws'
  8. import serve from 'koa-static'
  9. import { createApplication, createNodeServer } from '../../src/server/app.js'
  10. import { loadServerConfig } from '../../src/server/config.js'
  11. import { LocalAuthStore } from '../../src/server/localAuth.js'
  12. const home =
  13. process.env.WORKSPACE_FIXTURE_HOME || mkdtempSync(join(tmpdir(), 'yaoyao-workspace-browser-'))
  14. const port = Number(process.env.WORKSPACE_FIXTURE_PORT || 18800),
  15. upstreamPort = Number(process.env.WORKSPACE_FIXTURE_UPSTREAM_PORT || 19119)
  16. const sessions = new Map<
  17. string,
  18. {
  19. id: string
  20. profile: string
  21. messages: Array<{ role: string; content: string; id: string; timestamp: number }>
  22. running: boolean
  23. }
  24. >()
  25. const calls: Array<{ method: string; params: Record<string, unknown> }> = []
  26. const upstream = createServer((req, res) => {
  27. const url = new URL(req.url || '/', `http://127.0.0.1:${upstreamPort}`)
  28. res.setHeader('content-type', 'application/json')
  29. const send = (v: unknown) => res.end(JSON.stringify(v))
  30. if (url.pathname.includes('/plugins/')) {
  31. res.statusCode = 404
  32. send({ error: 'This fixture has no plugins' })
  33. return
  34. }
  35. if (url.pathname === '/__calls') {
  36. send({ calls })
  37. return
  38. }
  39. if (url.pathname === '/api/status') {
  40. send({ auth_required: true, overall: 'ready', gateway_running: true })
  41. return
  42. }
  43. if (url.pathname === '/api/auth/me') {
  44. send({ user_id: 'fixture', display_name: 'Fixture', provider: 'basic' })
  45. return
  46. }
  47. if (url.pathname === '/auth/password-login') {
  48. res.setHeader('set-cookie', 'hermes_session_at=fixture; Path=/; HttpOnly')
  49. send({ ok: true })
  50. return
  51. }
  52. if (url.pathname === '/api/auth/ws-ticket') {
  53. send({ ticket: 'fixture' })
  54. return
  55. }
  56. if (url.pathname === '/api/profiles') {
  57. send({
  58. profiles: [
  59. { name: 'default', display_name: '通用助手', is_default: true, gateway_running: true },
  60. { name: 'server', display_name: '开发助手', is_default: false, gateway_running: true },
  61. ],
  62. })
  63. return
  64. }
  65. if (url.pathname === '/api/sessions') {
  66. send({
  67. sessions: [...sessions.values()].map((s) => ({
  68. id: s.id,
  69. title: 'Internal',
  70. source: 'yaoyao_workspace',
  71. profile: s.profile,
  72. })),
  73. total: sessions.size,
  74. })
  75. return
  76. }
  77. const history = /^\/api\/sessions\/([^/]+)\/messages$/.exec(url.pathname)
  78. if (history) {
  79. send({ messages: sessions.get(history[1]!)?.messages || [] })
  80. return
  81. }
  82. if (url.pathname === '/api/files/download') {
  83. res.setHeader('content-type', 'text/plain')
  84. res.end('Fixture report\n')
  85. return
  86. }
  87. send({ ok: true })
  88. })
  89. const wss = new WebSocketServer({ server: upstream, path: '/api/ws' })
  90. wss.on('connection', (socket) => {
  91. const runtimes = new Map<string, string>()
  92. socket.send(
  93. JSON.stringify({
  94. method: 'event',
  95. params: { type: 'gateway.ready', payload: { capabilities: [] } },
  96. }),
  97. )
  98. socket.on('message', (raw) => {
  99. const f = JSON.parse(String(raw))
  100. calls.push({ method: f.method, params: f.params })
  101. const respond = (result: unknown) => socket.send(JSON.stringify({ id: f.id, result }))
  102. const event = (type: string, payload: unknown, sessionId: string) => {
  103. if (socket.readyState === 1)
  104. socket.send(
  105. JSON.stringify({
  106. method: 'event',
  107. params: { type, payload, session_id: sessionId, profile: 'default' },
  108. }),
  109. )
  110. }
  111. if (f.method === 'profiles.list') {
  112. respond({
  113. profiles: [
  114. { name: 'default', display_name: '通用助手' },
  115. { name: 'server', display_name: '开发助手', is_default: false, gateway_running: true },
  116. ],
  117. })
  118. return
  119. }
  120. if (f.method === 'session.create' || f.method === 'session.resume') {
  121. let stored = f.method === 'session.resume' ? sessions.get(f.params.session_id) : undefined
  122. if (!stored) {
  123. stored = { id: randomUUID(), profile: f.params.profile, messages: [], running: false }
  124. sessions.set(stored.id, stored)
  125. }
  126. const runtimeId = randomUUID()
  127. runtimes.set(runtimeId, stored.id)
  128. respond({
  129. session_id: runtimeId,
  130. stored_session_id: stored.id,
  131. session_key: stored.id,
  132. running: stored.running,
  133. info: { profile_name: stored.profile },
  134. })
  135. return
  136. }
  137. const stored = sessions.get(runtimes.get(f.params.session_id) ?? '')
  138. if (f.method === 'prompt.submit' && stored) {
  139. stored.running = true
  140. stored.messages.push({
  141. id: randomUUID(),
  142. role: 'user',
  143. content: f.params.text,
  144. timestamp: Date.now() / 1000,
  145. })
  146. respond({ status: 'streaming' })
  147. const name = /你是 (.*?)。/.exec(f.params.text)?.[1] || '助手'
  148. const text = `我是${name}。已按角色规则处理这条消息。\n\n- 会话独立保存\n- 可以继续交流\n\n[报告](/tmp/workspace-report.txt)`
  149. event('message.start', {}, f.params.session_id)
  150. setTimeout(() => event('message.delta', { text: text.slice(0, 12) }, f.params.session_id), 80)
  151. setTimeout(() => {
  152. stored!.running = false
  153. stored!.messages.push({
  154. id: randomUUID(),
  155. role: 'assistant',
  156. content: text,
  157. timestamp: Date.now() / 1000,
  158. })
  159. event('message.complete', { text, status: 'complete' }, f.params.session_id)
  160. }, 250)
  161. return
  162. }
  163. if (f.method === 'session.interrupt' && stored) stored.running = false
  164. if (f.method === 'file.attach') {
  165. respond({ attached: true, ref_text: '@file:fixture-upload.txt' })
  166. return
  167. }
  168. respond({ ok: true, status: 'interrupted' })
  169. })
  170. })
  171. await new Promise<void>((resolve) => upstream.listen(upstreamPort, '127.0.0.1', resolve))
  172. const config = loadServerConfig({
  173. HERMES_YAOYAO_HOME: home,
  174. HERMES_YAOYAO_PORT: String(port),
  175. HERMES_YAOYAO_UPSTREAM: `http://127.0.0.1:${upstreamPort}`,
  176. HERMES_YAOYAO_UPSTREAM_USERNAME: 'fixture',
  177. HERMES_YAOYAO_UPSTREAM_PASSWORD: 'fixture',
  178. HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1,localhost',
  179. })
  180. // Synthetic credentials are seeded only into this disposable fixture directory.
  181. const usersPath = join(home, 'users.json')
  182. if (!existsSync(usersPath)) {
  183. const salt = randomBytes(16),
  184. now = Date.now()
  185. writeFileSync(
  186. usersPath,
  187. JSON.stringify({
  188. version: 1,
  189. users: [
  190. {
  191. id: randomUUID(),
  192. username: 'fixture',
  193. normalizedUsername: 'fixture',
  194. role: 'admin',
  195. enabled: true,
  196. mustChangePassword: false,
  197. salt: salt.toString('base64'),
  198. passwordHash: scryptSync('fixture-pass', salt, 32, {
  199. N: 2 ** 14,
  200. r: 8,
  201. p: 1,
  202. maxmem: 64 * 1024 * 1024,
  203. }).toString('base64'),
  204. authVersion: 1,
  205. createdAt: now,
  206. updatedAt: now,
  207. },
  208. ],
  209. }),
  210. { mode: 0o600 },
  211. )
  212. }
  213. const auth = new LocalAuthStore(home, false)
  214. const runtime = createApplication({ config, auth }),
  215. node = createNodeServer(runtime)
  216. runtime.app.use(serve(resolve('dist')))
  217. runtime.app.use((ctx) => {
  218. if (!ctx.path.startsWith('/api/')) {
  219. ctx.type = 'html'
  220. ctx.body = readFileSync(resolve('dist/index.html'))
  221. }
  222. })
  223. await new Promise<void>((resolve) => node.server.listen(port, '127.0.0.1', resolve))
  224. process.stdout.write(`Workspace fixture http://127.0.0.1:${port}; home=${home}\n`)
  225. for (const signal of ['SIGTERM', 'SIGINT'])
  226. process.once(signal, () => {
  227. void node.close().then(() => {
  228. for (const socket of wss.clients) socket.terminate()
  229. wss.close()
  230. upstream.close()
  231. })
  232. })