workspace-server.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  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. source: string
  22. title: string
  23. messages: Array<{ role: string; content: string; id: string; timestamp: number }>
  24. running: boolean
  25. }
  26. >()
  27. const latestRuntimeBySession = new Map<string, string>()
  28. const calls: Array<{ method: string; params: Record<string, unknown> }> = []
  29. const heldReplies: Array<() => void> = []
  30. const profileState = new Map([
  31. ['default', { name: 'default', display_name: '通用助手', is_default: true, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
  32. ['server', { name: 'server', display_name: '开发助手', is_default: false, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
  33. ])
  34. function broadcastUpstreamEvent(
  35. type: string,
  36. payload: Record<string, unknown>,
  37. sessionId?: string,
  38. profile = 'default',
  39. ) {
  40. const frame = JSON.stringify({
  41. method: 'event',
  42. params: {
  43. type,
  44. payload,
  45. ...(sessionId ? { session_id: sessionId, profile } : {}),
  46. },
  47. })
  48. for (const socket of wss.clients) {
  49. if (socket.readyState === 1) socket.send(frame)
  50. }
  51. }
  52. const upstream = createServer((req, res) => {
  53. const url = new URL(req.url || '/', `http://127.0.0.1:${upstreamPort}`)
  54. res.setHeader('content-type', 'application/json')
  55. const send = (v: unknown) => res.end(JSON.stringify(v))
  56. if (url.pathname === '/__release' && req.method === 'POST') {
  57. const held = heldReplies.splice(0)
  58. for (const complete of held) complete()
  59. send({ released: held.length })
  60. return
  61. }
  62. if (url.pathname.includes('/plugins/')) {
  63. res.statusCode = 404
  64. send({ error: 'This fixture has no plugins' })
  65. return
  66. }
  67. if (url.pathname === '/__calls') {
  68. send({ calls })
  69. return
  70. }
  71. if (url.pathname === '/__test/sessions/title' && req.method === 'POST') {
  72. const id = url.searchParams.get('id')?.trim() || ''
  73. const title = url.searchParams.get('title')?.trim() || ''
  74. const session = sessions.get(id)
  75. const runtimeId = latestRuntimeBySession.get(id)
  76. if (!session || !runtimeId || !title) {
  77. res.statusCode = 409
  78. send({ error: 'session route is not active' })
  79. return
  80. }
  81. session.title = title
  82. broadcastUpstreamEvent(
  83. 'session.title',
  84. { session_id: id, title },
  85. runtimeId,
  86. session.profile,
  87. )
  88. send({ id, title })
  89. return
  90. }
  91. if (url.pathname === '/api/status') {
  92. send({ auth_required: true, overall: 'ready', gateway_running: true })
  93. return
  94. }
  95. if (url.pathname === '/api/auth/me') {
  96. send({ user_id: 'fixture', display_name: 'Fixture', provider: 'basic' })
  97. return
  98. }
  99. if (url.pathname === '/auth/password-login') {
  100. res.setHeader('set-cookie', 'hermes_session_at=fixture; Path=/; HttpOnly')
  101. send({ ok: true })
  102. return
  103. }
  104. if (url.pathname === '/api/auth/ws-ticket') {
  105. send({ ticket: 'fixture' })
  106. return
  107. }
  108. if (url.pathname === '/api/profiles') {
  109. send({ profiles: [...profileState.values()] })
  110. return
  111. }
  112. if (url.pathname === '/api/sessions') {
  113. const requestedSource = url.searchParams.get('source')
  114. const visible = [...sessions.values()].filter(session =>
  115. !requestedSource || session.source === requestedSource)
  116. send({
  117. sessions: visible.map((s) => ({
  118. id: s.id,
  119. title: s.title,
  120. source: s.source,
  121. profile: s.profile,
  122. })),
  123. total: visible.length,
  124. hasMore: false,
  125. offset: Number(url.searchParams.get('offset') ?? 0),
  126. limit: Number(url.searchParams.get('limit') ?? 100),
  127. })
  128. return
  129. }
  130. const detail = /^\/api\/sessions\/([^/]+)$/.exec(url.pathname)
  131. if (detail) {
  132. const session = sessions.get(detail[1]!)
  133. if (!session) {
  134. res.statusCode = 404
  135. send({ error: 'session not found' })
  136. return
  137. }
  138. send({
  139. id: session.id,
  140. profile: session.profile,
  141. source: session.source,
  142. title: session.title,
  143. message_count: session.messages.length,
  144. })
  145. return
  146. }
  147. const history = /^\/api\/sessions\/([^/]+)\/messages$/.exec(url.pathname)
  148. if (history) {
  149. const messages = sessions.get(history[1]!)?.messages || []
  150. send({ session_id: history[1], messages,
  151. pagination: {offset: 0, limit: 100, returned: messages.length, total: messages.length, has_more: false} })
  152. return
  153. }
  154. if (url.pathname === '/api/files/download') {
  155. res.setHeader('content-type', 'text/plain')
  156. res.end('Fixture report\n')
  157. return
  158. }
  159. send({ ok: true })
  160. })
  161. const wss = new WebSocketServer({ server: upstream, path: '/api/ws' })
  162. wss.on('connection', (socket) => {
  163. const runtimes = new Map<string, string>()
  164. socket.send(
  165. JSON.stringify({
  166. method: 'event',
  167. params: { type: 'gateway.ready', payload: { capabilities: [] } },
  168. }),
  169. )
  170. socket.on('message', (raw) => {
  171. const f = JSON.parse(String(raw))
  172. calls.push({ method: f.method, params: f.params })
  173. const respond = (result: unknown) => socket.send(JSON.stringify({ id: f.id, result }))
  174. const event = (type: string, payload: unknown, sessionId: string) => {
  175. if (socket.readyState === 1)
  176. socket.send(
  177. JSON.stringify({
  178. method: 'event',
  179. params: { type, payload, session_id: sessionId, profile: 'default' },
  180. }),
  181. )
  182. }
  183. if (f.method === 'profiles.list') {
  184. respond({ profiles: [...profileState.values()] })
  185. return
  186. }
  187. if (f.method === 'profiles.configure') {
  188. const profile = profileState.get(String(f.params.name))
  189. if (!profile) { respond({ error: 'profile_not_found' }); return }
  190. for (const [namespace, value] of Object.entries(f.params.ui_meta ?? {})) {
  191. profile.ui_meta[namespace] = value
  192. profile.ui_meta_revisions[namespace] = (profile.ui_meta_revisions[namespace] ?? 0) + 1
  193. }
  194. respond({ profile })
  195. return
  196. }
  197. if (f.method === 'session.create' || f.method === 'session.resume') {
  198. let stored = f.method === 'session.resume' ? sessions.get(f.params.session_id) : undefined
  199. if (!stored) {
  200. stored = {
  201. id: randomUUID(),
  202. profile: f.params.profile,
  203. source: String(f.params.source ?? 'web'),
  204. title: String(f.params.title ?? '新对话'),
  205. messages: [],
  206. running: false,
  207. }
  208. sessions.set(stored.id, stored)
  209. }
  210. const runtimeId = randomUUID()
  211. runtimes.set(runtimeId, stored.id)
  212. latestRuntimeBySession.set(stored.id, runtimeId)
  213. respond({
  214. session_id: runtimeId,
  215. stored_session_id: stored.id,
  216. session_key: stored.id,
  217. running: stored.running,
  218. info: { profile_name: stored.profile },
  219. })
  220. return
  221. }
  222. const stored = sessions.get(runtimes.get(f.params.session_id) ?? '')
  223. if (f.method === 'prompt.submit' && stored) {
  224. stored.running = true
  225. const requestedTitle = /^\[cross-client-title:([^\]\r\n]+)\]/
  226. .exec(String(f.params.text))?.[1]?.trim()
  227. if (requestedTitle) {
  228. stored.title = requestedTitle
  229. setTimeout(() => broadcastUpstreamEvent('sessions.changed', {}), 0)
  230. }
  231. stored.messages.push({
  232. id: randomUUID(),
  233. role: 'user',
  234. content: f.params.text,
  235. timestamp: Date.now() / 1000,
  236. })
  237. respond({ status: 'streaming' })
  238. const name = /你是 (.*?)。/.exec(f.params.text)?.[1] || '助手'
  239. const requested = /本轮用户指定成员:([^\n]*)/.exec(f.params.text)?.[1] ?? ''
  240. const delegates = f.params.text.includes('你是管理员。') && !f.params.text.includes('本批次执行结果:') && requested.startsWith('@')
  241. const text = `我是${name}。已按角色规则处理这条消息。\n\n- 会话独立保存\n- 可以继续交流\n\n[报告](/tmp/workspace-report.txt)${delegates ? `\n请${requested}处理任务。` : ''}`
  242. event('message.start', {}, f.params.session_id)
  243. setTimeout(() => event('message.delta', { text: text.slice(0, 12) }, f.params.session_id), 80)
  244. const complete = () => {
  245. stored!.running = false
  246. stored!.messages.push({
  247. id: randomUUID(),
  248. role: 'assistant',
  249. content: text,
  250. timestamp: Date.now() / 1000,
  251. })
  252. event('message.complete', { text, status: 'complete' }, f.params.session_id)
  253. }
  254. if (f.params.text.includes('[hold-workspace]')) heldReplies.push(complete)
  255. else setTimeout(complete, 250)
  256. return
  257. }
  258. if (f.method === 'session.interrupt' && stored) stored.running = false
  259. if (f.method === 'file.attach') {
  260. respond({ attached: true, ref_text: '@file:fixture-upload.txt' })
  261. return
  262. }
  263. respond({ ok: true, status: 'interrupted' })
  264. })
  265. })
  266. await new Promise<void>((resolve) => upstream.listen(upstreamPort, '127.0.0.1', resolve))
  267. const config = loadServerConfig({
  268. HERMES_YAOYAO_HOME: home,
  269. HERMES_YAOYAO_PORT: String(port),
  270. HERMES_YAOYAO_UPSTREAM: `http://127.0.0.1:${upstreamPort}`,
  271. HERMES_YAOYAO_UPSTREAM_USERNAME: 'fixture',
  272. HERMES_YAOYAO_UPSTREAM_PASSWORD: 'fixture',
  273. HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1,localhost',
  274. })
  275. // Synthetic credentials are seeded only into this disposable fixture directory.
  276. const usersPath = join(home, 'users.json')
  277. if (!existsSync(usersPath)) {
  278. const salt = randomBytes(16),
  279. now = Date.now()
  280. writeFileSync(
  281. usersPath,
  282. JSON.stringify({
  283. version: 1,
  284. users: [
  285. {
  286. id: randomUUID(),
  287. username: 'fixture',
  288. normalizedUsername: 'fixture',
  289. role: 'admin',
  290. enabled: true,
  291. mustChangePassword: false,
  292. salt: salt.toString('base64'),
  293. passwordHash: scryptSync('fixture-pass', salt, 32, {
  294. N: 2 ** 14,
  295. r: 8,
  296. p: 1,
  297. maxmem: 64 * 1024 * 1024,
  298. }).toString('base64'),
  299. authVersion: 1,
  300. createdAt: now,
  301. updatedAt: now,
  302. },
  303. ],
  304. }),
  305. { mode: 0o600 },
  306. )
  307. }
  308. const auth = new LocalAuthStore(home, false)
  309. const runtime = createApplication({ config, auth }),
  310. node = createNodeServer(runtime)
  311. runtime.app.use(serve(resolve('dist')))
  312. runtime.app.use((ctx) => {
  313. if (!ctx.path.startsWith('/api/')) {
  314. ctx.type = 'html'
  315. ctx.body = readFileSync(resolve('dist/index.html'))
  316. }
  317. })
  318. await new Promise<void>((resolve) => node.server.listen(port, '127.0.0.1', resolve))
  319. process.stdout.write(`Workspace fixture http://127.0.0.1:${port}; home=${home}\n`)
  320. for (const signal of ['SIGTERM', 'SIGINT'])
  321. process.once(signal, () => {
  322. void node.close().then(() => {
  323. for (const socket of wss.clients) socket.terminate()
  324. wss.close()
  325. upstream.close()
  326. })
  327. })