workspace-server.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. /** Isolated, deterministic Hermes fixture. Optional team-tool readiness is only for editor UI tests. */
  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. cwd?: string
  26. }
  27. >()
  28. const latestRuntimeBySession = new Map<string, string>()
  29. const calls: Array<{ method: string; params: Record<string, unknown> }> = []
  30. const heldReplies: Array<() => void> = []
  31. const profileState = new Map([
  32. ['default', { name: 'default', display_name: '通用助手', is_default: true, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
  33. ['server', { name: 'server', display_name: '开发助手', is_default: false, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
  34. ])
  35. function broadcastUpstreamEvent(
  36. type: string,
  37. payload: Record<string, unknown>,
  38. sessionId?: string,
  39. profile = 'default',
  40. ) {
  41. const frame = JSON.stringify({
  42. method: 'event',
  43. params: {
  44. type,
  45. payload,
  46. ...(sessionId ? { session_id: sessionId, profile } : {}),
  47. },
  48. })
  49. for (const socket of wss.clients) {
  50. if (socket.readyState === 1) socket.send(frame)
  51. }
  52. }
  53. const upstream = createServer((req, res) => {
  54. const url = new URL(req.url || '/', `http://127.0.0.1:${upstreamPort}`)
  55. res.setHeader('content-type', 'application/json')
  56. const send = (v: unknown) => res.end(JSON.stringify(v))
  57. if (url.pathname === '/api/config') {
  58. send({ terminal: { cwd: `/tmp/hermes-fixture/${url.searchParams.get('profile') || 'default'}` } })
  59. return
  60. }
  61. if (url.pathname === '/__release' && req.method === 'POST') {
  62. const held = heldReplies.splice(0)
  63. for (const complete of held) complete()
  64. send({ released: held.length })
  65. return
  66. }
  67. if (url.pathname.includes('/plugins/')) {
  68. if (process.env.WORKSPACE_FIXTURE_TEAM_TOOLS === '1' && url.pathname === '/api/plugins/yaoyao-bot-bridge/capabilities') {
  69. send({ version: 1, ready: true, native_tools: true, in_process: true })
  70. return
  71. }
  72. res.statusCode = 404
  73. send({ error: 'This fixture has no plugins' })
  74. return
  75. }
  76. if (url.pathname === '/__calls') {
  77. send({ calls })
  78. return
  79. }
  80. if (url.pathname === '/__test/sessions/title' && req.method === 'POST') {
  81. const id = url.searchParams.get('id')?.trim() || ''
  82. const title = url.searchParams.get('title')?.trim() || ''
  83. const session = sessions.get(id)
  84. const runtimeId = latestRuntimeBySession.get(id)
  85. if (!session || !runtimeId || !title) {
  86. res.statusCode = 409
  87. send({ error: 'session route is not active' })
  88. return
  89. }
  90. session.title = title
  91. broadcastUpstreamEvent(
  92. 'session.title',
  93. { session_id: id, title },
  94. runtimeId,
  95. session.profile,
  96. )
  97. send({ id, title })
  98. return
  99. }
  100. if (url.pathname === '/api/status') {
  101. send({ auth_required: true, overall: 'ready', gateway_running: true })
  102. return
  103. }
  104. if (url.pathname === '/api/auth/me') {
  105. send({ user_id: 'fixture', display_name: 'Fixture', provider: 'basic' })
  106. return
  107. }
  108. if (url.pathname === '/auth/password-login') {
  109. res.setHeader('set-cookie', 'hermes_session_at=fixture; Path=/; HttpOnly')
  110. send({ ok: true })
  111. return
  112. }
  113. if (url.pathname === '/api/auth/ws-ticket') {
  114. send({ ticket: 'fixture' })
  115. return
  116. }
  117. if (url.pathname === '/api/profiles') {
  118. send({ profiles: [...profileState.values()] })
  119. return
  120. }
  121. if (url.pathname === '/api/sessions') {
  122. const requestedSource = url.searchParams.get('source')
  123. const visible = [...sessions.values()].filter(session =>
  124. !requestedSource || session.source === requestedSource)
  125. send({
  126. sessions: visible.map((s) => ({
  127. id: s.id,
  128. title: s.title,
  129. source: s.source,
  130. profile: s.profile,
  131. })),
  132. total: visible.length,
  133. hasMore: false,
  134. offset: Number(url.searchParams.get('offset') ?? 0),
  135. limit: Number(url.searchParams.get('limit') ?? 100),
  136. })
  137. return
  138. }
  139. const detail = /^\/api\/sessions\/([^/]+)$/.exec(url.pathname)
  140. if (detail) {
  141. const session = sessions.get(detail[1]!)
  142. if (!session) {
  143. res.statusCode = 404
  144. send({ error: 'session not found' })
  145. return
  146. }
  147. send({
  148. id: session.id,
  149. profile: session.profile,
  150. source: session.source,
  151. title: session.title,
  152. message_count: session.messages.length,
  153. })
  154. return
  155. }
  156. const history = /^\/api\/sessions\/([^/]+)\/messages$/.exec(url.pathname)
  157. if (history) {
  158. const messages = sessions.get(history[1]!)?.messages || []
  159. send({ session_id: history[1], messages,
  160. pagination: {offset: 0, limit: 100, returned: messages.length, total: messages.length, has_more: false} })
  161. return
  162. }
  163. if (url.pathname === '/api/files/download') {
  164. res.setHeader('content-type', 'text/plain')
  165. res.end('Fixture report\n')
  166. return
  167. }
  168. send({ ok: true })
  169. })
  170. const wss = new WebSocketServer({ server: upstream, path: '/api/ws' })
  171. wss.on('connection', (socket) => {
  172. const runtimes = new Map<string, string>()
  173. socket.send(
  174. JSON.stringify({
  175. method: 'event',
  176. params: { type: 'gateway.ready', payload: { capabilities: [] } },
  177. }),
  178. )
  179. socket.on('message', (raw) => {
  180. const f = JSON.parse(String(raw))
  181. calls.push({ method: f.method, params: f.params })
  182. const respond = (result: unknown) => socket.send(JSON.stringify({ id: f.id, result }))
  183. const event = (type: string, payload: unknown, sessionId: string) => {
  184. if (socket.readyState === 1)
  185. socket.send(
  186. JSON.stringify({
  187. method: 'event',
  188. params: { type, payload, session_id: sessionId, profile: 'default' },
  189. }),
  190. )
  191. }
  192. if (f.method === 'profiles.list') {
  193. respond({ profiles: [...profileState.values()] })
  194. return
  195. }
  196. if (f.method === 'profiles.configure') {
  197. const profile = profileState.get(String(f.params.name))
  198. if (!profile) { respond({ error: 'profile_not_found' }); return }
  199. for (const [namespace, value] of Object.entries(f.params.ui_meta ?? {})) {
  200. profile.ui_meta[namespace] = value
  201. profile.ui_meta_revisions[namespace] = (profile.ui_meta_revisions[namespace] ?? 0) + 1
  202. }
  203. respond({ profile })
  204. return
  205. }
  206. if (f.method === 'session.create' || f.method === 'session.resume') {
  207. let stored = f.method === 'session.resume' ? sessions.get(f.params.session_id) : undefined
  208. if (!stored) {
  209. stored = {
  210. id: randomUUID(),
  211. profile: f.params.profile,
  212. source: String(f.params.source ?? 'web'),
  213. title: String(f.params.title ?? '新对话'),
  214. messages: [],
  215. running: false,
  216. }
  217. sessions.set(stored.id, stored)
  218. }
  219. // A live Hermes resume joins the same runtime, including other viewers.
  220. const runtimeId = latestRuntimeBySession.get(stored.id) ?? randomUUID()
  221. runtimes.set(runtimeId, stored.id)
  222. latestRuntimeBySession.set(stored.id, runtimeId)
  223. respond({
  224. session_id: runtimeId,
  225. stored_session_id: stored.id,
  226. session_key: stored.id,
  227. running: stored.running,
  228. info: { profile_name: stored.profile, cwd: stored.cwd },
  229. })
  230. return
  231. }
  232. const stored = sessions.get(runtimes.get(f.params.session_id) ?? '')
  233. if (f.method === 'session.cwd.set' && stored) {
  234. stored.cwd = String(f.params.cwd)
  235. respond({ cwd: stored.cwd })
  236. return
  237. }
  238. if (f.method === 'prompt.submit' && stored) {
  239. stored.running = true
  240. const requestedTitle = /^\[cross-client-title:([^\]\r\n]+)\]/
  241. .exec(String(f.params.text))?.[1]?.trim()
  242. if (requestedTitle) {
  243. stored.title = requestedTitle
  244. setTimeout(() => broadcastUpstreamEvent('sessions.changed', {}), 0)
  245. }
  246. stored.messages.push({
  247. id: randomUUID(),
  248. role: 'user',
  249. content: f.params.text,
  250. timestamp: Date.now() / 1000,
  251. })
  252. respond({ status: 'streaming' })
  253. const name = /你是 (.*?)。/.exec(f.params.text)?.[1] || '助手'
  254. const requested = /本轮用户指定成员:([^\n]*)/.exec(f.params.text)?.[1] ?? ''
  255. const delegates = f.params.text.includes('你是管理员。') && !f.params.text.includes('本批次执行结果:') && requested.startsWith('@')
  256. const markdownStream = String(f.params.text).includes('[streaming-markdown]')
  257. const crossClientRun = String(f.params.text).includes('[cross-client-live]')
  258. const markdownPrefix = '# 流式标题\n\n**即时格式化**\n\n- 第一项\n- 第二项\n\n```ts\nconst answer = 42' + (String(f.params.text).includes('[long]') ? `\n\x60\x60\x60\n\n${'这是持续生成的长回复段落。\n\n'.repeat(80)}流式末尾标记\n\n\x60\x60\x60ts\nconst tail = 1` : '')
  259. const markdownFinal = `${markdownPrefix}\n\x60\x60\x60\n\n| 名称 | 数量 |\n| --- | --- |\n| 项目 | 2 |\n\n最终完整标记`
  260. const text = markdownStream ? markdownFinal : `我是${name}。已按角色规则处理这条消息。\n\n- 会话独立保存\n- 可以继续交流\n\n[报告](/tmp/workspace-report.txt)${delegates ? `\n请${requested}处理任务。` : ''}`
  261. event('message.start', {}, f.params.session_id)
  262. if (crossClientRun) event('tool.start', { tool_id: 'cross-client-tool', name: 'read_file' }, f.params.session_id)
  263. else setTimeout(() => event('message.delta', { text: markdownStream ? markdownPrefix : text.slice(0, 12) }, f.params.session_id), 80)
  264. const complete = () => {
  265. if (crossClientRun) event('tool.complete', { tool_id: 'cross-client-tool', name: 'read_file', result: 'ok' }, f.params.session_id)
  266. stored!.running = false
  267. stored!.messages.push({
  268. id: randomUUID(),
  269. role: 'assistant',
  270. content: text,
  271. timestamp: Date.now() / 1000,
  272. })
  273. event('message.complete', { text, status: 'complete' }, f.params.session_id)
  274. }
  275. if (crossClientRun || markdownStream || f.params.text.includes('[hold-workspace]')) heldReplies.push(complete)
  276. else setTimeout(complete, 250)
  277. return
  278. }
  279. if (f.method === 'session.interrupt' && stored) stored.running = false
  280. if (f.method === 'file.attach') {
  281. respond({ attached: true, ref_text: '@file:fixture-upload.txt' })
  282. return
  283. }
  284. respond({ ok: true, status: 'interrupted' })
  285. })
  286. })
  287. await new Promise<void>((resolve) => upstream.listen(upstreamPort, '127.0.0.1', resolve))
  288. const config = loadServerConfig({
  289. HERMES_YAOYAO_HOME: home,
  290. HERMES_YAOYAO_PORT: String(port),
  291. HERMES_YAOYAO_UPSTREAM: `http://127.0.0.1:${upstreamPort}`,
  292. HERMES_YAOYAO_UPSTREAM_USERNAME: 'fixture',
  293. HERMES_YAOYAO_UPSTREAM_PASSWORD: 'fixture',
  294. HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1,localhost',
  295. })
  296. // Synthetic credentials are seeded only into this disposable fixture directory.
  297. const usersPath = join(home, 'users.json')
  298. if (!existsSync(usersPath)) {
  299. const salt = randomBytes(16),
  300. now = Date.now()
  301. writeFileSync(
  302. usersPath,
  303. JSON.stringify({
  304. version: 1,
  305. users: [
  306. {
  307. id: randomUUID(),
  308. username: 'fixture',
  309. normalizedUsername: 'fixture',
  310. role: 'admin',
  311. enabled: true,
  312. mustChangePassword: false,
  313. salt: salt.toString('base64'),
  314. passwordHash: scryptSync('fixture-pass', salt, 32, {
  315. N: 2 ** 14,
  316. r: 8,
  317. p: 1,
  318. maxmem: 64 * 1024 * 1024,
  319. }).toString('base64'),
  320. authVersion: 1,
  321. createdAt: now,
  322. updatedAt: now,
  323. },
  324. ],
  325. }),
  326. { mode: 0o600 },
  327. )
  328. }
  329. const auth = new LocalAuthStore(home, false)
  330. const runtime = createApplication({ config, auth }),
  331. node = createNodeServer(runtime)
  332. runtime.app.use((ctx,next) => {
  333. if (ctx.path !== '/__test/task-plan' || ctx.method !== 'POST') return next()
  334. const owner=auth.require(ctx).id
  335. const lead=runtime.workspace.createAgent(owner,{name:'验收负责人',profile:'default',canManageTeam:true})
  336. const worker=runtime.workspace.createAgent(owner,{name:'验收研究员',profile:'default'})
  337. const team=runtime.workspace.createGroup(owner,{name:'任务分工验收',memberIds:[lead.id,worker.id],administratorId:lead.id})
  338. const first=runtime.workspace.tasks(owner,team.id)[0]!
  339. runtime.workspace.updateTask(owner,team.id,first.id,{title:'调研任务'})
  340. const second=runtime.workspace.createTask(owner,team.id,{title:'独立任务'})
  341. const source=runtime.workspace.list<any>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===lead.id)!
  342. const goal=runtime.workspaceRuntime.tasks.begin(owner,first,lead,'完成可核对的研究报告',{conversationId:source.id,runId:randomUUID(),agentId:lead.id},['来源可核对'])
  343. const temporary=ctx.query.helper==='1'?runtime.workspace.createAgent(owner,{name:'验收研究员(临时)',profile:'default',execution:'computer'},{createdByAgentId:lead.id,createdFromRunId:randomUUID(),temporaryGoalId:goal.id,helperActivation:goal.activation??1,helperRunnerId:randomUUID()}):undefined
  344. const assignment={id:randomUUID(),goalId:goal.id,conversationId:team.id,agentId:temporary?.id??worker.id,title:'研究资料',brief:'核对资料',acceptanceCriteria:['来源可核对'],dependsOn:[],status:'review',attempt:1,result:'资料已汇总',artifactIds:[],createdAt:Date.now(),updatedAt:Date.now()}
  345. runtime.workspace.put(owner,'assignment',assignment.id,assignment)
  346. runtime.workspace.event(owner,'assignment.changed',assignment,team.id)
  347. runtime.workspace.saveMessage(owner,{id:randomUUID(),conversationId:source.id,seq:0,role:'system',content:`任务已有结果。[打开任务](/conversations/${team.id}?taskId=${first.id})`,reasoning:'',status:'complete',attachments:[],tools:[],createdAt:Date.now()})
  348. ctx.body={conversationId:team.id,firstTaskId:first.id,secondTaskId:second.id,sourceConversationId:source.id}
  349. })
  350. runtime.app.use(serve(resolve('dist')))
  351. runtime.app.use((ctx) => {
  352. if (!ctx.path.startsWith('/api/')) {
  353. ctx.type = 'html'
  354. ctx.body = readFileSync(resolve('dist/index.html'))
  355. }
  356. })
  357. await new Promise<void>((resolve) => node.server.listen(port, '127.0.0.1', resolve))
  358. process.stdout.write(`Workspace fixture http://127.0.0.1:${port}; home=${home}\n`)
  359. for (const signal of ['SIGTERM', 'SIGINT'])
  360. process.once(signal, () => {
  361. void node.close().then(() => {
  362. for (const socket of wss.clients) socket.terminate()
  363. wss.close()
  364. upstream.close()
  365. })
  366. })